openai-compatible-chat.test.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Schema } from "effect"
  3. import { HttpClientRequest } from "effect/unstable/http"
  4. import { LLM, Message, ToolCallPart } from "../../src"
  5. import { Auth, LLMClient } from "../../src/route"
  6. import * as OpenAICompatible from "../../src/providers/openai-compatible"
  7. import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
  8. import { it } from "../lib/effect"
  9. import { dynamicResponse } from "../lib/http"
  10. import { sseEvents } from "../lib/sse"
  11. const Json = Schema.fromJsonString(Schema.Unknown)
  12. const decodeJson = Schema.decodeUnknownSync(Json)
  13. const model = OpenAICompatibleChat.route
  14. .with({
  15. provider: "deepseek",
  16. endpoint: { baseURL: "https://api.deepseek.test/v1/", query: { "api-version": "2026-01-01" } },
  17. auth: Auth.bearer("test-key"),
  18. })
  19. .model({ id: "deepseek-chat" })
  20. const request = LLM.request({
  21. id: "req_1",
  22. model,
  23. system: "You are concise.",
  24. prompt: "Say hello.",
  25. generation: { maxTokens: 20, temperature: 0 },
  26. })
  27. const deltaChunk = (delta: object, finishReason: string | null = null) => ({
  28. id: "chatcmpl_fixture",
  29. choices: [{ delta, finish_reason: finishReason }],
  30. usage: null,
  31. })
  32. const usageChunk = (usage: object) => ({
  33. id: "chatcmpl_fixture",
  34. choices: [],
  35. usage,
  36. })
  37. const providerFamilies = [
  38. ["baseten", OpenAICompatible.baseten, "https://inference.baseten.co/v1"],
  39. ["cerebras", OpenAICompatible.cerebras, "https://api.cerebras.ai/v1"],
  40. ["deepinfra", OpenAICompatible.deepinfra, "https://api.deepinfra.com/v1/openai"],
  41. ["deepseek", OpenAICompatible.deepseek, "https://api.deepseek.com/v1"],
  42. ["fireworks", OpenAICompatible.fireworks, "https://api.fireworks.ai/inference/v1"],
  43. ["togetherai", OpenAICompatible.togetherai, "https://api.together.xyz/v1"],
  44. ] as const
  45. describe("OpenAI-compatible Chat route", () => {
  46. it.effect("prepares generic Chat target", () =>
  47. Effect.gen(function* () {
  48. const prepared = yield* LLMClient.prepare(
  49. LLM.updateRequest(request, {
  50. tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
  51. toolChoice: { type: "required" },
  52. }),
  53. )
  54. expect(prepared.route).toBe("openai-compatible-chat")
  55. expect(prepared.model).toMatchObject({
  56. id: "deepseek-chat",
  57. provider: "deepseek",
  58. route: { id: "openai-compatible-chat" },
  59. })
  60. expect(prepared.model.route.endpoint).toMatchObject({
  61. baseURL: "https://api.deepseek.test/v1/",
  62. query: { "api-version": "2026-01-01" },
  63. })
  64. expect(prepared.body).toEqual({
  65. model: "deepseek-chat",
  66. messages: [
  67. { role: "system", content: "You are concise." },
  68. { role: "user", content: "Say hello." },
  69. ],
  70. tools: [
  71. {
  72. type: "function",
  73. function: { name: "lookup", description: "Lookup data", parameters: { type: "object" } },
  74. },
  75. ],
  76. tool_choice: "required",
  77. stream: true,
  78. stream_options: { include_usage: true },
  79. max_tokens: 20,
  80. temperature: 0,
  81. })
  82. }),
  83. )
  84. it.effect("provides model helpers for compatible provider families", () =>
  85. Effect.gen(function* () {
  86. expect(
  87. providerFamilies.map(([provider, family]) => {
  88. const model = family.configure({ apiKey: "test-key" }).model(`${provider}-model`)
  89. return {
  90. id: String(model.id),
  91. provider: String(model.provider),
  92. route: model.route.id,
  93. baseURL: model.route.endpoint.baseURL,
  94. }
  95. }),
  96. ).toEqual(
  97. providerFamilies.map(([provider, _, baseURL]) => ({
  98. id: `${provider}-model`,
  99. provider,
  100. route: "openai-compatible-chat",
  101. baseURL,
  102. })),
  103. )
  104. const custom = OpenAICompatible.deepseek
  105. .configure({
  106. apiKey: "test-key",
  107. baseURL: "https://custom.deepseek.test/v1",
  108. })
  109. .model("deepseek-chat")
  110. expect(custom).toMatchObject({
  111. provider: "deepseek",
  112. route: { id: "openai-compatible-chat" },
  113. })
  114. expect(custom.route.endpoint.baseURL).toBe("https://custom.deepseek.test/v1")
  115. }),
  116. )
  117. it.effect("matches AI SDK compatible basic request body fixture", () =>
  118. Effect.gen(function* () {
  119. const prepared = yield* LLMClient.prepare(request)
  120. expect(prepared.body).toEqual({
  121. model: "deepseek-chat",
  122. messages: [
  123. { role: "system", content: "You are concise." },
  124. { role: "user", content: "Say hello." },
  125. ],
  126. stream: true,
  127. stream_options: { include_usage: true },
  128. max_tokens: 20,
  129. temperature: 0,
  130. })
  131. }),
  132. )
  133. it.effect("matches AI SDK compatible tool request body fixture", () =>
  134. Effect.gen(function* () {
  135. const prepared = yield* LLMClient.prepare(
  136. LLM.request({
  137. id: "req_tool_parity",
  138. model,
  139. tools: [
  140. {
  141. name: "lookup",
  142. description: "Lookup data",
  143. inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
  144. },
  145. ],
  146. toolChoice: "lookup",
  147. messages: [
  148. Message.user("What is the weather?"),
  149. Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
  150. Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
  151. ],
  152. }),
  153. )
  154. expect(prepared.body).toEqual({
  155. model: "deepseek-chat",
  156. messages: [
  157. { role: "user", content: "What is the weather?" },
  158. {
  159. role: "assistant",
  160. content: null,
  161. tool_calls: [
  162. {
  163. id: "call_1",
  164. type: "function",
  165. function: { name: "lookup", arguments: '{"query":"weather"}' },
  166. },
  167. ],
  168. },
  169. { role: "tool", tool_call_id: "call_1", content: '{"forecast":"sunny"}' },
  170. ],
  171. tools: [
  172. {
  173. type: "function",
  174. function: {
  175. name: "lookup",
  176. description: "Lookup data",
  177. parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
  178. },
  179. },
  180. ],
  181. tool_choice: { type: "function", function: { name: "lookup" } },
  182. stream: true,
  183. stream_options: { include_usage: true },
  184. })
  185. }),
  186. )
  187. it.effect("posts to the configured compatible endpoint and parses text usage", () =>
  188. Effect.gen(function* () {
  189. const response = yield* LLMClient.generate(request).pipe(
  190. Effect.provide(
  191. dynamicResponse((input) =>
  192. Effect.gen(function* () {
  193. const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  194. expect(web.url).toBe("https://api.deepseek.test/v1/chat/completions?api-version=2026-01-01")
  195. expect(web.headers.get("authorization")).toBe("Bearer test-key")
  196. expect(decodeJson(input.text)).toMatchObject({
  197. model: "deepseek-chat",
  198. stream: true,
  199. messages: [
  200. { role: "system", content: "You are concise." },
  201. { role: "user", content: "Say hello." },
  202. ],
  203. })
  204. return input.respond(
  205. sseEvents(
  206. deltaChunk({ role: "assistant", content: "Hello" }),
  207. deltaChunk({ content: "!" }),
  208. deltaChunk({}, "stop"),
  209. usageChunk({ prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }),
  210. ),
  211. { headers: { "content-type": "text/event-stream" } },
  212. )
  213. }),
  214. ),
  215. ),
  216. )
  217. expect(response.text).toBe("Hello!")
  218. expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
  219. expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
  220. }),
  221. )
  222. })