diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/deepseek-streams-text.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/deepseek-streams-text.json new file mode 100644 index 0000000000..47e198f7b1 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/deepseek-streams-text.json @@ -0,0 +1 @@ +{"version":1,"interactions":[{"request":{"method":"POST","url":"https://api.deepseek.com/v1/chat/completions","headers":{"content-type":"application/json"},"body":"{\"model\":\"deepseek-chat\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply with exactly: Hello!\"}],\"stream\":true,\"max_tokens\":20,\"temperature\":0}"},"response":{"status":200,"headers":{"content-type":"text/event-stream; charset=utf-8"},"body":"data: {\"id\":\"37be8034-f5e7-41e4-8ee7-39e0b5c613a2\",\"object\":\"chat.completion.chunk\",\"created\":1777218434,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"37be8034-f5e7-41e4-8ee7-39e0b5c613a2\",\"object\":\"chat.completion.chunk\",\"created\":1777218434,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"37be8034-f5e7-41e4-8ee7-39e0b5c613a2\",\"object\":\"chat.completion.chunk\",\"created\":1777218434,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"37be8034-f5e7-41e4-8ee7-39e0b5c613a2\",\"object\":\"chat.completion.chunk\",\"created\":1777218434,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\"},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":14,\"completion_tokens\":2,\"total_tokens\":16,\"prompt_tokens_details\":{\"cached_tokens\":0},\"prompt_cache_hit_tokens\":0,\"prompt_cache_miss_tokens\":14}}\n\ndata: [DONE]\n\n"}}]} \ No newline at end of file diff --git a/packages/llm/test/provider/openai-compatible-chat.recorded.test.ts b/packages/llm/test/provider/openai-compatible-chat.recorded.test.ts new file mode 100644 index 0000000000..32d00e89f9 --- /dev/null +++ b/packages/llm/test/provider/openai-compatible-chat.recorded.test.ts @@ -0,0 +1,33 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLM } from "../../src" +import { client } from "../../src/adapter" +import { OpenAICompatibleChat } from "../../src/provider/openai-compatible-chat" +import { recordedTests } from "../recorded-test" + +const deepseekModel = OpenAICompatibleChat.deepseek({ + id: "deepseek-chat", + apiKey: process.env.DEEPSEEK_API_KEY ?? "fixture", +}) + +const deepseekRequest = LLM.request({ + id: "recorded_deepseek_text", + model: deepseekModel, + system: "You are concise.", + prompt: "Reply with exactly: Hello!", + generation: { maxTokens: 20, temperature: 0 }, +}) + +const recorded = recordedTests({ prefix: "openai-compatible-chat" }) +const llm = client({ adapters: [OpenAICompatibleChat.adapter] }) + +describe("OpenAI-compatible Chat recorded", () => { + recorded.effect.with("deepseek streams text", { requires: ["DEEPSEEK_API_KEY"] }, () => + Effect.gen(function* () { + const response = yield* llm.generate(deepseekRequest) + + expect(LLM.outputText(response)).toMatch(/^Hello!?$/) + expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "stop" }) + }), + ) +}) diff --git a/packages/llm/test/provider/openai-compatible-chat.test.ts b/packages/llm/test/provider/openai-compatible-chat.test.ts index 37c813f943..2394e3c492 100644 --- a/packages/llm/test/provider/openai-compatible-chat.test.ts +++ b/packages/llm/test/provider/openai-compatible-chat.test.ts @@ -124,6 +124,72 @@ describe("OpenAI-compatible Chat adapter", () => { }), ) + it.effect("matches AI SDK compatible basic request body fixture", () => + Effect.gen(function* () { + const prepared = yield* client({ adapters: [OpenAICompatibleChat.adapter] }).prepare(request) + + expect(prepared.target).toEqual({ + model: "deepseek-chat", + messages: [ + { role: "system", content: "You are concise." }, + { role: "user", content: "Say hello." }, + ], + stream: true, + max_tokens: 20, + temperature: 0, + }) + }), + ) + + it.effect("matches AI SDK compatible tool request body fixture", () => + Effect.gen(function* () { + const prepared = yield* client({ adapters: [OpenAICompatibleChat.adapter] }).prepare( + LLM.request({ + id: "req_tool_parity", + model, + tools: [{ + name: "lookup", + description: "Lookup data", + inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }, + }], + toolChoice: "lookup", + messages: [ + LLM.user("What is the weather?"), + LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })]), + LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), + ], + }), + ) + + expect(prepared.target).toEqual({ + model: "deepseek-chat", + messages: [ + { role: "user", content: "What is the weather?" }, + { + role: "assistant", + content: null, + tool_calls: [{ + id: "call_1", + type: "function", + function: { name: "lookup", arguments: '{"query":"weather"}' }, + }], + }, + { role: "tool", tool_call_id: "call_1", content: '{"forecast":"sunny"}' }, + ], + tools: [{ + type: "function", + function: { + name: "lookup", + description: "Lookup data", + parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }, + }, + }], + tool_choice: { type: "function", function: { name: "lookup" } }, + stream: true, + }) + }), + ) + it.effect("posts to the configured compatible endpoint and parses text usage", () => Effect.gen(function* () { const response = yield* client({