openai-compatible-chat.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Schema } from "effect"
  3. import { HttpClientRequest } from "effect/unstable/http"
  4. import { LLM, LLMRequest, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src"
  5. import { Auth, LLMClient } from "../../src/route"
  6. import { compileRequest } from "../../src/route/client"
  7. import * as OpenAICompatible from "../../src/providers/openai-compatible"
  8. import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
  9. import { it } from "../lib/effect"
  10. import { dynamicResponse, fixedResponse } from "../lib/http"
  11. import { sseEvents } from "../lib/sse"
  12. const Json = Schema.fromJsonString(Schema.Unknown)
  13. const decodeJson = Schema.decodeUnknownSync(Json)
  14. const model = OpenAICompatibleChat.route
  15. .with({
  16. provider: "deepseek",
  17. endpoint: { baseURL: "https://api.deepseek.test/v1/", query: { "api-version": "2026-01-01" } },
  18. auth: Auth.bearer("test-key"),
  19. })
  20. .model({ id: "deepseek-chat" })
  21. const request = LLM.request({
  22. id: "req_1",
  23. model,
  24. system: "You are concise.",
  25. prompt: "Say hello.",
  26. generation: { maxTokens: 20, temperature: 0 },
  27. })
  28. const deltaChunk = (delta: object, finishReason: string | null = null) => ({
  29. id: "chatcmpl_fixture",
  30. choices: [{ delta, finish_reason: finishReason }],
  31. usage: null,
  32. })
  33. const usageChunk = (usage: object) => ({
  34. id: "chatcmpl_fixture",
  35. choices: [],
  36. usage,
  37. })
  38. const providerFamilies = [
  39. ["baseten", OpenAICompatible.baseten, "https://inference.baseten.co/v1"],
  40. ["cerebras", OpenAICompatible.cerebras, "https://api.cerebras.ai/v1"],
  41. ["deepinfra", OpenAICompatible.deepinfra, "https://api.deepinfra.com/v1/openai"],
  42. ["deepseek", OpenAICompatible.deepseek, "https://api.deepseek.com/v1"],
  43. ["fireworks", OpenAICompatible.fireworks, "https://api.fireworks.ai/inference/v1"],
  44. ["togetherai", OpenAICompatible.togetherai, "https://api.together.xyz/v1"],
  45. ] as const
  46. describe("OpenAI-compatible Chat route", () => {
  47. it.effect("prepares generic Chat target", () =>
  48. Effect.gen(function* () {
  49. const prepared = yield* compileRequest(
  50. LLMRequest.update(request, {
  51. tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
  52. toolChoice: ToolChoice.make({ type: "required" }),
  53. }),
  54. )
  55. expect(prepared.route).toBe("openai-compatible-chat")
  56. expect(prepared.model).toMatchObject({
  57. id: "deepseek-chat",
  58. provider: "deepseek",
  59. route: { id: "openai-compatible-chat" },
  60. })
  61. expect(prepared.model.route.endpoint).toMatchObject({
  62. baseURL: "https://api.deepseek.test/v1/",
  63. query: { "api-version": "2026-01-01" },
  64. })
  65. expect(prepared.body).toEqual({
  66. model: "deepseek-chat",
  67. messages: [
  68. { role: "system", content: "You are concise." },
  69. { role: "user", content: "Say hello." },
  70. ],
  71. tools: [
  72. {
  73. type: "function",
  74. function: { name: "lookup", description: "Lookup data", parameters: { type: "object" } },
  75. },
  76. ],
  77. tool_choice: "required",
  78. stream: true,
  79. stream_options: { include_usage: true },
  80. max_tokens: 20,
  81. temperature: 0,
  82. })
  83. }),
  84. )
  85. it.effect("provides model helpers for compatible provider families", () =>
  86. Effect.gen(function* () {
  87. expect(
  88. providerFamilies.map(([provider, family]) => {
  89. const model = family.configure({ apiKey: "test-key" }).model(`${provider}-model`)
  90. return {
  91. id: String(model.id),
  92. provider: String(model.provider),
  93. route: model.route.id,
  94. baseURL: model.route.endpoint.baseURL,
  95. }
  96. }),
  97. ).toEqual(
  98. providerFamilies.map(([provider, _, baseURL]) => ({
  99. id: `${provider}-model`,
  100. provider,
  101. route: "openai-compatible-chat",
  102. baseURL,
  103. })),
  104. )
  105. const custom = OpenAICompatible.deepseek
  106. .configure({
  107. apiKey: "test-key",
  108. baseURL: "https://custom.deepseek.test/v1",
  109. })
  110. .model("deepseek-chat")
  111. expect(custom).toMatchObject({
  112. provider: "deepseek",
  113. route: { id: "openai-compatible-chat" },
  114. })
  115. expect(custom.route.endpoint.baseURL).toBe("https://custom.deepseek.test/v1")
  116. }),
  117. )
  118. it.effect("matches AI SDK compatible basic request body fixture", () =>
  119. Effect.gen(function* () {
  120. const prepared = yield* compileRequest(request)
  121. expect(prepared.body).toEqual({
  122. model: "deepseek-chat",
  123. messages: [
  124. { role: "system", content: "You are concise." },
  125. { role: "user", content: "Say hello." },
  126. ],
  127. stream: true,
  128. stream_options: { include_usage: true },
  129. max_tokens: 20,
  130. temperature: 0,
  131. })
  132. }),
  133. )
  134. it.effect("configures the max tokens request field", () =>
  135. Effect.gen(function* () {
  136. const compatible = OpenAICompatibleChat.route
  137. .with({ provider: "custom", endpoint: { baseURL: "https://api.custom.test/v1" } })
  138. .model({ id: "custom-model", compatibility: { maxTokensField: "max_completion_tokens" } })
  139. const prepared = yield* compileRequest(
  140. LLM.request({ model: compatible, prompt: "Say hello.", generation: { maxTokens: 20 } }),
  141. )
  142. expect(prepared.body).toMatchObject({ max_completion_tokens: 20 })
  143. expect(prepared.body).not.toHaveProperty("max_tokens")
  144. }),
  145. )
  146. it.effect("matches AI SDK compatible tool request body fixture", () =>
  147. Effect.gen(function* () {
  148. const prepared = yield* compileRequest(
  149. LLM.request({
  150. id: "req_tool_parity",
  151. model,
  152. tools: [
  153. {
  154. name: "lookup",
  155. description: "Lookup data",
  156. inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
  157. },
  158. ],
  159. toolChoice: "lookup",
  160. messages: [
  161. Message.user("What is the weather?"),
  162. Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
  163. Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
  164. ],
  165. }),
  166. )
  167. expect(prepared.body).toEqual({
  168. model: "deepseek-chat",
  169. messages: [
  170. { role: "user", content: "What is the weather?" },
  171. {
  172. role: "assistant",
  173. content: null,
  174. tool_calls: [
  175. {
  176. id: "call_1",
  177. type: "function",
  178. function: { name: "lookup", arguments: '{"query":"weather"}' },
  179. },
  180. ],
  181. },
  182. { role: "tool", tool_call_id: "call_1", content: '{"forecast":"sunny"}' },
  183. ],
  184. tools: [
  185. {
  186. type: "function",
  187. function: {
  188. name: "lookup",
  189. description: "Lookup data",
  190. parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
  191. },
  192. },
  193. ],
  194. tool_choice: { type: "function", function: { name: "lookup" } },
  195. stream: true,
  196. stream_options: { include_usage: true },
  197. })
  198. }),
  199. )
  200. it.effect("posts to the configured compatible endpoint and parses text usage", () =>
  201. Effect.gen(function* () {
  202. const response = yield* LLMClient.generate(request).pipe(
  203. Effect.provide(
  204. dynamicResponse((input) =>
  205. Effect.gen(function* () {
  206. const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  207. expect(web.url).toBe("https://api.deepseek.test/v1/chat/completions?api-version=2026-01-01")
  208. expect(web.headers.get("authorization")).toBe("Bearer test-key")
  209. expect(decodeJson(input.text)).toMatchObject({
  210. model: "deepseek-chat",
  211. stream: true,
  212. messages: [
  213. { role: "system", content: "You are concise." },
  214. { role: "user", content: "Say hello." },
  215. ],
  216. })
  217. return input.respond(
  218. sseEvents(
  219. deltaChunk({ role: "assistant", content: "Hello" }),
  220. deltaChunk({ content: "!" }),
  221. deltaChunk({}, "stop"),
  222. usageChunk({ prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }),
  223. ),
  224. { headers: { "content-type": "text/event-stream" } },
  225. )
  226. }),
  227. ),
  228. ),
  229. )
  230. expect(response.text).toBe("Hello!")
  231. expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
  232. expect(response.events.at(-1)).toMatchObject({
  233. type: "finish",
  234. reason: { normalized: "stop", raw: "stop" },
  235. })
  236. }),
  237. )
  238. it.effect("accepts nullable usage and preserves provider fields", () =>
  239. Effect.gen(function* () {
  240. const response = yield* LLMClient.generate(request).pipe(
  241. Effect.provide(
  242. fixedResponse(
  243. sseEvents(
  244. deltaChunk({ content: "Hello" }),
  245. deltaChunk({}, "stop"),
  246. usageChunk({
  247. prompt_tokens: null,
  248. completion_tokens: null,
  249. total_tokens: null,
  250. prompt_tokens_details: { cached_tokens: null, vendor_cache_tokens: 3 },
  251. completion_tokens_details: {
  252. reasoning_tokens: null,
  253. accepted_prediction_tokens: null,
  254. rejected_prediction_tokens: null,
  255. },
  256. cost: "0.001",
  257. }),
  258. ),
  259. ),
  260. ),
  261. )
  262. expect(response.usage).toMatchObject({
  263. inputTokens: undefined,
  264. outputTokens: undefined,
  265. totalTokens: undefined,
  266. providerMetadata: {
  267. openai: {
  268. prompt_tokens: null,
  269. completion_tokens: null,
  270. total_tokens: null,
  271. prompt_tokens_details: { cached_tokens: null, vendor_cache_tokens: 3 },
  272. cost: "0.001",
  273. },
  274. },
  275. })
  276. }),
  277. )
  278. it.effect("assembles indexless parallel tool calls across sparse chunks", () =>
  279. Effect.gen(function* () {
  280. const response = yield* LLMClient.generate(
  281. LLMRequest.update(request, {
  282. tools: [
  283. ToolDefinition.make({ name: "weather", description: "Get weather", inputSchema: { type: "object" } }),
  284. ],
  285. }),
  286. ).pipe(
  287. Effect.provide(
  288. fixedResponse(
  289. sseEvents(
  290. deltaChunk({
  291. tool_calls: [
  292. { id: "call_paris", function: { name: "weather", arguments: '{"city":"' } },
  293. { index: null, id: "call_london", function: { name: "weather", arguments: '{"city":"' } },
  294. ],
  295. }),
  296. deltaChunk({ tool_calls: [{ function: { arguments: 'London"}' } }] }),
  297. deltaChunk({ tool_calls: [{ id: "call_paris", function: { arguments: 'Paris"}' } }] }),
  298. deltaChunk({}, "tool_calls"),
  299. ),
  300. ),
  301. ),
  302. )
  303. expect(response.toolCalls).toMatchObject([
  304. { id: "call_paris", name: "weather", input: { city: "Paris" } },
  305. { id: "call_london", name: "weather", input: { city: "London" } },
  306. ])
  307. }),
  308. )
  309. it.effect("treats an empty finish reason as terminal", () =>
  310. Effect.gen(function* () {
  311. const response = yield* LLMClient.generate(request).pipe(
  312. Effect.provide(fixedResponse(sseEvents(deltaChunk({ content: "Hello" }), deltaChunk({}, "")))),
  313. )
  314. expect(response.finishReason).toEqual({ normalized: "unknown", raw: "" })
  315. }),
  316. )
  317. it.effect("rejects content after a terminal chunk", () =>
  318. Effect.gen(function* () {
  319. const error = yield* LLMClient.generate(request).pipe(
  320. Effect.provide(
  321. fixedResponse(
  322. sseEvents(
  323. deltaChunk({ content: "Hello" }),
  324. deltaChunk({}, "stop"),
  325. deltaChunk({ tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: "{}" } }] }),
  326. ),
  327. ),
  328. ),
  329. Effect.flip,
  330. )
  331. expect(error.message).toContain("OpenAI Chat received content after the finish reason")
  332. }),
  333. )
  334. })