openai-compatible-chat.test.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Schema } from "effect"
  3. import { HttpClientRequest } from "effect/unstable/http"
  4. import { LLM } from "../../src"
  5. import { 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.model({
  14. id: "deepseek-chat",
  15. provider: "deepseek",
  16. baseURL: "https://api.deepseek.test/v1/",
  17. apiKey: "test-key",
  18. queryParams: { "api-version": "2026-01-01" },
  19. })
  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: "openai-compatible-chat",
  59. baseURL: "https://api.deepseek.test/v1/",
  60. apiKey: "test-key",
  61. queryParams: { "api-version": "2026-01-01" },
  62. })
  63. expect(prepared.body).toEqual({
  64. model: "deepseek-chat",
  65. messages: [
  66. { role: "system", content: "You are concise." },
  67. { role: "user", content: "Say hello." },
  68. ],
  69. tools: [
  70. {
  71. type: "function",
  72. function: { name: "lookup", description: "Lookup data", parameters: { type: "object" } },
  73. },
  74. ],
  75. tool_choice: "required",
  76. stream: true,
  77. stream_options: { include_usage: true },
  78. max_tokens: 20,
  79. temperature: 0,
  80. })
  81. }),
  82. )
  83. it.effect("provides model helpers for compatible provider families", () =>
  84. Effect.gen(function* () {
  85. expect(
  86. providerFamilies.map(([provider, family]) => {
  87. const model = family.model(`${provider}-model`, { apiKey: "test-key" })
  88. return {
  89. id: String(model.id),
  90. provider: String(model.provider),
  91. route: model.route,
  92. baseURL: model.baseURL,
  93. apiKey: model.apiKey,
  94. }
  95. }),
  96. ).toEqual(
  97. providerFamilies.map(([provider, _, baseURL]) => ({
  98. id: `${provider}-model`,
  99. provider,
  100. route: "openai-compatible-chat",
  101. baseURL,
  102. apiKey: "test-key",
  103. })),
  104. )
  105. const custom = OpenAICompatible.deepseek.model("deepseek-chat", {
  106. apiKey: "test-key",
  107. baseURL: "https://custom.deepseek.test/v1",
  108. })
  109. expect(custom).toMatchObject({
  110. provider: "deepseek",
  111. route: "openai-compatible-chat",
  112. baseURL: "https://custom.deepseek.test/v1",
  113. })
  114. }),
  115. )
  116. it.effect("matches AI SDK compatible basic request body fixture", () =>
  117. Effect.gen(function* () {
  118. const prepared = yield* LLMClient.prepare(request)
  119. expect(prepared.body).toEqual({
  120. model: "deepseek-chat",
  121. messages: [
  122. { role: "system", content: "You are concise." },
  123. { role: "user", content: "Say hello." },
  124. ],
  125. stream: true,
  126. stream_options: { include_usage: true },
  127. max_tokens: 20,
  128. temperature: 0,
  129. })
  130. }),
  131. )
  132. it.effect("matches AI SDK compatible tool request body fixture", () =>
  133. Effect.gen(function* () {
  134. const prepared = yield* LLMClient.prepare(
  135. LLM.request({
  136. id: "req_tool_parity",
  137. model,
  138. tools: [
  139. {
  140. name: "lookup",
  141. description: "Lookup data",
  142. inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
  143. },
  144. ],
  145. toolChoice: "lookup",
  146. messages: [
  147. LLM.user("What is the weather?"),
  148. LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
  149. LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
  150. ],
  151. }),
  152. )
  153. expect(prepared.body).toEqual({
  154. model: "deepseek-chat",
  155. messages: [
  156. { role: "user", content: "What is the weather?" },
  157. {
  158. role: "assistant",
  159. content: null,
  160. tool_calls: [
  161. {
  162. id: "call_1",
  163. type: "function",
  164. function: { name: "lookup", arguments: '{"query":"weather"}' },
  165. },
  166. ],
  167. },
  168. { role: "tool", tool_call_id: "call_1", content: '{"forecast":"sunny"}' },
  169. ],
  170. tools: [
  171. {
  172. type: "function",
  173. function: {
  174. name: "lookup",
  175. description: "Lookup data",
  176. parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
  177. },
  178. },
  179. ],
  180. tool_choice: { type: "function", function: { name: "lookup" } },
  181. stream: true,
  182. stream_options: { include_usage: true },
  183. })
  184. }),
  185. )
  186. it.effect("posts to the configured compatible endpoint and parses text usage", () =>
  187. Effect.gen(function* () {
  188. const response = yield* LLMClient.generate(request).pipe(
  189. Effect.provide(
  190. dynamicResponse((input) =>
  191. Effect.gen(function* () {
  192. const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  193. expect(web.url).toBe("https://api.deepseek.test/v1/chat/completions?api-version=2026-01-01")
  194. expect(web.headers.get("authorization")).toBe("Bearer test-key")
  195. expect(decodeJson(input.text)).toMatchObject({
  196. model: "deepseek-chat",
  197. stream: true,
  198. messages: [
  199. { role: "system", content: "You are concise." },
  200. { role: "user", content: "Say hello." },
  201. ],
  202. })
  203. return input.respond(
  204. sseEvents(
  205. deltaChunk({ role: "assistant", content: "Hello" }),
  206. deltaChunk({ content: "!" }),
  207. deltaChunk({}, "stop"),
  208. usageChunk({ prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }),
  209. ),
  210. { headers: { "content-type": "text/event-stream" } },
  211. )
  212. }),
  213. ),
  214. ),
  215. )
  216. expect(response.text).toBe("Hello!")
  217. expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
  218. expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "stop" })
  219. }),
  220. )
  221. })