From d23de3254374d4d63c8e15c6ab9311c3c6f4da5b Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sun, 21 Jun 2026 12:09:09 +0900 Subject: feat(provider-umans): Umans AI Coding Plan provider + openai-stream lib MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract a generic @dispatch/openai-stream library from provider-openai-compat (convert-messages, convert-tools, parse-sse, listModels, stream, provider), parameterizing createOpenAICompatProvider with uid=1000(tradam) gid=1000(tradam) groups=1000(tradam),966(docker),968(ollama),998(wheel) + hook. Refactor provider-openai-compat to import from the lib (byte-identical behavior). New @dispatch/provider-umans extension wraps the Umans OpenAI-compatible backend (https://api.code.umans.ai/v1). Self-contained: reads UMANS_API_KEY from env directly (no auth-apikey dep). transformBody maps reasoningEffort → reasoning_effort (capping xhigh/max → high). Dynamic listModels via GET /v1/models. host-bin: registered provider-umans in CORE_EXTENSIONS + umans credential (gated on UMANS_API_KEY — the credential is the model-catalog index). Verified: tsc EXIT 0, 1059 vitest, biome clean (293 files). Boot smoke: umans models appear in GET /models (7 models live). --- packages/host-bin/package.json | 1 + packages/host-bin/src/main.ts | 10 + packages/openai-stream/package.json | 13 + .../src/__fixtures__/flash-text-turn.json | 26 + .../src/__fixtures__/tool-call-turn.json | 25 + .../openai-stream/src/convert-messages.test.ts | 259 ++++++ packages/openai-stream/src/convert-messages.ts | 99 +++ packages/openai-stream/src/convert-tools.test.ts | 106 +++ packages/openai-stream/src/convert-tools.ts | 25 + packages/openai-stream/src/index.ts | 8 + packages/openai-stream/src/listModels.test.ts | 102 +++ packages/openai-stream/src/listModels.ts | 68 ++ packages/openai-stream/src/parse-sse.test.ts | 263 +++++++ packages/openai-stream/src/parse-sse.ts | 130 ++++ packages/openai-stream/src/provider.test.ts | 153 ++++ packages/openai-stream/src/provider.ts | 73 ++ packages/openai-stream/src/stream.test.ts | 864 +++++++++++++++++++++ packages/openai-stream/src/stream.ts | 411 ++++++++++ packages/openai-stream/tsconfig.json | 6 + packages/provider-openai-compat/package.json | 1 + .../src/__fixtures__/flash-text-turn.json | 26 - .../src/__fixtures__/tool-call-turn.json | 25 - .../src/convert-messages.test.ts | 259 ------ .../provider-openai-compat/src/convert-messages.ts | 99 --- .../src/convert-tools.test.ts | 106 --- .../provider-openai-compat/src/convert-tools.ts | 25 - packages/provider-openai-compat/src/extension.ts | 8 +- packages/provider-openai-compat/src/index.ts | 21 +- .../provider-openai-compat/src/listModels.test.ts | 101 --- packages/provider-openai-compat/src/listModels.ts | 67 -- .../provider-openai-compat/src/parse-sse.test.ts | 263 ------- packages/provider-openai-compat/src/parse-sse.ts | 130 ---- packages/provider-openai-compat/src/provider.ts | 59 -- packages/provider-openai-compat/src/stream.test.ts | 864 --------------------- packages/provider-openai-compat/src/stream.ts | 393 ---------- packages/provider-openai-compat/tsconfig.json | 6 +- packages/provider-umans/package.json | 12 + packages/provider-umans/src/extension.test.ts | 52 ++ packages/provider-umans/src/extension.ts | 55 ++ packages/provider-umans/src/index.ts | 8 + packages/provider-umans/src/reasoning.test.ts | 28 + packages/provider-umans/src/reasoning.ts | 36 + packages/provider-umans/src/resolver.test.ts | 43 + packages/provider-umans/src/resolver.ts | 51 ++ packages/provider-umans/tsconfig.json | 6 + 45 files changed, 2958 insertions(+), 2428 deletions(-) create mode 100644 packages/openai-stream/package.json create mode 100644 packages/openai-stream/src/__fixtures__/flash-text-turn.json create mode 100644 packages/openai-stream/src/__fixtures__/tool-call-turn.json create mode 100644 packages/openai-stream/src/convert-messages.test.ts create mode 100644 packages/openai-stream/src/convert-messages.ts create mode 100644 packages/openai-stream/src/convert-tools.test.ts create mode 100644 packages/openai-stream/src/convert-tools.ts create mode 100644 packages/openai-stream/src/index.ts create mode 100644 packages/openai-stream/src/listModels.test.ts create mode 100644 packages/openai-stream/src/listModels.ts create mode 100644 packages/openai-stream/src/parse-sse.test.ts create mode 100644 packages/openai-stream/src/parse-sse.ts create mode 100644 packages/openai-stream/src/provider.test.ts create mode 100644 packages/openai-stream/src/provider.ts create mode 100644 packages/openai-stream/src/stream.test.ts create mode 100644 packages/openai-stream/src/stream.ts create mode 100644 packages/openai-stream/tsconfig.json delete mode 100644 packages/provider-openai-compat/src/__fixtures__/flash-text-turn.json delete mode 100644 packages/provider-openai-compat/src/__fixtures__/tool-call-turn.json delete mode 100644 packages/provider-openai-compat/src/convert-messages.test.ts delete mode 100644 packages/provider-openai-compat/src/convert-messages.ts delete mode 100644 packages/provider-openai-compat/src/convert-tools.test.ts delete mode 100644 packages/provider-openai-compat/src/convert-tools.ts delete mode 100644 packages/provider-openai-compat/src/listModels.test.ts delete mode 100644 packages/provider-openai-compat/src/listModels.ts delete mode 100644 packages/provider-openai-compat/src/parse-sse.test.ts delete mode 100644 packages/provider-openai-compat/src/parse-sse.ts delete mode 100644 packages/provider-openai-compat/src/provider.ts delete mode 100644 packages/provider-openai-compat/src/stream.test.ts delete mode 100644 packages/provider-openai-compat/src/stream.ts create mode 100644 packages/provider-umans/package.json create mode 100644 packages/provider-umans/src/extension.test.ts create mode 100644 packages/provider-umans/src/extension.ts create mode 100644 packages/provider-umans/src/index.ts create mode 100644 packages/provider-umans/src/reasoning.test.ts create mode 100644 packages/provider-umans/src/reasoning.ts create mode 100644 packages/provider-umans/src/resolver.test.ts create mode 100644 packages/provider-umans/src/resolver.ts create mode 100644 packages/provider-umans/tsconfig.json (limited to 'packages') diff --git a/packages/host-bin/package.json b/packages/host-bin/package.json index d55b369..63b78bc 100644 --- a/packages/host-bin/package.json +++ b/packages/host-bin/package.json @@ -11,6 +11,7 @@ "@dispatch/cache-warming": "workspace:*", "@dispatch/credential-store": "workspace:*", "@dispatch/provider-openai-compat": "workspace:*", + "@dispatch/provider-umans": "workspace:*", "@dispatch/message-queue": "workspace:*", "@dispatch/session-orchestrator": "workspace:*", "@dispatch/skills": "workspace:*", diff --git a/packages/host-bin/src/main.ts b/packages/host-bin/src/main.ts index 59ce47d..1928a8a 100644 --- a/packages/host-bin/src/main.ts +++ b/packages/host-bin/src/main.ts @@ -22,6 +22,7 @@ import { import { extension as lspExt } from "@dispatch/lsp"; import { extension as messageQueueExt } from "@dispatch/message-queue"; import { extension as providerOpenaiCompatExt } from "@dispatch/provider-openai-compat"; +import { extension as providerUmansExt } from "@dispatch/provider-umans"; import { extension as sessionOrchestratorExt } from "@dispatch/session-orchestrator"; import { extension as skillsExt } from "@dispatch/skills"; import { createSqliteStorage, extension as storageSqliteExt } from "@dispatch/storage-sqlite"; @@ -69,6 +70,7 @@ const CORE_EXTENSIONS: readonly Extension[] = [ conversationStoreExt, authApikeyExt, providerOpenaiCompatExt, + providerUmansExt, toolEditFileExt, toolReadFileExt, toolShellExt, @@ -148,6 +150,14 @@ async function boot(): Promise { // Assemble the credential list. MVP keeps the hardcoded `opencode` credential // and adds a `claude` credential when an external Anthropic provider is loaded. const credentials = [{ name: "opencode", providerId: "openai-compat" }]; + + // The umans credential is always listed (it's the model-catalog index); the + // provider itself only registers when UMANS_API_KEY is set, so listCatalog + // gracefully skips it when the provider is absent. + if (process.env.UMANS_API_KEY) { + credentials.push({ name: "umans", providerId: "umans" }); + logger.info(`Registered credential "umans" → umans provider`); + } const hasAnthropic = externalExtensions.some((e) => e.manifest.contributes?.providers?.includes("anthropic"), ); diff --git a/packages/openai-stream/package.json b/packages/openai-stream/package.json new file mode 100644 index 0000000..5b35ff7 --- /dev/null +++ b/packages/openai-stream/package.json @@ -0,0 +1,13 @@ +{ + "name": "@dispatch/openai-stream", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/trace-replay": "workspace:*", + "@dispatch/wire": "workspace:*" + } +} diff --git a/packages/openai-stream/src/__fixtures__/flash-text-turn.json b/packages/openai-stream/src/__fixtures__/flash-text-turn.json new file mode 100644 index 0000000..d7e71cb --- /dev/null +++ b/packages/openai-stream/src/__fixtures__/flash-text-turn.json @@ -0,0 +1,26 @@ +{ + "request": { + "method": "POST", + "url": "https://opencode.ai/zen/go/v1/chat/completions", + "headers": { + "Content-Type": "application/json", + "Authorization": "Bearer sk-…redacted…UN0" + }, + "body": "{\"model\":\"deepseek-v4-flash\",\"messages\":[{\"role\":\"user\",\"content\":\"Say hello in exactly three words.\"},{\"role\":\"assistant\",\"content\":\"The user asks: \\\"Say hello in exactly three words.\\\" So I need to respond with exactly three words. The obvious answer is \\\"Hello, how are you?\\\" But that's four words. Actually \\\"Hello there friend\\\" is three words. Or \\\"Hello, my friend\\\"? That's three words if we count \\\"Hello,\\\" as one word? Better to be simple: \\\"Hello, world!\\\" is two words. \\\"Hello everyone here\\\" is three. I'll go with \\\"Hello there friend.\\\" But let's make sure it's exactly three words. I'll respond: \\\"Hello, my friend.\\\" That's three words: Hello, my, friend. But includes a comma? Usually words mean space-separated tokens. So \\\"Hello my friend\\\" is three words. I'll respond with that.\\n\\nBut the instruction is to say hello in exactly three words. I'll just output: \\\"Hello my friend\\\"Hello my friend\"},{\"role\":\"user\",\"content\":\"Say hello in exactly three words.\"}],\"stream\":true,\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"description\":\"Read the contents of a file. Returns lines with 1-indexed line numbers. Supports offset/limit for reading specific sections of large files.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file, relative to the working directory.\"},\"offset\":{\"type\":\"number\",\"description\":\"1-indexed start line number (default: 1).\",\"default\":1},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to return (default: 500, hard cap: 5000).\",\"default\":500}},\"required\":[\"path\"]}}}]}" + }, + "response": { + "status": 200, + "statusText": "OK", + "headers": { + "cache-control": "no-cache", + "cf-placement": "remote-ORD", + "cf-ray": "a06dc6711bb51f51-DEN", + "connection": "keep-alive", + "content-type": "text/event-stream; charset=utf-8", + "date": "Fri, 05 Jun 2026 08:23:26 GMT", + "server": "cloudflare", + "transfer-encoding": "chunked" + }, + "body": "data: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"reasoning_content\":\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"The\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" user\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" asks\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" to\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"Say\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" in\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" exactly\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" three\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" words\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" I\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" need\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" to\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" respond\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" with\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" exactly\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" three\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" words\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" I\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" already\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" said\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" my\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" friend\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"\\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" but\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" that\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" might\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" have\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" been\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" cut\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" off\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" Let\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" me\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" respond\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" again\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" with\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" exactly\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" three\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" words\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\":\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\",\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" world\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"!\\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" two\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" words\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" there\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" friend\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"\\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" three\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" to\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" you\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"\\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" three\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" everyone\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"\\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" two\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" I\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"'ll\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" use\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" there\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" friend\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\",\"reasoning_content\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" there\",\"reasoning_content\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" friend\",\"reasoning_content\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"reasoning_content\":null},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":665,\"completion_tokens\":90,\"total_tokens\":755,\"prompt_tokens_details\":{\"cached_tokens\":384},\"completion_tokens_details\":{\"reasoning_tokens\":86},\"prompt_cache_hit_tokens\":384,\"prompt_cache_miss_tokens\":281}}\n\ndata: [DONE]\n\ndata: {\"choices\":[],\"cost\":\"0\"}\n\n" + } +} diff --git a/packages/openai-stream/src/__fixtures__/tool-call-turn.json b/packages/openai-stream/src/__fixtures__/tool-call-turn.json new file mode 100644 index 0000000..48bdb8d --- /dev/null +++ b/packages/openai-stream/src/__fixtures__/tool-call-turn.json @@ -0,0 +1,25 @@ +{ + "request": { + "method": "POST", + "url": "https://api.example.com/v1/chat/completions", + "headers": { + "content-type": "application/json", + "authorization": "Bearer sk-…redacted…xyz" + }, + "body": "{\"model\":\"deepseek-v4-flash\",\"messages\":[{\"role\":\"user\",\"content\":\"What is the weather in Tokyo?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a location\",\"parameters\":{\"type\":\"object\",\"properties\":{\"location\":{\"type\":\"string\"}},\"required\":[\"location\"]}}}],\"stream\":true}" + }, + "response": { + "status": 200, + "statusText": "OK", + "headers": { + "content-type": "text/event-stream", + "cache-control": "no-cache" + }, + "body": "data: {\"id\":\"chatcmpl-fixture-002\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-fixture-002\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_abc123\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-fixture-002\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"locat\"}}]},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-fixture-002\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"ion\\\":\\\"Tokyo\\\"}\"}}]},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-fixture-002\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":45,\"completion_tokens\":12,\"cache_read_tokens\":30,\"cache_write_tokens\":5}}\n\ndata: [DONE]\n" + }, + "meta": { + "description": "Tool-call fixture: user asks about weather → model calls get_weather tool with split argument chunks.", + "captured_by": "provider-openai-compat record mode", + "version": 1 + } +} diff --git a/packages/openai-stream/src/convert-messages.test.ts b/packages/openai-stream/src/convert-messages.test.ts new file mode 100644 index 0000000..51513ea --- /dev/null +++ b/packages/openai-stream/src/convert-messages.test.ts @@ -0,0 +1,259 @@ +import type { ChatMessage } from "@dispatch/kernel"; +import { describe, expect, it } from "vitest"; +import { convertMessages } from "./convert-messages.js"; + +describe("convertMessages", () => { + it("converts a system message with text chunks", () => { + const messages: ChatMessage[] = [ + { + role: "system", + chunks: [ + { type: "system", text: "You are a helpful assistant." }, + { type: "text", text: " Additional context." }, + ], + }, + ]; + + const result = convertMessages(messages); + expect(result).toEqual([ + { role: "system", content: "You are a helpful assistant. Additional context." }, + ]); + }); + + it("converts a user message with text chunks", () => { + const messages: ChatMessage[] = [ + { + role: "user", + chunks: [ + { type: "text", text: "Hello, " }, + { type: "text", text: "world!" }, + ], + }, + ]; + + const result = convertMessages(messages); + expect(result).toEqual([{ role: "user", content: "Hello, world!" }]); + }); + + it("converts an assistant message with text only", () => { + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { type: "text", text: "I can help " }, + { type: "text", text: "with that." }, + ], + }, + ]; + + const result = convertMessages(messages); + expect(result).toEqual([{ role: "assistant", content: "I can help with that." }]); + }); + + it("converts an assistant message with tool calls", () => { + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { type: "text", text: "Let me check that." }, + { + type: "tool-call", + toolCallId: "call_1", + toolName: "read_file", + input: { path: "/src/main.ts" }, + }, + ], + }, + ]; + + const result = convertMessages(messages); + expect(result).toEqual([ + { + role: "assistant", + content: "Let me check that.", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { + name: "read_file", + arguments: JSON.stringify({ path: "/src/main.ts" }), + }, + }, + ], + }, + ]); + }); + + it("converts an assistant message with tool calls but no text", () => { + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_2", + toolName: "run_shell", + input: { command: "ls" }, + }, + ], + }, + ]; + + const result = convertMessages(messages); + expect(result).toEqual([ + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_2", + type: "function", + function: { + name: "run_shell", + arguments: JSON.stringify({ command: "ls" }), + }, + }, + ], + }, + ]); + }); + + it("converts tool result messages", () => { + const messages: ChatMessage[] = [ + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_1", + toolName: "read_file", + content: "file contents here", + isError: false, + }, + ], + }, + ]; + + const result = convertMessages(messages); + expect(result).toEqual([ + { + role: "tool", + content: "file contents here", + tool_call_id: "call_1", + }, + ]); + }); + + it("converts a full multi-turn history with tool round-trip", () => { + const messages: ChatMessage[] = [ + { + role: "system", + chunks: [{ type: "system", text: "You are helpful." }], + }, + { + role: "user", + chunks: [{ type: "text", text: "Read main.ts" }], + }, + { + role: "assistant", + chunks: [ + { type: "text", text: "Sure." }, + { + type: "tool-call", + toolCallId: "call_1", + toolName: "read_file", + input: { path: "main.ts" }, + }, + ], + }, + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_1", + toolName: "read_file", + content: "console.log('hello')", + isError: false, + }, + ], + }, + { + role: "assistant", + chunks: [{ type: "text", text: "The file logs hello." }], + }, + ]; + + const result = convertMessages(messages); + expect(result).toEqual([ + { role: "system", content: "You are helpful." }, + { role: "user", content: "Read main.ts" }, + { + role: "assistant", + content: "Sure.", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { + name: "read_file", + arguments: JSON.stringify({ path: "main.ts" }), + }, + }, + ], + }, + { + role: "tool", + content: "console.log('hello')", + tool_call_id: "call_1", + }, + { role: "assistant", content: "The file logs hello." }, + ]); + }); + + it("handles multiple tool results in one tool message", () => { + const messages: ChatMessage[] = [ + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_1", + toolName: "read_file", + content: "file1", + isError: false, + }, + { + type: "tool-result", + toolCallId: "call_2", + toolName: "read_file", + content: "file2", + isError: false, + }, + ], + }, + ]; + + const result = convertMessages(messages); + expect(result).toEqual([ + { role: "tool", content: "file1", tool_call_id: "call_1" }, + { role: "tool", content: "file2", tool_call_id: "call_2" }, + ]); + }); + + it("includes thinking chunks in assistant content", () => { + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { type: "thinking", text: "Let me think..." }, + { type: "text", text: "Here is my answer." }, + ], + }, + ]; + + const result = convertMessages(messages); + expect(result).toEqual([{ role: "assistant", content: "Let me think...Here is my answer." }]); + }); +}); diff --git a/packages/openai-stream/src/convert-messages.ts b/packages/openai-stream/src/convert-messages.ts new file mode 100644 index 0000000..786a70d --- /dev/null +++ b/packages/openai-stream/src/convert-messages.ts @@ -0,0 +1,99 @@ +import type { ChatMessage, Chunk } from "@dispatch/kernel"; + +export interface OpenAIMessage { + readonly role: "system" | "user" | "assistant" | "tool"; + readonly content: string | null; + readonly tool_calls?: readonly OpenAIToolCall[]; + readonly tool_call_id?: string; +} + +export interface OpenAIToolCall { + readonly id: string; + readonly type: "function"; + readonly function: { readonly name: string; readonly arguments: string }; +} + +export function convertMessages(messages: readonly ChatMessage[]): OpenAIMessage[] { + const result: OpenAIMessage[] = []; + for (const msg of messages) { + const converted = convertMessage(msg); + for (const m of converted) { + result.push(m); + } + } + return result; +} + +function convertMessage(msg: ChatMessage): OpenAIMessage[] { + switch (msg.role) { + case "system": + return [convertSystemMessage(msg)]; + case "user": + return [convertUserMessage(msg)]; + case "assistant": + return [convertAssistantMessage(msg)]; + case "tool": + return convertToolResultMessages(msg); + } +} + +function convertSystemMessage(msg: ChatMessage): OpenAIMessage { + const text = msg.chunks + .filter( + (c): c is Extract => + c.type === "text" || c.type === "system", + ) + .map((c) => c.text) + .join(""); + return { role: "system", content: text }; +} + +function convertUserMessage(msg: ChatMessage): OpenAIMessage { + const text = msg.chunks + .filter((c): c is Extract => c.type === "text") + .map((c) => c.text) + .join(""); + return { role: "user", content: text }; +} + +function convertAssistantMessage(msg: ChatMessage): OpenAIMessage { + const textChunks = msg.chunks.filter( + (c): c is Extract => + c.type === "text" || c.type === "thinking", + ); + const content = textChunks.map((c) => c.text).join(""); + + const toolCalls = msg.chunks + .filter((c): c is Extract => c.type === "tool-call") + .map( + (c): OpenAIToolCall => ({ + id: c.toolCallId, + type: "function", + function: { + name: c.toolName, + arguments: typeof c.input === "string" ? c.input : JSON.stringify(c.input), + }, + }), + ); + + if (toolCalls.length > 0) { + return { + role: "assistant", + content: content || null, + tool_calls: toolCalls, + }; + } + return { role: "assistant", content }; +} + +function convertToolResultMessages(msg: ChatMessage): OpenAIMessage[] { + return msg.chunks + .filter((c): c is Extract => c.type === "tool-result") + .map( + (c): OpenAIMessage => ({ + role: "tool", + content: c.content, + tool_call_id: c.toolCallId, + }), + ); +} diff --git a/packages/openai-stream/src/convert-tools.test.ts b/packages/openai-stream/src/convert-tools.test.ts new file mode 100644 index 0000000..d739652 --- /dev/null +++ b/packages/openai-stream/src/convert-tools.test.ts @@ -0,0 +1,106 @@ +import type { ToolContract } from "@dispatch/kernel"; +import { describe, expect, it } from "vitest"; +import { convertTools } from "./convert-tools.js"; + +describe("convertTools", () => { + it("converts a single tool to OpenAI function format", () => { + const tools: ToolContract[] = [ + { + name: "read_file", + description: "Read a file from disk", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "File path" }, + }, + required: ["path"], + additionalProperties: false, + }, + execute: async () => ({ content: "" }), + }, + ]; + + const result = convertTools(tools); + expect(result).toEqual([ + { + type: "function", + function: { + name: "read_file", + description: "Read a file from disk", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "File path" }, + }, + required: ["path"], + additionalProperties: false, + }, + }, + }, + ]); + }); + + it("converts multiple tools", () => { + const tools: ToolContract[] = [ + { + name: "read_file", + description: "Read a file", + parameters: { type: "object" }, + execute: async () => ({ content: "" }), + }, + { + name: "run_shell", + description: "Run a shell command", + parameters: { + type: "object", + properties: { + command: { type: "string", description: "The command" }, + }, + required: ["command"], + }, + execute: async () => ({ content: "" }), + }, + ]; + + const result = convertTools(tools); + expect(result).toHaveLength(2); + expect(result[0]?.function.name).toBe("read_file"); + expect(result[1]?.function.name).toBe("run_shell"); + }); + + it("returns empty array for no tools", () => { + const result = convertTools([]); + expect(result).toEqual([]); + }); + + it("preserves nested parameter schema properties", () => { + const tools: ToolContract[] = [ + { + name: "search", + description: "Search code", + parameters: { + type: "object", + properties: { + query: { type: "string", description: "Search query" }, + options: { + type: "object", + properties: { + limit: { type: "number", description: "Max results", default: 10 }, + }, + }, + }, + required: ["query"], + }, + execute: async () => ({ content: "" }), + }, + ]; + + const result = convertTools(tools); + expect(result[0]?.function.parameters.properties?.options).toEqual({ + type: "object", + properties: { + limit: { type: "number", description: "Max results", default: 10 }, + }, + }); + }); +}); diff --git a/packages/openai-stream/src/convert-tools.ts b/packages/openai-stream/src/convert-tools.ts new file mode 100644 index 0000000..65416bb --- /dev/null +++ b/packages/openai-stream/src/convert-tools.ts @@ -0,0 +1,25 @@ +import type { ToolContract, ToolParameterSchema } from "@dispatch/kernel"; + +export interface OpenAITool { + readonly type: "function"; + readonly function: { + readonly name: string; + readonly description: string; + readonly parameters: ToolParameterSchema; + }; +} + +export function convertTools(tools: readonly ToolContract[]): OpenAITool[] { + return tools.map(convertTool); +} + +function convertTool(tool: ToolContract): OpenAITool { + return { + type: "function", + function: { + name: tool.name, + description: tool.description, + parameters: tool.parameters, + }, + }; +} diff --git a/packages/openai-stream/src/index.ts b/packages/openai-stream/src/index.ts new file mode 100644 index 0000000..bd2f673 --- /dev/null +++ b/packages/openai-stream/src/index.ts @@ -0,0 +1,8 @@ +export type { OpenAIMessage, OpenAIToolCall } from "./convert-messages.js"; +export { convertMessages } from "./convert-messages.js"; +export type { OpenAITool } from "./convert-tools.js"; +export { convertTools } from "./convert-tools.js"; +export { parseModelList } from "./listModels.js"; +export { parseSSELines } from "./parse-sse.js"; +export type { CreateOpenAICompatProviderOpts } from "./provider.js"; +export { createOpenAICompatProvider } from "./provider.js"; diff --git a/packages/openai-stream/src/listModels.test.ts b/packages/openai-stream/src/listModels.test.ts new file mode 100644 index 0000000..63c95fc --- /dev/null +++ b/packages/openai-stream/src/listModels.test.ts @@ -0,0 +1,102 @@ +import type { ApiKeyCredentials, ModelInfo, ProviderContract } from "@dispatch/kernel"; +import type { FetchLike } from "@dispatch/trace-replay"; +import { describe, expect, it, vi } from "vitest"; +import { parseModelList } from "./listModels.js"; +import { createOpenAICompatProvider } from "./provider.js"; + +function makeProvider(fetchFn: FetchLike, apiKey = "sk-test-1234567890abcdef"): ProviderContract { + const creds: ApiKeyCredentials = { + type: "api-key", + apiKey, + baseURL: "https://api.example.com/v1", + }; + return createOpenAICompatProvider({ + credentials: creds, + model: "test-model", + id: "openai-compat", + fetchFn, + }); +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("listModels — pure mapping (parseModelList)", () => { + it("maps OpenAI model entries to ModelInfo", () => { + const result = parseModelList([{ id: "a" }, { id: "b" }]); + expect(result).toEqual([{ id: "a" }, { id: "b" }]); + }); + + it("returns empty array for empty input", () => { + const result = parseModelList([]); + expect(result).toEqual([]); + }); +}); + +describe("listModels — provider contract", () => { + it("GETs models endpoint with bearer key and returns mapped ModelInfo[]", async () => { + const fetchFn = vi.fn( + () => jsonResponse({ data: [{ id: "a" }, { id: "b" }] }) as unknown as ReturnType, + ); + const provider = makeProvider(fetchFn); + const listModels = provider.listModels; + if (!listModels) throw new Error("listModels not defined"); + + const models = await listModels(); + + expect(fetchFn).toHaveBeenCalledOnce(); + const callArgs = fetchFn.mock.calls[0]; + if (!callArgs) throw new Error("no call args"); + const [url, init] = callArgs as unknown as [string, RequestInit]; + expect(url).toBe("https://api.example.com/v1/models"); + expect(init.method).toBe("GET"); + expect(init.headers).toEqual({ Authorization: "Bearer sk-test-1234567890abcdef" }); + + expect(models).toEqual([{ id: "a" }, { id: "b" }] as readonly ModelInfo[]); + }); + + it("throws on non-OK HTTP status with a clear message", async () => { + const fetchFn = vi.fn( + () => + new Response("Unauthorized", { + status: 401, + headers: { "Content-Type": "text/plain" }, + }) as unknown as ReturnType, + ); + const provider = makeProvider(fetchFn); + const listModels = provider.listModels; + if (!listModels) throw new Error("listModels not defined"); + + await expect(listModels()).rejects.toThrow( + "listModels[openai-compat]: HTTP 401 — Unauthorized", + ); + }); + + it("throws on network error with a clear message", async () => { + const fetchFn = vi.fn(() => { + throw new Error("connection refused"); + }) as unknown as FetchLike; + const provider = makeProvider(fetchFn); + const listModels = provider.listModels; + if (!listModels) throw new Error("listModels not defined"); + + await expect(listModels()).rejects.toThrow( + "listModels[openai-compat]: network error — connection refused", + ); + }); + + it("throws when response shape is missing data array", async () => { + const fetchFn = vi.fn(() => jsonResponse({ models: [] }) as unknown as ReturnType); + const provider = makeProvider(fetchFn); + const listModels = provider.listModels; + if (!listModels) throw new Error("listModels not defined"); + + await expect(listModels()).rejects.toThrow( + 'listModels[openai-compat]: unexpected response shape — missing "data" array', + ); + }); +}); diff --git a/packages/openai-stream/src/listModels.ts b/packages/openai-stream/src/listModels.ts new file mode 100644 index 0000000..3f783f0 --- /dev/null +++ b/packages/openai-stream/src/listModels.ts @@ -0,0 +1,68 @@ +import type { ModelInfo } from "@dispatch/kernel"; +import type { FetchLike } from "@dispatch/trace-replay"; + +/** + * Generic OpenAI-compatible model-list fetch + mapping. Lives in this library + * (`@dispatch/openai-stream`) so any OpenAI-compatible provider extension can + * reuse it without cross-extension code import (isolation-over-DRY: coupling + * is via this typed library surface, not a sibling's internals). + * + * A provider extension supplies its own `id` (used in error labels) via + * `createOpenAICompatProvider({ id })`. + */ + +interface OpenAIModelEntry { + readonly id: string; +} + +interface OpenAIModelListResponse { + readonly data: readonly OpenAIModelEntry[]; +} + +/** + * Pure mapping: raw OpenAI-compatible model list → ModelInfo[]. + * Extracted for direct unit testing with no I/O. + */ +export function parseModelList(data: readonly OpenAIModelEntry[]): readonly ModelInfo[] { + return data.map((entry) => ({ id: entry.id })); +} + +export interface ListModelsConfig { + readonly baseURL: string; + readonly apiKey: string; + readonly fetchFn?: FetchLike; + readonly providerId: string; +} + +export async function listModels(config: ListModelsConfig): Promise { + const effectiveFetch: FetchLike = config.fetchFn ?? fetch; + const url = `${config.baseURL}/models`; + + let response: Response; + try { + response = await effectiveFetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${config.apiKey}`, + }, + }); + } catch (err) { + throw new Error( + `listModels[${config.providerId}]: network error — ${err instanceof Error ? err.message : String(err)}`, + ); + } + + if (!response.ok) { + const text = await response.text().catch(() => "unknown"); + throw new Error(`listModels[${config.providerId}]: HTTP ${response.status} — ${text}`); + } + + const body = (await response.json()) as OpenAIModelListResponse; + if (!Array.isArray(body.data)) { + throw new Error( + `listModels[${config.providerId}]: unexpected response shape — missing "data" array`, + ); + } + + return parseModelList(body.data); +} diff --git a/packages/openai-stream/src/parse-sse.test.ts b/packages/openai-stream/src/parse-sse.test.ts new file mode 100644 index 0000000..1910833 --- /dev/null +++ b/packages/openai-stream/src/parse-sse.test.ts @@ -0,0 +1,263 @@ +import type { ProviderEvent } from "@dispatch/kernel"; +import { describe, expect, it } from "vitest"; +import { parseSSELines } from "./parse-sse.js"; + +describe("parseSSELines", () => { + it("parses text delta events", () => { + const lines = [ + 'data: {"id":"chatcmpl-1","choices":[{"delta":{"content":"Hello"},"index":0}]}', + 'data: {"id":"chatcmpl-1","choices":[{"delta":{"content":" world"},"index":0}]}', + 'data: {"id":"chatcmpl-1","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ]; + + const events = parseSSELines(lines); + expect(events).toEqual([ + { type: "text-delta", delta: "Hello" }, + { type: "text-delta", delta: " world" }, + { type: "finish", reason: "stop" }, + ]); + }); + + it("parses a fragmented tool_call across chunks", () => { + const lines = [ + 'data: {"id":"chatcmpl-2","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_abc","function":{"name":"read_file","arguments":""}}]},"index":0}]}', + 'data: {"id":"chatcmpl-2","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"path\\""}}]},"index":0}]}', + 'data: {"id":"chatcmpl-2","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":":\\"main.ts\\"}"}}]},"index":0}]}', + 'data: {"id":"chatcmpl-2","choices":[{"delta":{},"finish_reason":"tool_calls","index":0}]}', + "data: [DONE]", + ]; + + const events = parseSSELines(lines); + expect(events).toEqual([ + { + type: "tool-call", + toolCallId: "call_abc", + toolName: "read_file", + input: { path: "main.ts" }, + }, + { type: "finish", reason: "tool_calls" }, + ]); + }); + + it("parses multiple tool_calls in one response", () => { + const lines = [ + 'data: {"id":"chatcmpl-3","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"read_file","arguments":""}}]},"index":0}]}', + 'data: {"id":"chatcmpl-3","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"path\\":\\"a.ts\\"}"}}]},"index":0}]}', + 'data: {"id":"chatcmpl-3","choices":[{"delta":{"tool_calls":[{"index":1,"id":"call_2","function":{"name":"read_file","arguments":""}}]},"index":0}]}', + 'data: {"id":"chatcmpl-3","choices":[{"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\\"path\\":\\"b.ts\\"}"}}]},"index":0}]}', + 'data: {"id":"chatcmpl-3","choices":[{"delta":{},"finish_reason":"tool_calls","index":0}]}', + "data: [DONE]", + ]; + + const events = parseSSELines(lines); + expect(events).toEqual([ + { type: "tool-call", toolCallId: "call_1", toolName: "read_file", input: { path: "a.ts" } }, + { type: "tool-call", toolCallId: "call_2", toolName: "read_file", input: { path: "b.ts" } }, + { type: "finish", reason: "tool_calls" }, + ]); + }); + + it("parses usage from the final chunk", () => { + const lines = [ + 'data: {"id":"chatcmpl-4","choices":[{"delta":{"content":"Hi"},"index":0}]}', + 'data: {"id":"chatcmpl-4","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + 'data: {"id":"chatcmpl-4","usage":{"prompt_tokens":10,"completion_tokens":5}}', + "data: [DONE]", + ]; + + const events = parseSSELines(lines); + expect(events).toEqual([ + { type: "text-delta", delta: "Hi" }, + { type: "finish", reason: "stop" }, + { + type: "usage", + usage: { + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + }, + ]); + }); + + it("parses reasoning_content deltas", () => { + const lines = [ + 'data: {"id":"chatcmpl-5","choices":[{"delta":{"reasoning_content":"Let me think..."},"index":0}]}', + 'data: {"id":"chatcmpl-5","choices":[{"delta":{"content":"Here is my answer."},"index":0}]}', + 'data: {"id":"chatcmpl-5","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ]; + + const events = parseSSELines(lines); + expect(events).toEqual([ + { type: "reasoning-delta", delta: "Let me think..." }, + { type: "text-delta", delta: "Here is my answer." }, + { type: "finish", reason: "stop" }, + ]); + }); + + it("handles invalid JSON gracefully", () => { + const lines = [ + "data: {invalid json}", + 'data: {"id":"chatcmpl-6","choices":[{"delta":{"content":"ok"},"index":0}]}', + "data: [DONE]", + ]; + + const events = parseSSELines(lines); + expect(events).toHaveLength(2); + expect(events[0]?.type).toBe("error"); + expect(events[1]).toEqual({ type: "text-delta", delta: "ok" }); + }); + + it("ignores non-data lines", () => { + const lines = [ + "event: message", + ": comment line", + 'data: {"id":"chatcmpl-7","choices":[{"delta":{"content":"hi"},"index":0}]}', + "", + "data: [DONE]", + ]; + + const events = parseSSELines(lines); + expect(events).toEqual([{ type: "text-delta", delta: "hi" }]); + }); + + it("stops at [DONE] sentinel", () => { + const lines = [ + 'data: {"id":"chatcmpl-8","choices":[{"delta":{"content":"before"},"index":0}]}', + "data: [DONE]", + 'data: {"id":"chatcmpl-8","choices":[{"delta":{"content":"after"},"index":0}]}', + ]; + + const events = parseSSELines(lines); + expect(events).toEqual([{ type: "text-delta", delta: "before" }]); + }); + + it("parses nested prompt_tokens_details.cached_tokens → cacheReadTokens", () => { + const lines = [ + 'data: {"id":"chatcmpl-nested","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + 'data: {"id":"chatcmpl-nested","usage":{"prompt_tokens":665,"completion_tokens":90,"prompt_tokens_details":{"cached_tokens":384},"completion_tokens_details":{"reasoning_tokens":86}}}', + "data: [DONE]", + ]; + + const events = parseSSELines(lines); + const usageEvent = events.find((e) => e.type === "usage") as Extract< + ProviderEvent, + { type: "usage" } + >; + expect(usageEvent.usage.inputTokens).toBe(665); + expect(usageEvent.usage.outputTokens).toBe(90); + expect(usageEvent.usage.cacheReadTokens).toBe(384); + expect(usageEvent.usage.cacheWriteTokens).toBeUndefined(); + }); + + it("flat cache_read_tokens takes precedence over nested cached_tokens", () => { + const lines = [ + 'data: {"id":"chatcmpl-both","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + 'data: {"id":"chatcmpl-both","usage":{"prompt_tokens":100,"completion_tokens":20,"cache_read_tokens":50,"prompt_tokens_details":{"cached_tokens":99}}}', + "data: [DONE]", + ]; + + const events = parseSSELines(lines); + const usageEvent = events.find((e) => e.type === "usage") as Extract< + ProviderEvent, + { type: "usage" } + >; + expect(usageEvent.usage.cacheReadTokens).toBe(50); + }); + + it("returns undefined for cacheReadTokens when neither flat nor nested present", () => { + const lines = [ + 'data: {"id":"chatcmpl-none","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + 'data: {"id":"chatcmpl-none","usage":{"prompt_tokens":10,"completion_tokens":5}}', + "data: [DONE]", + ]; + + const events = parseSSELines(lines); + const usageEvent = events.find((e) => e.type === "usage") as Extract< + ProviderEvent, + { type: "usage" } + >; + expect(usageEvent.usage.cacheReadTokens).toBeUndefined(); + expect(usageEvent.usage.cacheWriteTokens).toBeUndefined(); + }); + + it("handles missing/partial prompt_tokens_details safely", () => { + const lines = [ + 'data: {"id":"chatcmpl-partial","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + 'data: {"id":"chatcmpl-partial","usage":{"prompt_tokens":50,"completion_tokens":10,"prompt_tokens_details":{}}}', + "data: [DONE]", + ]; + + const events = parseSSELines(lines); + const usageEvent = events.find((e) => e.type === "usage") as Extract< + ProviderEvent, + { type: "usage" } + >; + expect(usageEvent.usage.cacheReadTokens).toBeUndefined(); + }); + + it("handles empty prompt_tokens_details object safely", () => { + const lines = [ + 'data: {"id":"chatcmpl-empty","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + 'data: {"id":"chatcmpl-empty","usage":{"prompt_tokens":30,"completion_tokens":8,"prompt_tokens_details":null}}', + "data: [DONE]", + ]; + + const events = parseSSELines(lines); + const usageEvent = events.find((e) => e.type === "usage") as Extract< + ProviderEvent, + { type: "usage" } + >; + expect(usageEvent.usage.cacheReadTokens).toBeUndefined(); + }); + + it("handles a complete turn with text, tool call, usage, and finish", () => { + const lines = [ + 'data: {"id":"chatcmpl-9","choices":[{"delta":{"content":"Let me check."},"index":0}]}', + 'data: {"id":"chatcmpl-9","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_xyz","function":{"name":"search","arguments":""}}]},"index":0}]}', + 'data: {"id":"chatcmpl-9","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"query\\":"}}]},"index":0}]}', + 'data: {"id":"chatcmpl-9","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"dispatch\\"}"}}]},"index":0}]}', + 'data: {"id":"chatcmpl-9","choices":[{"delta":{},"finish_reason":"tool_calls","index":0}]}', + 'data: {"id":"chatcmpl-9","usage":{"prompt_tokens":50,"completion_tokens":20}}', + "data: [DONE]", + ]; + + const events = parseSSELines(lines); + expect(events).toEqual([ + { type: "text-delta", delta: "Let me check." }, + { + type: "tool-call", + toolCallId: "call_xyz", + toolName: "search", + input: { query: "dispatch" }, + }, + { type: "finish", reason: "tool_calls" }, + { + type: "usage", + usage: { + inputTokens: 50, + outputTokens: 20, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + }, + ]); + }); + + it("handles tool_call with unparseable arguments as raw string", () => { + const lines = [ + 'data: {"id":"chatcmpl-10","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_bad","function":{"name":"foo","arguments":"not-json"}}]},"index":0}]}', + 'data: {"id":"chatcmpl-10","choices":[{"delta":{},"finish_reason":"tool_calls","index":0}]}', + "data: [DONE]", + ]; + + const events = parseSSELines(lines); + expect(events).toEqual([ + { type: "tool-call", toolCallId: "call_bad", toolName: "foo", input: "not-json" }, + { type: "finish", reason: "tool_calls" }, + ]); + }); +}); diff --git a/packages/openai-stream/src/parse-sse.ts b/packages/openai-stream/src/parse-sse.ts new file mode 100644 index 0000000..cfeb5b0 --- /dev/null +++ b/packages/openai-stream/src/parse-sse.ts @@ -0,0 +1,130 @@ +import type { ProviderEvent } from "@dispatch/kernel"; + +interface ToolCallAccumulator { + id: string; + name: string; + arguments: string; +} + +interface SSEChunkDelta { + content?: string; + reasoning_content?: string; + tool_calls?: Array<{ + index: number; + id?: string; + function?: { name?: string; arguments?: string }; + }>; +} + +interface SSEChunkChoice { + delta: SSEChunkDelta; + finish_reason?: string | null; + index: number; +} + +interface SSEChunkUsageDetails { + cached_tokens?: number; +} + +interface SSEChunk { + id?: string; + choices?: SSEChunkChoice[]; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + cache_read_tokens?: number; + cache_write_tokens?: number; + prompt_tokens_details?: SSEChunkUsageDetails; + completion_tokens_details?: Record; + }; +} + +export function parseSSELines(lines: readonly string[]): ProviderEvent[] { + const events: ProviderEvent[] = []; + const toolCalls = new Map(); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith("data:")) continue; + + const data = trimmed.slice(5).trim(); + if (data === "[DONE]") break; + + let chunk: SSEChunk; + try { + chunk = JSON.parse(data) as SSEChunk; + } catch { + events.push({ type: "error", message: `Invalid JSON in SSE data: ${data}` }); + continue; + } + + if (chunk.choices) { + for (const choice of chunk.choices) { + const delta = choice.delta; + + if (delta.content) { + events.push({ type: "text-delta", delta: delta.content }); + } + + if (delta.reasoning_content) { + events.push({ type: "reasoning-delta", delta: delta.reasoning_content }); + } + + if (delta.tool_calls) { + for (const tc of delta.tool_calls) { + const existing = toolCalls.get(tc.index); + if (existing) { + if (tc.function?.arguments) { + existing.arguments += tc.function.arguments; + } + } else { + toolCalls.set(tc.index, { + id: tc.id ?? "", + name: tc.function?.name ?? "", + arguments: tc.function?.arguments ?? "", + }); + } + } + } + + if (choice.finish_reason) { + const sortedIndices = [...toolCalls.keys()].sort((a, b) => a - b); + for (const idx of sortedIndices) { + const acc = toolCalls.get(idx); + if (!acc) continue; + let input: unknown; + try { + input = JSON.parse(acc.arguments); + } catch { + input = acc.arguments; + } + events.push({ + type: "tool-call", + toolCallId: acc.id, + toolName: acc.name, + input, + }); + } + events.push({ type: "finish", reason: choice.finish_reason }); + } + } + } + + if (chunk.usage) { + const cacheRead = + chunk.usage.cache_read_tokens ?? chunk.usage.prompt_tokens_details?.cached_tokens; + const cacheWrite = chunk.usage.cache_write_tokens; + events.push({ + type: "usage", + usage: { + inputTokens: chunk.usage.prompt_tokens ?? 0, + outputTokens: chunk.usage.completion_tokens ?? 0, + ...(cacheRead !== undefined ? { cacheReadTokens: cacheRead } : {}), + ...(cacheWrite !== undefined ? { cacheWriteTokens: cacheWrite } : {}), + }, + }); + } + } + + return events; +} diff --git a/packages/openai-stream/src/provider.test.ts b/packages/openai-stream/src/provider.test.ts new file mode 100644 index 0000000..7b2d938 --- /dev/null +++ b/packages/openai-stream/src/provider.test.ts @@ -0,0 +1,153 @@ +import type { ApiKeyCredentials, ChatMessage, ProviderStreamOptions } from "@dispatch/kernel"; +import type { FetchLike } from "@dispatch/trace-replay"; +import { describe, expect, it, vi } from "vitest"; +import { createOpenAICompatProvider } from "./provider.js"; + +function makeCreds(): ApiKeyCredentials { + return { + type: "api-key", + apiKey: "sk-test-1234567890abcdef", + baseURL: "https://api.example.com/v1", + }; +} + +function makeMessages(): readonly ChatMessage[] { + return [{ role: "user", chunks: [{ type: "text", text: "Hello" }] }]; +} + +function sseBody(...lines: string[]): ReadableStream { + const encoder = new TextEncoder(); + const chunks = lines.map((l) => encoder.encode(`${l}\n`)); + let index = 0; + return new ReadableStream({ + pull(controller) { + if (index < chunks.length) { + const chunk = chunks[index]; + if (chunk === undefined) throw new Error("empty chunk"); + controller.enqueue(chunk); + index++; + } else { + controller.close(); + } + }, + }); +} + +function okSseResponse(): Response { + return new Response( + sseBody( + 'data: {"id":"cmpl-1","choices":[{"delta":{"content":"Hi"},"index":0}]}', + 'data: {"id":"cmpl-1","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ); +} + +async function collectEvents(iter: AsyncIterable): Promise { + const events: unknown[] = []; + for await (const event of iter) { + events.push(event); + } + return events; +} + +describe("createOpenAICompatProvider stamps the given id on the ProviderContract + listModels", () => { + it("stamps opts.id on ProviderContract.id", () => { + const provider = createOpenAICompatProvider({ + credentials: makeCreds(), + model: "test-model", + id: "my-custom-id", + }); + expect(provider.id).toBe("my-custom-id"); + }); + + it("uses opts.id in listModels error labels (was hardcoded 'openai-compat')", async () => { + const fetchFn = vi.fn( + () => + new Response("Unauthorized", { + status: 401, + headers: { "Content-Type": "text/plain" }, + }) as unknown as ReturnType, + ); + const provider = createOpenAICompatProvider({ + credentials: makeCreds(), + model: "test-model", + id: "my-custom-id", + fetchFn, + }); + const listModels = provider.listModels; + if (!listModels) throw new Error("listModels not defined"); + + await expect(listModels()).rejects.toThrow("listModels[my-custom-id]: HTTP 401 — Unauthorized"); + }); +}); + +describe("transformBody", () => { + it("transformBody merges its returned fields into the request body", async () => { + let capturedInit: RequestInit | undefined; + const fetchFn = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + capturedInit = init; + return okSseResponse(); + }) as unknown as FetchLike; + + let receivedBody: Record | undefined; + let receivedOpts: ProviderStreamOptions | undefined; + const provider = createOpenAICompatProvider({ + credentials: makeCreds(), + model: "test-model", + id: "umans", + fetchFn, + transformBody: (body, opts) => { + receivedBody = body; + receivedOpts = opts; + return { reasoning_effort: "high" }; + }, + }); + + await collectEvents(provider.stream(makeMessages(), [], { temperature: 0.5 })); + + // The hook was called with the body built so far + the stream opts. + expect(receivedBody).toBeDefined(); + expect(receivedOpts?.temperature).toBe(0.5); + expect(receivedBody?.model).toBe("test-model"); + + // The captured wire body carries the merged field. + expect(capturedInit?.body).toBeTypeOf("string"); + const wireBody = JSON.parse(capturedInit?.body as string) as Record; + expect(wireBody.reasoning_effort).toBe("high"); + expect(wireBody.model).toBe("test-model"); + expect(wireBody.stream).toBe(true); + expect(wireBody.temperature).toBe(0.5); + }); + + it("transformBody absent → body byte-identical to before (regression)", async () => { + let capturedInit: RequestInit | undefined; + const fetchFn = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + capturedInit = init; + return okSseResponse(); + }) as unknown as FetchLike; + + const provider = createOpenAICompatProvider({ + credentials: makeCreds(), + model: "test-model", + id: "openai-compat", + fetchFn, + // No transformBody — default behavior. + }); + + await collectEvents(provider.stream(makeMessages(), [], { temperature: 0.5, maxTokens: 42 })); + + expect(capturedInit?.body).toBeTypeOf("string"); + const wireBody = JSON.parse(capturedInit?.body as string) as Record; + // Exact pre-refactor shape — no extra fields, no transformBody key leakage. + expect(wireBody).toEqual({ + model: "test-model", + messages: [{ role: "user", content: "Hello" }], + stream: true, + temperature: 0.5, + max_tokens: 42, + }); + expect("reasoning_effort" in wireBody).toBe(false); + }); +}); diff --git a/packages/openai-stream/src/provider.ts b/packages/openai-stream/src/provider.ts new file mode 100644 index 0000000..c13d60e --- /dev/null +++ b/packages/openai-stream/src/provider.ts @@ -0,0 +1,73 @@ +import type { + ApiKeyCredentials, + ChatMessage, + ModelInfo, + ProviderContract, + ProviderStreamOptions, + ToolContract, +} from "@dispatch/kernel"; +import type { FetchLike } from "@dispatch/trace-replay"; +import { listModels as fetchModels } from "./listModels.js"; +import { streamChat } from "./stream.js"; + +/** + * Generic factory for an OpenAI-compatible provider. A provider extension + * supplies its own `id` (stamped on the ProviderContract + used in listModels + * error labels) and an optional `transformBody` hook to add provider-specific + * body fields (e.g. `reasoning_effort`) before the request is sent. The library + * names no concrete feature — those knobs belong to the extension layer. + */ + +export interface CreateOpenAICompatProviderOpts { + readonly credentials: ApiKeyCredentials; + readonly model: string; + /** Provider id (was hardcoded "openai-compat"). Stamped on the ProviderContract.id + * + used in listModels error labels. */ + readonly id: string; + /** + * Internal injectable fetch — used by tests and replay mode. + * When absent, falls back to globalThis.fetch (production default). + */ + readonly fetchFn?: FetchLike; + /** + * Optional hook a provider extension uses to add provider-specific body fields (e.g. + * `reasoning_effort`) before the request is sent. Receives the body built so far + + * the ProviderStreamOptions; returns ADDITIONAL fields to merge (or the full body). + * Default (absent): no extra fields. Generic — the library names no feature. + */ + readonly transformBody?: ( + body: Record, + opts: ProviderStreamOptions, + ) => Record; +} + +export function createOpenAICompatProvider(opts: CreateOpenAICompatProviderOpts): ProviderContract { + const baseURL = opts.credentials.baseURL ?? "https://opencode.ai/zen/go/v1"; + const apiKey = opts.credentials.apiKey; + const fetchFn = opts.fetchFn; + const transformBody = opts.transformBody; + + const streamConfig = { + baseURL, + apiKey, + model: opts.model, + ...(fetchFn !== undefined ? { fetchFn } : {}), + ...(transformBody !== undefined ? { transformBody } : {}), + }; + + return { + id: opts.id, + stream: ( + messages: readonly ChatMessage[], + tools: readonly ToolContract[], + streamOpts?: ProviderStreamOptions, + ) => streamChat(streamConfig, messages, tools, streamOpts), + listModels: (): Promise => + fetchModels({ + baseURL, + apiKey, + providerId: opts.id, + ...(fetchFn !== undefined ? { fetchFn } : {}), + }), + }; +} diff --git a/packages/openai-stream/src/stream.test.ts b/packages/openai-stream/src/stream.test.ts new file mode 100644 index 0000000..0650153 --- /dev/null +++ b/packages/openai-stream/src/stream.test.ts @@ -0,0 +1,864 @@ +import type { ChatMessage, Logger, ProviderEvent, Span } from "@dispatch/kernel"; +import type { HttpExchangeFixture } from "@dispatch/trace-replay"; +import { loadFixture, recordFetch, replayFetch, serializeFixture } from "@dispatch/trace-replay"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { type StreamConfig, streamChat } from "./stream.js"; + +async function collectEvents(iter: AsyncIterable): Promise { + const events: ProviderEvent[] = []; + for await (const event of iter) { + events.push(event); + } + return events; +} + +function assertDefined(v: T, msg?: string): asserts v is NonNullable { + if (v === undefined || v === null) { + throw new Error(msg ?? "expected defined"); + } +} + +interface CapturedSpan { + name: string; + attrs: Record; + body?: string | undefined; + endOutcome?: + | { err?: unknown; attrs?: Record } + | undefined; +} + +function createFakeLogger(): { logger: Logger; spans: CapturedSpan[] } { + const spans: CapturedSpan[] = []; + let spanAttrBuffer: Record = {}; + let spanBodyBuffer: string | undefined; + + const fakeSpan: Span = { + id: "fake-span-id", + log: {} as Logger, + setAttributes(attrs) { + Object.assign(spanAttrBuffer, attrs); + }, + addLink() {}, + child() { + return fakeSpan; + }, + end(outcome?) { + spans.push({ + name: "provider.request", + attrs: { ...spanAttrBuffer }, + body: spanBodyBuffer, + endOutcome: outcome as CapturedSpan["endOutcome"], + }); + }, + }; + + const logger: Logger = { + debug() {}, + info() {}, + warn() {}, + error() {}, + child() { + return logger; + }, + span(_name, attrs, body) { + spanAttrBuffer = attrs ? { ...attrs } : {}; + spanBodyBuffer = body; + return fakeSpan; + }, + }; + + return { logger, spans }; +} + +function makeConfig(apiKey = "sk-test-1234567890abcdef"): StreamConfig { + return { + baseURL: "https://api.example.com/v1", + apiKey, + model: "test-model", + }; +} + +function mockFetch(handler: (url: string | URL | Request, init?: RequestInit) => unknown): void { + globalThis.fetch = vi.fn(handler) as unknown as typeof globalThis.fetch; +} + +function makeMessages(): readonly ChatMessage[] { + return [ + { + role: "user", + chunks: [{ type: "text", text: "Hello" }], + }, + ]; +} + +function sseBody(...lines: string[]): ReadableStream { + const encoder = new TextEncoder(); + const chunks = lines.map((l) => encoder.encode(`${l}\n`)); + let index = 0; + return new ReadableStream({ + pull(controller) { + if (index < chunks.length) { + const chunk = chunks[index]; + assertDefined(chunk); + controller.enqueue(chunk); + index++; + } else { + controller.close(); + } + }, + }); +} + +describe("streamChat — provider.request AFTER capture", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("opens a provider.request span with verbatim request body", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig(); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-1","choices":[{"delta":{"content":"Hi"},"index":0}]}', + 'data: {"id":"cmpl-1","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + const events = await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + expect(events.some((e) => e.type === "text-delta")).toBe(true); + expect(spans).toHaveLength(1); + + assertDefined(spans[0]); + const span = spans[0]; + expect(span.name).toBe("provider.request"); + expect(span.attrs["request.method"]).toBe("POST"); + expect(span.attrs["request.body"]).toBeUndefined(); + + assertDefined(span.body); + const capturedBody = JSON.parse(span.body); + expect(capturedBody.model).toBe("test-model"); + expect(capturedBody.stream).toBe(true); + expect(capturedBody.messages).toEqual([{ role: "user", content: "Hello" }]); + + expect(span.endOutcome?.attrs?.status).toBe(200); + }); + + it("redacts a long API key (≥13 chars → reveal 3 each side)", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig("sk-abcdefghijkmnop"); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-2","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-2","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + assertDefined(spans[0]); + const span = spans[0]; + const authHeader = span.attrs["request.headers.authorization"] as string; + expect(authHeader).toBe("Bearer sk-…redacted…nop"); + expect(authHeader).not.toContain("abcdefghijkm"); + }); + + it("redacts a medium API key (8–10 chars → reveal 1 each side)", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig("sk-abcde"); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-3","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-3","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + assertDefined(spans[0]); + const span = spans[0]; + const authHeader = span.attrs["request.headers.authorization"] as string; + expect(authHeader).toBe("Bearer s…redacted…e"); + }); + + it("redacts a short API key (≤7 chars → full mask)", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig("secret!"); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-4","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-4","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + assertDefined(spans[0]); + const span = spans[0]; + const authHeader = span.attrs["request.headers.authorization"] as string; + expect(authHeader).toBe("Bearer …redacted…"); + }); + + it("captures cache tokens from the response", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig(); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-5","choices":[{"delta":{"content":"Hi"},"index":0}]}', + 'data: {"id":"cmpl-5","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + 'data: {"id":"cmpl-5","usage":{"prompt_tokens":100,"completion_tokens":20,"cache_read_tokens":80,"cache_write_tokens":10}}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + assertDefined(spans[0]); + const span = spans[0]; + expect(span.endOutcome?.attrs?.["usage.inputTokens"]).toBe(100); + expect(span.endOutcome?.attrs?.["usage.outputTokens"]).toBe(20); + expect(span.endOutcome?.attrs?.["usage.cacheReadTokens"]).toBe(80); + expect(span.endOutcome?.attrs?.["usage.cacheWriteTokens"]).toBe(10); + }); + + it("captures cache_read_tokens alone", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig(); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-6","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-6","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + 'data: {"id":"cmpl-6","usage":{"prompt_tokens":50,"completion_tokens":5,"cache_read_tokens":45}}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + assertDefined(spans[0]); + const span = spans[0]; + expect(span.endOutcome?.attrs?.["usage.cacheReadTokens"]).toBe(45); + expect(span.endOutcome?.attrs?.["usage.cacheWriteTokens"]).toBeUndefined(); + }); + + it("records HTTP error status and error body without throwing", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig(); + + mockFetch( + () => + new Response("Invalid request body", { + status: 400, + headers: { "Content-Type": "text/plain" }, + }), + ); + + const events = await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + type: "error", + message: "HTTP 400: Invalid request body", + code: "400", + retryable: false, + }); + + expect(spans).toHaveLength(1); + assertDefined(spans[0]); + const span = spans[0]; + expect(span.endOutcome?.attrs?.status).toBe(400); + expect(span.endOutcome?.attrs?.["response.error_body"]).toBe("Invalid request body"); + expect(span.endOutcome?.err).toBeInstanceOf(Error); + }); + + it("records network error without throwing", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig(); + + mockFetch(() => { + throw new Error("connection refused"); + }); + + const events = await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + type: "error", + message: "connection refused", + retryable: true, + }); + + expect(spans).toHaveLength(1); + assertDefined(spans[0]); + const span = spans[0]; + expect(span.endOutcome?.err).toBeInstanceOf(Error); + expect((span.endOutcome?.err as Error).message).toBe("connection refused"); + }); + + it("detects cache_control breakpoint absence in a normal request body", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig(); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-7","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-7","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + assertDefined(spans[0]); + const span = spans[0]; + expect(span.attrs["request.cache_control_present"]).toBe(false); + }); + + it("does not open a span when opts.logger is absent", async () => { + const config = makeConfig(); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-8","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-8","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + const events = await collectEvents(streamChat(config, makeMessages(), [])); + + expect(events.some((e) => e.type === "text-delta")).toBe(true); + }); + + it("fail-safe: logger throwing does not break stream()", async () => { + const brokenLogger: Logger = { + debug() {}, + info() {}, + warn() {}, + error() {}, + child() { + return brokenLogger; + }, + span() { + throw new Error("logger exploded"); + }, + }; + + const config = makeConfig(); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-9","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-9","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + const events = await collectEvents( + streamChat(config, makeMessages(), [], { logger: brokenLogger }), + ); + + expect(events.some((e) => e.type === "text-delta")).toBe(true); + expect(events.some((e) => e.type === "finish")).toBe(true); + }); + + it("redacts an 11-char API key (reveal 2 each side)", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig("sk-abcde1234"); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-10","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-10","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + assertDefined(spans[0]); + const span = spans[0]; + const authHeader = span.attrs["request.headers.authorization"] as string; + expect(authHeader).toBe("Bearer sk…redacted…34"); + }); + + it("records server error (500) as retryable", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig(); + + mockFetch( + () => + new Response("Internal Server Error", { + status: 500, + headers: { "Content-Type": "text/plain" }, + }), + ); + + const events = await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + type: "error", + message: "HTTP 500: Internal Server Error", + code: "500", + retryable: true, + }); + + expect(spans).toHaveLength(1); + assertDefined(spans[0]); + expect(spans[0].endOutcome?.attrs?.status).toBe(500); + }); + + it("captures model and url on the span", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig(); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-11","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-11","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + await collectEvents(streamChat(config, makeMessages(), [], { logger })); + + assertDefined(spans[0]); + const span = spans[0]; + expect(span.attrs.model).toBe("test-model"); + expect(span.attrs.url).toBe("https://api.example.com/v1/chat/completions"); + }); + + it("uses opts.model override in capture", async () => { + const { logger, spans } = createFakeLogger(); + const config = makeConfig(); + + mockFetch( + () => + new Response( + sseBody( + 'data: {"id":"cmpl-12","choices":[{"delta":{"content":"ok"},"index":0}]}', + 'data: {"id":"cmpl-12","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', + "data: [DONE]", + ), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ), + ); + + await collectEvents( + streamChat(config, makeMessages(), [], { logger, model: "override-model" }), + ); + + assertDefined(spans[0]); + const span = spans[0]; + expect(span.attrs.model).toBe("override-model"); + + assertDefined(span.body); + const capturedBody = JSON.parse(span.body); + expect(capturedBody.model).toBe("override-model"); + }); +}); + +describe("streamChat — hermetic replay (trace-replay)", () => { + const testDir = new URL(".", import.meta.url).pathname; + const fixturePath = `${testDir}__fixtures__/flash-text-turn.json`; + const toolFixturePath = `${testDir}__fixtures__/tool-call-turn.json`; + + it("replays a text-turn fixture and produces correct ProviderEvents", async () => { + const fixture = loadFixture(fixturePath); + const { fetch: replayFetchFn, getCapturedRequest } = replayFetch(fixture, { chunkBytes: 64 }); + + const config: StreamConfig = { + baseURL: "https://api.example.com/v1", + apiKey: "sk-test-1234567890abcdef", + model: "deepseek-v4-flash", + fetchFn: replayFetchFn, + }; + + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "Hello, how are you?" }] }, + ]; + + const events = await collectEvents(streamChat(config, messages, [])); + + const textDeltas = events.filter( + (e): e is Extract => e.type === "text-delta", + ); + const fullText = textDeltas.map((e) => e.delta).join(""); + expect(fullText).toBe("Hello there friend"); + + const finishEvents = events.filter((e) => e.type === "finish"); + expect(finishEvents).toHaveLength(1); + expect(finishEvents[0]).toEqual({ type: "finish", reason: "stop" }); + + const usageEvents = events.filter( + (e): e is Extract => e.type === "usage", + ); + expect(usageEvents).toHaveLength(1); + expect(usageEvents[0]?.usage.inputTokens).toBe(665); + expect(usageEvents[0]?.usage.outputTokens).toBe(90); + expect(usageEvents[0]?.usage.cacheReadTokens).toBe(384); + + const captured = getCapturedRequest(); + assertDefined(captured); + expect(captured.method).toBe("POST"); + expect(captured.url).toBe("https://api.example.com/v1/chat/completions"); + expect(captured.headers["Content-Type"]).toBe("application/json"); + expect(captured.headers.Authorization).toBe("Bearer sk-test-1234567890abcdef"); + + assertDefined(captured.body); + const capturedBody = JSON.parse(captured.body); + expect(capturedBody.model).toBe("deepseek-v4-flash"); + expect(capturedBody.stream).toBe(true); + expect(capturedBody.messages).toEqual([{ role: "user", content: "Hello, how are you?" }]); + }); + + it("replays a tool-call-turn fixture and produces tool-call + finish events", async () => { + const fixture = loadFixture(toolFixturePath); + const { fetch: replayFetchFn, getCapturedRequest } = replayFetch(fixture, { chunkBytes: 48 }); + + const config: StreamConfig = { + baseURL: "https://api.example.com/v1", + apiKey: "sk-test-1234567890abcdef", + model: "deepseek-v4-flash", + fetchFn: replayFetchFn, + }; + + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "What is the weather in Tokyo?" }] }, + ]; + + const weatherTool = { + name: "get_weather", + description: "Get current weather for a location", + parameters: { + type: "object" as const, + properties: { location: { type: "string" as const } }, + required: ["location"], + }, + execute: async () => ({ content: "" }), + }; + + const events = await collectEvents(streamChat(config, messages, [weatherTool])); + + const toolCalls = events.filter( + (e): e is Extract => e.type === "tool-call", + ); + expect(toolCalls).toHaveLength(1); + expect(toolCalls[0]?.toolCallId).toBe("call_abc123"); + expect(toolCalls[0]?.toolName).toBe("get_weather"); + expect(toolCalls[0]?.input).toEqual({ location: "Tokyo" }); + + const finishEvents = events.filter((e) => e.type === "finish"); + expect(finishEvents).toHaveLength(1); + expect(finishEvents[0]).toEqual({ type: "finish", reason: "tool_calls" }); + + const usageEvents = events.filter( + (e): e is Extract => e.type === "usage", + ); + expect(usageEvents).toHaveLength(1); + expect(usageEvents[0]?.usage.inputTokens).toBe(45); + expect(usageEvents[0]?.usage.outputTokens).toBe(12); + expect(usageEvents[0]?.usage.cacheReadTokens).toBe(30); + expect(usageEvents[0]?.usage.cacheWriteTokens).toBe(5); + + const captured = getCapturedRequest(); + assertDefined(captured); + expect(captured.method).toBe("POST"); + assertDefined(captured.body); + const capturedBody = JSON.parse(captured.body); + expect(capturedBody.tools).toHaveLength(1); + expect(capturedBody.tools[0].function.name).toBe("get_weather"); + }); +}); + +describe("streamChat — record-mode redaction (trace-replay)", () => { + /** + * Graduated secret mask — §6 tiers. Duplicated locally (isolation-over-dry). + * ≥13 → reveal 3 each side · 11–12 → 2 · 8–10 → 1 · ≤7 → full mask. + */ + function maskSecret(value: string): string { + const len = value.length; + if (len <= 7) return "…redacted…"; + let reveal: number; + if (len >= 13) { + reveal = 3; + } else if (len >= 11) { + reveal = 2; + } else { + reveal = 1; + } + return `${value.slice(0, reveal)}…redacted…${value.slice(-reveal)}`; + } + + it("self-redacts auth header in onExchange and produces a secret-free fixture", async () => { + const apiKey = "sk-abcdefghijkmnop"; + const responseBody = + 'data: {"id":"cmpl-r","choices":[{"delta":{"content":"ok"},"index":0}],"usage":{"prompt_tokens":5,"completion_tokens":1,"cache_read_tokens":0,"cache_write_tokens":0}}\n\ndata: [DONE]\n'; + + let capturedFixture: HttpExchangeFixture | undefined; + const wrappedFetch = recordFetch( + async () => + new Response(responseBody, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + (fx) => { + const redactedHeaders: Record = {}; + for (const [key, value] of Object.entries(fx.request.headers)) { + if (key.toLowerCase() === "authorization") { + const token = value.replace(/^Bearer\s+/i, ""); + redactedHeaders[key] = `Bearer ${maskSecret(token)}`; + } else { + redactedHeaders[key] = value; + } + } + capturedFixture = { + request: { ...fx.request, headers: redactedHeaders }, + response: fx.response, + ...(fx.meta !== undefined ? { meta: fx.meta } : {}), + }; + }, + ); + + await wrappedFetch("https://api.example.com/v1/chat/completions", { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${apiKey}`, + }, + body: '{"model":"test","messages":[{"role":"user","content":"hi"}],"stream":true}', + }); + + assertDefined(capturedFixture); + + expect(capturedFixture.request.headers.authorization).toBe("Bearer sk-…redacted…nop"); + expect(capturedFixture.request.headers.authorization).not.toContain("abcdefghijkm"); + expect(capturedFixture.request.headers["content-type"]).toBe("application/json"); + + expect(capturedFixture.request.body).toContain('"model":"test"'); + expect(capturedFixture.request.body).toContain('"content":"hi"'); + + expect(capturedFixture.response.status).toBe(200); + expect(capturedFixture.response.body).toBe(responseBody); + + const serialized = serializeFixture(capturedFixture); + expect(serialized).toContain("Bearer sk-…redacted…nop"); + expect(serialized).not.toContain("abcdefghijkm"); + expect(serialized).toContain("content"); + expect(serialized).toContain("hi"); + }); + + it("redacts capitalized Authorization header (the real leak casing)", async () => { + const apiKey = "sk-LIVEKEY1234567890abcdef"; + const responseBody = "data: [DONE]\n"; + + let capturedFixture: HttpExchangeFixture | undefined; + const wrappedFetch = recordFetch( + async () => + new Response(responseBody, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + (fx) => { + const redactedHeaders: Record = {}; + for (const [key, value] of Object.entries(fx.request.headers)) { + if (key.toLowerCase() === "authorization") { + const token = value.replace(/^Bearer\s+/i, ""); + redactedHeaders[key] = `Bearer ${maskSecret(token)}`; + } else { + redactedHeaders[key] = value; + } + } + capturedFixture = { + request: { ...fx.request, headers: redactedHeaders }, + response: fx.response, + }; + }, + ); + + await wrappedFetch("https://api.example.com/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: '{"model":"test","messages":[],"stream":true}', + }); + + assertDefined(capturedFixture); + expect(capturedFixture.request.headers.Authorization).toBe("Bearer sk-…redacted…def"); + expect(capturedFixture.request.headers.Authorization).not.toContain("LIVEKEY1234567890abc"); + + const serialized = serializeFixture(capturedFixture); + expect(serialized).not.toContain("LIVEKEY1234567890abc"); + expect(serialized).toContain("Bearer sk-…redacted…def"); + }); + + it("redacts lowercase authorization header", async () => { + const apiKey = "sk-abcdefghijkmnop"; + + let capturedFixture: HttpExchangeFixture | undefined; + const wrappedFetch = recordFetch( + async () => new Response("data: [DONE]\n", { status: 200 }), + (fx) => { + const redactedHeaders: Record = {}; + for (const [key, value] of Object.entries(fx.request.headers)) { + if (key.toLowerCase() === "authorization") { + const token = value.replace(/^Bearer\s+/i, ""); + redactedHeaders[key] = `Bearer ${maskSecret(token)}`; + } else { + redactedHeaders[key] = value; + } + } + capturedFixture = { + request: { ...fx.request, headers: redactedHeaders }, + response: fx.response, + }; + }, + ); + + await wrappedFetch("https://api.example.com/v1/chat/completions", { + method: "POST", + headers: { authorization: `Bearer ${apiKey}` }, + body: null, + }); + + assertDefined(capturedFixture); + expect(capturedFixture.request.headers.authorization).toBe("Bearer sk-…redacted…nop"); + expect(capturedFixture.request.headers.authorization).not.toContain("abcdefghijkm"); + }); + + it("guard: no header named authorization (any case) survives with a raw sk- token", async () => { + const apiKey = "sk-REALKEY_1234567890abcdef"; + + let capturedFixture: HttpExchangeFixture | undefined; + const wrappedFetch = recordFetch( + async () => new Response("data: [DONE]\n", { status: 200 }), + (fx) => { + const redactedHeaders: Record = {}; + for (const [key, value] of Object.entries(fx.request.headers)) { + if (key.toLowerCase() === "authorization") { + const token = value.replace(/^Bearer\s+/i, ""); + redactedHeaders[key] = `Bearer ${maskSecret(token)}`; + } else { + redactedHeaders[key] = value; + } + } + capturedFixture = { + request: { ...fx.request, headers: redactedHeaders }, + response: fx.response, + }; + }, + ); + + await wrappedFetch("https://api.example.com/v1/chat/completions", { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}` }, + body: null, + }); + + assertDefined(capturedFixture); + for (const [key, value] of Object.entries(capturedFixture.request.headers)) { + if (key.toLowerCase() === "authorization") { + expect(value).not.toContain(apiKey); + expect(value).not.toMatch(/sk-[A-Za-z0-9]{10,}/); + } + } + + const serialized = serializeFixture(capturedFixture); + expect(serialized).not.toContain(apiKey); + expect(serialized).not.toMatch(/sk-[A-Za-z0-9]{10,}/); + }); + + it("redacts a short API key (≤7 chars → full mask)", async () => { + let capturedFixture: HttpExchangeFixture | undefined; + const wrappedFetch = recordFetch( + async () => new Response("data: [DONE]\n", { status: 200 }), + (fx) => { + const redactedHeaders: Record = {}; + for (const [key, value] of Object.entries(fx.request.headers)) { + if (key.toLowerCase() === "authorization") { + const token = value.replace(/^Bearer\s+/i, ""); + redactedHeaders[key] = `Bearer ${maskSecret(token)}`; + } else { + redactedHeaders[key] = value; + } + } + capturedFixture = { + request: { ...fx.request, headers: redactedHeaders }, + response: fx.response, + }; + }, + ); + + await wrappedFetch("https://api.example.com/v1/chat/completions", { + method: "POST", + headers: { authorization: "Bearer secret!" }, + body: null, + }); + + assertDefined(capturedFixture); + expect(capturedFixture.request.headers.authorization).toBe("Bearer …redacted…"); + }); +}); diff --git a/packages/openai-stream/src/stream.ts b/packages/openai-stream/src/stream.ts new file mode 100644 index 0000000..2916432 --- /dev/null +++ b/packages/openai-stream/src/stream.ts @@ -0,0 +1,411 @@ +import type { + ChatMessage, + ProviderEvent, + ProviderStreamOptions, + Span, + ToolContract, +} from "@dispatch/kernel"; +import type { FetchLike, HttpExchangeFixture } from "@dispatch/trace-replay"; +import { convertMessages, type OpenAIMessage } from "./convert-messages.js"; +import { convertTools, type OpenAITool } from "./convert-tools.js"; + +export interface StreamConfig { + readonly baseURL: string; + readonly apiKey: string; + readonly model: string; + /** + * Internal injectable fetch — used by replay tests and record mode. + * When absent, falls back to globalThis.fetch (production default). + */ + readonly fetchFn?: FetchLike; + /** + * Optional hook a provider extension uses to add provider-specific body + * fields (e.g. `reasoning_effort`) before the request is sent. Receives the + * body built so far + the ProviderStreamOptions; returns ADDITIONAL fields + * to merge into the body (or a full body). Generic — the library names no + * feature. Applied AFTER building `body` and BEFORE `JSON.stringify`, so + * the verbatim post-transform bytes are what hit the wire (and what the + * provider.request span captures). Default (absent): no extra fields. + */ + readonly transformBody?: ( + body: Record, + opts: ProviderStreamOptions, + ) => Record; +} + +/** + * Graduated secret mask — §6 tiers. Reimplemented locally (isolation-over-dry). + * ≥13 → reveal 3 each side · 11–12 → 2 · 8–10 → 1 · ≤7 → full mask. + */ +function maskSecret(value: string): string { + const len = value.length; + if (len <= 7) return "…redacted…"; + let reveal: number; + if (len >= 13) { + reveal = 3; + } else if (len >= 11) { + reveal = 2; + } else { + reveal = 1; + } + return `${value.slice(0, reveal)}…redacted…${value.slice(-reveal)}`; +} + +export async function* streamChat( + config: StreamConfig, + messages: readonly ChatMessage[], + tools: readonly ToolContract[], + opts?: ProviderStreamOptions, +): AsyncIterable { + const openaiMessages = convertMessages(messages); + const openaiTools = convertTools(tools); + + const systemPrompt = opts?.systemPrompt; + const finalMessages: OpenAIMessage[] = systemPrompt + ? [{ role: "system", content: systemPrompt }, ...openaiMessages] + : openaiMessages; + + const body: Record = { + model: opts?.model ?? config.model, + messages: finalMessages, + stream: true, + }; + + if (openaiTools.length > 0) { + body.tools = openaiTools satisfies OpenAITool[]; + } + if (opts?.temperature !== undefined) { + body.temperature = opts.temperature; + } + if (opts?.maxTokens !== undefined) { + body.max_tokens = opts.maxTokens; + } + + if (config.transformBody) { + const extra = config.transformBody(body, opts ?? {}); + Object.assign(body, extra); + } + + const url = `${config.baseURL}/chat/completions`; + const bodyString = JSON.stringify(body); + + let reqSpan: Span | undefined; + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalCacheReadTokens: number | undefined; + let totalCacheWriteTokens: number | undefined; + + if (opts?.logger) { + try { + const model = opts?.model ?? config.model; + const hasCacheBreakpoint = bodyString.includes("cache_control"); + reqSpan = opts.logger.span( + "provider.request", + { + model, + url, + "request.method": "POST", + "request.cache_control_present": hasCacheBreakpoint, + "request.headers.authorization": `Bearer ${maskSecret(config.apiKey)}`, + }, + bodyString, + ); + } catch { + // Fail-safe: capture must never break stream(). + } + } + + let effectiveFetch: FetchLike = config.fetchFn ?? fetch; + + const recordPath = + typeof process !== "undefined" ? process.env.DISPATCH_RECORD_FIXTURE : undefined; + if (recordPath && !config.fetchFn) { + try { + const { recordFetch: rf, saveFixture } = await import("@dispatch/trace-replay"); + effectiveFetch = rf(effectiveFetch, (fx: HttpExchangeFixture) => { + try { + const redactedHeaders: Record = {}; + for (const [key, value] of Object.entries(fx.request.headers)) { + if (key.toLowerCase() === "authorization") { + const token = value.replace(/^Bearer\s+/i, ""); + redactedHeaders[key] = `Bearer ${maskSecret(token)}`; + } else { + redactedHeaders[key] = value; + } + } + const redacted: HttpExchangeFixture = { + request: { ...fx.request, headers: redactedHeaders }, + response: fx.response, + ...(fx.meta !== undefined ? { meta: fx.meta } : {}), + }; + saveFixture(recordPath, redacted); + } catch { + // Fail-safe: capture/write must never break the turn. + } + }); + } catch { + // Fail-safe: dynamic import or wrapping failure must never break the turn. + } + } + + let response: Response; + try { + response = await effectiveFetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${config.apiKey}`, + }, + body: bodyString, + }); + } catch (err) { + if (reqSpan) { + try { + reqSpan.end({ + err, + attrs: { status: 0 }, + }); + } catch { + // Fail-safe. + } + } + yield { + type: "error", + message: err instanceof Error ? err.message : String(err), + retryable: true, + }; + return; + } + + if (!response.ok) { + const text = await response.text().catch(() => "unknown"); + if (reqSpan) { + try { + reqSpan.setAttributes({ status: response.status }); + reqSpan.end({ + err: new Error(`HTTP ${response.status}: ${text}`), + attrs: { + status: response.status, + "response.error_body": text, + }, + }); + } catch { + // Fail-safe. + } + } + yield { + type: "error", + message: `HTTP ${response.status}: ${text}`, + code: String(response.status), + retryable: response.status >= 500 || response.status === 429, + }; + return; + } + + if (!response.body) { + if (reqSpan) { + try { + reqSpan.end({ + err: new Error("Response body is null"), + attrs: { status: response.status }, + }); + } catch { + // Fail-safe. + } + } + yield { type: "error", message: "Response body is null" }; + return; + } + + try { + yield* readSSEStream(response.body, (usage) => { + totalInputTokens = usage.inputTokens; + totalOutputTokens = usage.outputTokens; + totalCacheReadTokens = usage.cacheReadTokens; + totalCacheWriteTokens = usage.cacheWriteTokens; + }); + } catch (err) { + if (reqSpan) { + try { + reqSpan.end({ + err, + attrs: { status: response.status }, + }); + } catch { + // Fail-safe. + } + } + throw err; + } + + if (reqSpan) { + try { + const attrs: Record = { + status: response.status, + "usage.inputTokens": totalInputTokens, + "usage.outputTokens": totalOutputTokens, + }; + if (totalCacheReadTokens !== undefined) { + attrs["usage.cacheReadTokens"] = totalCacheReadTokens; + } + if (totalCacheWriteTokens !== undefined) { + attrs["usage.cacheWriteTokens"] = totalCacheWriteTokens; + } + reqSpan.end({ attrs }); + } catch { + // Fail-safe. + } + } +} + +async function* readSSEStream( + body: ReadableStream, + onUsage?: (usage: { + inputTokens: number; + outputTokens: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + }) => void, +): AsyncIterable { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + const toolCalls = new Map(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith("data:")) continue; + + const data = trimmed.slice(5).trim(); + if (data === "[DONE]") return; + + let chunk: Record; + try { + chunk = JSON.parse(data); + } catch { + yield { type: "error", message: `Invalid JSON in SSE data: ${data}` }; + continue; + } + + const choices = chunk.choices as + | Array<{ + delta: Record; + finish_reason?: string | null; + }> + | undefined; + + if (choices) { + for (const choice of choices) { + const delta = choice.delta; + + if (typeof delta.content === "string" && delta.content) { + yield { type: "text-delta", delta: delta.content }; + } + + if (typeof delta.reasoning_content === "string" && delta.reasoning_content) { + yield { type: "reasoning-delta", delta: delta.reasoning_content }; + } + + const tcs = delta.tool_calls as + | Array<{ + index: number; + id?: string; + function?: { name?: string; arguments?: string }; + }> + | undefined; + + if (tcs) { + for (const tc of tcs) { + const existing = toolCalls.get(tc.index); + if (existing) { + if (tc.function?.arguments) { + existing.arguments += tc.function.arguments; + } + } else { + toolCalls.set(tc.index, { + id: tc.id ?? "", + name: tc.function?.name ?? "", + arguments: tc.function?.arguments ?? "", + }); + } + } + } + + if (choice.finish_reason) { + const sortedIndices = [...toolCalls.keys()].sort((a, b) => a - b); + for (const idx of sortedIndices) { + const acc = toolCalls.get(idx); + if (!acc) continue; + let input: unknown; + try { + input = JSON.parse(acc.arguments); + } catch { + input = acc.arguments; + } + yield { + type: "tool-call", + toolCallId: acc.id, + toolName: acc.name, + input, + }; + } + yield { type: "finish", reason: choice.finish_reason }; + } + } + } + + const usage = chunk.usage as + | { + prompt_tokens?: number; + completion_tokens?: number; + cache_read_tokens?: number; + cache_write_tokens?: number; + prompt_tokens_details?: { cached_tokens?: number }; + completion_tokens_details?: Record; + } + | undefined; + + if (usage) { + const cacheRead = usage.cache_read_tokens ?? usage.prompt_tokens_details?.cached_tokens; + const cacheWrite = usage.cache_write_tokens; + const usageObj: { + inputTokens: number; + outputTokens: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + } = { + inputTokens: usage.prompt_tokens ?? 0, + outputTokens: usage.completion_tokens ?? 0, + }; + if (cacheRead !== undefined) { + usageObj.cacheReadTokens = cacheRead; + } + if (cacheWrite !== undefined) { + usageObj.cacheWriteTokens = cacheWrite; + } + onUsage?.(usageObj); + yield { + type: "usage", + usage: { + inputTokens: usage.prompt_tokens ?? 0, + outputTokens: usage.completion_tokens ?? 0, + ...(cacheRead !== undefined ? { cacheReadTokens: cacheRead } : {}), + ...(cacheWrite !== undefined ? { cacheWriteTokens: cacheWrite } : {}), + }, + }; + } + } + } + } finally { + reader.releaseLock(); + } +} diff --git a/packages/openai-stream/tsconfig.json b/packages/openai-stream/tsconfig.json new file mode 100644 index 0000000..39be10e --- /dev/null +++ b/packages/openai-stream/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }, { "path": "../trace-replay" }, { "path": "../wire" }] +} diff --git a/packages/provider-openai-compat/package.json b/packages/provider-openai-compat/package.json index 36db8a5..465df0c 100644 --- a/packages/provider-openai-compat/package.json +++ b/packages/provider-openai-compat/package.json @@ -7,6 +7,7 @@ "types": "dist/index.d.ts", "dependencies": { "@dispatch/kernel": "workspace:*", + "@dispatch/openai-stream": "workspace:*", "@dispatch/trace-replay": "workspace:*" } } diff --git a/packages/provider-openai-compat/src/__fixtures__/flash-text-turn.json b/packages/provider-openai-compat/src/__fixtures__/flash-text-turn.json deleted file mode 100644 index d7e71cb..0000000 --- a/packages/provider-openai-compat/src/__fixtures__/flash-text-turn.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "request": { - "method": "POST", - "url": "https://opencode.ai/zen/go/v1/chat/completions", - "headers": { - "Content-Type": "application/json", - "Authorization": "Bearer sk-…redacted…UN0" - }, - "body": "{\"model\":\"deepseek-v4-flash\",\"messages\":[{\"role\":\"user\",\"content\":\"Say hello in exactly three words.\"},{\"role\":\"assistant\",\"content\":\"The user asks: \\\"Say hello in exactly three words.\\\" So I need to respond with exactly three words. The obvious answer is \\\"Hello, how are you?\\\" But that's four words. Actually \\\"Hello there friend\\\" is three words. Or \\\"Hello, my friend\\\"? That's three words if we count \\\"Hello,\\\" as one word? Better to be simple: \\\"Hello, world!\\\" is two words. \\\"Hello everyone here\\\" is three. I'll go with \\\"Hello there friend.\\\" But let's make sure it's exactly three words. I'll respond: \\\"Hello, my friend.\\\" That's three words: Hello, my, friend. But includes a comma? Usually words mean space-separated tokens. So \\\"Hello my friend\\\" is three words. I'll respond with that.\\n\\nBut the instruction is to say hello in exactly three words. I'll just output: \\\"Hello my friend\\\"Hello my friend\"},{\"role\":\"user\",\"content\":\"Say hello in exactly three words.\"}],\"stream\":true,\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"description\":\"Read the contents of a file. Returns lines with 1-indexed line numbers. Supports offset/limit for reading specific sections of large files.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file, relative to the working directory.\"},\"offset\":{\"type\":\"number\",\"description\":\"1-indexed start line number (default: 1).\",\"default\":1},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to return (default: 500, hard cap: 5000).\",\"default\":500}},\"required\":[\"path\"]}}}]}" - }, - "response": { - "status": 200, - "statusText": "OK", - "headers": { - "cache-control": "no-cache", - "cf-placement": "remote-ORD", - "cf-ray": "a06dc6711bb51f51-DEN", - "connection": "keep-alive", - "content-type": "text/event-stream; charset=utf-8", - "date": "Fri, 05 Jun 2026 08:23:26 GMT", - "server": "cloudflare", - "transfer-encoding": "chunked" - }, - "body": "data: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"reasoning_content\":\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"The\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" user\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" asks\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" to\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"Say\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" in\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" exactly\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" three\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" words\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" I\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" need\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" to\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" respond\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" with\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" exactly\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" three\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" words\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" I\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" already\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" said\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" my\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" friend\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"\\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" but\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" that\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" might\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" have\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" been\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" cut\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" off\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" Let\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" me\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" respond\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" again\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" with\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" exactly\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" three\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" words\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\":\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\",\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" world\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"!\\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" two\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" words\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" there\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" friend\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"\\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" three\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" to\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" you\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"\\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" three\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" everyone\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"\\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" two\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" I\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"'ll\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" use\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" there\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\" friend\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"reasoning_content\":\".\\\"\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\",\"reasoning_content\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" there\",\"reasoning_content\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" friend\",\"reasoning_content\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"id\":\"c45495a0-befb-441f-9222-a3109fbbfa78\",\"object\":\"chat.completion.chunk\",\"created\":1780647805,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_8b330d02d0_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"reasoning_content\":null},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":665,\"completion_tokens\":90,\"total_tokens\":755,\"prompt_tokens_details\":{\"cached_tokens\":384},\"completion_tokens_details\":{\"reasoning_tokens\":86},\"prompt_cache_hit_tokens\":384,\"prompt_cache_miss_tokens\":281}}\n\ndata: [DONE]\n\ndata: {\"choices\":[],\"cost\":\"0\"}\n\n" - } -} diff --git a/packages/provider-openai-compat/src/__fixtures__/tool-call-turn.json b/packages/provider-openai-compat/src/__fixtures__/tool-call-turn.json deleted file mode 100644 index 48bdb8d..0000000 --- a/packages/provider-openai-compat/src/__fixtures__/tool-call-turn.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "request": { - "method": "POST", - "url": "https://api.example.com/v1/chat/completions", - "headers": { - "content-type": "application/json", - "authorization": "Bearer sk-…redacted…xyz" - }, - "body": "{\"model\":\"deepseek-v4-flash\",\"messages\":[{\"role\":\"user\",\"content\":\"What is the weather in Tokyo?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a location\",\"parameters\":{\"type\":\"object\",\"properties\":{\"location\":{\"type\":\"string\"}},\"required\":[\"location\"]}}}],\"stream\":true}" - }, - "response": { - "status": 200, - "statusText": "OK", - "headers": { - "content-type": "text/event-stream", - "cache-control": "no-cache" - }, - "body": "data: {\"id\":\"chatcmpl-fixture-002\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-fixture-002\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_abc123\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-fixture-002\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"locat\"}}]},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-fixture-002\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"ion\\\":\\\"Tokyo\\\"}\"}}]},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-fixture-002\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"deepseek-v4-flash\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":45,\"completion_tokens\":12,\"cache_read_tokens\":30,\"cache_write_tokens\":5}}\n\ndata: [DONE]\n" - }, - "meta": { - "description": "Tool-call fixture: user asks about weather → model calls get_weather tool with split argument chunks.", - "captured_by": "provider-openai-compat record mode", - "version": 1 - } -} diff --git a/packages/provider-openai-compat/src/convert-messages.test.ts b/packages/provider-openai-compat/src/convert-messages.test.ts deleted file mode 100644 index 51513ea..0000000 --- a/packages/provider-openai-compat/src/convert-messages.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -import type { ChatMessage } from "@dispatch/kernel"; -import { describe, expect, it } from "vitest"; -import { convertMessages } from "./convert-messages.js"; - -describe("convertMessages", () => { - it("converts a system message with text chunks", () => { - const messages: ChatMessage[] = [ - { - role: "system", - chunks: [ - { type: "system", text: "You are a helpful assistant." }, - { type: "text", text: " Additional context." }, - ], - }, - ]; - - const result = convertMessages(messages); - expect(result).toEqual([ - { role: "system", content: "You are a helpful assistant. Additional context." }, - ]); - }); - - it("converts a user message with text chunks", () => { - const messages: ChatMessage[] = [ - { - role: "user", - chunks: [ - { type: "text", text: "Hello, " }, - { type: "text", text: "world!" }, - ], - }, - ]; - - const result = convertMessages(messages); - expect(result).toEqual([{ role: "user", content: "Hello, world!" }]); - }); - - it("converts an assistant message with text only", () => { - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { type: "text", text: "I can help " }, - { type: "text", text: "with that." }, - ], - }, - ]; - - const result = convertMessages(messages); - expect(result).toEqual([{ role: "assistant", content: "I can help with that." }]); - }); - - it("converts an assistant message with tool calls", () => { - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { type: "text", text: "Let me check that." }, - { - type: "tool-call", - toolCallId: "call_1", - toolName: "read_file", - input: { path: "/src/main.ts" }, - }, - ], - }, - ]; - - const result = convertMessages(messages); - expect(result).toEqual([ - { - role: "assistant", - content: "Let me check that.", - tool_calls: [ - { - id: "call_1", - type: "function", - function: { - name: "read_file", - arguments: JSON.stringify({ path: "/src/main.ts" }), - }, - }, - ], - }, - ]); - }); - - it("converts an assistant message with tool calls but no text", () => { - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_2", - toolName: "run_shell", - input: { command: "ls" }, - }, - ], - }, - ]; - - const result = convertMessages(messages); - expect(result).toEqual([ - { - role: "assistant", - content: null, - tool_calls: [ - { - id: "call_2", - type: "function", - function: { - name: "run_shell", - arguments: JSON.stringify({ command: "ls" }), - }, - }, - ], - }, - ]); - }); - - it("converts tool result messages", () => { - const messages: ChatMessage[] = [ - { - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_1", - toolName: "read_file", - content: "file contents here", - isError: false, - }, - ], - }, - ]; - - const result = convertMessages(messages); - expect(result).toEqual([ - { - role: "tool", - content: "file contents here", - tool_call_id: "call_1", - }, - ]); - }); - - it("converts a full multi-turn history with tool round-trip", () => { - const messages: ChatMessage[] = [ - { - role: "system", - chunks: [{ type: "system", text: "You are helpful." }], - }, - { - role: "user", - chunks: [{ type: "text", text: "Read main.ts" }], - }, - { - role: "assistant", - chunks: [ - { type: "text", text: "Sure." }, - { - type: "tool-call", - toolCallId: "call_1", - toolName: "read_file", - input: { path: "main.ts" }, - }, - ], - }, - { - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_1", - toolName: "read_file", - content: "console.log('hello')", - isError: false, - }, - ], - }, - { - role: "assistant", - chunks: [{ type: "text", text: "The file logs hello." }], - }, - ]; - - const result = convertMessages(messages); - expect(result).toEqual([ - { role: "system", content: "You are helpful." }, - { role: "user", content: "Read main.ts" }, - { - role: "assistant", - content: "Sure.", - tool_calls: [ - { - id: "call_1", - type: "function", - function: { - name: "read_file", - arguments: JSON.stringify({ path: "main.ts" }), - }, - }, - ], - }, - { - role: "tool", - content: "console.log('hello')", - tool_call_id: "call_1", - }, - { role: "assistant", content: "The file logs hello." }, - ]); - }); - - it("handles multiple tool results in one tool message", () => { - const messages: ChatMessage[] = [ - { - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_1", - toolName: "read_file", - content: "file1", - isError: false, - }, - { - type: "tool-result", - toolCallId: "call_2", - toolName: "read_file", - content: "file2", - isError: false, - }, - ], - }, - ]; - - const result = convertMessages(messages); - expect(result).toEqual([ - { role: "tool", content: "file1", tool_call_id: "call_1" }, - { role: "tool", content: "file2", tool_call_id: "call_2" }, - ]); - }); - - it("includes thinking chunks in assistant content", () => { - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { type: "thinking", text: "Let me think..." }, - { type: "text", text: "Here is my answer." }, - ], - }, - ]; - - const result = convertMessages(messages); - expect(result).toEqual([{ role: "assistant", content: "Let me think...Here is my answer." }]); - }); -}); diff --git a/packages/provider-openai-compat/src/convert-messages.ts b/packages/provider-openai-compat/src/convert-messages.ts deleted file mode 100644 index 786a70d..0000000 --- a/packages/provider-openai-compat/src/convert-messages.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { ChatMessage, Chunk } from "@dispatch/kernel"; - -export interface OpenAIMessage { - readonly role: "system" | "user" | "assistant" | "tool"; - readonly content: string | null; - readonly tool_calls?: readonly OpenAIToolCall[]; - readonly tool_call_id?: string; -} - -export interface OpenAIToolCall { - readonly id: string; - readonly type: "function"; - readonly function: { readonly name: string; readonly arguments: string }; -} - -export function convertMessages(messages: readonly ChatMessage[]): OpenAIMessage[] { - const result: OpenAIMessage[] = []; - for (const msg of messages) { - const converted = convertMessage(msg); - for (const m of converted) { - result.push(m); - } - } - return result; -} - -function convertMessage(msg: ChatMessage): OpenAIMessage[] { - switch (msg.role) { - case "system": - return [convertSystemMessage(msg)]; - case "user": - return [convertUserMessage(msg)]; - case "assistant": - return [convertAssistantMessage(msg)]; - case "tool": - return convertToolResultMessages(msg); - } -} - -function convertSystemMessage(msg: ChatMessage): OpenAIMessage { - const text = msg.chunks - .filter( - (c): c is Extract => - c.type === "text" || c.type === "system", - ) - .map((c) => c.text) - .join(""); - return { role: "system", content: text }; -} - -function convertUserMessage(msg: ChatMessage): OpenAIMessage { - const text = msg.chunks - .filter((c): c is Extract => c.type === "text") - .map((c) => c.text) - .join(""); - return { role: "user", content: text }; -} - -function convertAssistantMessage(msg: ChatMessage): OpenAIMessage { - const textChunks = msg.chunks.filter( - (c): c is Extract => - c.type === "text" || c.type === "thinking", - ); - const content = textChunks.map((c) => c.text).join(""); - - const toolCalls = msg.chunks - .filter((c): c is Extract => c.type === "tool-call") - .map( - (c): OpenAIToolCall => ({ - id: c.toolCallId, - type: "function", - function: { - name: c.toolName, - arguments: typeof c.input === "string" ? c.input : JSON.stringify(c.input), - }, - }), - ); - - if (toolCalls.length > 0) { - return { - role: "assistant", - content: content || null, - tool_calls: toolCalls, - }; - } - return { role: "assistant", content }; -} - -function convertToolResultMessages(msg: ChatMessage): OpenAIMessage[] { - return msg.chunks - .filter((c): c is Extract => c.type === "tool-result") - .map( - (c): OpenAIMessage => ({ - role: "tool", - content: c.content, - tool_call_id: c.toolCallId, - }), - ); -} diff --git a/packages/provider-openai-compat/src/convert-tools.test.ts b/packages/provider-openai-compat/src/convert-tools.test.ts deleted file mode 100644 index d739652..0000000 --- a/packages/provider-openai-compat/src/convert-tools.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { ToolContract } from "@dispatch/kernel"; -import { describe, expect, it } from "vitest"; -import { convertTools } from "./convert-tools.js"; - -describe("convertTools", () => { - it("converts a single tool to OpenAI function format", () => { - const tools: ToolContract[] = [ - { - name: "read_file", - description: "Read a file from disk", - parameters: { - type: "object", - properties: { - path: { type: "string", description: "File path" }, - }, - required: ["path"], - additionalProperties: false, - }, - execute: async () => ({ content: "" }), - }, - ]; - - const result = convertTools(tools); - expect(result).toEqual([ - { - type: "function", - function: { - name: "read_file", - description: "Read a file from disk", - parameters: { - type: "object", - properties: { - path: { type: "string", description: "File path" }, - }, - required: ["path"], - additionalProperties: false, - }, - }, - }, - ]); - }); - - it("converts multiple tools", () => { - const tools: ToolContract[] = [ - { - name: "read_file", - description: "Read a file", - parameters: { type: "object" }, - execute: async () => ({ content: "" }), - }, - { - name: "run_shell", - description: "Run a shell command", - parameters: { - type: "object", - properties: { - command: { type: "string", description: "The command" }, - }, - required: ["command"], - }, - execute: async () => ({ content: "" }), - }, - ]; - - const result = convertTools(tools); - expect(result).toHaveLength(2); - expect(result[0]?.function.name).toBe("read_file"); - expect(result[1]?.function.name).toBe("run_shell"); - }); - - it("returns empty array for no tools", () => { - const result = convertTools([]); - expect(result).toEqual([]); - }); - - it("preserves nested parameter schema properties", () => { - const tools: ToolContract[] = [ - { - name: "search", - description: "Search code", - parameters: { - type: "object", - properties: { - query: { type: "string", description: "Search query" }, - options: { - type: "object", - properties: { - limit: { type: "number", description: "Max results", default: 10 }, - }, - }, - }, - required: ["query"], - }, - execute: async () => ({ content: "" }), - }, - ]; - - const result = convertTools(tools); - expect(result[0]?.function.parameters.properties?.options).toEqual({ - type: "object", - properties: { - limit: { type: "number", description: "Max results", default: 10 }, - }, - }); - }); -}); diff --git a/packages/provider-openai-compat/src/convert-tools.ts b/packages/provider-openai-compat/src/convert-tools.ts deleted file mode 100644 index 65416bb..0000000 --- a/packages/provider-openai-compat/src/convert-tools.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { ToolContract, ToolParameterSchema } from "@dispatch/kernel"; - -export interface OpenAITool { - readonly type: "function"; - readonly function: { - readonly name: string; - readonly description: string; - readonly parameters: ToolParameterSchema; - }; -} - -export function convertTools(tools: readonly ToolContract[]): OpenAITool[] { - return tools.map(convertTool); -} - -function convertTool(tool: ToolContract): OpenAITool { - return { - type: "function", - function: { - name: tool.name, - description: tool.description, - parameters: tool.parameters, - }, - }; -} diff --git a/packages/provider-openai-compat/src/extension.ts b/packages/provider-openai-compat/src/extension.ts index 4a580d3..042a807 100644 --- a/packages/provider-openai-compat/src/extension.ts +++ b/packages/provider-openai-compat/src/extension.ts @@ -1,5 +1,5 @@ import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; -import { createOpenAICompatProvider } from "./provider.js"; +import { createOpenAICompatProvider } from "@dispatch/openai-stream"; export const manifest: Manifest = { id: "provider-openai-compat", @@ -39,7 +39,11 @@ export async function activate(host: HostAPI): Promise { const model = host.config.get("provider.openai-compat.model") ?? "deepseek-v4-flash"; - const provider = createOpenAICompatProvider({ credentials: creds, model }); + const provider = createOpenAICompatProvider({ + credentials: creds, + model, + id: "openai-compat", + }); host.defineProvider(provider); host.logger.info(`provider-openai-compat: registered (model=${model})`); } diff --git a/packages/provider-openai-compat/src/index.ts b/packages/provider-openai-compat/src/index.ts index 3498a9d..78e30bb 100644 --- a/packages/provider-openai-compat/src/index.ts +++ b/packages/provider-openai-compat/src/index.ts @@ -1,9 +1,14 @@ -export type { OpenAIMessage, OpenAIToolCall } from "./convert-messages.js"; -export { convertMessages } from "./convert-messages.js"; -export type { OpenAITool } from "./convert-tools.js"; -export { convertTools } from "./convert-tools.js"; +export type { + CreateOpenAICompatProviderOpts, + OpenAIMessage, + OpenAITool, + OpenAIToolCall, +} from "@dispatch/openai-stream"; +export { + convertMessages, + convertTools, + createOpenAICompatProvider, + parseModelList, + parseSSELines, +} from "@dispatch/openai-stream"; export { activate, extension, manifest } from "./extension.js"; -export { parseModelList } from "./listModels.js"; -export { parseSSELines } from "./parse-sse.js"; -export type { CreateOpenAICompatProviderOpts } from "./provider.js"; -export { createOpenAICompatProvider } from "./provider.js"; diff --git a/packages/provider-openai-compat/src/listModels.test.ts b/packages/provider-openai-compat/src/listModels.test.ts deleted file mode 100644 index 97badaa..0000000 --- a/packages/provider-openai-compat/src/listModels.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { ApiKeyCredentials, ModelInfo, ProviderContract } from "@dispatch/kernel"; -import type { FetchLike } from "@dispatch/trace-replay"; -import { describe, expect, it, vi } from "vitest"; -import { parseModelList } from "./listModels.js"; -import { createOpenAICompatProvider } from "./provider.js"; - -function makeProvider(fetchFn: FetchLike, apiKey = "sk-test-1234567890abcdef"): ProviderContract { - const creds: ApiKeyCredentials = { - type: "api-key", - apiKey, - baseURL: "https://api.example.com/v1", - }; - return createOpenAICompatProvider({ - credentials: creds, - model: "test-model", - fetchFn, - }); -} - -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, - }); -} - -describe("listModels — pure mapping (parseModelList)", () => { - it("maps OpenAI model entries to ModelInfo", () => { - const result = parseModelList([{ id: "a" }, { id: "b" }]); - expect(result).toEqual([{ id: "a" }, { id: "b" }]); - }); - - it("returns empty array for empty input", () => { - const result = parseModelList([]); - expect(result).toEqual([]); - }); -}); - -describe("listModels — provider contract", () => { - it("GETs models endpoint with bearer key and returns mapped ModelInfo[]", async () => { - const fetchFn = vi.fn( - () => jsonResponse({ data: [{ id: "a" }, { id: "b" }] }) as unknown as ReturnType, - ); - const provider = makeProvider(fetchFn); - const listModels = provider.listModels; - if (!listModels) throw new Error("listModels not defined"); - - const models = await listModels(); - - expect(fetchFn).toHaveBeenCalledOnce(); - const callArgs = fetchFn.mock.calls[0]; - if (!callArgs) throw new Error("no call args"); - const [url, init] = callArgs as unknown as [string, RequestInit]; - expect(url).toBe("https://api.example.com/v1/models"); - expect(init.method).toBe("GET"); - expect(init.headers).toEqual({ Authorization: "Bearer sk-test-1234567890abcdef" }); - - expect(models).toEqual([{ id: "a" }, { id: "b" }] as readonly ModelInfo[]); - }); - - it("throws on non-OK HTTP status with a clear message", async () => { - const fetchFn = vi.fn( - () => - new Response("Unauthorized", { - status: 401, - headers: { "Content-Type": "text/plain" }, - }) as unknown as ReturnType, - ); - const provider = makeProvider(fetchFn); - const listModels = provider.listModels; - if (!listModels) throw new Error("listModels not defined"); - - await expect(listModels()).rejects.toThrow( - "listModels[openai-compat]: HTTP 401 — Unauthorized", - ); - }); - - it("throws on network error with a clear message", async () => { - const fetchFn = vi.fn(() => { - throw new Error("connection refused"); - }) as unknown as FetchLike; - const provider = makeProvider(fetchFn); - const listModels = provider.listModels; - if (!listModels) throw new Error("listModels not defined"); - - await expect(listModels()).rejects.toThrow( - "listModels[openai-compat]: network error — connection refused", - ); - }); - - it("throws when response shape is missing data array", async () => { - const fetchFn = vi.fn(() => jsonResponse({ models: [] }) as unknown as ReturnType); - const provider = makeProvider(fetchFn); - const listModels = provider.listModels; - if (!listModels) throw new Error("listModels not defined"); - - await expect(listModels()).rejects.toThrow( - 'listModels[openai-compat]: unexpected response shape — missing "data" array', - ); - }); -}); diff --git a/packages/provider-openai-compat/src/listModels.ts b/packages/provider-openai-compat/src/listModels.ts deleted file mode 100644 index d253ebe..0000000 --- a/packages/provider-openai-compat/src/listModels.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { ModelInfo } from "@dispatch/kernel"; -import type { FetchLike } from "@dispatch/trace-replay"; - -/** - * opencode-go specifics (model-list URL, usage/cache-token mapping, headers) - * live in this generic `provider-openai-compat` for now. When a SECOND - * OpenAI-compatible backend lands, split this into a generic OpenAI-stream - * capability exposed as a typed SERVICE handle and a `provider-opencode-go` - * extension that `dependsOn` it and layers the specifics — coupling via the - * typed handle only (isolation-over-DRY: no cross-extension code import). - */ - -interface OpenAIModelEntry { - readonly id: string; -} - -interface OpenAIModelListResponse { - readonly data: readonly OpenAIModelEntry[]; -} - -/** - * Pure mapping: raw OpenAI-compatible model list → ModelInfo[]. - * Extracted for direct unit testing with no I/O. - */ -export function parseModelList(data: readonly OpenAIModelEntry[]): readonly ModelInfo[] { - return data.map((entry) => ({ id: entry.id })); -} - -export interface ListModelsConfig { - readonly baseURL: string; - readonly apiKey: string; - readonly fetchFn?: FetchLike; - readonly providerId: string; -} - -export async function listModels(config: ListModelsConfig): Promise { - const effectiveFetch: FetchLike = config.fetchFn ?? fetch; - const url = `${config.baseURL}/models`; - - let response: Response; - try { - response = await effectiveFetch(url, { - method: "GET", - headers: { - Authorization: `Bearer ${config.apiKey}`, - }, - }); - } catch (err) { - throw new Error( - `listModels[${config.providerId}]: network error — ${err instanceof Error ? err.message : String(err)}`, - ); - } - - if (!response.ok) { - const text = await response.text().catch(() => "unknown"); - throw new Error(`listModels[${config.providerId}]: HTTP ${response.status} — ${text}`); - } - - const body = (await response.json()) as OpenAIModelListResponse; - if (!Array.isArray(body.data)) { - throw new Error( - `listModels[${config.providerId}]: unexpected response shape — missing "data" array`, - ); - } - - return parseModelList(body.data); -} diff --git a/packages/provider-openai-compat/src/parse-sse.test.ts b/packages/provider-openai-compat/src/parse-sse.test.ts deleted file mode 100644 index 1910833..0000000 --- a/packages/provider-openai-compat/src/parse-sse.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -import type { ProviderEvent } from "@dispatch/kernel"; -import { describe, expect, it } from "vitest"; -import { parseSSELines } from "./parse-sse.js"; - -describe("parseSSELines", () => { - it("parses text delta events", () => { - const lines = [ - 'data: {"id":"chatcmpl-1","choices":[{"delta":{"content":"Hello"},"index":0}]}', - 'data: {"id":"chatcmpl-1","choices":[{"delta":{"content":" world"},"index":0}]}', - 'data: {"id":"chatcmpl-1","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ]; - - const events = parseSSELines(lines); - expect(events).toEqual([ - { type: "text-delta", delta: "Hello" }, - { type: "text-delta", delta: " world" }, - { type: "finish", reason: "stop" }, - ]); - }); - - it("parses a fragmented tool_call across chunks", () => { - const lines = [ - 'data: {"id":"chatcmpl-2","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_abc","function":{"name":"read_file","arguments":""}}]},"index":0}]}', - 'data: {"id":"chatcmpl-2","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"path\\""}}]},"index":0}]}', - 'data: {"id":"chatcmpl-2","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":":\\"main.ts\\"}"}}]},"index":0}]}', - 'data: {"id":"chatcmpl-2","choices":[{"delta":{},"finish_reason":"tool_calls","index":0}]}', - "data: [DONE]", - ]; - - const events = parseSSELines(lines); - expect(events).toEqual([ - { - type: "tool-call", - toolCallId: "call_abc", - toolName: "read_file", - input: { path: "main.ts" }, - }, - { type: "finish", reason: "tool_calls" }, - ]); - }); - - it("parses multiple tool_calls in one response", () => { - const lines = [ - 'data: {"id":"chatcmpl-3","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"read_file","arguments":""}}]},"index":0}]}', - 'data: {"id":"chatcmpl-3","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"path\\":\\"a.ts\\"}"}}]},"index":0}]}', - 'data: {"id":"chatcmpl-3","choices":[{"delta":{"tool_calls":[{"index":1,"id":"call_2","function":{"name":"read_file","arguments":""}}]},"index":0}]}', - 'data: {"id":"chatcmpl-3","choices":[{"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\\"path\\":\\"b.ts\\"}"}}]},"index":0}]}', - 'data: {"id":"chatcmpl-3","choices":[{"delta":{},"finish_reason":"tool_calls","index":0}]}', - "data: [DONE]", - ]; - - const events = parseSSELines(lines); - expect(events).toEqual([ - { type: "tool-call", toolCallId: "call_1", toolName: "read_file", input: { path: "a.ts" } }, - { type: "tool-call", toolCallId: "call_2", toolName: "read_file", input: { path: "b.ts" } }, - { type: "finish", reason: "tool_calls" }, - ]); - }); - - it("parses usage from the final chunk", () => { - const lines = [ - 'data: {"id":"chatcmpl-4","choices":[{"delta":{"content":"Hi"},"index":0}]}', - 'data: {"id":"chatcmpl-4","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - 'data: {"id":"chatcmpl-4","usage":{"prompt_tokens":10,"completion_tokens":5}}', - "data: [DONE]", - ]; - - const events = parseSSELines(lines); - expect(events).toEqual([ - { type: "text-delta", delta: "Hi" }, - { type: "finish", reason: "stop" }, - { - type: "usage", - usage: { - inputTokens: 10, - outputTokens: 5, - cacheReadTokens: undefined, - cacheWriteTokens: undefined, - }, - }, - ]); - }); - - it("parses reasoning_content deltas", () => { - const lines = [ - 'data: {"id":"chatcmpl-5","choices":[{"delta":{"reasoning_content":"Let me think..."},"index":0}]}', - 'data: {"id":"chatcmpl-5","choices":[{"delta":{"content":"Here is my answer."},"index":0}]}', - 'data: {"id":"chatcmpl-5","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ]; - - const events = parseSSELines(lines); - expect(events).toEqual([ - { type: "reasoning-delta", delta: "Let me think..." }, - { type: "text-delta", delta: "Here is my answer." }, - { type: "finish", reason: "stop" }, - ]); - }); - - it("handles invalid JSON gracefully", () => { - const lines = [ - "data: {invalid json}", - 'data: {"id":"chatcmpl-6","choices":[{"delta":{"content":"ok"},"index":0}]}', - "data: [DONE]", - ]; - - const events = parseSSELines(lines); - expect(events).toHaveLength(2); - expect(events[0]?.type).toBe("error"); - expect(events[1]).toEqual({ type: "text-delta", delta: "ok" }); - }); - - it("ignores non-data lines", () => { - const lines = [ - "event: message", - ": comment line", - 'data: {"id":"chatcmpl-7","choices":[{"delta":{"content":"hi"},"index":0}]}', - "", - "data: [DONE]", - ]; - - const events = parseSSELines(lines); - expect(events).toEqual([{ type: "text-delta", delta: "hi" }]); - }); - - it("stops at [DONE] sentinel", () => { - const lines = [ - 'data: {"id":"chatcmpl-8","choices":[{"delta":{"content":"before"},"index":0}]}', - "data: [DONE]", - 'data: {"id":"chatcmpl-8","choices":[{"delta":{"content":"after"},"index":0}]}', - ]; - - const events = parseSSELines(lines); - expect(events).toEqual([{ type: "text-delta", delta: "before" }]); - }); - - it("parses nested prompt_tokens_details.cached_tokens → cacheReadTokens", () => { - const lines = [ - 'data: {"id":"chatcmpl-nested","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - 'data: {"id":"chatcmpl-nested","usage":{"prompt_tokens":665,"completion_tokens":90,"prompt_tokens_details":{"cached_tokens":384},"completion_tokens_details":{"reasoning_tokens":86}}}', - "data: [DONE]", - ]; - - const events = parseSSELines(lines); - const usageEvent = events.find((e) => e.type === "usage") as Extract< - ProviderEvent, - { type: "usage" } - >; - expect(usageEvent.usage.inputTokens).toBe(665); - expect(usageEvent.usage.outputTokens).toBe(90); - expect(usageEvent.usage.cacheReadTokens).toBe(384); - expect(usageEvent.usage.cacheWriteTokens).toBeUndefined(); - }); - - it("flat cache_read_tokens takes precedence over nested cached_tokens", () => { - const lines = [ - 'data: {"id":"chatcmpl-both","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - 'data: {"id":"chatcmpl-both","usage":{"prompt_tokens":100,"completion_tokens":20,"cache_read_tokens":50,"prompt_tokens_details":{"cached_tokens":99}}}', - "data: [DONE]", - ]; - - const events = parseSSELines(lines); - const usageEvent = events.find((e) => e.type === "usage") as Extract< - ProviderEvent, - { type: "usage" } - >; - expect(usageEvent.usage.cacheReadTokens).toBe(50); - }); - - it("returns undefined for cacheReadTokens when neither flat nor nested present", () => { - const lines = [ - 'data: {"id":"chatcmpl-none","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - 'data: {"id":"chatcmpl-none","usage":{"prompt_tokens":10,"completion_tokens":5}}', - "data: [DONE]", - ]; - - const events = parseSSELines(lines); - const usageEvent = events.find((e) => e.type === "usage") as Extract< - ProviderEvent, - { type: "usage" } - >; - expect(usageEvent.usage.cacheReadTokens).toBeUndefined(); - expect(usageEvent.usage.cacheWriteTokens).toBeUndefined(); - }); - - it("handles missing/partial prompt_tokens_details safely", () => { - const lines = [ - 'data: {"id":"chatcmpl-partial","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - 'data: {"id":"chatcmpl-partial","usage":{"prompt_tokens":50,"completion_tokens":10,"prompt_tokens_details":{}}}', - "data: [DONE]", - ]; - - const events = parseSSELines(lines); - const usageEvent = events.find((e) => e.type === "usage") as Extract< - ProviderEvent, - { type: "usage" } - >; - expect(usageEvent.usage.cacheReadTokens).toBeUndefined(); - }); - - it("handles empty prompt_tokens_details object safely", () => { - const lines = [ - 'data: {"id":"chatcmpl-empty","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - 'data: {"id":"chatcmpl-empty","usage":{"prompt_tokens":30,"completion_tokens":8,"prompt_tokens_details":null}}', - "data: [DONE]", - ]; - - const events = parseSSELines(lines); - const usageEvent = events.find((e) => e.type === "usage") as Extract< - ProviderEvent, - { type: "usage" } - >; - expect(usageEvent.usage.cacheReadTokens).toBeUndefined(); - }); - - it("handles a complete turn with text, tool call, usage, and finish", () => { - const lines = [ - 'data: {"id":"chatcmpl-9","choices":[{"delta":{"content":"Let me check."},"index":0}]}', - 'data: {"id":"chatcmpl-9","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_xyz","function":{"name":"search","arguments":""}}]},"index":0}]}', - 'data: {"id":"chatcmpl-9","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"query\\":"}}]},"index":0}]}', - 'data: {"id":"chatcmpl-9","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"dispatch\\"}"}}]},"index":0}]}', - 'data: {"id":"chatcmpl-9","choices":[{"delta":{},"finish_reason":"tool_calls","index":0}]}', - 'data: {"id":"chatcmpl-9","usage":{"prompt_tokens":50,"completion_tokens":20}}', - "data: [DONE]", - ]; - - const events = parseSSELines(lines); - expect(events).toEqual([ - { type: "text-delta", delta: "Let me check." }, - { - type: "tool-call", - toolCallId: "call_xyz", - toolName: "search", - input: { query: "dispatch" }, - }, - { type: "finish", reason: "tool_calls" }, - { - type: "usage", - usage: { - inputTokens: 50, - outputTokens: 20, - cacheReadTokens: undefined, - cacheWriteTokens: undefined, - }, - }, - ]); - }); - - it("handles tool_call with unparseable arguments as raw string", () => { - const lines = [ - 'data: {"id":"chatcmpl-10","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_bad","function":{"name":"foo","arguments":"not-json"}}]},"index":0}]}', - 'data: {"id":"chatcmpl-10","choices":[{"delta":{},"finish_reason":"tool_calls","index":0}]}', - "data: [DONE]", - ]; - - const events = parseSSELines(lines); - expect(events).toEqual([ - { type: "tool-call", toolCallId: "call_bad", toolName: "foo", input: "not-json" }, - { type: "finish", reason: "tool_calls" }, - ]); - }); -}); diff --git a/packages/provider-openai-compat/src/parse-sse.ts b/packages/provider-openai-compat/src/parse-sse.ts deleted file mode 100644 index cfeb5b0..0000000 --- a/packages/provider-openai-compat/src/parse-sse.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { ProviderEvent } from "@dispatch/kernel"; - -interface ToolCallAccumulator { - id: string; - name: string; - arguments: string; -} - -interface SSEChunkDelta { - content?: string; - reasoning_content?: string; - tool_calls?: Array<{ - index: number; - id?: string; - function?: { name?: string; arguments?: string }; - }>; -} - -interface SSEChunkChoice { - delta: SSEChunkDelta; - finish_reason?: string | null; - index: number; -} - -interface SSEChunkUsageDetails { - cached_tokens?: number; -} - -interface SSEChunk { - id?: string; - choices?: SSEChunkChoice[]; - usage?: { - prompt_tokens?: number; - completion_tokens?: number; - cache_read_tokens?: number; - cache_write_tokens?: number; - prompt_tokens_details?: SSEChunkUsageDetails; - completion_tokens_details?: Record; - }; -} - -export function parseSSELines(lines: readonly string[]): ProviderEvent[] { - const events: ProviderEvent[] = []; - const toolCalls = new Map(); - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed.startsWith("data:")) continue; - - const data = trimmed.slice(5).trim(); - if (data === "[DONE]") break; - - let chunk: SSEChunk; - try { - chunk = JSON.parse(data) as SSEChunk; - } catch { - events.push({ type: "error", message: `Invalid JSON in SSE data: ${data}` }); - continue; - } - - if (chunk.choices) { - for (const choice of chunk.choices) { - const delta = choice.delta; - - if (delta.content) { - events.push({ type: "text-delta", delta: delta.content }); - } - - if (delta.reasoning_content) { - events.push({ type: "reasoning-delta", delta: delta.reasoning_content }); - } - - if (delta.tool_calls) { - for (const tc of delta.tool_calls) { - const existing = toolCalls.get(tc.index); - if (existing) { - if (tc.function?.arguments) { - existing.arguments += tc.function.arguments; - } - } else { - toolCalls.set(tc.index, { - id: tc.id ?? "", - name: tc.function?.name ?? "", - arguments: tc.function?.arguments ?? "", - }); - } - } - } - - if (choice.finish_reason) { - const sortedIndices = [...toolCalls.keys()].sort((a, b) => a - b); - for (const idx of sortedIndices) { - const acc = toolCalls.get(idx); - if (!acc) continue; - let input: unknown; - try { - input = JSON.parse(acc.arguments); - } catch { - input = acc.arguments; - } - events.push({ - type: "tool-call", - toolCallId: acc.id, - toolName: acc.name, - input, - }); - } - events.push({ type: "finish", reason: choice.finish_reason }); - } - } - } - - if (chunk.usage) { - const cacheRead = - chunk.usage.cache_read_tokens ?? chunk.usage.prompt_tokens_details?.cached_tokens; - const cacheWrite = chunk.usage.cache_write_tokens; - events.push({ - type: "usage", - usage: { - inputTokens: chunk.usage.prompt_tokens ?? 0, - outputTokens: chunk.usage.completion_tokens ?? 0, - ...(cacheRead !== undefined ? { cacheReadTokens: cacheRead } : {}), - ...(cacheWrite !== undefined ? { cacheWriteTokens: cacheWrite } : {}), - }, - }); - } - } - - return events; -} diff --git a/packages/provider-openai-compat/src/provider.ts b/packages/provider-openai-compat/src/provider.ts deleted file mode 100644 index 19c29a1..0000000 --- a/packages/provider-openai-compat/src/provider.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { - ApiKeyCredentials, - ChatMessage, - ModelInfo, - ProviderContract, - ProviderStreamOptions, - ToolContract, -} from "@dispatch/kernel"; -import type { FetchLike } from "@dispatch/trace-replay"; -import { listModels as fetchModels } from "./listModels.js"; -import { streamChat } from "./stream.js"; - -/** - * opencode-go specifics (model-list URL, usage/cache-token mapping, headers) - * live in this generic `provider-openai-compat` for now. When a SECOND - * OpenAI-compatible backend lands, split this into a generic OpenAI-stream - * capability exposed as a typed SERVICE handle and a `provider-opencode-go` - * extension that `dependsOn` it and layers the specifics — coupling via the - * typed handle only (isolation-over-DRY: no cross-extension code import). - */ - -export interface CreateOpenAICompatProviderOpts { - readonly credentials: ApiKeyCredentials; - readonly model: string; - /** - * Internal injectable fetch — used by tests and replay mode. - * When absent, falls back to globalThis.fetch (production default). - */ - readonly fetchFn?: FetchLike; -} - -export function createOpenAICompatProvider(opts: CreateOpenAICompatProviderOpts): ProviderContract { - const baseURL = opts.credentials.baseURL ?? "https://opencode.ai/zen/go/v1"; - const apiKey = opts.credentials.apiKey; - const fetchFn = opts.fetchFn; - - const streamConfig = { - baseURL, - apiKey, - model: opts.model, - ...(fetchFn !== undefined ? { fetchFn } : {}), - }; - - return { - id: "openai-compat", - stream: ( - messages: readonly ChatMessage[], - tools: readonly ToolContract[], - streamOpts?: ProviderStreamOptions, - ) => streamChat(streamConfig, messages, tools, streamOpts), - listModels: (): Promise => - fetchModels({ - baseURL, - apiKey, - providerId: "openai-compat", - ...(fetchFn !== undefined ? { fetchFn } : {}), - }), - }; -} diff --git a/packages/provider-openai-compat/src/stream.test.ts b/packages/provider-openai-compat/src/stream.test.ts deleted file mode 100644 index 0650153..0000000 --- a/packages/provider-openai-compat/src/stream.test.ts +++ /dev/null @@ -1,864 +0,0 @@ -import type { ChatMessage, Logger, ProviderEvent, Span } from "@dispatch/kernel"; -import type { HttpExchangeFixture } from "@dispatch/trace-replay"; -import { loadFixture, recordFetch, replayFetch, serializeFixture } from "@dispatch/trace-replay"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { type StreamConfig, streamChat } from "./stream.js"; - -async function collectEvents(iter: AsyncIterable): Promise { - const events: ProviderEvent[] = []; - for await (const event of iter) { - events.push(event); - } - return events; -} - -function assertDefined(v: T, msg?: string): asserts v is NonNullable { - if (v === undefined || v === null) { - throw new Error(msg ?? "expected defined"); - } -} - -interface CapturedSpan { - name: string; - attrs: Record; - body?: string | undefined; - endOutcome?: - | { err?: unknown; attrs?: Record } - | undefined; -} - -function createFakeLogger(): { logger: Logger; spans: CapturedSpan[] } { - const spans: CapturedSpan[] = []; - let spanAttrBuffer: Record = {}; - let spanBodyBuffer: string | undefined; - - const fakeSpan: Span = { - id: "fake-span-id", - log: {} as Logger, - setAttributes(attrs) { - Object.assign(spanAttrBuffer, attrs); - }, - addLink() {}, - child() { - return fakeSpan; - }, - end(outcome?) { - spans.push({ - name: "provider.request", - attrs: { ...spanAttrBuffer }, - body: spanBodyBuffer, - endOutcome: outcome as CapturedSpan["endOutcome"], - }); - }, - }; - - const logger: Logger = { - debug() {}, - info() {}, - warn() {}, - error() {}, - child() { - return logger; - }, - span(_name, attrs, body) { - spanAttrBuffer = attrs ? { ...attrs } : {}; - spanBodyBuffer = body; - return fakeSpan; - }, - }; - - return { logger, spans }; -} - -function makeConfig(apiKey = "sk-test-1234567890abcdef"): StreamConfig { - return { - baseURL: "https://api.example.com/v1", - apiKey, - model: "test-model", - }; -} - -function mockFetch(handler: (url: string | URL | Request, init?: RequestInit) => unknown): void { - globalThis.fetch = vi.fn(handler) as unknown as typeof globalThis.fetch; -} - -function makeMessages(): readonly ChatMessage[] { - return [ - { - role: "user", - chunks: [{ type: "text", text: "Hello" }], - }, - ]; -} - -function sseBody(...lines: string[]): ReadableStream { - const encoder = new TextEncoder(); - const chunks = lines.map((l) => encoder.encode(`${l}\n`)); - let index = 0; - return new ReadableStream({ - pull(controller) { - if (index < chunks.length) { - const chunk = chunks[index]; - assertDefined(chunk); - controller.enqueue(chunk); - index++; - } else { - controller.close(); - } - }, - }); -} - -describe("streamChat — provider.request AFTER capture", () => { - let originalFetch: typeof globalThis.fetch; - - beforeEach(() => { - originalFetch = globalThis.fetch; - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - it("opens a provider.request span with verbatim request body", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig(); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-1","choices":[{"delta":{"content":"Hi"},"index":0}]}', - 'data: {"id":"cmpl-1","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - const events = await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - expect(events.some((e) => e.type === "text-delta")).toBe(true); - expect(spans).toHaveLength(1); - - assertDefined(spans[0]); - const span = spans[0]; - expect(span.name).toBe("provider.request"); - expect(span.attrs["request.method"]).toBe("POST"); - expect(span.attrs["request.body"]).toBeUndefined(); - - assertDefined(span.body); - const capturedBody = JSON.parse(span.body); - expect(capturedBody.model).toBe("test-model"); - expect(capturedBody.stream).toBe(true); - expect(capturedBody.messages).toEqual([{ role: "user", content: "Hello" }]); - - expect(span.endOutcome?.attrs?.status).toBe(200); - }); - - it("redacts a long API key (≥13 chars → reveal 3 each side)", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig("sk-abcdefghijkmnop"); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-2","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-2","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - assertDefined(spans[0]); - const span = spans[0]; - const authHeader = span.attrs["request.headers.authorization"] as string; - expect(authHeader).toBe("Bearer sk-…redacted…nop"); - expect(authHeader).not.toContain("abcdefghijkm"); - }); - - it("redacts a medium API key (8–10 chars → reveal 1 each side)", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig("sk-abcde"); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-3","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-3","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - assertDefined(spans[0]); - const span = spans[0]; - const authHeader = span.attrs["request.headers.authorization"] as string; - expect(authHeader).toBe("Bearer s…redacted…e"); - }); - - it("redacts a short API key (≤7 chars → full mask)", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig("secret!"); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-4","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-4","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - assertDefined(spans[0]); - const span = spans[0]; - const authHeader = span.attrs["request.headers.authorization"] as string; - expect(authHeader).toBe("Bearer …redacted…"); - }); - - it("captures cache tokens from the response", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig(); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-5","choices":[{"delta":{"content":"Hi"},"index":0}]}', - 'data: {"id":"cmpl-5","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - 'data: {"id":"cmpl-5","usage":{"prompt_tokens":100,"completion_tokens":20,"cache_read_tokens":80,"cache_write_tokens":10}}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - assertDefined(spans[0]); - const span = spans[0]; - expect(span.endOutcome?.attrs?.["usage.inputTokens"]).toBe(100); - expect(span.endOutcome?.attrs?.["usage.outputTokens"]).toBe(20); - expect(span.endOutcome?.attrs?.["usage.cacheReadTokens"]).toBe(80); - expect(span.endOutcome?.attrs?.["usage.cacheWriteTokens"]).toBe(10); - }); - - it("captures cache_read_tokens alone", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig(); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-6","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-6","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - 'data: {"id":"cmpl-6","usage":{"prompt_tokens":50,"completion_tokens":5,"cache_read_tokens":45}}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - assertDefined(spans[0]); - const span = spans[0]; - expect(span.endOutcome?.attrs?.["usage.cacheReadTokens"]).toBe(45); - expect(span.endOutcome?.attrs?.["usage.cacheWriteTokens"]).toBeUndefined(); - }); - - it("records HTTP error status and error body without throwing", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig(); - - mockFetch( - () => - new Response("Invalid request body", { - status: 400, - headers: { "Content-Type": "text/plain" }, - }), - ); - - const events = await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - expect(events).toHaveLength(1); - expect(events[0]).toEqual({ - type: "error", - message: "HTTP 400: Invalid request body", - code: "400", - retryable: false, - }); - - expect(spans).toHaveLength(1); - assertDefined(spans[0]); - const span = spans[0]; - expect(span.endOutcome?.attrs?.status).toBe(400); - expect(span.endOutcome?.attrs?.["response.error_body"]).toBe("Invalid request body"); - expect(span.endOutcome?.err).toBeInstanceOf(Error); - }); - - it("records network error without throwing", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig(); - - mockFetch(() => { - throw new Error("connection refused"); - }); - - const events = await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - expect(events).toHaveLength(1); - expect(events[0]).toEqual({ - type: "error", - message: "connection refused", - retryable: true, - }); - - expect(spans).toHaveLength(1); - assertDefined(spans[0]); - const span = spans[0]; - expect(span.endOutcome?.err).toBeInstanceOf(Error); - expect((span.endOutcome?.err as Error).message).toBe("connection refused"); - }); - - it("detects cache_control breakpoint absence in a normal request body", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig(); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-7","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-7","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - assertDefined(spans[0]); - const span = spans[0]; - expect(span.attrs["request.cache_control_present"]).toBe(false); - }); - - it("does not open a span when opts.logger is absent", async () => { - const config = makeConfig(); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-8","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-8","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - const events = await collectEvents(streamChat(config, makeMessages(), [])); - - expect(events.some((e) => e.type === "text-delta")).toBe(true); - }); - - it("fail-safe: logger throwing does not break stream()", async () => { - const brokenLogger: Logger = { - debug() {}, - info() {}, - warn() {}, - error() {}, - child() { - return brokenLogger; - }, - span() { - throw new Error("logger exploded"); - }, - }; - - const config = makeConfig(); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-9","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-9","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - const events = await collectEvents( - streamChat(config, makeMessages(), [], { logger: brokenLogger }), - ); - - expect(events.some((e) => e.type === "text-delta")).toBe(true); - expect(events.some((e) => e.type === "finish")).toBe(true); - }); - - it("redacts an 11-char API key (reveal 2 each side)", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig("sk-abcde1234"); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-10","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-10","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - assertDefined(spans[0]); - const span = spans[0]; - const authHeader = span.attrs["request.headers.authorization"] as string; - expect(authHeader).toBe("Bearer sk…redacted…34"); - }); - - it("records server error (500) as retryable", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig(); - - mockFetch( - () => - new Response("Internal Server Error", { - status: 500, - headers: { "Content-Type": "text/plain" }, - }), - ); - - const events = await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - expect(events).toHaveLength(1); - expect(events[0]).toEqual({ - type: "error", - message: "HTTP 500: Internal Server Error", - code: "500", - retryable: true, - }); - - expect(spans).toHaveLength(1); - assertDefined(spans[0]); - expect(spans[0].endOutcome?.attrs?.status).toBe(500); - }); - - it("captures model and url on the span", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig(); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-11","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-11","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - await collectEvents(streamChat(config, makeMessages(), [], { logger })); - - assertDefined(spans[0]); - const span = spans[0]; - expect(span.attrs.model).toBe("test-model"); - expect(span.attrs.url).toBe("https://api.example.com/v1/chat/completions"); - }); - - it("uses opts.model override in capture", async () => { - const { logger, spans } = createFakeLogger(); - const config = makeConfig(); - - mockFetch( - () => - new Response( - sseBody( - 'data: {"id":"cmpl-12","choices":[{"delta":{"content":"ok"},"index":0}]}', - 'data: {"id":"cmpl-12","choices":[{"delta":{},"finish_reason":"stop","index":0}]}', - "data: [DONE]", - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - ); - - await collectEvents( - streamChat(config, makeMessages(), [], { logger, model: "override-model" }), - ); - - assertDefined(spans[0]); - const span = spans[0]; - expect(span.attrs.model).toBe("override-model"); - - assertDefined(span.body); - const capturedBody = JSON.parse(span.body); - expect(capturedBody.model).toBe("override-model"); - }); -}); - -describe("streamChat — hermetic replay (trace-replay)", () => { - const testDir = new URL(".", import.meta.url).pathname; - const fixturePath = `${testDir}__fixtures__/flash-text-turn.json`; - const toolFixturePath = `${testDir}__fixtures__/tool-call-turn.json`; - - it("replays a text-turn fixture and produces correct ProviderEvents", async () => { - const fixture = loadFixture(fixturePath); - const { fetch: replayFetchFn, getCapturedRequest } = replayFetch(fixture, { chunkBytes: 64 }); - - const config: StreamConfig = { - baseURL: "https://api.example.com/v1", - apiKey: "sk-test-1234567890abcdef", - model: "deepseek-v4-flash", - fetchFn: replayFetchFn, - }; - - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "Hello, how are you?" }] }, - ]; - - const events = await collectEvents(streamChat(config, messages, [])); - - const textDeltas = events.filter( - (e): e is Extract => e.type === "text-delta", - ); - const fullText = textDeltas.map((e) => e.delta).join(""); - expect(fullText).toBe("Hello there friend"); - - const finishEvents = events.filter((e) => e.type === "finish"); - expect(finishEvents).toHaveLength(1); - expect(finishEvents[0]).toEqual({ type: "finish", reason: "stop" }); - - const usageEvents = events.filter( - (e): e is Extract => e.type === "usage", - ); - expect(usageEvents).toHaveLength(1); - expect(usageEvents[0]?.usage.inputTokens).toBe(665); - expect(usageEvents[0]?.usage.outputTokens).toBe(90); - expect(usageEvents[0]?.usage.cacheReadTokens).toBe(384); - - const captured = getCapturedRequest(); - assertDefined(captured); - expect(captured.method).toBe("POST"); - expect(captured.url).toBe("https://api.example.com/v1/chat/completions"); - expect(captured.headers["Content-Type"]).toBe("application/json"); - expect(captured.headers.Authorization).toBe("Bearer sk-test-1234567890abcdef"); - - assertDefined(captured.body); - const capturedBody = JSON.parse(captured.body); - expect(capturedBody.model).toBe("deepseek-v4-flash"); - expect(capturedBody.stream).toBe(true); - expect(capturedBody.messages).toEqual([{ role: "user", content: "Hello, how are you?" }]); - }); - - it("replays a tool-call-turn fixture and produces tool-call + finish events", async () => { - const fixture = loadFixture(toolFixturePath); - const { fetch: replayFetchFn, getCapturedRequest } = replayFetch(fixture, { chunkBytes: 48 }); - - const config: StreamConfig = { - baseURL: "https://api.example.com/v1", - apiKey: "sk-test-1234567890abcdef", - model: "deepseek-v4-flash", - fetchFn: replayFetchFn, - }; - - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "What is the weather in Tokyo?" }] }, - ]; - - const weatherTool = { - name: "get_weather", - description: "Get current weather for a location", - parameters: { - type: "object" as const, - properties: { location: { type: "string" as const } }, - required: ["location"], - }, - execute: async () => ({ content: "" }), - }; - - const events = await collectEvents(streamChat(config, messages, [weatherTool])); - - const toolCalls = events.filter( - (e): e is Extract => e.type === "tool-call", - ); - expect(toolCalls).toHaveLength(1); - expect(toolCalls[0]?.toolCallId).toBe("call_abc123"); - expect(toolCalls[0]?.toolName).toBe("get_weather"); - expect(toolCalls[0]?.input).toEqual({ location: "Tokyo" }); - - const finishEvents = events.filter((e) => e.type === "finish"); - expect(finishEvents).toHaveLength(1); - expect(finishEvents[0]).toEqual({ type: "finish", reason: "tool_calls" }); - - const usageEvents = events.filter( - (e): e is Extract => e.type === "usage", - ); - expect(usageEvents).toHaveLength(1); - expect(usageEvents[0]?.usage.inputTokens).toBe(45); - expect(usageEvents[0]?.usage.outputTokens).toBe(12); - expect(usageEvents[0]?.usage.cacheReadTokens).toBe(30); - expect(usageEvents[0]?.usage.cacheWriteTokens).toBe(5); - - const captured = getCapturedRequest(); - assertDefined(captured); - expect(captured.method).toBe("POST"); - assertDefined(captured.body); - const capturedBody = JSON.parse(captured.body); - expect(capturedBody.tools).toHaveLength(1); - expect(capturedBody.tools[0].function.name).toBe("get_weather"); - }); -}); - -describe("streamChat — record-mode redaction (trace-replay)", () => { - /** - * Graduated secret mask — §6 tiers. Duplicated locally (isolation-over-dry). - * ≥13 → reveal 3 each side · 11–12 → 2 · 8–10 → 1 · ≤7 → full mask. - */ - function maskSecret(value: string): string { - const len = value.length; - if (len <= 7) return "…redacted…"; - let reveal: number; - if (len >= 13) { - reveal = 3; - } else if (len >= 11) { - reveal = 2; - } else { - reveal = 1; - } - return `${value.slice(0, reveal)}…redacted…${value.slice(-reveal)}`; - } - - it("self-redacts auth header in onExchange and produces a secret-free fixture", async () => { - const apiKey = "sk-abcdefghijkmnop"; - const responseBody = - 'data: {"id":"cmpl-r","choices":[{"delta":{"content":"ok"},"index":0}],"usage":{"prompt_tokens":5,"completion_tokens":1,"cache_read_tokens":0,"cache_write_tokens":0}}\n\ndata: [DONE]\n'; - - let capturedFixture: HttpExchangeFixture | undefined; - const wrappedFetch = recordFetch( - async () => - new Response(responseBody, { - status: 200, - headers: { "content-type": "text/event-stream" }, - }), - (fx) => { - const redactedHeaders: Record = {}; - for (const [key, value] of Object.entries(fx.request.headers)) { - if (key.toLowerCase() === "authorization") { - const token = value.replace(/^Bearer\s+/i, ""); - redactedHeaders[key] = `Bearer ${maskSecret(token)}`; - } else { - redactedHeaders[key] = value; - } - } - capturedFixture = { - request: { ...fx.request, headers: redactedHeaders }, - response: fx.response, - ...(fx.meta !== undefined ? { meta: fx.meta } : {}), - }; - }, - ); - - await wrappedFetch("https://api.example.com/v1/chat/completions", { - method: "POST", - headers: { - "content-type": "application/json", - authorization: `Bearer ${apiKey}`, - }, - body: '{"model":"test","messages":[{"role":"user","content":"hi"}],"stream":true}', - }); - - assertDefined(capturedFixture); - - expect(capturedFixture.request.headers.authorization).toBe("Bearer sk-…redacted…nop"); - expect(capturedFixture.request.headers.authorization).not.toContain("abcdefghijkm"); - expect(capturedFixture.request.headers["content-type"]).toBe("application/json"); - - expect(capturedFixture.request.body).toContain('"model":"test"'); - expect(capturedFixture.request.body).toContain('"content":"hi"'); - - expect(capturedFixture.response.status).toBe(200); - expect(capturedFixture.response.body).toBe(responseBody); - - const serialized = serializeFixture(capturedFixture); - expect(serialized).toContain("Bearer sk-…redacted…nop"); - expect(serialized).not.toContain("abcdefghijkm"); - expect(serialized).toContain("content"); - expect(serialized).toContain("hi"); - }); - - it("redacts capitalized Authorization header (the real leak casing)", async () => { - const apiKey = "sk-LIVEKEY1234567890abcdef"; - const responseBody = "data: [DONE]\n"; - - let capturedFixture: HttpExchangeFixture | undefined; - const wrappedFetch = recordFetch( - async () => - new Response(responseBody, { - status: 200, - headers: { "content-type": "text/event-stream" }, - }), - (fx) => { - const redactedHeaders: Record = {}; - for (const [key, value] of Object.entries(fx.request.headers)) { - if (key.toLowerCase() === "authorization") { - const token = value.replace(/^Bearer\s+/i, ""); - redactedHeaders[key] = `Bearer ${maskSecret(token)}`; - } else { - redactedHeaders[key] = value; - } - } - capturedFixture = { - request: { ...fx.request, headers: redactedHeaders }, - response: fx.response, - }; - }, - ); - - await wrappedFetch("https://api.example.com/v1/chat/completions", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - body: '{"model":"test","messages":[],"stream":true}', - }); - - assertDefined(capturedFixture); - expect(capturedFixture.request.headers.Authorization).toBe("Bearer sk-…redacted…def"); - expect(capturedFixture.request.headers.Authorization).not.toContain("LIVEKEY1234567890abc"); - - const serialized = serializeFixture(capturedFixture); - expect(serialized).not.toContain("LIVEKEY1234567890abc"); - expect(serialized).toContain("Bearer sk-…redacted…def"); - }); - - it("redacts lowercase authorization header", async () => { - const apiKey = "sk-abcdefghijkmnop"; - - let capturedFixture: HttpExchangeFixture | undefined; - const wrappedFetch = recordFetch( - async () => new Response("data: [DONE]\n", { status: 200 }), - (fx) => { - const redactedHeaders: Record = {}; - for (const [key, value] of Object.entries(fx.request.headers)) { - if (key.toLowerCase() === "authorization") { - const token = value.replace(/^Bearer\s+/i, ""); - redactedHeaders[key] = `Bearer ${maskSecret(token)}`; - } else { - redactedHeaders[key] = value; - } - } - capturedFixture = { - request: { ...fx.request, headers: redactedHeaders }, - response: fx.response, - }; - }, - ); - - await wrappedFetch("https://api.example.com/v1/chat/completions", { - method: "POST", - headers: { authorization: `Bearer ${apiKey}` }, - body: null, - }); - - assertDefined(capturedFixture); - expect(capturedFixture.request.headers.authorization).toBe("Bearer sk-…redacted…nop"); - expect(capturedFixture.request.headers.authorization).not.toContain("abcdefghijkm"); - }); - - it("guard: no header named authorization (any case) survives with a raw sk- token", async () => { - const apiKey = "sk-REALKEY_1234567890abcdef"; - - let capturedFixture: HttpExchangeFixture | undefined; - const wrappedFetch = recordFetch( - async () => new Response("data: [DONE]\n", { status: 200 }), - (fx) => { - const redactedHeaders: Record = {}; - for (const [key, value] of Object.entries(fx.request.headers)) { - if (key.toLowerCase() === "authorization") { - const token = value.replace(/^Bearer\s+/i, ""); - redactedHeaders[key] = `Bearer ${maskSecret(token)}`; - } else { - redactedHeaders[key] = value; - } - } - capturedFixture = { - request: { ...fx.request, headers: redactedHeaders }, - response: fx.response, - }; - }, - ); - - await wrappedFetch("https://api.example.com/v1/chat/completions", { - method: "POST", - headers: { Authorization: `Bearer ${apiKey}` }, - body: null, - }); - - assertDefined(capturedFixture); - for (const [key, value] of Object.entries(capturedFixture.request.headers)) { - if (key.toLowerCase() === "authorization") { - expect(value).not.toContain(apiKey); - expect(value).not.toMatch(/sk-[A-Za-z0-9]{10,}/); - } - } - - const serialized = serializeFixture(capturedFixture); - expect(serialized).not.toContain(apiKey); - expect(serialized).not.toMatch(/sk-[A-Za-z0-9]{10,}/); - }); - - it("redacts a short API key (≤7 chars → full mask)", async () => { - let capturedFixture: HttpExchangeFixture | undefined; - const wrappedFetch = recordFetch( - async () => new Response("data: [DONE]\n", { status: 200 }), - (fx) => { - const redactedHeaders: Record = {}; - for (const [key, value] of Object.entries(fx.request.headers)) { - if (key.toLowerCase() === "authorization") { - const token = value.replace(/^Bearer\s+/i, ""); - redactedHeaders[key] = `Bearer ${maskSecret(token)}`; - } else { - redactedHeaders[key] = value; - } - } - capturedFixture = { - request: { ...fx.request, headers: redactedHeaders }, - response: fx.response, - }; - }, - ); - - await wrappedFetch("https://api.example.com/v1/chat/completions", { - method: "POST", - headers: { authorization: "Bearer secret!" }, - body: null, - }); - - assertDefined(capturedFixture); - expect(capturedFixture.request.headers.authorization).toBe("Bearer …redacted…"); - }); -}); diff --git a/packages/provider-openai-compat/src/stream.ts b/packages/provider-openai-compat/src/stream.ts deleted file mode 100644 index b60efc1..0000000 --- a/packages/provider-openai-compat/src/stream.ts +++ /dev/null @@ -1,393 +0,0 @@ -import type { - ChatMessage, - ProviderEvent, - ProviderStreamOptions, - Span, - ToolContract, -} from "@dispatch/kernel"; -import type { FetchLike, HttpExchangeFixture } from "@dispatch/trace-replay"; -import { convertMessages, type OpenAIMessage } from "./convert-messages.js"; -import { convertTools, type OpenAITool } from "./convert-tools.js"; - -export interface StreamConfig { - readonly baseURL: string; - readonly apiKey: string; - readonly model: string; - /** - * Internal injectable fetch — used by replay tests and record mode. - * When absent, falls back to globalThis.fetch (production default). - */ - readonly fetchFn?: FetchLike; -} - -/** - * Graduated secret mask — §6 tiers. Reimplemented locally (isolation-over-dry). - * ≥13 → reveal 3 each side · 11–12 → 2 · 8–10 → 1 · ≤7 → full mask. - */ -function maskSecret(value: string): string { - const len = value.length; - if (len <= 7) return "…redacted…"; - let reveal: number; - if (len >= 13) { - reveal = 3; - } else if (len >= 11) { - reveal = 2; - } else { - reveal = 1; - } - return `${value.slice(0, reveal)}…redacted…${value.slice(-reveal)}`; -} - -export async function* streamChat( - config: StreamConfig, - messages: readonly ChatMessage[], - tools: readonly ToolContract[], - opts?: ProviderStreamOptions, -): AsyncIterable { - const openaiMessages = convertMessages(messages); - const openaiTools = convertTools(tools); - - const systemPrompt = opts?.systemPrompt; - const finalMessages: OpenAIMessage[] = systemPrompt - ? [{ role: "system", content: systemPrompt }, ...openaiMessages] - : openaiMessages; - - const body: Record = { - model: opts?.model ?? config.model, - messages: finalMessages, - stream: true, - }; - - if (openaiTools.length > 0) { - body.tools = openaiTools satisfies OpenAITool[]; - } - if (opts?.temperature !== undefined) { - body.temperature = opts.temperature; - } - if (opts?.maxTokens !== undefined) { - body.max_tokens = opts.maxTokens; - } - - const url = `${config.baseURL}/chat/completions`; - const bodyString = JSON.stringify(body); - - let reqSpan: Span | undefined; - let totalInputTokens = 0; - let totalOutputTokens = 0; - let totalCacheReadTokens: number | undefined; - let totalCacheWriteTokens: number | undefined; - - if (opts?.logger) { - try { - const model = opts?.model ?? config.model; - const hasCacheBreakpoint = bodyString.includes("cache_control"); - reqSpan = opts.logger.span( - "provider.request", - { - model, - url, - "request.method": "POST", - "request.cache_control_present": hasCacheBreakpoint, - "request.headers.authorization": `Bearer ${maskSecret(config.apiKey)}`, - }, - bodyString, - ); - } catch { - // Fail-safe: capture must never break stream(). - } - } - - let effectiveFetch: FetchLike = config.fetchFn ?? fetch; - - const recordPath = - typeof process !== "undefined" ? process.env.DISPATCH_RECORD_FIXTURE : undefined; - if (recordPath && !config.fetchFn) { - try { - const { recordFetch: rf, saveFixture } = await import("@dispatch/trace-replay"); - effectiveFetch = rf(effectiveFetch, (fx: HttpExchangeFixture) => { - try { - const redactedHeaders: Record = {}; - for (const [key, value] of Object.entries(fx.request.headers)) { - if (key.toLowerCase() === "authorization") { - const token = value.replace(/^Bearer\s+/i, ""); - redactedHeaders[key] = `Bearer ${maskSecret(token)}`; - } else { - redactedHeaders[key] = value; - } - } - const redacted: HttpExchangeFixture = { - request: { ...fx.request, headers: redactedHeaders }, - response: fx.response, - ...(fx.meta !== undefined ? { meta: fx.meta } : {}), - }; - saveFixture(recordPath, redacted); - } catch { - // Fail-safe: capture/write must never break the turn. - } - }); - } catch { - // Fail-safe: dynamic import or wrapping failure must never break the turn. - } - } - - let response: Response; - try { - response = await effectiveFetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${config.apiKey}`, - }, - body: bodyString, - }); - } catch (err) { - if (reqSpan) { - try { - reqSpan.end({ - err, - attrs: { status: 0 }, - }); - } catch { - // Fail-safe. - } - } - yield { - type: "error", - message: err instanceof Error ? err.message : String(err), - retryable: true, - }; - return; - } - - if (!response.ok) { - const text = await response.text().catch(() => "unknown"); - if (reqSpan) { - try { - reqSpan.setAttributes({ status: response.status }); - reqSpan.end({ - err: new Error(`HTTP ${response.status}: ${text}`), - attrs: { - status: response.status, - "response.error_body": text, - }, - }); - } catch { - // Fail-safe. - } - } - yield { - type: "error", - message: `HTTP ${response.status}: ${text}`, - code: String(response.status), - retryable: response.status >= 500 || response.status === 429, - }; - return; - } - - if (!response.body) { - if (reqSpan) { - try { - reqSpan.end({ - err: new Error("Response body is null"), - attrs: { status: response.status }, - }); - } catch { - // Fail-safe. - } - } - yield { type: "error", message: "Response body is null" }; - return; - } - - try { - yield* readSSEStream(response.body, (usage) => { - totalInputTokens = usage.inputTokens; - totalOutputTokens = usage.outputTokens; - totalCacheReadTokens = usage.cacheReadTokens; - totalCacheWriteTokens = usage.cacheWriteTokens; - }); - } catch (err) { - if (reqSpan) { - try { - reqSpan.end({ - err, - attrs: { status: response.status }, - }); - } catch { - // Fail-safe. - } - } - throw err; - } - - if (reqSpan) { - try { - const attrs: Record = { - status: response.status, - "usage.inputTokens": totalInputTokens, - "usage.outputTokens": totalOutputTokens, - }; - if (totalCacheReadTokens !== undefined) { - attrs["usage.cacheReadTokens"] = totalCacheReadTokens; - } - if (totalCacheWriteTokens !== undefined) { - attrs["usage.cacheWriteTokens"] = totalCacheWriteTokens; - } - reqSpan.end({ attrs }); - } catch { - // Fail-safe. - } - } -} - -async function* readSSEStream( - body: ReadableStream, - onUsage?: (usage: { - inputTokens: number; - outputTokens: number; - cacheReadTokens?: number; - cacheWriteTokens?: number; - }) => void, -): AsyncIterable { - const reader = body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - const toolCalls = new Map(); - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed.startsWith("data:")) continue; - - const data = trimmed.slice(5).trim(); - if (data === "[DONE]") return; - - let chunk: Record; - try { - chunk = JSON.parse(data); - } catch { - yield { type: "error", message: `Invalid JSON in SSE data: ${data}` }; - continue; - } - - const choices = chunk.choices as - | Array<{ - delta: Record; - finish_reason?: string | null; - }> - | undefined; - - if (choices) { - for (const choice of choices) { - const delta = choice.delta; - - if (typeof delta.content === "string" && delta.content) { - yield { type: "text-delta", delta: delta.content }; - } - - if (typeof delta.reasoning_content === "string" && delta.reasoning_content) { - yield { type: "reasoning-delta", delta: delta.reasoning_content }; - } - - const tcs = delta.tool_calls as - | Array<{ - index: number; - id?: string; - function?: { name?: string; arguments?: string }; - }> - | undefined; - - if (tcs) { - for (const tc of tcs) { - const existing = toolCalls.get(tc.index); - if (existing) { - if (tc.function?.arguments) { - existing.arguments += tc.function.arguments; - } - } else { - toolCalls.set(tc.index, { - id: tc.id ?? "", - name: tc.function?.name ?? "", - arguments: tc.function?.arguments ?? "", - }); - } - } - } - - if (choice.finish_reason) { - const sortedIndices = [...toolCalls.keys()].sort((a, b) => a - b); - for (const idx of sortedIndices) { - const acc = toolCalls.get(idx); - if (!acc) continue; - let input: unknown; - try { - input = JSON.parse(acc.arguments); - } catch { - input = acc.arguments; - } - yield { - type: "tool-call", - toolCallId: acc.id, - toolName: acc.name, - input, - }; - } - yield { type: "finish", reason: choice.finish_reason }; - } - } - } - - const usage = chunk.usage as - | { - prompt_tokens?: number; - completion_tokens?: number; - cache_read_tokens?: number; - cache_write_tokens?: number; - prompt_tokens_details?: { cached_tokens?: number }; - completion_tokens_details?: Record; - } - | undefined; - - if (usage) { - const cacheRead = usage.cache_read_tokens ?? usage.prompt_tokens_details?.cached_tokens; - const cacheWrite = usage.cache_write_tokens; - const usageObj: { - inputTokens: number; - outputTokens: number; - cacheReadTokens?: number; - cacheWriteTokens?: number; - } = { - inputTokens: usage.prompt_tokens ?? 0, - outputTokens: usage.completion_tokens ?? 0, - }; - if (cacheRead !== undefined) { - usageObj.cacheReadTokens = cacheRead; - } - if (cacheWrite !== undefined) { - usageObj.cacheWriteTokens = cacheWrite; - } - onUsage?.(usageObj); - yield { - type: "usage", - usage: { - inputTokens: usage.prompt_tokens ?? 0, - outputTokens: usage.completion_tokens ?? 0, - ...(cacheRead !== undefined ? { cacheReadTokens: cacheRead } : {}), - ...(cacheWrite !== undefined ? { cacheWriteTokens: cacheWrite } : {}), - }, - }; - } - } - } - } finally { - reader.releaseLock(); - } -} diff --git a/packages/provider-openai-compat/tsconfig.json b/packages/provider-openai-compat/tsconfig.json index c5997ed..9cc0414 100644 --- a/packages/provider-openai-compat/tsconfig.json +++ b/packages/provider-openai-compat/tsconfig.json @@ -2,5 +2,9 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }, { "path": "../trace-replay" }] + "references": [ + { "path": "../kernel" }, + { "path": "../openai-stream" }, + { "path": "../trace-replay" } + ] } diff --git a/packages/provider-umans/package.json b/packages/provider-umans/package.json new file mode 100644 index 0000000..ca09e06 --- /dev/null +++ b/packages/provider-umans/package.json @@ -0,0 +1,12 @@ +{ + "name": "@dispatch/provider-umans", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/openai-stream": "workspace:*" + } +} diff --git a/packages/provider-umans/src/extension.test.ts b/packages/provider-umans/src/extension.test.ts new file mode 100644 index 0000000..59efddc --- /dev/null +++ b/packages/provider-umans/src/extension.test.ts @@ -0,0 +1,52 @@ +import type { HostAPI } from "@dispatch/kernel"; +import { describe, expect, it, vi } from "vitest"; +import { activate, manifest } from "./extension.js"; +import type { EnvSource } from "./resolver.js"; + +function makeFakeHost(overrides: { configGet?: (key: string) => unknown }): { + host: HostAPI; + defineProvider: ReturnType; + warn: ReturnType; + info: ReturnType; +} { + const defineProvider = vi.fn(); + const warn = vi.fn(); + const info = vi.fn(); + const host = { + defineProvider, + config: { get: overrides.configGet ?? (() => undefined) }, + logger: { debug: vi.fn(), info, warn, error: vi.fn() }, + } as unknown as HostAPI; + return { host, defineProvider, warn, info }; +} + +describe("provider-umans activation", () => { + it('activate registers the "umans" provider when UMANS_API_KEY is set (defineProvider called with id "umans")', async () => { + const { host, defineProvider } = makeFakeHost({}); + const env: EnvSource = { UMANS_API_KEY: "sk-test" }; + + await activate(host, env); + + expect(defineProvider).toHaveBeenCalledTimes(1); + expect(defineProvider.mock.calls[0]?.[0]?.id).toBe("umans"); + }); + + it("activate does NOT register + warns when UMANS_API_KEY is unset", async () => { + const { host, defineProvider, warn } = makeFakeHost({}); + const env: EnvSource = {}; + + await activate(host, env); + + expect(defineProvider).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith("provider-umans: no UMANS_API_KEY. Provider not registered."); + }); + + it("declares no dependsOn (self-contained, reads env directly)", () => { + expect(manifest.dependsOn).toBeUndefined(); + }); + + it("declares the umans provider contribution + network capability", () => { + expect(manifest.contributes?.providers).toEqual(["umans"]); + expect(manifest.capabilities?.network).toBe(true); + }); +}); diff --git a/packages/provider-umans/src/extension.ts b/packages/provider-umans/src/extension.ts new file mode 100644 index 0000000..74f8481 --- /dev/null +++ b/packages/provider-umans/src/extension.ts @@ -0,0 +1,55 @@ +import type { ApiKeyCredentials, Extension, HostAPI, Manifest } from "@dispatch/kernel"; +import { createOpenAICompatProvider } from "@dispatch/openai-stream"; +import { transformBody } from "./reasoning.js"; +import { type EnvSource, resolveUmansConfig } from "./resolver.js"; + +export const manifest: Manifest = { + id: "provider-umans", + name: "Umans AI Coding Plan", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + capabilities: { network: true }, + contributes: { providers: ["umans"] }, +}; + +/** + * Activate the Umans provider. Reads `UMANS_API_KEY` / `UMANS_BASE_URL` / + * `UMANS_MODEL` from the environment (the imperative shell — at the edge) and + * `provider.umans.model` from host config, resolves the config via the pure + * `resolveUmansConfig`, then builds + registers the `"umans"` provider through + * `@dispatch/openai-stream`'s `createOpenAICompatProvider`. + * + * `env` defaults to `process.env` and is a parameter only so tests can inject a + * fake environment (faking the outermost edge) without mutating the real one. + * No `UMANS_API_KEY` is a config state, not an error — warn + skip registration. + */ +export async function activate(host: HostAPI, env: EnvSource = process.env): Promise { + const configModel = host.config.get("provider.umans.model"); + const cfg = resolveUmansConfig(env, configModel); + if (!cfg) { + host.logger.warn("provider-umans: no UMANS_API_KEY. Provider not registered."); + return; + } + + const credentials: ApiKeyCredentials = { + type: "api-key", + apiKey: cfg.apiKey, + baseURL: cfg.baseURL, + }; + + const provider = createOpenAICompatProvider({ + credentials, + model: cfg.model, + id: "umans", + transformBody, + }); + host.defineProvider(provider); + host.logger.info(`provider-umans: registered (model=${cfg.model})`); +} + +export const extension: Extension = { + manifest, + activate, +}; diff --git a/packages/provider-umans/src/index.ts b/packages/provider-umans/src/index.ts new file mode 100644 index 0000000..d4d143b --- /dev/null +++ b/packages/provider-umans/src/index.ts @@ -0,0 +1,8 @@ +export { activate, extension, manifest } from "./extension.js"; +export { mapReasoningEffort, transformBody } from "./reasoning.js"; +export { + type EnvSource, + resolveUmansConfig, + toUmansCredentials, + type UmansConfig, +} from "./resolver.js"; diff --git a/packages/provider-umans/src/reasoning.test.ts b/packages/provider-umans/src/reasoning.test.ts new file mode 100644 index 0000000..2e57e25 --- /dev/null +++ b/packages/provider-umans/src/reasoning.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { mapReasoningEffort, transformBody } from "./reasoning.js"; + +describe("mapReasoningEffort", () => { + it('mapReasoningEffort: low → "low", medium → "medium", high → "high", xhigh → "high", max → "high"', () => { + expect(mapReasoningEffort("low")).toBe("low"); + expect(mapReasoningEffort("medium")).toBe("medium"); + expect(mapReasoningEffort("high")).toBe("high"); + expect(mapReasoningEffort("xhigh")).toBe("high"); + expect(mapReasoningEffort("max")).toBe("high"); + }); + + it("mapReasoningEffort: undefined → undefined (no field)", () => { + expect(mapReasoningEffort(undefined)).toBe(undefined); + }); +}); + +describe("transformBody", () => { + it("transformBody adds reasoning_effort when opts.reasoningEffort is set", () => { + const result = transformBody({}, { reasoningEffort: "high" }); + expect(result).toEqual({ reasoning_effort: "high" }); + }); + + it("transformBody adds nothing when opts.reasoningEffort is absent (byte-stable)", () => { + const result = transformBody({}, {}); + expect(result).toEqual({}); + }); +}); diff --git a/packages/provider-umans/src/reasoning.ts b/packages/provider-umans/src/reasoning.ts new file mode 100644 index 0000000..9015848 --- /dev/null +++ b/packages/provider-umans/src/reasoning.ts @@ -0,0 +1,36 @@ +import type { ProviderStreamOptions, ReasoningEffort } from "@dispatch/kernel"; + +/** + * Map a resolved `ReasoningEffort` to Umans' `reasoning_effort` wire value. + * + * Umans' OpenAI route accepts `"none"|"low"|"medium"|"high"`; Dispatch's + * `ReasoningEffort` adds `"xhigh"|"max"`, which Umans caps to `"high"`. An + * absent effort (`undefined`) maps to `undefined` so the caller emits no + * `reasoning_effort` field at all — byte-stable when the caller has no + * preference. + * + * Pure: the `transformBody` decision, factored out for direct unit testing. + */ +export function mapReasoningEffort( + effort: ReasoningEffort | undefined, +): "low" | "medium" | "high" | undefined { + if (effort === undefined) return undefined; + if (effort === "xhigh" || effort === "max") return "high"; + return effort; +} + +/** + * Provider-specific body transform handed to `@dispatch/openai-stream`'s + * `createOpenAICompatProvider`. Returns the extra fields the library merges + * into the chat-completions body before send. Adds `reasoning_effort` only when + * a resolved effort is present; returns `{}` (no fields) otherwise — + * byte-stable for calls with no reasoning preference. + */ +export function transformBody( + _body: Record, + opts: ProviderStreamOptions, +): Record { + const mapped = mapReasoningEffort(opts.reasoningEffort); + if (mapped === undefined) return {}; + return { reasoning_effort: mapped }; +} diff --git a/packages/provider-umans/src/resolver.test.ts b/packages/provider-umans/src/resolver.test.ts new file mode 100644 index 0000000..47d436e --- /dev/null +++ b/packages/provider-umans/src/resolver.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { resolveUmansConfig } from "./resolver.js"; + +describe("resolveUmansConfig", () => { + it("activate uses UMANS_BASE_URL override when set (default otherwise)", () => { + const override = resolveUmansConfig( + { UMANS_API_KEY: "sk-test", UMANS_BASE_URL: "https://custom.example.com/v1" }, + undefined, + ); + expect(override?.baseURL).toBe("https://custom.example.com/v1"); + + const fallback = resolveUmansConfig({ UMANS_API_KEY: "sk-test" }, undefined); + expect(fallback?.baseURL).toBe("https://api.code.umans.ai/v1"); + }); + + it('activate uses config provider.umans.model → UMANS_MODEL → "umans-coder" resolution', () => { + // config wins over env + default + const fromConfig = resolveUmansConfig( + { UMANS_API_KEY: "sk-test", UMANS_MODEL: "env-model" }, + "config-model", + ); + expect(fromConfig?.model).toBe("config-model"); + + // env wins when config is absent + const fromEnv = resolveUmansConfig( + { UMANS_API_KEY: "sk-test", UMANS_MODEL: "env-model" }, + undefined, + ); + expect(fromEnv?.model).toBe("env-model"); + + // default when neither is set + const fromDefault = resolveUmansConfig({ UMANS_API_KEY: "sk-test" }, undefined); + expect(fromDefault?.model).toBe("umans-coder"); + }); + + it("returns null when UMANS_API_KEY is unset", () => { + expect(resolveUmansConfig({}, undefined)).toBeNull(); + }); + + it("returns null when UMANS_API_KEY is empty string", () => { + expect(resolveUmansConfig({ UMANS_API_KEY: "" }, undefined)).toBeNull(); + }); +}); diff --git a/packages/provider-umans/src/resolver.ts b/packages/provider-umans/src/resolver.ts new file mode 100644 index 0000000..416a281 --- /dev/null +++ b/packages/provider-umans/src/resolver.ts @@ -0,0 +1,51 @@ +import type { ApiKeyCredentials } from "@dispatch/kernel"; + +const DEFAULT_BASE_URL = "https://api.code.umans.ai/v1"; +const DEFAULT_MODEL = "umans-coder"; + +/** + * Resolved Umans provider config — the API key plus the overridable base URL + * and model that `activate` threads into `createOpenAICompatProvider`. + */ +export interface UmansConfig { + readonly apiKey: string; + readonly baseURL: string; + readonly model: string; +} + +/** + * Environment source for `resolveUmansConfig` — `process.env` (or a fake for + * tests). Matches the canonical `Readonly>` + * env view used across the codebase. + */ +export type EnvSource = Readonly>; + +/** + * Resolve Umans provider config from the environment + the host config. + * + * Precedence (mirrors `provider-openai-compat`): + * - `apiKey`: `UMANS_API_KEY` — unset/empty → `null` (caller warns + skips). + * - `baseURL`: `UMANS_BASE_URL` → `https://api.code.umans.ai/v1`. + * - `model`: `provider.umans.model` (host config) → `UMANS_MODEL` → `umans-coder`. + * + * Pure: the decision logic `activate` delegates to, factored out for direct + * unit testing with zero mocks. + */ +export function resolveUmansConfig( + env: EnvSource, + configModel: string | undefined, +): UmansConfig | null { + const apiKey = env.UMANS_API_KEY; + if (!apiKey) return null; + const baseURL = env.UMANS_BASE_URL ?? DEFAULT_BASE_URL; + const model = configModel ?? env.UMANS_MODEL ?? DEFAULT_MODEL; + return { apiKey, baseURL, model }; +} + +/** + * Build the `ApiKeyCredentials` the `@dispatch/openai-stream` library expects + * — `baseURL` on the credential so it is overridable per-credential. + */ +export function toUmansCredentials(cfg: UmansConfig): ApiKeyCredentials { + return { type: "api-key", apiKey: cfg.apiKey, baseURL: cfg.baseURL }; +} diff --git a/packages/provider-umans/tsconfig.json b/packages/provider-umans/tsconfig.json new file mode 100644 index 0000000..f450b9a --- /dev/null +++ b/packages/provider-umans/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }, { "path": "../openai-stream" }] +} -- cgit v1.2.3