From a0cea434f31f96a8a66b45182f8d95908ea82492 Mon Sep 17 00:00:00 2001 From: tzzs Date: Sun, 20 Sep 2026 20:43:31 +0800 Subject: [PATCH 1/9] chore: ignore worktree dirs and local artifacts --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index d34ad54..fba155c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ node_modules/ dist/ .claude/ +.DS_Store +*.tsbuildinfo +.env +.env.* +coverage/ +.worktrees/ From 7dde998d931ae0daf3e6448978111492a12e19a4 Mon Sep 17 00:00:00 2001 From: tzzs Date: Sun, 20 Sep 2026 20:48:17 +0800 Subject: [PATCH 2/9] feat: replace the client auto-install prompt with a plain install hint Drops the interactive npm-install recovery flow (todo item G): a missing claude/codex now prints the recommended install command and exits. Also removes dead exports and narrows module-internal ones. --- README.md | 2 +- README.zh-CN.md | 2 +- src/catalog.ts | 2 +- src/cli.ts | 41 +--- src/process.ts | 13 +- src/providers/registry.ts | 5 - src/quota.ts | 2 +- src/streaming/anthropic-to-responses.ts | 167 +++++++--------- src/streaming/chat-to-responses.ts | 165 +++++++--------- src/streaming/common.ts | 242 +++++++++++++++++++++++- src/streaming/to-anthropic.ts | 224 +++++++++++----------- src/ui.ts | 1 - src/usage/cli.ts | 2 +- src/usage/storage.ts | 2 +- test/cli-commands.test.ts | 60 ++---- 15 files changed, 516 insertions(+), 414 deletions(-) diff --git a/README.md b/README.md index bce743e..9f2223f 100644 --- a/README.md +++ b/README.md @@ -511,7 +511,7 @@ Install Claude Code and ensure `claude` is available in the same shell's `PATH`, **`Codex not found: the "codex" command is not installed or not on PATH`** -When a client executable is missing, AgentX explains the problem and prints the recommended install command (for example, `npm install -g @openai/codex`). In an interactive terminal it also offers to run that command for you and relaunches the client after a verified install. Decline to install manually; re-run the same `agentx ` command afterwards. +When a client executable is missing, AgentX explains the problem and prints the recommended install command (for example, `npm install -g @openai/codex`), then exits. AgentX never installs clients for you — run the command yourself, then re-run the same `agentx ` command. **The port is busy** diff --git a/README.zh-CN.md b/README.zh-CN.md index b4703a6..8302f17 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -497,7 +497,7 @@ GitHub Actions 会在每次 push 和针对 `main` 的 Pull Request 中运行构 **`Codex not found: the "codex" command is not installed or not on PATH`** -当客户端可执行文件缺失时,AgentX 会说明问题并给出推荐的安装命令(例如 `npm install -g @openai/codex`)。在交互式终端中还会询问是否立即执行该命令,并在确认安装成功后自动重新启动客户端。也可以选择跳过、手动安装,之后再次运行相同的 `agentx ` 命令。 +当客户端可执行文件缺失时,AgentX 会说明问题并给出推荐的安装命令(例如 `npm install -g @openai/codex`),然后退出。AgentX 不会代为执行安装——请自行运行该命令,之后再次运行相同的 `agentx ` 命令。 **端口被占用** diff --git a/src/catalog.ts b/src/catalog.ts index 7dce295..fe8f86a 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -1,7 +1,7 @@ import { allModels, providerFor as resolveProvider } from "./providers/registry.js"; import type { ProviderModel } from "./providers/types.js"; -export type ModelProvider = ProviderModel; +type ModelProvider = ProviderModel; export const providers = allModels; export function providerFor(model: string, provider?: string): ModelProvider { return resolveProvider(model, provider); } diff --git a/src/cli.ts b/src/cli.ts index ffd8aa7..a08a2ef 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,11 +3,11 @@ import { readFileSync, realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { CLAUDE_EFFORT_LEVELS, CODEX_EFFORT_LEVELS, loadConfig, parseCliOptions as options } from "./config.js"; import { startAdapter } from "./server.js"; -import { runCommand, runShellCommand, ClientNotFoundError, CLIENT_INSTALL_COMMANDS, clientEnvironment, codexLaunchArgs, nativeClientEnvironment } from "./process.js"; +import { runCommand, ClientNotFoundError, CLIENT_INSTALL_COMMANDS, clientEnvironment, codexLaunchArgs, nativeClientEnvironment } from "./process.js"; import { providerEntries, runInteractiveLauncher, runProviderManager, runSavedModelManager, LaunchCancelledError, type ProviderEntry } from "./ui.js"; import { credentialEnvName, providerById, refreshProviderCatalog, fetchOpenRouterModels, hydrateOpenRouterCatalog, openRouterCatalogIds, providerDisplayName, registerCustomProvider, unregisterCustomProvider } from "./providers/registry.js"; import type { ProviderDefinition, ProviderProtocol } from "./providers/types.js"; -import { runDoctor, renderDoctor, executableExists } from "./doctor.js"; +import { runDoctor, renderDoctor } from "./doctor.js"; import { credentialInstructions, credentialSource, hydrateProfileCredentials, promptCredential, resolveCredential } from "./credentials.js"; import { runQuotaCommand } from "./quota.js"; import { runUsageStats } from "./usage/cli.js"; @@ -15,7 +15,6 @@ import { resolveRuntimeNonInteractive } from "./selection.js"; import { forgetCustomProvider, loadCustomProviders, loadLastSelection, loadSessionRecord, remembererProviders, rememberedModelIds, saveCustomProvider, saveLastModel, saveOpenRouterModels, saveSessionRecord } from "./runtime.js"; import { catalogModels, writeCodexCatalog } from "./codex-catalog.js"; import { applySessionRecord, discoverClaudeSessionId, discoverCodexSessionId, resumeSessionId } from "./sessions.js"; -import { confirm, isCancel } from "@clack/prompts"; const HELP: Record = { claude: "Start the local adapter and Claude Code together", @@ -233,48 +232,24 @@ function reportMissingClient(executable: string): void { } } -/** Injectable side effects for {@link launchClient}, so tests can exercise the install-recovery flow without a real terminal, shell, or spawned process. */ +/** Injectable side effects for {@link launchClient}, so tests can swap in a fake spawn without a real terminal or process. */ export interface ClientLaunchDeps { runCommand?: typeof runCommand; - confirm?: typeof confirm; - runShellCommand?: typeof runShellCommand; - executableExists?: typeof executableExists; } /** - * Launch a client, with a recovery path for a missing executable: explain the - * problem, and — interactively only — offer to run the known install command. - * After a confirmed, successful install (verified by spawning the binary), - * retry the launch; otherwise exit with a clear message. The adapter stays up - * throughout so a successful install flows straight into the client session. + * Launch a client. A missing executable is reported with the recommended + * install command and exit code 1 — AgentX never installs clients itself. */ export async function launchClient(executable: string, args: string[], env: NodeJS.ProcessEnv, deps: ClientLaunchDeps = {}): Promise { const run = deps.runCommand ?? runCommand; - const confirmFn = deps.confirm ?? confirm; - const runShell = deps.runShellCommand ?? runShellCommand; - const exists = deps.executableExists ?? executableExists; try { return await run(executable, args, env); } catch (error) { if (!(error instanceof ClientNotFoundError)) throw error; reportMissingClient(executable); - const installCommand = CLIENT_INSTALL_COMMANDS[executable]; - if (!isInteractive() || !installCommand) { process.exitCode = 1; return 1; } - const proceed = await confirmFn({ message: `Run \`${installCommand}\` now?`, initialValue: false }); - if (isCancel(proceed) || !proceed) { - console.error(`Skipped installation. Re-run agentx ${executable} once installed.`); - process.exitCode = 1; - return 1; - } - console.error(`Running: ${installCommand}`); - const code = await runShell(installCommand); - if (code !== 0 || !(await exists(executable))) { - console.error("✗ Installation did not complete or the executable is still missing.\n Install it manually, then re-run this command."); - process.exitCode = 1; - return 1; - } - console.error(`✓ ${CLIENT_LABELS[executable] ?? executable} installed`); - return run(executable, args, env); + process.exitCode = 1; + return 1; } } @@ -454,7 +429,7 @@ async function persistCustomProvider(opts: Record): * resolution (interactive or not), native-launch bypass, credential * resolution with a missing-key recovery flow, adapter startup, and finally * handing off to {@link launchClient}. `deps` only affects the final - * `launchClient` call, so tests can exercise the install-recovery flow. + * `launchClient` call, so tests can substitute the spawn. */ export async function runClientLaunch(command: string, args: string[], deps: ClientLaunchDeps = {}): Promise { let opts = options(args); diff --git a/src/process.ts b/src/process.ts index 1c166f0..307c6fb 100644 --- a/src/process.ts +++ b/src/process.ts @@ -137,7 +137,7 @@ const SIGNAL_NUMBERS: Record = { SIGHUP: 1, SIGINT: 2, SIGQUIT: /** * Thrown when the client executable itself cannot be spawned — most commonly * because it is not installed or not on PATH (`spawn x ENOENT`). Carries the - * executable name so callers can offer an actionable recovery flow. + * executable name so callers can print an actionable install hint. */ export class ClientNotFoundError extends Error { constructor(readonly executable: string) { @@ -152,17 +152,6 @@ export const CLIENT_INSTALL_COMMANDS: Record = { codex: "npm install -g @openai/codex", }; -/** Run a command through the user's shell with inherited stdio; resolves with the exit code. - * Spawn failures (e.g. no shell) resolve to 1 instead of rejecting, so the caller's - * install-recovery branch stays in charge of reporting. */ -export function runShellCommand(command: string): Promise { - const child = spawn(command, { stdio: "inherit", shell: true }); - return new Promise((resolve) => { - child.once("error", () => resolve(1)); - child.once("exit", (code) => resolve(code ?? 1)); - }); -} - /** * Windows-only: runCommand spawns with shell:true so npm's .cmd shims resolve * (plain spawn() without a shell can't find them by extension-less name), but diff --git a/src/providers/registry.ts b/src/providers/registry.ts index f4f6bf5..ac553c0 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -138,11 +138,6 @@ export function setOpenRouterCatalogIds(ids: string[]): void { } } -/** Isolation seam so tests can restore the module-level model cache. */ -export function setCachedOpenRouter(table: MetadataMap): void { - cachedOpenRouter = table; -} - /** * Fetch OpenRouter's auth-free public catalog for picker/search use. Failures * keep the previously cached ids so a flaky network degrades to the last good diff --git a/src/quota.ts b/src/quota.ts index 5a772ac..e73c277 100644 --- a/src/quota.ts +++ b/src/quota.ts @@ -43,7 +43,7 @@ export async function queryProviderUsage(providerId: string, apiKey: string): Pr return providerId === "deepseek" ? parseDeepSeekBalance(payload) : parseOpenRouterKey(payload); } -export function usageProvider(id?: string) { return providerById(id ?? process.env.AGENTX_PROVIDER ?? "opencode"); } +function usageProvider(id?: string) { return providerById(id ?? process.env.AGENTX_PROVIDER ?? "opencode"); } /** Resolve credentials, query quota, and format the result; shared by `agentx quota` and the deprecated `agentx usage --provider`. */ export async function runQuotaCommand(providerId: string | undefined): Promise<{ output: string; exitCode: number }> { diff --git a/src/streaming/anthropic-to-responses.ts b/src/streaming/anthropic-to-responses.ts index 78cbdde..ae37c26 100644 --- a/src/streaming/anthropic-to-responses.ts +++ b/src/streaming/anthropic-to-responses.ts @@ -1,5 +1,10 @@ import type { ServerResponse } from "node:http"; -import { cancelOnDisconnect, drain, errorMessage, event, REASONING_OUTPUT_INDEX, reportUsage, SSE_HEADERS, startHeartbeat, UpstreamFailure, type StreamUsageOptions } from "./common.js"; +import { + announceMessageItem, announceReasoning, dataLine, drain, emitFunctionCallDelta, emitReasoningDelta, + emitResponsesCompleted, emitResponsesError, emitTextDelta, event, failureMessage, + newUsageCapture, reportUsage, usageCapture, withSsePipe, UpstreamFailure, + type ReasoningState, type StreamUsageOptions, +} from "./common.js"; /** * Pipe a native Anthropic Messages SSE stream into Codex-style Responses @@ -12,107 +17,75 @@ import { cancelOnDisconnect, drain, errorMessage, event, REASONING_OUTPUT_INDEX, * per-block indices. */ export async function pipeAnthropicStreamToResponses(upstream: Response, response: ServerResponse, model: string, options?: StreamUsageOptions) { - const reader = upstream.body?.getReader(); - if (!reader) throw new Error("Upstream returned no stream"); - cancelOnDisconnect(response, reader); - response.writeHead(200, SSE_HEADERS); - // try/finally guarantees the interval is cleared even if a write to the - // local client throws. - const stopHeartbeat = startHeartbeat(response); - try { - const id = `resp_${crypto.randomUUID()}`; const msgId = `msg_${crypto.randomUUID()}`; - let text = ""; let inputTokens = 0; let outputTokens = 0; let sawUsage = false; let truncated = false; let lastUsage: any; - let rsId = ""; let rsText = ""; let messageAnnounced = false; - // A native Anthropic upstream must close every turn with message_stop; - // seeing neither it nor an in-band error means the connection dropped - // mid-turn and must not read back as a normal "completed" response. - let sawMessageStop = false; - const calls = new Map(); - event(response, "response.created", { type: "response.created", response: { id, object: "response", status: "in_progress", model, output: [] } }); - const consume = (line: string) => { - if (!line.startsWith("data:")) return; const value = line.slice(5).trim(); if (!value) return; - try { - const item = JSON.parse(value); - if (item.type === "error") throw new UpstreamFailure(item.error?.message ?? "Upstream stream failed"); - if (item.type === "message_start" && item.message?.usage) { - inputTokens = item.message.usage.input_tokens ?? inputTokens; - outputTokens = item.message.usage.output_tokens ?? outputTokens; - } - if (item.type === "content_block_start") { - const blockType = item.content_block?.type; - if (blockType === "tool_use") { - const call = { id: item.content_block.id ?? `call_${item.index}`, name: item.content_block.name ?? "", arguments: "" }; - calls.set(item.index, call); - event(response, "response.output_item.added", { type: "response.output_item.added", output_index: item.index, item: { type: "function_call", id: `fc_${item.index}`, call_id: call.id, name: call.name, arguments: "", status: "in_progress" } }); - } else if (blockType === "thinking" && !rsId) { - rsId = `rs_${crypto.randomUUID()}`; - event(response, "response.output_item.added", { type: "response.output_item.added", output_index: REASONING_OUTPUT_INDEX, item: { type: "reasoning", id: rsId, summary: [] } }); - event(response, "response.reasoning_summary_part.added", { type: "response.reasoning_summary_part.added", item_id: rsId, output_index: REASONING_OUTPUT_INDEX, summary_index: 0, part: { type: "summary_text", text: "" } }); - } - } - if (item.type === "content_block_delta") { - const delta = item.delta ?? {}; - if (delta.type === "text_delta" && typeof delta.text === "string") { - if (!messageAnnounced) { - messageAnnounced = true; - event(response, "response.output_item.added", { type: "response.output_item.added", output_index: 0, item: { type: "message", id: msgId, role: "assistant", status: "in_progress", content: [] } }); + await withSsePipe(upstream, response, async (reader) => { + const id = `resp_${crypto.randomUUID()}`; const msgId = `msg_${crypto.randomUUID()}`; + const capture = newUsageCapture(); + const usage = usageCapture(capture, "anthropic"); + let text = ""; let truncated = false; + const messageAnnounced = { done: false }; + const reasoning: ReasoningState = { id: "", text: "" }; + // A native Anthropic upstream must close every turn with message_stop; + // seeing neither it nor an in-band error means the connection dropped + // mid-turn and must not read back as a normal "completed" response. + let sawMessageStop = false; + const calls = new Map(); + event(response, "response.created", { type: "response.created", response: { id, object: "response", status: "in_progress", model, output: [] } }); + const consume = (line: string) => { + const value = dataLine(line); + if (!value) return; + try { + const item = JSON.parse(value); + const failure = failureMessage(item); + if (failure) throw new UpstreamFailure(failure); + if (item.type === "message_start" && item.message?.usage) usage.usage(item.message.usage); + if (item.type === "content_block_start") { + const blockType = item.content_block?.type; + if (blockType === "tool_use") { + const call = { id: item.content_block.id ?? `call_${item.index}`, name: item.content_block.name ?? "", arguments: "" }; + calls.set(item.index, call); + event(response, "response.output_item.added", { type: "response.output_item.added", output_index: item.index, item: { type: "function_call", id: `fc_${item.index}`, call_id: call.id, name: call.name, arguments: "", status: "in_progress" } }); + } else if (blockType === "thinking") { + announceReasoning(response, reasoning); } - text += delta.text; outputTokens++; - event(response, "response.output_text.delta", { type: "response.output_text.delta", item_id: msgId, output_index: 0, content_index: 0, delta: delta.text }); - } else if (delta.type === "thinking_delta" && typeof delta.thinking === "string") { - rsText += delta.thinking; - event(response, "response.reasoning_summary_text.delta", { type: "response.reasoning_summary_text.delta", item_id: rsId, output_index: REASONING_OUTPUT_INDEX, summary_index: 0, delta: delta.thinking }); - } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") { - const call = calls.get(item.index); - if (call) { - call.arguments += delta.partial_json; - event(response, "response.function_call_arguments.delta", { type: "response.function_call_arguments.delta", item_id: `fc_${item.index}`, output_index: item.index, call_id: call.id, delta: delta.partial_json }); + } + if (item.type === "content_block_delta") { + const delta = item.delta ?? {}; + if (delta.type === "text_delta" && typeof delta.text === "string") { + announceMessageItem(response, msgId, messageAnnounced); + text += delta.text; capture.output++; + emitTextDelta(response, msgId, delta.text); + } else if (delta.type === "thinking_delta" && typeof delta.thinking === "string") { + emitReasoningDelta(response, reasoning, delta.thinking); + } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") { + const call = calls.get(item.index); + if (call) { + call.arguments += delta.partial_json; + emitFunctionCallDelta(response, { itemId: `fc_${item.index}`, outputIndex: item.index, callId: call.id, delta: delta.partial_json }); + } } } + if (item.type === "message_delta") { + if (item.delta?.stop_reason === "max_tokens") truncated = true; + if (item.usage) usage.usage(item.usage); + } + if (item.type === "message_stop") sawMessageStop = true; + } catch (error) { + // In-band upstream failures must end the stream; only parse noise is ignored. + if (error instanceof UpstreamFailure) throw error; } - if (item.type === "message_delta") { - if (item.delta?.stop_reason === "max_tokens") truncated = true; - if (item.usage) { outputTokens = item.usage.output_tokens ?? outputTokens; lastUsage = item.usage; sawUsage = true; } + }; + try { + await drain(reader, consume); + if (!sawMessageStop) { + const message = "Upstream Anthropic stream ended before message_stop was received."; + options?.onDiagnostic?.(message); + throw new UpstreamFailure(message); } - if (item.type === "message_stop") sawMessageStop = true; + emitResponsesCompleted(response, { id, model, capture, truncated, text, msgId, reasoning, calls }); } catch (error) { - // In-band upstream failures must end the stream; only parse noise is ignored. - if (error instanceof UpstreamFailure) throw error; - } - }; - try { - await drain(reader, consume); - if (!sawMessageStop) { - const message = "Upstream Anthropic stream ended before message_stop was received."; - options?.onDiagnostic?.(message); - throw new UpstreamFailure(message); - } - if (rsId) { - event(response, "response.reasoning_summary_text.done", { type: "response.reasoning_summary_text.done", item_id: rsId, output_index: REASONING_OUTPUT_INDEX, summary_index: 0, text: rsText }); - event(response, "response.reasoning_summary_part.done", { type: "response.reasoning_summary_part.done", item_id: rsId, output_index: REASONING_OUTPUT_INDEX, summary_index: 0, part: { type: "summary_text", text: rsText } }); - event(response, "response.output_item.done", { type: "response.output_item.done", output_index: REASONING_OUTPUT_INDEX, item: { type: "reasoning", id: rsId, summary: [{ type: "summary_text", text: rsText }] } }); - } - if (text) { - event(response, "response.output_text.done", { type: "response.output_text.done", item_id: msgId, output_index: 0, content_index: 0, text }); - // Codex collects turn items from output_item.done; the message must be - // finalized there or only the reasoning/tool items reach the client. - event(response, "response.output_item.done", { type: "response.output_item.done", output_index: 0, item: { type: "message", id: msgId, role: "assistant", status: "completed", content: [{ type: "output_text", text }] } }); - } - const output: any[] = []; - if (rsId) output.push({ type: "reasoning", id: rsId, summary: [{ type: "summary_text", text: rsText }] }); - if (text) output.push({ type: "message", id: msgId, role: "assistant", status: "completed", content: [{ type: "output_text", text }] }); - for (const [index, call] of calls) { - event(response, "response.function_call_arguments.done", { type: "response.function_call_arguments.done", item_id: `fc_${index}`, call_id: call.id, arguments: call.arguments }); - event(response, "response.output_item.done", { type: "response.output_item.done", output_index: index, item: { type: "function_call", id: `fc_${index}`, call_id: call.id, name: call.name, arguments: call.arguments, status: "completed" } }); - output.push({ type: "function_call", id: `fc_${index}`, call_id: call.id, name: call.name, arguments: call.arguments, status: "completed" }); + emitResponsesError(response, id, model, error); } - // Codex strictly requires total_tokens on the completed response usage. - event(response, "response.completed", { type: "response.completed", response: { id, object: "response", status: truncated ? "incomplete" : "completed", model, output, usage: { input_tokens: inputTokens, output_tokens: outputTokens, total_tokens: inputTokens + outputTokens }, ...(truncated ? { incomplete_details: { reason: "max_output_tokens" } } : {}) } }); - response.write("data: [DONE]\n\n"); - } catch (error) { - event(response, "response.failed", { type: "response.failed", response: { id, object: "response", status: "failed", model, error: { code: "upstream_error", message: errorMessage(error) } } }); - } - response.end(); - reportUsage(options, sawUsage, inputTokens, outputTokens, lastUsage); - } finally { stopHeartbeat(); } + response.end(); + reportUsage(options, capture); + }); } diff --git a/src/streaming/chat-to-responses.ts b/src/streaming/chat-to-responses.ts index 8099e0d..a0a3535 100644 --- a/src/streaming/chat-to-responses.ts +++ b/src/streaming/chat-to-responses.ts @@ -1,106 +1,85 @@ +import type { ServerResponse } from "node:http"; import { chatResponseFailure } from "../convert/index.js"; -import { cancelOnDisconnect, drain, errorMessage, event, failureMessage, REASONING_OUTPUT_INDEX, reasoningDeltaOf, reportUsage, SSE_HEADERS, startHeartbeat, UpstreamFailure, type StreamUsageOptions } from "./common.js"; +import { + announceMessageItem, announceReasoning, dataLine, drain, emitChatError, emitFunctionCallDelta, + emitReasoningDelta, emitResponsesCompleted, emitResponsesError, emitTextDelta, event, failureMessage, + newUsageCapture, reasoningDeltaOf, reportUsage, usageCapture, withSsePipe, + UpstreamFailure, type ReasoningState, type StreamUsageOptions, +} from "./common.js"; /** * Pipe a Chat Completions SSE upstream into Codex-style Responses events. Used * for non-OpenCode providers that still speak the legacy chat protocol. */ -export async function pipeChatStreamToResponses(upstream: Response, response: import("node:http").ServerResponse, model: string, options?: StreamUsageOptions) { - const reader = upstream.body?.getReader(); - if (!reader) throw new Error("Upstream returned no stream"); - cancelOnDisconnect(response, reader); - response.writeHead(200, SSE_HEADERS); - // try/finally guarantees the interval is cleared even if a write to the - // local client throws. - const stopHeartbeat = startHeartbeat(response); - try { - const id = `resp_${crypto.randomUUID()}`; const msgId = `msg_${crypto.randomUUID()}`; let text = ""; let inputTokens = 0; let outputTokens = 0; let sawUsage = false; let truncated = false; let lastUsage: any; let rsId = ""; let rsText = ""; let messageAnnounced = false; const calls = new Map(); - // A chat-completions upstream must close every turn with either a - // finish_reason or [DONE]; seeing neither means the connection dropped - // mid-turn and must not read back as a normal "completed" response. - let sawFinishReason = false; let sawDone = false; - event(response, "response.created", { type: "response.created", response: { id, object: "response", status: "in_progress", model, output: [] } }); - const consume = (line: string) => { - if (!line.startsWith("data:")) return; const value = line.slice(5).trim(); if (!value) return; - if (value === "[DONE]") { sawDone = true; return; } - try { - const item = JSON.parse(value); - const failure = failureMessage(item); - if (failure) throw new UpstreamFailure(failure); - const choice = item.choices?.[0]; const delta = choice?.delta?.content; - if (choice?.finish_reason != null) { - sawFinishReason = true; - const chatFailure = chatResponseFailure(item); - if (chatFailure) { options?.onDiagnostic?.(`chat completions finish_reason=${choice.finish_reason}: ${chatFailure}`); throw new UpstreamFailure(chatFailure); } - } - if (choice?.finish_reason === "length") truncated = true; - if (typeof delta === "string" && delta) { - // Codex keys text on the announced message item; announce it once. - if (!messageAnnounced) { - messageAnnounced = true; - event(response, "response.output_item.added", { type: "response.output_item.added", output_index: 0, item: { type: "message", id: msgId, role: "assistant", status: "in_progress", content: [] } }); +export async function pipeChatStreamToResponses(upstream: Response, response: ServerResponse, model: string, options?: StreamUsageOptions) { + await withSsePipe(upstream, response, async (reader) => { + const id = `resp_${crypto.randomUUID()}`; const msgId = `msg_${crypto.randomUUID()}`; + const capture = newUsageCapture(); + const usage = usageCapture(capture, "chat-completions"); + let text = ""; let truncated = false; + const messageAnnounced = { done: false }; + const reasoning: ReasoningState = { id: "", text: "" }; + const calls = new Map(); + // A chat-completions upstream must close every turn with either a + // finish_reason or [DONE]; seeing neither means the connection dropped + // mid-turn and must not read back as a normal "completed" response. + let sawFinishReason = false; let sawDone = false; + event(response, "response.created", { type: "response.created", response: { id, object: "response", status: "in_progress", model, output: [] } }); + const consume = (line: string) => { + const value = dataLine(line); + if (!value) return; + if (value === "[DONE]") { sawDone = true; return; } + try { + const item = JSON.parse(value); + const failure = failureMessage(item); + if (failure) throw new UpstreamFailure(failure); + const choice = item.choices?.[0]; const delta = choice?.delta?.content; + if (choice?.finish_reason != null) { + sawFinishReason = true; + const chatFailure = chatResponseFailure(item); + if (chatFailure) { options?.onDiagnostic?.(`chat completions finish_reason=${choice.finish_reason}: ${chatFailure}`); throw new UpstreamFailure(chatFailure); } } - text += delta; outputTokens++; event(response, "response.output_text.delta", { type: "response.output_text.delta", item_id: msgId, output_index: 0, content_index: 0, delta }); - } - const reasoningDelta = reasoningDeltaOf(item); - if (reasoningDelta) { - if (!rsId) { - rsId = `rs_${crypto.randomUUID()}`; - event(response, "response.output_item.added", { type: "response.output_item.added", output_index: REASONING_OUTPUT_INDEX, item: { type: "reasoning", id: rsId, summary: [] } }); - event(response, "response.reasoning_summary_part.added", { type: "response.reasoning_summary_part.added", item_id: rsId, output_index: REASONING_OUTPUT_INDEX, summary_index: 0, part: { type: "summary_text", text: "" } }); + if (choice?.finish_reason === "length") truncated = true; + if (typeof delta === "string" && delta) { + announceMessageItem(response, msgId, messageAnnounced); + text += delta; capture.output++; emitTextDelta(response, msgId, delta); + } + const reasoningDelta = reasoningDeltaOf(item); + if (reasoningDelta) { + announceReasoning(response, reasoning); + emitReasoningDelta(response, reasoning, reasoningDelta); + } + for (const tool of choice?.delta?.tool_calls ?? []) { + const index = tool.index ?? 0; + // Announce each call exactly once even when providers repeat id/name in deltas. + const call = calls.get(index) ?? { id: tool.id ?? `call_${index}`, name: "", arguments: "", announced: false }; + if (tool.id) call.id = tool.id; + if (tool.function?.name) call.name = tool.function.name; + calls.set(index, call); + if (!call.announced) { event(response, "response.output_item.added", { type: "response.output_item.added", output_index: index, item: { type: "function_call", id: `fc_${index}`, call_id: call.id, name: call.name, arguments: "", status: "in_progress" } }); call.announced = true; } + if (tool.function?.arguments) { + call.arguments += tool.function.arguments; + emitFunctionCallDelta(response, { itemId: `fc_${index}`, outputIndex: index, callId: call.id, delta: tool.function.arguments }); + } } - rsText += reasoningDelta; - event(response, "response.reasoning_summary_text.delta", { type: "response.reasoning_summary_text.delta", item_id: rsId, output_index: REASONING_OUTPUT_INDEX, summary_index: 0, delta: reasoningDelta }); + if (item.usage) usage.usage(item.usage); + } catch (error) { + // In-band upstream failures must end the stream; only parse noise is ignored. + if (error instanceof UpstreamFailure) throw error; } - for (const tool of choice?.delta?.tool_calls ?? []) { - const index = tool.index ?? 0; - // Announce each call exactly once even when providers repeat id/name in deltas. - const call = calls.get(index) ?? { id: tool.id ?? `call_${index}`, name: "", arguments: "", announced: false }; - if (tool.id) call.id = tool.id; - if (tool.function?.name) call.name = tool.function.name; - calls.set(index, call); - if (!call.announced) { event(response, "response.output_item.added", { type: "response.output_item.added", output_index: index, item: { type: "function_call", id: `fc_${index}`, call_id: call.id, name: call.name, arguments: "", status: "in_progress" } }); call.announced = true; } - if (tool.function?.arguments) { call.arguments += tool.function.arguments; event(response, "response.function_call_arguments.delta", { type: "response.function_call_arguments.delta", item_id: `fc_${index}`, output_index: index, call_id: call.id, delta: tool.function.arguments }); } + }; + try { + await drain(reader, consume); + if (!sawFinishReason && !sawDone) { + const message = "Upstream chat completions stream ended before a finish_reason or [DONE] was received."; + options?.onDiagnostic?.(message); + throw new UpstreamFailure(message); } - if (item.usage) { inputTokens = item.usage.prompt_tokens ?? inputTokens; outputTokens = item.usage.completion_tokens ?? outputTokens; lastUsage = item.usage; sawUsage = true; } + emitResponsesCompleted(response, { id, model, capture, truncated, text, msgId, reasoning, calls }); } catch (error) { - // In-band upstream failures must end the stream; only parse noise is ignored. - if (error instanceof UpstreamFailure) throw error; - } - }; - try { - await drain(reader, consume); - if (!sawFinishReason && !sawDone) { - const message = "Upstream chat completions stream ended before a finish_reason or [DONE] was received."; - options?.onDiagnostic?.(message); - throw new UpstreamFailure(message); - } - if (rsId) { - event(response, "response.reasoning_summary_text.done", { type: "response.reasoning_summary_text.done", item_id: rsId, output_index: REASONING_OUTPUT_INDEX, summary_index: 0, text: rsText }); - event(response, "response.reasoning_summary_part.done", { type: "response.reasoning_summary_part.done", item_id: rsId, output_index: REASONING_OUTPUT_INDEX, summary_index: 0, part: { type: "summary_text", text: rsText } }); - event(response, "response.output_item.done", { type: "response.output_item.done", output_index: REASONING_OUTPUT_INDEX, item: { type: "reasoning", id: rsId, summary: [{ type: "summary_text", text: rsText }] } }); - } - if (text) { - event(response, "response.output_text.done", { type: "response.output_text.done", item_id: msgId, output_index: 0, content_index: 0, text }); - // Codex collects turn items from output_item.done; the message must be - // finalized there or only the reasoning item reaches the client. - event(response, "response.output_item.done", { type: "response.output_item.done", output_index: 0, item: { type: "message", id: msgId, role: "assistant", status: "completed", content: [{ type: "output_text", text }] } }); - } - const output: any[] = []; - if (rsId) output.push({ type: "reasoning", id: rsId, summary: [{ type: "summary_text", text: rsText }] }); - if (text) output.push({ type: "message", id: msgId, role: "assistant", status: "completed", content: [{ type: "output_text", text }] }); - for (const [index, call] of calls) { - event(response, "response.function_call_arguments.done", { type: "response.function_call_arguments.done", item_id: `fc_${index}`, call_id: call.id, arguments: call.arguments }); - event(response, "response.output_item.done", { type: "response.output_item.done", output_index: index, item: { type: "function_call", id: `fc_${index}`, call_id: call.id, name: call.name, arguments: call.arguments, status: "completed" } }); - output.push({ type: "function_call", id: `fc_${index}`, call_id: call.id, name: call.name, arguments: call.arguments, status: "completed" }); + emitResponsesError(response, id, model, error); } - // Codex strictly requires total_tokens on the completed response usage. - event(response, "response.completed", { type: "response.completed", response: { id, object: "response", status: truncated ? "incomplete" : "completed", model, output, usage: { input_tokens: inputTokens, output_tokens: outputTokens, total_tokens: inputTokens + outputTokens }, ...(truncated ? { incomplete_details: { reason: "max_output_tokens" } } : {}) } }); - response.write("data: [DONE]\n\n"); - } catch (error) { - event(response, "response.failed", { type: "response.failed", response: { id, object: "response", status: "failed", model, error: { code: "upstream_error", message: errorMessage(error) } } }); - } - response.end(); - reportUsage(options, sawUsage, inputTokens, outputTokens, lastUsage); - } finally { stopHeartbeat(); } + response.end(); + reportUsage(options, capture); + }); } diff --git a/src/streaming/common.ts b/src/streaming/common.ts index decbf17..7297b7e 100644 --- a/src/streaming/common.ts +++ b/src/streaming/common.ts @@ -6,7 +6,7 @@ import { mapAnthropicUsage, mapChatUsage, mapResponsesUsage } from "../providers export const SSE_HEADERS = { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" }; /** How often to emit an SSE comment while the upstream is quiet. */ -export const HEARTBEAT_MS = 15_000; +const HEARTBEAT_MS = 15_000; /** Dedicated high output_index for synthesized reasoning items so they never collide with text (0) or tool call indexes. */ export const REASONING_OUTPUT_INDEX = 1000; @@ -25,11 +25,42 @@ export interface StreamUsageOptions { * SSE comment lines during long upstream silences. Clients and intermediate * hops ignore them, but they reset idle timeouts so the turn is not dropped. */ -export function startHeartbeat(response: ServerResponse) { +function startHeartbeat(response: ServerResponse) { const timer = setInterval(() => response.write(": ping\n\n"), HEARTBEAT_MS); return () => clearInterval(timer); } +/** Stop reading from the upstream as soon as the local client disconnects. */ +function cancelOnDisconnect(response: ServerResponse, reader: ReadableStreamDefaultReader) { + response.on("close", () => { void reader.cancel().catch(() => {}); }); +} + +/** + * Shared preamble/teardown for every stream pipe: take the upstream reader, + * arm disconnect cancellation, send SSE headers, and run `run` with the SSE + * heartbeat active (cleared even if setup or `run` throws). + */ +export async function withSsePipe(upstream: Response, response: ServerResponse, run: (reader: ReadableStreamDefaultReader) => Promise): Promise { + const reader = upstream.body?.getReader(); + if (!reader) throw new Error("Upstream returned no stream"); + cancelOnDisconnect(response, reader); + response.writeHead(200, SSE_HEADERS); + // try/finally guarantees the interval is cleared even if a write to the + // local client throws before the main try block is entered. + const stopHeartbeat = startHeartbeat(response); + try { + await run(reader); + } finally { + stopHeartbeat(); + } +} + +/** Payload of an SSE `data:` line, or undefined for non-data/blank lines. */ +export function dataLine(line: string): string | undefined { + if (!line.startsWith("data:")) return undefined; + return line.slice(5).trim() || undefined; +} + /** A failure carried inside otherwise valid SSE data; must end the stream. */ export class UpstreamFailure extends Error {} @@ -49,9 +80,23 @@ export function errorMessage(error: unknown): string { return error instanceof Error ? error.message : "Upstream stream failed"; } -/** Stop reading from the upstream as soon as the local client disconnects. */ -export function cancelOnDisconnect(response: ServerResponse, reader: ReadableStreamDefaultReader) { - response.on("close", () => { void reader.cancel().catch(() => {}); }); +/** + * Terminal error handling per client-facing protocol grammar: + * Anthropic clients expect a final `error` event, Responses clients a + * `response.failed`, and Chat Completions clients — whose chunk grammar has + * no error event pair — get the message inlined as content before [DONE]. + */ +export function emitAnthropicError(response: ServerResponse, error: unknown) { + event(response, "error", { type: "error", error: { type: "api_error", message: errorMessage(error) } }); +} + +export function emitResponsesError(response: ServerResponse, id: string, model: string, error: unknown) { + event(response, "response.failed", { type: "response.failed", response: { id, object: "response", status: "failed", model, error: { code: "upstream_error", message: errorMessage(error) } } }); +} + +export function emitChatError(response: ServerResponse, chunk: (delta: Record, finishReason?: string | null) => void, error: unknown) { + chunk({ content: `\n[error: ${errorMessage(error)}]` }, "stop"); + response.write("data: [DONE]\n\n"); } /** Read the upstream SSE body line by line; `onChunk` sees each decoded chunk (passthrough). */ @@ -105,6 +150,55 @@ export function withCacheTokens(usage: TokenUsage, source: any): TokenUsage { return usage; } +/** Per-request token counters every pipe tracks; the single store pipes read when emitting and reporting usage. */ +export interface UsageCapture { + input: number; + output: number; + /** The most recent raw upstream usage payload, for cache-field capture. */ + raw?: any; + sawUsage: boolean; +} + +export function newUsageCapture(): UsageCapture { + return { input: 0, output: 0, sawUsage: false }; +} + +function mapRawUsage(raw: any, protocol: ProviderProtocol): TokenUsage | null { + if (!raw || typeof raw !== "object") return null; + switch (protocol) { + // Bare-usage field lists live in providers/usage/*; the core never parses + // provider payloads itself (CLAUDE.md architecture rule). + case "anthropic": return mapAnthropicUsage(raw, {}); + case "responses": return mapResponsesUsage(raw, {}); + case "chat-completions": return mapChatUsage(raw, {}); + } +} + +/** + * Bind usage capture to the upstream protocol so token fields are read via + * the same provider field mappers as the non-streaming path. `usage()` merges + * a raw usage object into the capture; `total()` prefers the upstream's own + * total when it carries one. + */ +export function usageCapture(capture: UsageCapture, protocol: ProviderProtocol): { + usage(raw: any): void; + total(): number; +} { + return { + usage(raw) { + const mapped = mapRawUsage(raw, protocol); + if (!mapped) return; + capture.raw = raw; + capture.sawUsage = true; + if (mapped.inputTokens) capture.input = mapped.inputTokens; + if (mapped.outputTokens) capture.output = mapped.outputTokens; + }, + total() { + return Number(capture.raw?.total_tokens ?? capture.input + capture.output); + }, + }; +} + function estimatedUsage(provider: string, model: string, inputTokens: number, outputTokens: number, sessionId?: string): TokenUsage { return { provider, model, inputTokens, outputTokens, totalTokens: inputTokens + outputTokens, estimated: true, ...(sessionId ? { sessionId } : {}) }; } @@ -122,9 +216,139 @@ export function reasoningDeltaOf(item: any): string | undefined { } /** Final usage reporting; called by every pipe. */ -export function reportUsage(options: StreamUsageOptions | undefined, sawUsage: boolean, inputTokens: number, outputTokens: number, lastUsage?: any) { +export function reportUsage(options: StreamUsageOptions | undefined, capture: UsageCapture) { if (!options?.onUsage) return; - options.onUsage(sawUsage - ? withCacheTokens({ provider: options.provider, model: options.model, inputTokens, outputTokens, totalTokens: inputTokens + outputTokens, ...(options.sessionId ? { sessionId: options.sessionId } : {}) }, lastUsage) - : estimatedUsage(options.provider, options.model, inputTokens, outputTokens, options.sessionId)); + options.onUsage(capture.sawUsage + ? withCacheTokens({ provider: options.provider, model: options.model, inputTokens: capture.input, outputTokens: capture.output, totalTokens: capture.input + capture.output, ...(options.sessionId ? { sessionId: options.sessionId } : {}) }, capture.raw) + : estimatedUsage(options.provider, options.model, capture.input, capture.output, options.sessionId)); +} + +/* ---------- Responses-protocol (Codex-facing) emitters ---------- */ + +/** Per-pipe state for the synthesized reasoning item (`rs_*` id plus accumulated text). */ +export interface ReasoningState { + id: string; + text: string; +} + +/** Announce the reasoning item once, when the first reasoning delta arrives. */ +export function announceReasoning(response: ServerResponse, state: ReasoningState) { + if (state.id) return; + state.id = `rs_${crypto.randomUUID()}`; + event(response, "response.output_item.added", { type: "response.output_item.added", output_index: REASONING_OUTPUT_INDEX, item: { type: "reasoning", id: state.id, summary: [] } }); + event(response, "response.reasoning_summary_part.added", { type: "response.reasoning_summary_part.added", item_id: state.id, output_index: REASONING_OUTPUT_INDEX, summary_index: 0, part: { type: "summary_text", text: "" } }); +} + +export function emitReasoningDelta(response: ServerResponse, state: ReasoningState, delta: string) { + state.text += delta; + event(response, "response.reasoning_summary_text.delta", { type: "response.reasoning_summary_text.delta", item_id: state.id, output_index: REASONING_OUTPUT_INDEX, summary_index: 0, delta }); +} + +/** Close out the reasoning item; both Responses-facing pipes emit the identical terminal triplet. */ +export function emitReasoningDone(response: ServerResponse, state: ReasoningState) { + if (!state.id) return; + event(response, "response.reasoning_summary_text.done", { type: "response.reasoning_summary_text.done", item_id: state.id, output_index: REASONING_OUTPUT_INDEX, summary_index: 0, text: state.text }); + event(response, "response.reasoning_summary_part.done", { type: "response.reasoning_summary_part.done", item_id: state.id, output_index: REASONING_OUTPUT_INDEX, summary_index: 0, part: { type: "summary_text", text: state.text } }); + event(response, "response.output_item.done", { type: "response.output_item.done", output_index: REASONING_OUTPUT_INDEX, item: { type: "reasoning", id: state.id, summary: [{ type: "summary_text", text: state.text }] } }); +} + +/** Announce the assistant message item once, when the first text delta arrives. */ +export function announceMessageItem(response: ServerResponse, msgId: string, announced: { done: boolean }) { + // Codex keys text on the announced message item; announce it once. + if (announced.done) return; + announced.done = true; + event(response, "response.output_item.added", { type: "response.output_item.added", output_index: 0, item: { type: "message", id: msgId, role: "assistant", status: "in_progress", content: [] } }); +} + +export function emitTextDelta(response: ServerResponse, msgId: string, delta: string) { + event(response, "response.output_text.delta", { type: "response.output_text.delta", item_id: msgId, output_index: 0, content_index: 0, delta }); +} + +export function emitFunctionCallDelta(response: ServerResponse, args: { itemId: string; outputIndex?: number; callId: string; delta: string }) { + event(response, "response.function_call_arguments.delta", { + type: "response.function_call_arguments.delta", + item_id: args.itemId, + ...(args.outputIndex === undefined ? {} : { output_index: args.outputIndex }), + call_id: args.callId, + delta: args.delta, + }); +} + +export function reasoningItem(state: ReasoningState) { + return { type: "reasoning", id: state.id, summary: [{ type: "summary_text", text: state.text }] }; +} + +export function messageItem(msgId: string, text: string) { + return { type: "message", id: msgId, role: "assistant", status: "completed", content: [{ type: "output_text", text }] }; +} + +export function functionCallItem(call: { id: string; name: string; arguments: string }, index: number | string) { + return { type: "function_call", id: `fc_${index}`, call_id: call.id, name: call.name, arguments: call.arguments, status: "completed" }; +} + +/** + * Finalize the turn for Responses-facing clients: text/reasoning done events, + * per-call argument-done + item-done events, then the completed response — + * Codex strictly requires total_tokens there. Chat-style `calls` carry no + * `arguments`, so the caller supplies them via `argsOf`. + */ +export function emitResponsesCompleted( + response: ServerResponse, + args: { + id: string; + model: string; + capture: UsageCapture; + truncated: boolean; + text?: string; + msgId?: string; + reasoning?: ReasoningState; + calls: Iterable<[number | string, { id: string; name: string; arguments?: string }]>; + argsOf?(index: number | string): string; + }, +) { + const { id, model, capture, truncated } = args; + if (args.reasoning) emitReasoningDone(response, args.reasoning); + if (args.text) { + const msgId = args.msgId ?? ""; + event(response, "response.output_text.done", { type: "response.output_text.done", item_id: msgId, output_index: 0, content_index: 0, text: args.text }); + // Codex collects turn items from output_item.done; the message must be + // finalized there or only the reasoning/tool items reach the client. + event(response, "response.output_item.done", { type: "response.output_item.done", output_index: 0, item: messageItem(msgId, args.text) }); + } + const output: unknown[] = []; + if (args.reasoning?.id) output.push(reasoningItem(args.reasoning)); + if (args.text) output.push(messageItem(args.msgId ?? "", args.text)); + for (const [index, call] of args.calls) { + const callArgs = call.arguments ?? args.argsOf?.(index) ?? ""; + event(response, "response.function_call_arguments.done", { type: "response.function_call_arguments.done", item_id: `fc_${index}`, call_id: call.id, arguments: callArgs }); + event(response, "response.output_item.done", { type: "response.output_item.done", output_index: index, item: functionCallItem({ ...call, arguments: callArgs }, index) }); + output.push(functionCallItem({ ...call, arguments: callArgs }, index)); + } + event(response, "response.completed", { type: "response.completed", response: { id, object: "response", status: truncated ? "incomplete" : "completed", model, output, usage: { input_tokens: capture.input, output_tokens: capture.output, total_tokens: capture.input + capture.output }, ...(truncated ? { incomplete_details: { reason: "max_output_tokens" } } : {}) } }); + response.write("data: [DONE]\n\n"); +} + +/* ---------- Chat-Completions-protocol emitters ---------- */ + +export interface ChatToolCallState { + id: string; + name: string; + announced: boolean; +} + +/** `data:` writer for `chat.completion.chunk` events, shared by both Chat-facing pipes. */ +export function createChatEmitter(response: ServerResponse, model: string) { + const id = `chatcmpl_${crypto.randomUUID()}`; + const created = Math.floor(Date.now() / 1000); + return function chunk(delta: Record, finishReason: string | null = null) { + response.write(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta, finish_reason: finishReason }] })}\n\n`); + }; +} + +/** Emit one streamed tool-call delta, announcing id/name/type on the first chunk for that call. */ +export function emitChatToolDelta(chunk: (delta: Record, finishReason?: string | null) => void, call: ChatToolCallState, index: number, args: string) { + const fn: Record = { arguments: args }; + const delta: Record = { index, function: fn }; + if (!call.announced) { call.announced = true; delta.id = call.id; delta.type = "function"; fn.name = call.name; } + chunk({ tool_calls: [delta] }); } diff --git a/src/streaming/to-anthropic.ts b/src/streaming/to-anthropic.ts index b0b7816..47fb8b9 100644 --- a/src/streaming/to-anthropic.ts +++ b/src/streaming/to-anthropic.ts @@ -1,6 +1,9 @@ import type { ServerResponse } from "node:http"; import { chatResponseFailure } from "../convert/index.js"; -import { cacheTokensOf, cancelOnDisconnect, drain, errorMessage, event, failureMessage, reasoningDeltaOf, reportUsage, SSE_HEADERS, startHeartbeat, UpstreamFailure, type StreamUsageOptions } from "./common.js"; +import { + cacheTokensOf, dataLine, drain, emitAnthropicError, event, failureMessage, newUsageCapture, reasoningDeltaOf, + reportUsage, usageCapture, withSsePipe, UpstreamFailure, type StreamUsageOptions, +} from "./common.js"; /** * Translate a Responses or Chat Completions SSE stream into Anthropic message @@ -8,120 +11,119 @@ import { cacheTokensOf, cancelOnDisconnect, drain, errorMessage, event, failureM * provider actually speaks. */ export async function pipeResponsesStream(upstream: Response, response: ServerResponse, model: string, options?: StreamUsageOptions) { - const reader = upstream.body?.getReader(); - if (!reader) throw new Error("Upstream returned no stream"); - cancelOnDisconnect(response, reader); - response.writeHead(200, SSE_HEADERS); - // try/finally guarantees the interval is cleared even if a write to the - // local client throws before the main try block is entered. - const stopHeartbeat = startHeartbeat(response); - try { - const id = `msg_${crypto.randomUUID()}`; - event(response, "message_start", { type: "message_start", message: { id, type: "message", role: "assistant", model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } } }); - let outputTokens = 0; let inputTokens = 0; let sawUsage = false; let blockIndex = 0; let blockStarted = false; let blockType: "text" | "tool_use" | "thinking" | undefined; let toolStop = false; let truncated = false; let lastUsage: any; - // A chat-completions-shaped upstream (unlike a native Responses one) must - // close every turn with either a finish_reason or [DONE]; seeing neither - // means the connection dropped mid-turn and must not read back as end_turn. - let sawChatShape = false; let sawFinishReason = false; let sawDone = false; - // Parallel tool calls arrive interleaved and keyed by chat `index` or - // Responses `item_id`; each key gets its own Anthropic content block. - const calls = new Map(); - let activeTool: string | number | null = null; - const stopBlock = () => { if (blockStarted) { event(response, "content_block_stop", { type: "content_block_stop", index: blockIndex }); blockStarted = false; blockType = undefined; blockIndex++; activeTool = null; } }; - const startText = () => { - // Text after a tool call or thinking must not append to that block. - if (!blockStarted || blockType !== "text") { - stopBlock(); - event(response, "content_block_start", { type: "content_block_start", index: blockIndex, content_block: { type: "text", text: "" } }); - blockStarted = true; blockType = "text"; - } - }; - const startThinking = () => { - if (!blockStarted || blockType !== "thinking") { - stopBlock(); - event(response, "content_block_start", { type: "content_block_start", index: blockIndex, content_block: { type: "thinking", thinking: "" } }); - blockStarted = true; blockType = "thinking"; - } - }; - const startTool = (id: string, name: string) => { stopBlock(); event(response, "content_block_start", { type: "content_block_start", index: blockIndex, content_block: { type: "tool_use", id, name, input: {} } }); blockStarted = true; blockType = "tool_use"; toolStop = true; }; - /** Switch to the block of another tool call when the stream jumps between them. */ - const openTool = (key: string | number, id: string, name: string) => { - if (activeTool === key && blockStarted && blockType === "tool_use") return; - startTool(id, name); - activeTool = key; - }; - const consume = (line: string) => { - if (!line.startsWith("data:")) return; - const value = line.slice(5).trim(); if (!value) return; - if (value === "[DONE]") { sawDone = true; return; } - try { - const item = JSON.parse(value); - const failure = failureMessage(item); - if (failure) throw new UpstreamFailure(failure); - const choice = item.choices?.[0]; - if (Array.isArray(item.choices)) { - sawChatShape = true; - if (choice?.finish_reason != null) { - sawFinishReason = true; - const chatFailure = chatResponseFailure(item); - if (chatFailure) { options?.onDiagnostic?.(`chat completions finish_reason=${choice.finish_reason}: ${chatFailure}`); throw new UpstreamFailure(chatFailure); } - } + await withSsePipe(upstream, response, async (reader) => { + const id = `msg_${crypto.randomUUID()}`; + event(response, "message_start", { type: "message_start", message: { id, type: "message", role: "assistant", model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } } }); + const capture = newUsageCapture(); + // This pipe serves whichever non-Anthropic upstream protocol is actually + // configured, and the payload itself reveals the shape: usage arriving on + // `item.usage` is chat-shaped, on `item.response.usage` Responses-shaped. + const chat = usageCapture(capture, "chat-completions"); + const responses = usageCapture(capture, "responses"); + let blockIndex = 0; let blockStarted = false; let blockType: "text" | "tool_use" | "thinking" | undefined; let toolStop = false; let truncated = false; + // A chat-completions-shaped upstream (unlike a native Responses one) must + // close every turn with either a finish_reason or [DONE]; seeing neither + // means the connection dropped mid-turn and must not read back as end_turn. + let sawChatShape = false; let sawFinishReason = false; let sawDone = false; + // Parallel tool calls arrive interleaved and keyed by chat `index` or + // Responses `item_id`; each key gets its own Anthropic content block. + const calls = new Map(); + let activeTool: string | number | null = null; + const stopBlock = () => { if (blockStarted) { event(response, "content_block_stop", { type: "content_block_stop", index: blockIndex }); blockStarted = false; blockType = undefined; blockIndex++; activeTool = null; } }; + const startText = () => { + // Text after a tool call or thinking must not append to that block. + if (!blockStarted || blockType !== "text") { + stopBlock(); + event(response, "content_block_start", { type: "content_block_start", index: blockIndex, content_block: { type: "text", text: "" } }); + blockStarted = true; blockType = "text"; } - if (choice?.finish_reason === "length") truncated = true; - const reasoning = reasoningDeltaOf(item); - if (typeof reasoning === "string") { startThinking(); event(response, "content_block_delta", { type: "content_block_delta", index: blockIndex, delta: { type: "thinking_delta", thinking: reasoning } }); } - const text = item.type === "response.output_text.delta" - ? item.delta - : choice?.delta?.content; - if (typeof text === "string" && text) { startText(); outputTokens++; event(response, "content_block_delta", { type: "content_block_delta", index: blockIndex, delta: { type: "text_delta", text } }); } - const itemTool = item.type === "response.output_item.added" && item.item?.type === "function_call" ? item.item : undefined; - const responseArgs = item.type === "response.function_call_arguments.delta" ? item.delta : undefined; - for (const tool of choice?.delta?.tool_calls ?? []) { - const key = tool.index ?? 0; - const call = calls.get(key) ?? { id: tool.id ?? `call_${key}`, name: "" }; - if (tool.id) call.id = tool.id; - if (tool.function?.name) call.name = tool.function.name; - calls.set(key, call); - openTool(key, call.id, call.name); - if (tool.function?.arguments) event(response, "content_block_delta", { type: "content_block_delta", index: blockIndex, delta: { type: "input_json_delta", partial_json: tool.function.arguments } }); + }; + const startThinking = () => { + if (!blockStarted || blockType !== "thinking") { + stopBlock(); + event(response, "content_block_start", { type: "content_block_start", index: blockIndex, content_block: { type: "thinking", thinking: "" } }); + blockStarted = true; blockType = "thinking"; } - if (itemTool) { - const key = itemTool.call_id ?? itemTool.id ?? `call_${blockIndex}`; - calls.set(key, { id: key, name: itemTool.name ?? "" }); - openTool(key, key, itemTool.name ?? ""); + }; + const startTool = (id: string, name: string) => { stopBlock(); event(response, "content_block_start", { type: "content_block_start", index: blockIndex, content_block: { type: "tool_use", id, name, input: {} } }); blockStarted = true; blockType = "tool_use"; toolStop = true; }; + /** Switch to the block of another tool call when the stream jumps between them. */ + const openTool = (key: string | number, id: string, name: string) => { + if (activeTool === key && blockStarted && blockType === "tool_use") return; + startTool(id, name); + activeTool = key; + }; + const consume = (line: string) => { + const value = dataLine(line); + if (!value) return; + if (value === "[DONE]") { sawDone = true; return; } + try { + const item = JSON.parse(value); + const failure = failureMessage(item); + if (failure) throw new UpstreamFailure(failure); + const choice = item.choices?.[0]; + if (Array.isArray(item.choices)) { + sawChatShape = true; + if (choice?.finish_reason != null) { + sawFinishReason = true; + const chatFailure = chatResponseFailure(item); + if (chatFailure) { options?.onDiagnostic?.(`chat completions finish_reason=${choice.finish_reason}: ${chatFailure}`); throw new UpstreamFailure(chatFailure); } + } + } + if (choice?.finish_reason === "length") truncated = true; + const reasoning = reasoningDeltaOf(item); + if (typeof reasoning === "string") { startThinking(); event(response, "content_block_delta", { type: "content_block_delta", index: blockIndex, delta: { type: "thinking_delta", thinking: reasoning } }); } + const text = item.type === "response.output_text.delta" + ? item.delta + : choice?.delta?.content; + if (typeof text === "string" && text) { startText(); capture.output++; event(response, "content_block_delta", { type: "content_block_delta", index: blockIndex, delta: { type: "text_delta", text } }); } + const itemTool = item.type === "response.output_item.added" && item.item?.type === "function_call" ? item.item : undefined; + const responseArgs = item.type === "response.function_call_arguments.delta" ? item.delta : undefined; + for (const tool of choice?.delta?.tool_calls ?? []) { + const key = tool.index ?? 0; + const call = calls.get(key) ?? { id: tool.id ?? `call_${key}`, name: "" }; + if (tool.id) call.id = tool.id; + if (tool.function?.name) call.name = tool.function.name; + calls.set(key, call); + openTool(key, call.id, call.name); + if (tool.function?.arguments) event(response, "content_block_delta", { type: "content_block_delta", index: blockIndex, delta: { type: "input_json_delta", partial_json: tool.function.arguments } }); + } + if (itemTool) { + const key = itemTool.call_id ?? itemTool.id ?? `call_${blockIndex}`; + calls.set(key, { id: key, name: itemTool.name ?? "" }); + openTool(key, key, itemTool.name ?? ""); + } + if (typeof responseArgs === "string") { + const key = item.call_id ?? item.item_id ?? activeTool ?? ""; + const known = calls.get(key); + openTool(key, key, item.name ?? known?.name ?? ""); + event(response, "content_block_delta", { type: "content_block_delta", index: blockIndex, delta: { type: "input_json_delta", partial_json: responseArgs } }); + } + if (item.response?.usage) responses.usage(item.response.usage); + if (item.usage) chat.usage(item.usage); + } catch (error) { + // In-band upstream failures must end the stream; only parse noise is ignored. + if (error instanceof UpstreamFailure) throw error; } - if (typeof responseArgs === "string") { - const key = item.call_id ?? item.item_id ?? activeTool ?? ""; - const known = calls.get(key); - openTool(key, key, item.name ?? known?.name ?? ""); - event(response, "content_block_delta", { type: "content_block_delta", index: blockIndex, delta: { type: "input_json_delta", partial_json: responseArgs } }); + }; + try { + await drain(reader, consume); + if (sawChatShape && !sawFinishReason && !sawDone) { + const message = "Upstream chat completions stream ended before a finish_reason or [DONE] was received."; + options?.onDiagnostic?.(message); + throw new UpstreamFailure(message); } - if (item.response?.usage) { inputTokens = item.response.usage.input_tokens ?? inputTokens; outputTokens = item.response.usage.output_tokens ?? outputTokens; lastUsage = item.response.usage; sawUsage = true; } - if (item.usage) { inputTokens = item.usage.prompt_tokens ?? inputTokens; outputTokens = item.usage.completion_tokens ?? outputTokens; lastUsage = item.usage; sawUsage = true; } + if (!blockStarted) startText(); + stopBlock(); + const stopReason = truncated ? "max_tokens" : toolStop ? "tool_use" : "end_turn"; + const { cached } = cacheTokensOf(capture.raw); + event(response, "message_delta", { type: "message_delta", delta: { stop_reason: stopReason, stop_sequence: null }, usage: { output_tokens: capture.output, input_tokens: capture.input, cache_creation_input_tokens: 0, cache_read_input_tokens: cached ?? 0 } }); + event(response, "message_stop", { type: "message_stop" }); } catch (error) { - // In-band upstream failures must end the stream; only parse noise is ignored. - if (error instanceof UpstreamFailure) throw error; - } - }; - try { - await drain(reader, consume); - if (sawChatShape && !sawFinishReason && !sawDone) { - const message = "Upstream chat completions stream ended before a finish_reason or [DONE] was received."; - options?.onDiagnostic?.(message); - throw new UpstreamFailure(message); + // Match Anthropic semantics: a terminal error event closes the stream. + stopBlock(); + emitAnthropicError(response, error); } - if (!blockStarted) startText(); - stopBlock(); - const stopReason = truncated ? "max_tokens" : toolStop ? "tool_use" : "end_turn"; - const { cached } = cacheTokensOf(lastUsage); - event(response, "message_delta", { type: "message_delta", delta: { stop_reason: stopReason, stop_sequence: null }, usage: { output_tokens: outputTokens, input_tokens: inputTokens, cache_creation_input_tokens: 0, cache_read_input_tokens: cached ?? 0 } }); - event(response, "message_stop", { type: "message_stop" }); - } catch (error) { - // Match Anthropic semantics: a terminal error event closes the stream. - stopBlock(); - event(response, "error", { type: "error", error: { type: "api_error", message: errorMessage(error) } }); - } - response.end(); - reportUsage(options, sawUsage, inputTokens, outputTokens, lastUsage); - } finally { stopHeartbeat(); } + response.end(); + reportUsage(options, capture); + }); } diff --git a/src/ui.ts b/src/ui.ts index f3072d6..a24584b 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -21,7 +21,6 @@ function stdio() { return { input: io.input, output: io.output }; } * Test-only seam: point every prompt this module renders at fake streams * instead of real process stdio, so the interactive flows (Add/Remove custom * provider, provider/model pickers, …) are actually drivable in tests. - * Mirrors the `setCachedOpenRouter` isolation seam in providers/registry.ts. */ export function __setTestIO(next: { input: NodeJS.ReadStream; output: NodeJS.WriteStream }): void { io = next; } /** Restore real process stdio after a test that called `__setTestIO`. */ diff --git a/src/usage/cli.ts b/src/usage/cli.ts index 2103d8f..e5c1e35 100644 --- a/src/usage/cli.ts +++ b/src/usage/cli.ts @@ -1,7 +1,7 @@ import { defaultUsageStore } from "./storage.js"; import type { ModelUsageStat, UsagePeriod, UsageTotals } from "./types.js"; -export function formatPeriod(period: UsagePeriod): string { +function formatPeriod(period: UsagePeriod): string { return { today: "Today", week: "This week", month: "This month", all: "All time" }[period]; } diff --git a/src/usage/storage.ts b/src/usage/storage.ts index cfcf60a..a8203d4 100644 --- a/src/usage/storage.ts +++ b/src/usage/storage.ts @@ -153,7 +153,7 @@ export function defaultUsageLocation(backend: StoreBackend): string { return join(base, backend === "json" ? "usage.json" : "usage.db"); } -export function defaultStoreBackend(): StoreBackend { +function defaultStoreBackend(): StoreBackend { const value = process.env.AGENTX_USAGE_BACKEND; return value === "json" || value === "memory" || value === "sqlite" ? value : "sqlite"; } diff --git a/test/cli-commands.test.ts b/test/cli-commands.test.ts index 6126a31..111e3c3 100644 --- a/test/cli-commands.test.ts +++ b/test/cli-commands.test.ts @@ -175,6 +175,14 @@ test("configureMissingProvider returns the key on success and undefined on cance assert.equal(cancelled, undefined); }); +function withTTY(run: () => Promise): Promise { + const stdin = process.stdin as unknown as { isTTY?: boolean }; + const stdout = process.stdout as unknown as { isTTY?: boolean }; + const originalStdinTTY = stdin.isTTY; const originalStdoutTTY = stdout.isTTY; + stdin.isTTY = true; stdout.isTTY = true; + return run().finally(() => { stdin.isTTY = originalStdinTTY; stdout.isTTY = originalStdoutTTY; }); +} + test("launchClient returns the exit code from a successful run", async () => { const code = await launchClient("claude", [], process.env, { runCommand: async () => 0 }); assert.equal(code, 0); @@ -187,60 +195,18 @@ test("launchClient propagates errors that are not a missing executable", async ( ); }); -test("launchClient exits 1 without prompting when the terminal is not interactive", async () => { - const calls: string[] = []; - const code = await launchClient("claude", [], process.env, { - runCommand: async () => { throw new ClientNotFoundError("claude"); }, - confirm: async () => { calls.push("confirm"); return true; }, - runShellCommand: async () => { calls.push("shell"); return 0; }, - }); - assert.equal(code, 1); - assert.deepEqual(calls, []); -}); - -function withTTY(run: () => Promise): Promise { - const stdin = process.stdin as unknown as { isTTY?: boolean }; - const stdout = process.stdout as unknown as { isTTY?: boolean }; - const originalStdinTTY = stdin.isTTY; const originalStdoutTTY = stdout.isTTY; - stdin.isTTY = true; stdout.isTTY = true; - return run().finally(() => { stdin.isTTY = originalStdinTTY; stdout.isTTY = originalStdoutTTY; }); -} - -test("launchClient install-recovery flow: user declines the install", async () => { - let shellCalls = 0; - const code = await withTTY(() => launchClient("claude", [], process.env, { - runCommand: async () => { throw new ClientNotFoundError("claude"); }, - confirm: async () => false, - runShellCommand: async () => { shellCalls++; return 0; }, - })); - assert.equal(code, 1); - assert.equal(shellCalls, 0); - process.exitCode = undefined; -}); - -test("launchClient install-recovery flow: install succeeds but the executable is still missing", async () => { +test("launchClient reports a missing client with the install command and exits 1, even interactively", async () => { const code = await withTTY(() => launchClient("claude", [], process.env, { runCommand: async () => { throw new ClientNotFoundError("claude"); }, - confirm: async () => true, - runShellCommand: async () => 0, - executableExists: async () => false, })); assert.equal(code, 1); + const output = errors.join("\n"); + assert.match(output, /Claude Code not found/); + assert.match(output, /npm install -g @anthropic-ai\/claude-code/); + assert.match(output, /After installing, re-run: agentx claude/); process.exitCode = undefined; }); -test("launchClient install-recovery flow: install succeeds and the retried launch resolves its exit code", async () => { - let runCommandCalls = 0; - const code = await withTTY(() => launchClient("claude", [], process.env, { - runCommand: async () => { runCommandCalls++; if (runCommandCalls === 1) throw new ClientNotFoundError("claude"); return 0; }, - confirm: async () => true, - runShellCommand: async () => 0, - executableExists: async () => true, - })); - assert.equal(code, 0); - assert.equal(runCommandCalls, 2); -}); - test("exec injects OpenAI-shaped env vars only when --client-protocol openai is passed", async () => { process.env.AGENTX_DEEPSEEK_API_KEY = "test-key"; let capturedEnv: NodeJS.ProcessEnv | undefined; From 0038a673f931f57a58d81696be8c21be7728a958 Mon Sep 17 00:00:00 2001 From: tzzs Date: Sun, 20 Sep 2026 20:48:20 +0800 Subject: [PATCH 3/9] refactor(streaming): share pipe scaffolding and route usage capture through provider mappers All eight SSE pipes now run under withSsePipe (reader/heartbeat/teardown), emit protocol-appropriate terminal errors, and read token fields via the providers/usage field mappers instead of parsing raw payloads in the core. --- src/streaming/anthropic-passthrough.ts | 66 +++++++-------- src/streaming/anthropic-to-chat.ts | 111 ++++++++++++------------- src/streaming/chat-passthrough.ts | 57 ++++++------- src/streaming/common.ts | 34 ++++++-- src/streaming/responses-passthrough.ts | 57 ++++++------- src/streaming/responses-to-chat.ts | 107 ++++++++++++------------ 6 files changed, 212 insertions(+), 220 deletions(-) diff --git a/src/streaming/anthropic-passthrough.ts b/src/streaming/anthropic-passthrough.ts index 22ea892..0115141 100644 --- a/src/streaming/anthropic-passthrough.ts +++ b/src/streaming/anthropic-passthrough.ts @@ -1,49 +1,41 @@ import type { ServerResponse } from "node:http"; -import { cancelOnDisconnect, drain, errorMessage, event, reportUsage, SSE_HEADERS, startHeartbeat, withCacheTokens, type StreamUsageOptions } from "./common.js"; -import type { TokenUsage } from "../usage/types.js"; +import { + dataLine, drain, emitAnthropicError, newUsageCapture, reportUsage, + usageCapture, withSsePipe, type StreamUsageOptions, +} from "./common.js"; /** * Forward a native Anthropic Messages SSE stream byte-for-byte. Used for * Claude Code against a custom provider whose protocol is "anthropic": both - * sides already speak the same event grammar, so there is nothing to + * sides already speak the same event grammar, so there's nothing to * translate — this mirrors `pipeResponsesPassthrough`'s role for a * Responses-native upstream. Usage arrives split across two events: * `message_start` carries the initial input token count, `message_delta` - * carries the final output token count (and, via `withCacheTokens`, cache - * token fields) once the turn completes. + * carries the final output token count (and cache token fields) once the + * turn completes. */ export async function pipeAnthropicPassthrough(upstream: Response, response: ServerResponse, model: string, options?: StreamUsageOptions) { - const reader = upstream.body?.getReader(); - if (!reader) throw new Error("Upstream returned no stream"); - cancelOnDisconnect(response, reader); - response.writeHead(200, SSE_HEADERS); - // try/finally guarantees the interval is cleared even if a write to the - // local client throws before the main try block is entered. - const stopHeartbeat = startHeartbeat(response); - try { - let usage: TokenUsage | null = null; let inputTokens = 0; let outputTokens = 0; let lastUsage: any; - const consume = (line: string) => { - if (!line.startsWith("data:")) return; const value = line.slice(5).trim(); if (!value) return; + await withSsePipe(upstream, response, async (reader) => { + const capture = newUsageCapture(); + const usage = usageCapture(capture, options?.protocol ?? "anthropic", !!options); + const consume = (line: string) => { + const value = dataLine(line); + if (!value) return; + try { + const item = JSON.parse(value); + if (item.type === "message_start" && item.message?.usage) usage.usage(item.message.usage); + // message_delta is the turn's final usage; presence of options gates + // capture exactly as before (no options → nothing to report to). + if (item.type === "message_delta" && item.usage && options) usage.usage(item.usage); + } catch { /* Ignore incomplete provider events. */ } + }; try { - const item = JSON.parse(value); - if (item.type === "message_start" && item.message?.usage) { - inputTokens = item.message.usage.input_tokens ?? inputTokens; - outputTokens = item.message.usage.output_tokens ?? outputTokens; - } - if (item.type === "message_delta" && item.usage && options) { - outputTokens = item.usage.output_tokens ?? outputTokens; - lastUsage = item.usage; - usage = withCacheTokens({ provider: options.provider, model, inputTokens, outputTokens, totalTokens: inputTokens + outputTokens, ...(options.sessionId ? { sessionId: options.sessionId } : {}) }, item.usage); - } - } catch { /* Ignore incomplete provider events. */ } - }; - try { - // Forward the decoded chunks verbatim; SSE is text so this is byte-faithful. - await drain(reader, consume, (chunk) => response.write(chunk)); - } catch (error) { - event(response, "error", { type: "error", error: { type: "api_error", message: errorMessage(error) } }); - } - response.end(); - reportUsage(options, usage !== null, (usage as TokenUsage | null)?.inputTokens ?? inputTokens, (usage as TokenUsage | null)?.outputTokens ?? outputTokens, lastUsage); - } finally { stopHeartbeat(); } + // Forward the decoded chunks verbatim; SSE is text so this is byte-faithful. + await drain(reader, consume, (chunk) => response.write(chunk)); + } catch (error) { + emitAnthropicError(response, error); + } + response.end(); + reportUsage(options, capture); + }); } diff --git a/src/streaming/anthropic-to-chat.ts b/src/streaming/anthropic-to-chat.ts index 870a31f..7493531 100644 --- a/src/streaming/anthropic-to-chat.ts +++ b/src/streaming/anthropic-to-chat.ts @@ -1,5 +1,9 @@ import type { ServerResponse } from "node:http"; -import { cancelOnDisconnect, drain, errorMessage, reportUsage, SSE_HEADERS, startHeartbeat, UpstreamFailure, type StreamUsageOptions } from "./common.js"; +import { + createChatEmitter, dataLine, drain, emitAnthropicError, emitChatError, emitChatToolDelta, + failureMessage, newUsageCapture, reportUsage, usageCapture, withSsePipe, UpstreamFailure, + type ChatToolCallState, type StreamUsageOptions, +} from "./common.js"; /** * Pipe a native Anthropic Messages SSE stream into OpenAI-style @@ -11,67 +15,58 @@ import { cancelOnDisconnect, drain, errorMessage, reportUsage, SSE_HEADERS, star * since the chunk grammar has no announce/done event pair to satisfy. */ export async function pipeAnthropicStreamToChat(upstream: Response, response: ServerResponse, model: string, options?: StreamUsageOptions) { - const reader = upstream.body?.getReader(); - if (!reader) throw new Error("Upstream returned no stream"); - cancelOnDisconnect(response, reader); - response.writeHead(200, SSE_HEADERS); - const stopHeartbeat = startHeartbeat(response); - try { - const id = `chatcmpl_${crypto.randomUUID()}`; const created = Math.floor(Date.now() / 1000); - let inputTokens = 0; let outputTokens = 0; let sawUsage = false; let truncated = false; let lastUsage: any; - let sawMessageStop = false; - const calls = new Map(); - const chunk = (delta: Record, finishReason: string | null = null) => { - response.write(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta, finish_reason: finishReason }] })}\n\n`); - }; - const consume = (line: string) => { - if (!line.startsWith("data:")) return; const value = line.slice(5).trim(); if (!value) return; - try { - const item = JSON.parse(value); - if (item.type === "error") throw new UpstreamFailure(item.error?.message ?? "Upstream stream failed"); - if (item.type === "message_start" && item.message?.usage) { - inputTokens = item.message.usage.input_tokens ?? inputTokens; - outputTokens = item.message.usage.output_tokens ?? outputTokens; - } - if (item.type === "content_block_start" && item.content_block?.type === "tool_use") { - calls.set(item.index, { id: item.content_block.id ?? `call_${item.index}`, name: item.content_block.name ?? "", announced: false }); - } - if (item.type === "content_block_delta") { - const delta = item.delta ?? {}; - if (delta.type === "text_delta" && typeof delta.text === "string") { outputTokens++; chunk({ content: delta.text }); } - else if (delta.type === "thinking_delta" && typeof delta.thinking === "string") { chunk({ reasoning_content: delta.thinking }); } - else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") { - const call = calls.get(item.index); - if (call) { - const toolCall: Record = { index: item.index, function: { arguments: delta.partial_json } }; - if (!call.announced) { call.announced = true; toolCall.id = call.id; toolCall.type = "function"; (toolCall.function as any).name = call.name; } - chunk({ tool_calls: [toolCall] }); + await withSsePipe(upstream, response, async (reader) => { + const capture = newUsageCapture(); + const usage = usageCapture(capture, "anthropic"); + let truncated = false; + // A native Anthropic upstream must close with message_stop; seeing + // neither it nor an in-band error means the connection dropped mid-turn. + let sawMessageStop = false; + const calls = new Map(); + const chunk = createChatEmitter(response, model); + const consume = (line: string) => { + const value = dataLine(line); + if (!value) return; + try { + const item = JSON.parse(value); + const failure = failureMessage(item); + if (failure) throw new UpstreamFailure(failure); + if (item.type === "message_start" && item.message?.usage) usage.usage(item.message.usage); + if (item.type === "content_block_start" && item.content_block?.type === "tool_use") { + calls.set(item.index, { id: item.content_block.id ?? `call_${item.index}`, name: item.content_block.name ?? "", announced: false }); + } + if (item.type === "content_block_delta") { + const delta = item.delta ?? {}; + if (delta.type === "text_delta" && typeof delta.text === "string") { capture.output++; chunk({ content: delta.text }); } + else if (delta.type === "thinking_delta" && typeof delta.thinking === "string") { chunk({ reasoning_content: delta.thinking }); } + else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") { + const call = calls.get(item.index); + if (call) emitChatToolDelta(chunk, call, item.index, delta.partial_json); } } + if (item.type === "message_delta") { + if (item.delta?.stop_reason === "max_tokens") truncated = true; + if (item.usage) usage.usage(item.usage); + } + if (item.type === "message_stop") sawMessageStop = true; + } catch (error) { + // In-band upstream failures must end the stream; only parse noise is ignored. + if (error instanceof UpstreamFailure) throw error; } - if (item.type === "message_delta") { - if (item.delta?.stop_reason === "max_tokens") truncated = true; - if (item.usage) { outputTokens = item.usage.output_tokens ?? outputTokens; lastUsage = item.usage; sawUsage = true; } + }; + try { + await drain(reader, consume); + if (!sawMessageStop) { + const message = "Upstream Anthropic stream ended before message_stop was received."; + options?.onDiagnostic?.(message); + throw new UpstreamFailure(message); } - if (item.type === "message_stop") sawMessageStop = true; + chunk({}, calls.size ? "tool_calls" : truncated ? "length" : "stop"); + response.write("data: [DONE]\n\n"); } catch (error) { - if (error instanceof UpstreamFailure) throw error; - } - }; - try { - await drain(reader, consume); - if (!sawMessageStop) { - const message = "Upstream Anthropic stream ended before message_stop was received."; - options?.onDiagnostic?.(message); - throw new UpstreamFailure(message); + emitChatError(response, chunk, error); } - chunk({}, calls.size ? "tool_calls" : truncated ? "length" : "stop"); - response.write("data: [DONE]\n\n"); - } catch (error) { - chunk({ content: `\n[error: ${errorMessage(error)}]` }, "stop"); - response.write("data: [DONE]\n\n"); - } - response.end(); - reportUsage(options, sawUsage, inputTokens, outputTokens, lastUsage); - } finally { stopHeartbeat(); } + response.end(); + reportUsage(options, capture); + }); } diff --git a/src/streaming/chat-passthrough.ts b/src/streaming/chat-passthrough.ts index cb18ffa..865ca86 100644 --- a/src/streaming/chat-passthrough.ts +++ b/src/streaming/chat-passthrough.ts @@ -1,6 +1,8 @@ import type { ServerResponse } from "node:http"; -import { cancelOnDisconnect, drain, errorMessage, reportUsage, SSE_HEADERS, startHeartbeat, withCacheTokens, type StreamUsageOptions } from "./common.js"; -import type { TokenUsage } from "../usage/types.js"; +import { + createChatEmitter, dataLine, drain, emitChatError, newUsageCapture, + reportUsage, usageCapture, withSsePipe, type StreamUsageOptions, +} from "./common.js"; /** * Forward a Chat Completions SSE stream byte-for-byte. Used for the local @@ -13,33 +15,28 @@ import type { TokenUsage } from "../usage/types.js"; * not. */ export async function pipeChatPassthrough(upstream: Response, response: ServerResponse, model: string, options?: StreamUsageOptions) { - const reader = upstream.body?.getReader(); - if (!reader) throw new Error("Upstream returned no stream"); - cancelOnDisconnect(response, reader); - response.writeHead(200, SSE_HEADERS); - const stopHeartbeat = startHeartbeat(response); - try { - let usage: TokenUsage | null = null; let inputTokens = 0; let outputTokens = 0; let lastUsage: any; - const consume = (line: string) => { - if (!line.startsWith("data:")) return; const value = line.slice(5).trim(); if (!value || value === "[DONE]") return; + await withSsePipe(upstream, response, async (reader) => { + const capture = newUsageCapture(); + const usage = usageCapture(capture, options?.protocol ?? "chat-completions", !!options); + let countedOutput = 0; + const chunk = createChatEmitter(response, model); + const consume = (line: string) => { + const value = dataLine(line); + if (!value || value === "[DONE]") return; + try { + const item = JSON.parse(value); + const delta = item.choices?.[0]?.delta?.content; + if (typeof delta === "string" && delta) countedOutput++; + if (item.usage) usage.usage(item.usage); + } catch { /* Ignore incomplete provider events. */ } + }; try { - const item = JSON.parse(value); - const delta = item.choices?.[0]?.delta?.content; - if (typeof delta === "string" && delta) outputTokens++; - if (item.usage) { - inputTokens = item.usage.prompt_tokens ?? inputTokens; const usageOutputTokens = item.usage.completion_tokens ?? outputTokens; - lastUsage = item.usage; - usage = withCacheTokens({ provider: options?.provider ?? "", model, inputTokens, outputTokens: usageOutputTokens, totalTokens: item.usage.total_tokens ?? (inputTokens + usageOutputTokens), ...(options?.sessionId ? { sessionId: options.sessionId } : {}) }, item.usage); - } - } catch { /* Ignore incomplete provider events. */ } - }; - try { - // Forward the decoded chunks verbatim; SSE is text so this is byte-faithful. - await drain(reader, consume, (chunk) => response.write(chunk)); - } catch (error) { - response.write(`data: {"error":{"message":${JSON.stringify(errorMessage(error))},"type":"upstream_error"}}\n\n`); - } - response.end(); - reportUsage(options, usage !== null, (usage as TokenUsage | null)?.inputTokens ?? inputTokens, (usage as TokenUsage | null)?.outputTokens ?? outputTokens, lastUsage); - } finally { stopHeartbeat(); } + // Forward the decoded chunks verbatim; SSE is text so this is byte-faithful. + await drain(reader, consume, (chunkText) => response.write(chunkText)); + } catch (error) { + emitChatError(response, chunk, error); + } + response.end(); + reportUsage(options, { ...capture, output: capture.output || countedOutput }, usage.total()); + }); } diff --git a/src/streaming/common.ts b/src/streaming/common.ts index 7297b7e..c6ae5eb 100644 --- a/src/streaming/common.ts +++ b/src/streaming/common.ts @@ -154,9 +154,13 @@ export function withCacheTokens(usage: TokenUsage, source: any): TokenUsage { export interface UsageCapture { input: number; output: number; - /** The most recent raw upstream usage payload, for cache-field capture. */ + /** The most recent raw upstream usage payload, for protocol-shape-dependent emission (e.g. Anthropic cache_read). */ raw?: any; sawUsage: boolean; + /** Cache/reasoning fields mapped from the latest raw usage via the provider field mappers. */ + cached?: number; + reasoning?: number; + cacheWrite?: number; } export function newUsageCapture(): UsageCapture { @@ -178,9 +182,11 @@ function mapRawUsage(raw: any, protocol: ProviderProtocol): TokenUsage | null { * Bind usage capture to the upstream protocol so token fields are read via * the same provider field mappers as the non-streaming path. `usage()` merges * a raw usage object into the capture; `total()` prefers the upstream's own - * total when it carries one. + * total when it carries one. `enabled=false` (a pipe called with no options) + * keeps raw payloads as estimation hints without claiming real usage, which + * `reportUsage`'s fallback then reports. */ -export function usageCapture(capture: UsageCapture, protocol: ProviderProtocol): { +export function usageCapture(capture: UsageCapture, protocol: ProviderProtocol, enabled = true): { usage(raw: any): void; total(): number; } { @@ -189,9 +195,13 @@ export function usageCapture(capture: UsageCapture, protocol: ProviderProtocol): const mapped = mapRawUsage(raw, protocol); if (!mapped) return; capture.raw = raw; + if (!enabled) return; capture.sawUsage = true; if (mapped.inputTokens) capture.input = mapped.inputTokens; if (mapped.outputTokens) capture.output = mapped.outputTokens; + if (mapped.cachedInputTokens !== undefined) capture.cached = mapped.cachedInputTokens; + if (mapped.reasoningTokens !== undefined) capture.reasoning = mapped.reasoningTokens; + if (mapped.cacheWriteTokens !== undefined) capture.cacheWrite = mapped.cacheWriteTokens; }, total() { return Number(capture.raw?.total_tokens ?? capture.input + capture.output); @@ -215,12 +225,20 @@ export function reasoningDeltaOf(item: any): string | undefined { return undefined; } -/** Final usage reporting; called by every pipe. */ -export function reportUsage(options: StreamUsageOptions | undefined, capture: UsageCapture) { +/** Final usage reporting; called by every pipe. Captured fields ride on the + * usage record itself; a stream that never carried a usage payload reports an + * estimate from the per-delta counters instead. */ +export function reportUsage(options: StreamUsageOptions | undefined, capture: UsageCapture, totalOverride?: number) { if (!options?.onUsage) return; - options.onUsage(capture.sawUsage - ? withCacheTokens({ provider: options.provider, model: options.model, inputTokens: capture.input, outputTokens: capture.output, totalTokens: capture.input + capture.output, ...(options.sessionId ? { sessionId: options.sessionId } : {}) }, capture.raw) - : estimatedUsage(options.provider, options.model, capture.input, capture.output, options.sessionId)); + if (!capture.sawUsage) { + options.onUsage(estimatedUsage(options.provider, options.model, capture.input, capture.output, options.sessionId)); + return; + } + const usage: TokenUsage = { provider: options.provider, model: options.model, inputTokens: capture.input, outputTokens: capture.output, totalTokens: totalOverride ?? capture.input + capture.output, ...(options.sessionId ? { sessionId: options.sessionId } : {}) }; + if (capture.cached !== undefined) usage.cachedInputTokens = capture.cached; + if (capture.reasoning !== undefined) usage.reasoningTokens = capture.reasoning; + if (capture.cacheWrite !== undefined) usage.cacheWriteTokens = capture.cacheWrite; + options.onUsage(usage); } /* ---------- Responses-protocol (Codex-facing) emitters ---------- */ diff --git a/src/streaming/responses-passthrough.ts b/src/streaming/responses-passthrough.ts index daca7be..7d15fca 100644 --- a/src/streaming/responses-passthrough.ts +++ b/src/streaming/responses-passthrough.ts @@ -1,6 +1,8 @@ import type { ServerResponse } from "node:http"; -import { cancelOnDisconnect, drain, errorMessage, event, reportUsage, SSE_HEADERS, startHeartbeat, withCacheTokens, type StreamUsageOptions } from "./common.js"; -import type { TokenUsage } from "../usage/types.js"; +import { + dataLine, drain, emitResponsesError, event, newUsageCapture, reportUsage, + usageCapture, withSsePipe, type StreamUsageOptions, +} from "./common.js"; /** * Forward a Responses-protocol SSE stream byte-for-byte. Usage is captured from @@ -8,35 +10,26 @@ import type { TokenUsage } from "../usage/types.js"; * the same events the upstream emitted. */ export async function pipeResponsesPassthrough(upstream: Response, response: ServerResponse, model: string, options?: StreamUsageOptions) { - const reader = upstream.body?.getReader(); - if (!reader) throw new Error("Upstream returned no stream"); - cancelOnDisconnect(response, reader); - response.writeHead(200, SSE_HEADERS); - // try/finally guarantees the interval is cleared even if a write to the - // local client throws before the main try block is entered. - const stopHeartbeat = startHeartbeat(response); - try { - let usage: TokenUsage | null = null; let outputTokens = 0; let lastUsage: any; - const consume = (line: string) => { - if (!line.startsWith("data:")) return; const value = line.slice(5).trim(); if (!value || value === "[DONE]") return; + await withSsePipe(upstream, response, async (reader) => { + const capture = newUsageCapture(); + const usage = usageCapture(capture, options?.protocol ?? "responses", !!options); + let outputTokens = 0; + const consume = (line: string) => { + const value = dataLine(line); + if (!value || value === "[DONE]") return; + try { + const item = JSON.parse(value); + if (item.type === "response.output_text.delta" && typeof item.delta === "string") outputTokens++; + if (item.response?.usage) usage.usage(item.response.usage); + } catch { /* Ignore incomplete provider events. */ } + }; try { - const item = JSON.parse(value); - if (item.type === "response.output_text.delta" && typeof item.delta === "string") outputTokens++; - if (item.response?.usage && options) { - const inputTokens = item.response.usage.input_tokens ?? 0; const usageOutputTokens = item.response.usage.output_tokens ?? 0; - lastUsage = item.response.usage; - usage = withCacheTokens({ provider: options.provider, model, inputTokens, outputTokens: usageOutputTokens, totalTokens: item.response.usage.total_tokens ?? (inputTokens + usageOutputTokens), ...(options.sessionId ? { sessionId: options.sessionId } : {}) }, item.response.usage); - } - } catch { /* Ignore incomplete provider events. */ } - }; - try { - // Forward the decoded chunks verbatim; SSE is text so this is byte-faithful. - await drain(reader, consume, (chunk) => response.write(chunk)); - } catch (error) { - const id = `resp_${crypto.randomUUID()}`; - event(response, "response.failed", { type: "response.failed", response: { id, object: "response", status: "failed", model, error: { code: "upstream_error", message: errorMessage(error) } } }); - } - response.end(); - reportUsage(options, usage !== null, (usage as TokenUsage | null)?.inputTokens ?? 0, (usage as TokenUsage | null)?.outputTokens ?? outputTokens, lastUsage); - } finally { stopHeartbeat(); } + // Forward the decoded chunks verbatim; SSE is text so this is byte-faithful. + await drain(reader, consume, (chunk) => response.write(chunk)); + } catch (error) { + emitResponsesError(response, `resp_${crypto.randomUUID()}`, model, error); + } + response.end(); + reportUsage(options, { ...capture, output: capture.output || outputTokens }, usage.total()); + }); } diff --git a/src/streaming/responses-to-chat.ts b/src/streaming/responses-to-chat.ts index 016356e..eeea63b 100644 --- a/src/streaming/responses-to-chat.ts +++ b/src/streaming/responses-to-chat.ts @@ -1,5 +1,9 @@ import type { ServerResponse } from "node:http"; -import { cancelOnDisconnect, drain, errorMessage, failureMessage, reasoningDeltaOf, reportUsage, SSE_HEADERS, startHeartbeat, UpstreamFailure, type StreamUsageOptions } from "./common.js"; +import { + createChatEmitter, dataLine, drain, emitChatError, emitChatToolDelta, failureMessage, + newUsageCapture, reasoningDeltaOf, reportUsage, usageCapture, withSsePipe, UpstreamFailure, + type ChatToolCallState, type StreamUsageOptions, +} from "./common.js"; /** * Pipe a native Responses SSE stream into OpenAI-style `chat.completion.chunk` @@ -11,62 +15,55 @@ import { cancelOnDisconnect, drain, errorMessage, failureMessage, reasoningDelta * upstream shape): this pipe is only ever selected for `protocol === "responses"`. */ export async function pipeResponsesStreamToChat(upstream: Response, response: ServerResponse, model: string, options?: StreamUsageOptions) { - const reader = upstream.body?.getReader(); - if (!reader) throw new Error("Upstream returned no stream"); - cancelOnDisconnect(response, reader); - response.writeHead(200, SSE_HEADERS); - const stopHeartbeat = startHeartbeat(response); - try { - const id = `chatcmpl_${crypto.randomUUID()}`; const created = Math.floor(Date.now() / 1000); - let inputTokens = 0; let outputTokens = 0; let sawUsage = false; let truncated = false; let lastUsage: any; let sawCompleted = false; - const calls = new Map(); - const chunk = (delta: Record, finishReason: string | null = null) => { - response.write(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta, finish_reason: finishReason }] })}\n\n`); - }; - const consume = (line: string) => { - if (!line.startsWith("data:")) return; const value = line.slice(5).trim(); if (!value) return; - try { - const item = JSON.parse(value); - const failure = failureMessage(item); - if (failure) throw new UpstreamFailure(failure); - if (item.type === "response.output_text.delta" && typeof item.delta === "string") { outputTokens++; chunk({ content: item.delta }); } - const reasoning = reasoningDeltaOf(item); - if (typeof reasoning === "string") chunk({ reasoning_content: reasoning }); - if (item.type === "response.output_item.added" && item.item?.type === "function_call") { - const key = item.item.call_id ?? item.item.id ?? `call_${calls.size}`; - calls.set(key, { index: calls.size, name: item.item.name ?? "", announced: false }); - } - if (item.type === "response.function_call_arguments.delta" && typeof item.delta === "string") { - const key = item.call_id ?? item.item_id ?? ""; - const call = calls.get(key) ?? { index: calls.size, name: item.name ?? "", announced: false }; - calls.set(key, call); - const toolCall: Record = { index: call.index, function: { arguments: item.delta } }; - if (!call.announced) { call.announced = true; toolCall.id = key; toolCall.type = "function"; (toolCall.function as any).name = call.name; } - chunk({ tool_calls: [toolCall] }); + await withSsePipe(upstream, response, async (reader) => { + const capture = newUsageCapture(); + const usage = usageCapture(capture, "responses"); + let truncated = false; let sawCompleted = false; + const calls = new Map(); + const chunk = createChatEmitter(response, model); + const consume = (line: string) => { + const value = dataLine(line); + if (!value) return; + try { + const item = JSON.parse(value); + const failure = failureMessage(item); + if (failure) throw new UpstreamFailure(failure); + if (item.type === "response.output_text.delta" && typeof item.delta === "string") { capture.output++; chunk({ content: item.delta }); } + const reasoning = reasoningDeltaOf(item); + if (typeof reasoning === "string") chunk({ reasoning_content: reasoning }); + if (item.type === "response.output_item.added" && item.item?.type === "function_call") { + const key = item.item.call_id ?? item.item.id ?? `call_${calls.size}`; + calls.set(key, { index: calls.size, id: key, name: item.item.name ?? "", announced: false }); + } + if (item.type === "response.function_call_arguments.delta" && typeof item.delta === "string") { + const key = item.call_id ?? item.item_id ?? ""; + const call = calls.get(key) ?? { index: calls.size, id: key, name: item.name ?? "", announced: false }; + calls.set(key, call); + emitChatToolDelta(chunk, call, call.index, item.delta); + } + if (item.type === "response.completed") { + sawCompleted = true; + if (item.response?.status === "incomplete") truncated = true; + if (item.response?.usage) usage.usage(item.response.usage); + } + } catch (error) { + // In-band upstream failures must end the stream; only parse noise is ignored. + if (error instanceof UpstreamFailure) throw error; } - if (item.type === "response.completed") { - sawCompleted = true; - if (item.response?.status === "incomplete") truncated = true; - if (item.response?.usage) { inputTokens = item.response.usage.input_tokens ?? inputTokens; outputTokens = item.response.usage.output_tokens ?? outputTokens; lastUsage = item.response.usage; sawUsage = true; } + }; + try { + await drain(reader, consume); + if (!sawCompleted) { + const message = "Upstream Responses stream ended before response.completed was received."; + options?.onDiagnostic?.(message); + throw new UpstreamFailure(message); } + chunk({}, calls.size ? "tool_calls" : truncated ? "length" : "stop"); + response.write("data: [DONE]\n\n"); } catch (error) { - if (error instanceof UpstreamFailure) throw error; - } - }; - try { - await drain(reader, consume); - if (!sawCompleted) { - const message = "Upstream Responses stream ended before response.completed was received."; - options?.onDiagnostic?.(message); - throw new UpstreamFailure(message); + emitChatError(response, chunk, error); } - chunk({}, calls.size ? "tool_calls" : truncated ? "length" : "stop"); - response.write("data: [DONE]\n\n"); - } catch (error) { - chunk({ content: `\n[error: ${errorMessage(error)}]` }, "stop"); - response.write("data: [DONE]\n\n"); - } - response.end(); - reportUsage(options, sawUsage, inputTokens, outputTokens, lastUsage); - } finally { stopHeartbeat(); } + response.end(); + reportUsage(options, capture); + }); } From f10e2936d34b21cca02ff60db05f9e69f99b08d9 Mon Sep 17 00:00:00 2001 From: tzzs Date: Sun, 20 Sep 2026 20:53:21 +0800 Subject: [PATCH 4/9] refactor(server): drive the three client endpoints from one route table payloads/pipes/finish per (endpoint, upstream protocol) replace three hand-written endpoint blocks that repeated the same non-stream tail. --- src/server.ts | 168 ++++++++++++++++++++++++++++---------------------- 1 file changed, 94 insertions(+), 74 deletions(-) diff --git a/src/server.ts b/src/server.ts index 92d3279..f45fed5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,7 +3,7 @@ import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; import type { Config } from "./config.js"; import { chatResponseFailure, fromAnthropicResponse, fromAnthropicResponseToChat, fromChatResponse, fromChatResponseToResponses, fromResponsesResponse, fromResponsesResponseToChat, responsesResponseFailure, toAnthropicRequest, toAnthropicRequestFromChat, toChatCompletionsRequest, toChatRequest, toResponsesRequest, toResponsesRequestFromChat } from "./convert/index.js"; import { pipeAnthropicPassthrough, pipeAnthropicStreamToChat, pipeAnthropicStreamToResponses, pipeChatPassthrough, pipeChatStreamToResponses, pipeResponsesPassthrough, pipeResponsesStream, pipeResponsesStreamToChat, type StreamUsageOptions } from "./streaming/index.js"; -import type { ProviderModel } from "./providers/types.js"; +import type { ProviderModel, ProviderProtocol } from "./providers/types.js"; import type { TokenUsage } from "./usage/types.js"; import { honorRequestedModel, providerFor, providers } from "./catalog.js"; import { apiKeyFor, isPlaceholderModel, providerDisplayName } from "./providers/registry.js"; @@ -175,32 +175,34 @@ function watchedBody(stream: ReadableStream | null, progress: () => } /** - * Shared proxy pipeline for both client-facing endpoints: authenticate, parse - * the body, resolve provider/model/key, convert the payload to the upstream - * protocol, forward it with the idle watchdog, and map failures to errors. - * Returns `undefined` after answering the request directly (auth failure, - * non-OK upstream status); otherwise yields the parsed request and the - * watched upstream response. + * Shared proxy pipeline for one request: authenticate, parse the body, + * resolve provider/model/key, convert the payload to the upstream protocol, + * forward it with the idle watchdog, and map failures to errors. Returns + * `undefined` after answering the request directly (auth failure, non-OK + * upstream status); otherwise yields the parsed request and the watched + * upstream response. */ async function proxyRequest( config: Config, request: IncomingMessage, response: ServerResponse, token: string, - route: { fallbackModel: string; payload: (input: any, model: string, provider: ProviderModel) => unknown }, + fallbackModel: string, + payload: (input: any, model: string, provider: ProviderModel) => unknown, maxBodyBytes = MAX_BODY_BYTES, ): Promise<{ input: any; model: string; provider: ProviderModel; watched: Response } | undefined> { if (!authorized(request, token)) { json(response, 401, { error: { message: "Invalid API key", type: "authentication_error" } }); return undefined; } try { const input = JSON.parse(await body(request, maxBodyBytes)); - // The endpoint decides which model id counts as "configured" (/v1/responses - // honors the client-echoed input.model; /v1/messages only config/env). - const model = honorRequestedModel(input.model, route.fallbackModel, config.provider); + // The endpoint decides which model id counts as "configured"; clients + // that echo a preferred model (e.g. Codex sending OPENAI_MODEL=auto back) + // are honored per honorRequestedModel's rules. + const model = honorRequestedModel(input.model, fallbackModel, config.provider); debug(config, `POST ${request.url} model=${model}`); const provider = providerFor(model, config.provider); const apiKey = apiKeyFor(provider, config.apiKey); - const payload = route.payload(input, model, provider); + const payloadBody = payload(input, model, provider); const session = upstreamSession(response, config); - const upstream = await forwardWithRetry(config, provider, apiKey, payload, session.signal, config.retry); + const upstream = await forwardWithRetry(config, provider, apiKey, payloadBody, session.signal, config.retry); session.progress(); debug(config, `provider status=${upstream.status}`); if (!upstream.ok) { await upstreamError(response, providerDisplayName(provider.provider), upstream, upstream.status); return undefined; } @@ -213,6 +215,74 @@ async function proxyRequest( } } +/** + * Per client-facing endpoint: how to build the upstream payload for each + * provider protocol, which pipe serves each streaming combination, and how + * to translate a non-streaming upstream JSON back to this endpoint's format. + * Invariant: the pipe/translator selected always matches the protocol whose + * payload builder ran (e.g. a 200 with no body on an Anthropic upstream goes + * to the Anthropic pipe, never the Responses one). + */ +interface EndpointSpec { + payloads: Record unknown>; + pipes: Record Promise>; + /** Non-stream JSON translation; an upstream-reported failure maps to a 502 instead. */ + finish(upstream: unknown, provider: ProviderModel, model: string): unknown; + /** In-band failure message carried by a non-OK-shaped 200 payload, if any. */ + failure?(upstream: unknown, provider: ProviderModel): string | undefined; +} + +const ENDPOINT_SPECS: Record<"responses" | "chat" | "messages", EndpointSpec> = { + responses: { + payloads: { + "responses": (input, model) => ({ ...input, model }), + "chat-completions": (input, model, provider) => toChatCompletionsRequest(input, model, provider.provider), + "anthropic": (input, model) => toAnthropicRequest(input, model), + }, + pipes: { + "chat-completions": pipeChatStreamToResponses, + "responses": pipeResponsesPassthrough, + "anthropic": pipeAnthropicStreamToResponses, + }, + finish: (upstream, provider, model) => provider.protocol === "anthropic" + ? fromAnthropicResponse(upstream, model) + : provider.protocol === "chat-completions" ? fromChatResponseToResponses(upstream, model) : upstream, + failure: (upstream, provider) => provider.protocol === "chat-completions" ? chatResponseFailure(upstream) : provider.protocol === "responses" ? responsesResponseFailure(upstream) : undefined, + }, + chat: { + payloads: { + "responses": (input, model) => toResponsesRequestFromChat(input, model), + "chat-completions": (input, model) => ({ ...input, model }), + "anthropic": (input, model) => toAnthropicRequestFromChat(input, model), + }, + pipes: { + "chat-completions": pipeChatPassthrough, + "responses": pipeResponsesStreamToChat, + "anthropic": pipeAnthropicStreamToChat, + }, + finish: (upstream, provider, model) => provider.protocol === "anthropic" + ? fromAnthropicResponseToChat(upstream, model) + : provider.protocol === "responses" ? fromResponsesResponseToChat(upstream, model) : upstream, + failure: (upstream, provider) => provider.protocol === "responses" ? responsesResponseFailure(upstream) : provider.protocol === "chat-completions" ? chatResponseFailure(upstream) : undefined, + }, + messages: { + payloads: { + "responses": (input, model) => toResponsesRequest(input, model), + "chat-completions": (input, model, provider) => toChatRequest(input, model, provider.provider), + // already Anthropic-shaped; zero conversion + "anthropic": (input, model) => ({ ...input, model }), + }, + pipes: { + "chat-completions": pipeResponsesStream, + "responses": pipeResponsesStream, + "anthropic": pipeAnthropicPassthrough, + }, + // Anthropic upstreams are already Anthropic-shaped; others convert back. + finish: (upstream, provider) => provider.protocol === "anthropic" ? upstream : provider.protocol === "responses" ? fromResponsesResponse(upstream, provider.model) : fromChatResponse(upstream, provider.model), + failure: (upstream, provider) => provider.protocol === "chat-completions" ? chatResponseFailure(upstream) : provider.protocol === "responses" ? responsesResponseFailure(upstream) : undefined, + }, +}; + export async function startAdapter(config: Config, options: AdapterOptions = {}): Promise { const initialModel = config.model; const initialProvider = providerFor(initialModel, config.provider); const initialApiKey = apiKeyFor(initialProvider, config.apiKey); @@ -240,71 +310,21 @@ export async function startAdapter(config: Config, options: AdapterOptions = {}) if (pathname === "/v1/models" && request.method === "GET") return json(response, 200, { data: providers.filter((item) => (!config.provider || item.provider === config.provider) && !isPlaceholderModel(item)).map((item) => ({ id: item.model, object: "model", owned_by: item.provider })) }); - if (pathname === "/v1/responses" && request.method === "POST") { - // Honor auto routing here too: Codex echoes OPENAI_MODEL=auto back. - const proxied = await proxyRequest(config, request, response, token, { - fallbackModel: config.model, - payload: (input, model, provider) => { - if (provider.protocol === "anthropic") return toAnthropicRequest(input, model); - return provider.protocol === "responses" ? { ...input, model } : toChatCompletionsRequest(input, model, provider.provider); - }, - }, maxBodyBytes); - if (!proxied) return; - const { input, model, provider, watched } = proxied; - const options = streamOptions(config, provider, sessionFor(input), safeRecord); - if (input.stream && provider.protocol === "chat-completions") return pipeChatStreamToResponses(watched, response, model, options); - if (input.stream && provider.protocol === "anthropic") return pipeAnthropicStreamToResponses(watched, response, model, options); - if (input.stream) return pipeResponsesPassthrough(watched, response, model, options); - if (!watched.body) { response.end(); return; } - const value = await watched.json(); recordUsage(value, provider, sessionFor(input)); - if (provider.protocol === "anthropic") return json(response, watched.status, fromAnthropicResponse(value, model)); - const failure = provider.protocol === "chat-completions" ? chatResponseFailure(value) : responsesResponseFailure(value); - if (failure) return json(response, 502, { error: { message: failure, type: "upstream_error" } }); - return json(response, watched.status, provider.protocol === "chat-completions" ? fromChatResponseToResponses(value, model) : value); - } - if (pathname === "/v1/chat/completions" && request.method === "POST") { - const proxied = await proxyRequest(config, request, response, token, { - fallbackModel: config.model, - payload: (input, model, provider) => { - if (provider.protocol === "anthropic") return toAnthropicRequestFromChat(input, model); - if (provider.protocol === "responses") return toResponsesRequestFromChat(input, model); - return { ...input, model }; // already chat-completions-shaped - }, - }, maxBodyBytes); - if (!proxied) return; - const { input, model, provider, watched } = proxied; - const chatOptions = streamOptions(config, provider, sessionFor(input), safeRecord); - if (input.stream && provider.protocol === "anthropic") return pipeAnthropicStreamToChat(watched, response, model, chatOptions); - if (input.stream && provider.protocol === "responses") return pipeResponsesStreamToChat(watched, response, model, chatOptions); - if (input.stream) return pipeChatPassthrough(watched, response, model, chatOptions); - if (!watched.body) { response.end(); return; } - const value = await watched.json(); recordUsage(value, provider, sessionFor(input)); - if (provider.protocol === "anthropic") return json(response, watched.status, fromAnthropicResponseToChat(value, model)); - const failure = provider.protocol === "responses" ? responsesResponseFailure(value) : chatResponseFailure(value); - if (failure) return json(response, 502, { error: { message: failure, type: "upstream_error" } }); - return json(response, watched.status, provider.protocol === "responses" ? fromResponsesResponseToChat(value, model) : value); - } - if (pathname !== "/v1/messages" || request.method !== "POST") return json(response, 404, { error: { message: "Not found", type: "not_found" } }); - const proxied = await proxyRequest(config, request, response, token, { - // Claude Code pins every tier to the configured model, but its haiku - // background lane may carry a faster sibling (see clientEnvironment); - // honor such requests instead of forcing the main model. - fallbackModel: config.model, - payload: (input, model, provider) => { - if (provider.protocol === "anthropic") return { ...input, model }; // already Anthropic-shaped; zero conversion - return provider.protocol === "responses" ? toResponsesRequest(input, model) : toChatRequest(input, model, provider.provider); - }, - }, maxBodyBytes); + const specFor = pathname === "/v1/responses" ? ENDPOINT_SPECS.responses + : pathname === "/v1/chat/completions" ? ENDPOINT_SPECS.chat + : pathname === "/v1/messages" ? ENDPOINT_SPECS.messages + : undefined; + if (!specFor || request.method !== "POST") return json(response, 404, { error: { message: "Not found", type: "not_found" } }); + const proxied = await proxyRequest(config, request, response, token, config.model, (input, model, provider) => specFor.payloads[provider.protocol](input, model, provider), maxBodyBytes); if (!proxied) return; const { input, model, provider, watched } = proxied; - const messagesOptions = streamOptions(config, provider, sessionFor(input), safeRecord); - if (input.stream && provider.protocol === "anthropic") return pipeAnthropicPassthrough(watched, response, model, messagesOptions); - if (input.stream) return pipeResponsesStream(watched, response, model, messagesOptions); + const usageOptions = streamOptions(config, provider, sessionFor(input), safeRecord); + if (input.stream) return specFor.pipes[provider.protocol](watched, response, model, usageOptions); + if (!watched.body) { response.end(); return; } const value = await watched.json(); recordUsage(value, provider, sessionFor(input)); - if (provider.protocol === "anthropic") return json(response, watched.status, value); // already Anthropic-shaped - const failure = provider.protocol === "chat-completions" ? chatResponseFailure(value) : responsesResponseFailure(value); + const failure = specFor.failure?.(value, provider); if (failure) return json(response, 502, { error: { message: failure, type: "upstream_error" } }); - return json(response, watched.status, provider.protocol === "responses" ? fromResponsesResponse(value, model) : fromChatResponse(value, model)); + return json(response, watched.status, specFor.finish(value, provider, model)); }; const server = createServer((request, response) => { // Last-resort guard: a rejection escaping the per-route handling (an From abc7ab8229f1320a780e48f649d17dd73ff496fb Mon Sep 17 00:00:00 2001 From: tzzs Date: Sun, 20 Sep 2026 21:54:07 +0800 Subject: [PATCH 5/9] refactor(types): replace any with a typed JSON vocabulary on every wire path Providers and clients hand us parsed JSON that can't be fully trusted, so `any` let a misspelled field or a missing non-object check pass the compiler and fail at runtime. Introduce src/json.ts (JsonRecord plus typed field accessors) and read every payload through it, so those mistakes are now compiler errors. Also enables noUncheckedIndexedAccess, noUnusedLocals, noUnusedParameters and noImplicitOverride, and makes registerCustomProvider report the single model entry it always creates. Zero `any` remains under src/, and the strict flags catch two real gaps: a custom-provider model read that could be undefined, and unvalidated SQLite row fields. --- src/cli.ts | 6 +- src/config.ts | 2 +- src/convert/anthropic.ts | 194 ++++++++++++------------ src/convert/chat.ts | 153 +++++++++++-------- src/convert/responses.ts | 104 +++++++------ src/convert/shared.ts | 156 ++++++++++++------- src/json.ts | 61 ++++++++ src/providers/registry.ts | 65 +++++--- src/providers/usage/anthropic.ts | 25 +-- src/providers/usage/index.ts | 3 +- src/providers/usage/openai.ts | 37 ++--- src/quota.ts | 22 +-- src/runtime.ts | 2 +- src/server.ts | 26 ++-- src/sessions.ts | 2 +- src/shell-profile.ts | 1 + src/streaming/anthropic-passthrough.ts | 9 +- src/streaming/anthropic-to-chat.ts | 35 +++-- src/streaming/anthropic-to-responses.ts | 45 +++--- src/streaming/chat-passthrough.ts | 10 +- src/streaming/chat-to-responses.ts | 43 +++--- src/streaming/common.ts | 42 ++--- src/streaming/responses-passthrough.ts | 10 +- src/streaming/responses-to-chat.ts | 29 ++-- src/streaming/to-anthropic.ts | 63 ++++---- src/ui.ts | 4 +- src/usage/storage.ts | 33 ++-- test/cli-commands.test.ts | 4 +- test/codex-catalog.test.ts | 2 +- test/providers-registry.test.ts | 4 +- test/streaming.test.ts | 10 +- test/ui.test.ts | 12 +- test/usage-storage.test.ts | 8 +- test/usage-streaming.test.ts | 8 +- tsconfig.json | 4 +- 35 files changed, 735 insertions(+), 499 deletions(-) create mode 100644 src/json.ts diff --git a/src/cli.ts b/src/cli.ts index a08a2ef..dbce5a9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,7 +5,7 @@ import { CLAUDE_EFFORT_LEVELS, CODEX_EFFORT_LEVELS, loadConfig, parseCliOptions import { startAdapter } from "./server.js"; import { runCommand, ClientNotFoundError, CLIENT_INSTALL_COMMANDS, clientEnvironment, codexLaunchArgs, nativeClientEnvironment } from "./process.js"; import { providerEntries, runInteractiveLauncher, runProviderManager, runSavedModelManager, LaunchCancelledError, type ProviderEntry } from "./ui.js"; -import { credentialEnvName, providerById, refreshProviderCatalog, fetchOpenRouterModels, hydrateOpenRouterCatalog, openRouterCatalogIds, providerDisplayName, registerCustomProvider, unregisterCustomProvider } from "./providers/registry.js"; +import { credentialEnvName, providerById, refreshProviderCatalog, fetchOpenRouterModels, hydrateOpenRouterCatalog, openRouterCatalogIds, providerDisplayName, registerCustomProvider, unregisterCustomProvider, type CustomProviderDefinition } from "./providers/registry.js"; import type { ProviderDefinition, ProviderProtocol } from "./providers/types.js"; import { runDoctor, renderDoctor } from "./doctor.js"; import { credentialInstructions, credentialSource, hydrateProfileCredentials, promptCredential, resolveCredential } from "./credentials.js"; @@ -144,7 +144,7 @@ const BOOLEAN_ADAPTER_FLAGS = new Set(["--verbose", "--native"]); export function clientArguments(args: string[]): string[] { const isAdapterFlag = (arg: string) => ADAPTER_FLAGS.has(arg) || (arg.startsWith("--") && arg.includes("=") && ADAPTER_FLAGS.has(`--${arg.slice(2).split("=", 1)[0]}`)); const valueTaken = new Set(); - args.forEach((arg, index) => { if (ADAPTER_FLAGS.has(arg) && !BOOLEAN_ADAPTER_FLAGS.has(arg) && args[index + 1] !== undefined && !args[index + 1].startsWith("--")) valueTaken.add(index + 1); }); + args.forEach((arg, index) => { const next = args[index + 1]; if (ADAPTER_FLAGS.has(arg) && !BOOLEAN_ADAPTER_FLAGS.has(arg) && next !== undefined && !next.startsWith("--")) valueTaken.add(index + 1); }); return args.filter((arg, index) => !isAdapterFlag(arg) && !valueTaken.has(index)); } @@ -416,7 +416,7 @@ function resolveLaunchTarget(command: string, args: string[], opts: Record): Promise { +async function persistCustomProvider(opts: Record): Promise { const baseUrl = opts["base-url"] ?? ""; const protocol: ProviderProtocol = opts.protocol === "responses" || opts.protocol === "anthropic" ? opts.protocol : "chat-completions"; const definition = registerCustomProvider({ name: opts.provider ?? "custom", baseUrl, protocol, model: opts.model }); diff --git a/src/config.ts b/src/config.ts index 70186cf..02b0f43 100644 --- a/src/config.ts +++ b/src/config.ts @@ -40,7 +40,7 @@ export function parseCliOptions(args: string[]): Record { - if (part?.type === "input_text" || part?.type === "output_text") return part.text ? { type: "text", text: part.text } : undefined; - if (part?.type === "input_image" && typeof part.image_url === "string") { const source = toAnthropicImageSource(part.image_url); return source ? { type: "image", source } : undefined; } - return undefined; - }).filter((part: any) => part !== undefined); - if (!parts.length) return ""; - return parts.every((part: any) => part.type === "text") ? parts.map((part: any) => part.text).join("") : parts; + const parts: JsonRecord[] = []; + for (const raw of content) { + if (!isRecord(raw)) continue; + const text = recStr(raw, "text"); + if (raw.type === "input_text" || raw.type === "output_text") { if (text) parts.push({ type: "text", text }); continue; } + const url = recStr(raw, "image_url"); + if (raw.type === "input_image" && url) { const source = toAnthropicImageSource(url); if (source) parts.push({ type: "image", source }); } + } + return collapseAnthropicContent(parts); } /** Plain text of a Responses message item's content, ignoring non-text parts. */ -function responsesItemText(content: any): string { +function responsesItemText(content: JsonValue): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; - return content.filter((part: any) => part?.type === "output_text" || part?.type === "input_text").map((part: any) => part.text ?? "").join(""); + return content.filter(isRecord) + .filter((part) => part.type === "output_text" || part.type === "input_text") + .map((part) => recStr(part, "text") ?? "").join(""); } -export function toAnthropicRequest(input: any, model: string): Record { - const items = typeof input.input === "string" ? [{ role: "user", content: input.input }] : input.input ?? []; +export function toAnthropicRequest(input: JsonRecord, model: string): AnthropicRequest { + const rawInput = input.input; + const items: JsonRecord[] = typeof rawInput === "string" ? [{ role: "user", content: rawInput }] : asRecords(rawInput); const messages: AnthropicMessage[] = []; // Responses represents one assistant turn as separate top-level items // (a message item for text, a function_call item per tool call); Anthropic // wants them merged back into one assistant message's content blocks. - let pendingAssistantBlocks: any[] = []; + let pendingAssistantBlocks: JsonRecord[] = []; const flushAssistant = () => { if (pendingAssistantBlocks.length) { messages.push({ role: "assistant", content: pendingAssistantBlocks }); pendingAssistantBlocks = []; } }; for (const item of items) { if (item.type === "reasoning") continue; // no signature to echo upstream; dropped like other local-only reasoning echoes - if (item.type === "function_call") { pendingAssistantBlocks.push({ type: "tool_use", id: item.call_id ?? item.id, name: item.name, input: parse(item.arguments) }); continue; } + if (item.type === "function_call") { pendingAssistantBlocks.push({ type: "tool_use", id: recStr(item, "call_id") ?? recStr(item, "id"), name: recStr(item, "name"), input: parse(item.arguments) }); continue; } if (item.type === "function_call_output") { flushAssistant(); messages.push({ role: "user", content: [{ type: "tool_result", tool_use_id: item.call_id, content: typeof item.output === "string" ? item.output : JSON.stringify(item.output ?? "") }] }); continue; } if (item.role === "assistant") { const text = responsesItemText(item.content); if (text) pendingAssistantBlocks.push({ type: "text", text }); continue; } - if (item.role) { flushAssistant(); messages.push({ role: item.role === "developer" ? "user" : item.role, content: toAnthropicContent(item.content) }); } + if (typeof item.role === "string") { flushAssistant(); messages.push({ role: item.role === "developer" ? "user" : item.role, content: toAnthropicContent(item.content) }); } } flushAssistant(); // Anthropic requires max_tokens > thinking.budget_tokens (and budget_tokens // >= 1024), so the tiered default budgets are capped to leave visible-output // headroom; a request too small to think at all drops the block entirely. - const maxTokens = input.max_output_tokens ?? 4096; - let thinking: AnthropicThinking | undefined = anthropicThinking(input.reasoning?.effort); + const maxTokens = recNum(input, "max_output_tokens") ?? 4096; + let thinking: AnthropicThinking | undefined = anthropicThinking(recStr(recObj(input, "reasoning"), "effort")); if (thinking?.type === "enabled") { const budget = Math.min(thinking.budget_tokens ?? 0, Math.floor(maxTokens * 0.8)); thinking = budget >= 1024 ? { type: "enabled", budget_tokens: budget } : undefined; } const toolChoice = anthropicToolChoice(input.tool_choice); - const tools = Array.isArray(input.tools) - ? input.tools.filter((tool: any) => typeof tool?.name === "string").map((tool: any) => ({ name: tool.name, ...(tool.description === undefined ? {} : { description: tool.description }), input_schema: tool.parameters ?? { type: "object", properties: {} } })) - : undefined; + const instructions = recStr(input, "instructions"); + const temperature = recNum(input, "temperature"); + const top_p = recNum(input, "top_p"); + const tools = asRecords(input.tools).flatMap((tool) => { + const name = recStr(tool, "name"); + if (!name) return []; + const description = recStr(tool, "description"); + return [{ name, ...(description === undefined ? {} : { description }), input_schema: tool.parameters ?? { type: "object", properties: {} } }]; + }); return { model, messages, // Anthropic requires max_tokens; a Responses caller that omits max_output_tokens still needs a concrete value sent upstream. max_tokens: maxTokens, - ...(input.instructions ? { system: input.instructions } : {}), + ...(instructions ? { system: instructions } : {}), ...(input.stream ? { stream: true } : {}), - ...(input.temperature === undefined ? {} : { temperature: input.temperature }), - ...(input.top_p === undefined ? {} : { top_p: input.top_p }), + ...(temperature === undefined ? {} : { temperature }), + ...(top_p === undefined ? {} : { top_p }), ...(thinking ? { thinking } : {}), ...(toolChoice === undefined ? {} : { tool_choice: toolChoice }), - ...(tools?.length ? { tools } : {}), + ...(tools.length ? { tools } : {}), }; } -export function fromAnthropicResponse(response: any, model: string): Record { - const content = Array.isArray(response.content) ? response.content : []; - const output: any[] = []; - const thinkingText = content.filter((part: any) => part?.type === "thinking").map((part: any) => part.thinking ?? "").join(""); +export function fromAnthropicResponse(response: JsonRecord, model: string): JsonRecord { + const content = recObjs(response, "content"); + const output: JsonRecord[] = []; + const thinkingText = textOfBlocks(content, "thinking"); if (thinkingText) output.push({ type: "reasoning", id: `rs_${crypto.randomUUID()}`, summary: [{ type: "summary_text", text: thinkingText }] }); - const text = content.filter((part: any) => part?.type === "text").map((part: any) => part.text ?? "").join(""); + const text = textOfBlocks(content, "text"); if (text) output.push({ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text }] }); for (const part of content) { - if (part?.type !== "tool_use") continue; - output.push({ type: "function_call", call_id: part.id, name: part.name, arguments: JSON.stringify(part.input ?? {}), status: "completed" }); + if (part.type !== "tool_use") continue; + output.push({ type: "function_call", call_id: recStr(part, "id"), name: recStr(part, "name"), arguments: JSON.stringify(part.input ?? {}), status: "completed" }); } const incomplete = response.stop_reason === "max_tokens"; - const inputTokens = response.usage?.input_tokens ?? 0; - const outputTokens = response.usage?.output_tokens ?? 0; - const cached = response.usage?.cache_read_input_tokens; + const usage = recObj(response, "usage"); + const inputTokens = recNum(usage, "input_tokens") ?? 0; + const outputTokens = recNum(usage, "output_tokens") ?? 0; + const cached = recNum(usage, "cache_read_input_tokens"); return { - id: response.id ?? `resp_${crypto.randomUUID()}`, + id: recStr(response, "id") ?? `resp_${crypto.randomUUID()}`, object: "response", status: incomplete ? "incomplete" : "completed", model, @@ -108,7 +115,7 @@ export function fromAnthropicResponse(response: any, model: string): Record { - const source = tool?.function ?? tool; - if (!source || typeof source.name !== "string") return undefined; - return { name: source.name, ...(source.description === undefined ? {} : { description: source.description }), input_schema: source.parameters ?? { type: "object", properties: {} } }; - }).filter((tool) => tool !== undefined); +function chatToolsToAnthropic(tools: unknown): AnthropicRequest["tools"] | undefined { + const mapped = asRecords(tools).flatMap((tool) => { + const source = recObj(tool, "function") ?? tool; + const name = recStr(source, "name"); + if (!name) return []; + const description = recStr(source, "description"); + return [{ name, ...(description === undefined ? {} : { description }), input_schema: source.parameters ?? { type: "object", properties: {} } }]; + }); return mapped.length ? mapped : undefined; } -function chatContentToAnthropic(content: unknown): unknown { +/** Chat Completions content (string or text/image_url parts) to Anthropic content. */ +function chatContentToAnthropic(content: JsonValue): JsonValue { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; - const parts = content.map((part: any) => { - if (part?.type === "text" && typeof part.text === "string") return { type: "text", text: part.text }; - if (part?.type === "image_url" && typeof part.image_url?.url === "string") { const source = toAnthropicImageSource(part.image_url.url); return source ? { type: "image", source } : undefined; } - return undefined; - }).filter((part) => part !== undefined); - if (!parts.length) return ""; - return parts.every((part: any) => part.type === "text") ? parts.map((part: any) => part.text).join("") : parts; + const parts: JsonRecord[] = []; + for (const raw of content) { + if (!isRecord(raw)) continue; + if (raw.type === "text") { const text = recStr(raw, "text"); if (text) parts.push({ type: "text", text }); continue; } + if (raw.type === "image_url") { const source = toAnthropicImageSource(recStr(recObj(raw, "image_url"), "url")); if (source) parts.push({ type: "image", source }); } + } + return collapseAnthropicContent(parts); } -export function toAnthropicRequestFromChat(input: any, model: string): Record { +export function toAnthropicRequestFromChat(input: JsonRecord, model: string): AnthropicRequest { const systemParts: string[] = []; const messages: AnthropicMessage[] = []; - for (const message of input.messages ?? []) { - if (message.role === "system") { if (typeof message.content === "string") systemParts.push(message.content); continue; } - if (message.role === "tool") { + for (const message of recObjs(input, "messages")) { + const role = recStr(message, "role"); + if (role === "system") { const content = recStr(message, "content"); if (content) systemParts.push(content); continue; } + if (role === "tool") { messages.push({ role: "user", content: [{ type: "tool_result", tool_use_id: message.tool_call_id, content: typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? "") }] }); continue; } - const blocks: any[] = []; + if (typeof role !== "string") continue; + const blocks: JsonRecord[] = []; const converted = chatContentToAnthropic(message.content); - if (Array.isArray(converted)) blocks.push(...converted); + if (Array.isArray(converted)) blocks.push(...asRecords(converted)); else if (converted) blocks.push({ type: "text", text: converted }); - for (const call of message.tool_calls ?? []) blocks.push({ type: "tool_use", id: call.id, name: call.function?.name, input: parse(call.function?.arguments) }); - if (blocks.length) messages.push({ role: message.role, content: blocks }); + for (const call of recObjs(message, "tool_calls")) blocks.push({ type: "tool_use", id: recStr(call, "id"), name: recStr(recObj(call, "function"), "name"), input: parse(recStr(recObj(call, "function"), "arguments")) }); + if (blocks.length) messages.push({ role, content: blocks }); } - const stopSequences = typeof input.stop === "string" ? [input.stop] : Array.isArray(input.stop) ? input.stop : undefined; - const toolChoice = chatToolChoiceToAnthropic(input.tool_choice); + const rawStop = input.stop; + const stopSequences = typeof rawStop === "string" ? [rawStop] : Array.isArray(rawStop) ? rawStop.filter((part): part is string => typeof part === "string") : undefined; + const toolChoice = anthropicToolChoice(input.tool_choice); const tools = chatToolsToAnthropic(input.tools); + const temperature = recNum(input, "temperature"); + const top_p = recNum(input, "top_p"); return { model, messages, - max_tokens: input.max_tokens ?? 4096, + max_tokens: recNum(input, "max_tokens") ?? 4096, ...(systemParts.length ? { system: systemParts.join("\n") } : {}), ...(input.stream ? { stream: true } : {}), - ...(input.temperature === undefined ? {} : { temperature: input.temperature }), - ...(input.top_p === undefined ? {} : { top_p: input.top_p }), + ...(temperature === undefined ? {} : { temperature }), + ...(top_p === undefined ? {} : { top_p }), ...(stopSequences ? { stop_sequences: stopSequences } : {}), ...(toolChoice === undefined ? {} : { tool_choice: toolChoice }), ...(tools ? { tools } : {}), }; } -export function fromAnthropicResponseToChat(response: any, model: string): Record { - const content = Array.isArray(response.content) ? response.content : []; - const text = content.filter((part: any) => part?.type === "text").map((part: any) => part.text ?? "").join(""); - const thinking = content.filter((part: any) => part?.type === "thinking").map((part: any) => part.thinking ?? "").join(""); - const toolCalls = content.filter((part: any) => part?.type === "tool_use").map((part: any) => ({ id: part.id, type: "function", function: { name: part.name, arguments: JSON.stringify(part.input ?? {}) } })); - const inputTokens = response.usage?.input_tokens ?? 0; - const outputTokens = response.usage?.output_tokens ?? 0; - const cached = response.usage?.cache_read_input_tokens; +export function fromAnthropicResponseToChat(response: JsonRecord, model: string): JsonRecord { + const content = recObjs(response, "content"); + const text = textOfBlocks(content, "text"); + const thinking = textOfBlocks(content, "thinking"); + const toolCalls = content.filter((part) => part.type === "tool_use").map((part) => ({ id: recStr(part, "id"), type: "function", function: { name: recStr(part, "name"), arguments: JSON.stringify(part.input ?? {}) } })); + const usage = recObj(response, "usage"); + const inputTokens = recNum(usage, "input_tokens") ?? 0; + const outputTokens = recNum(usage, "output_tokens") ?? 0; + const cached = recNum(usage, "cache_read_input_tokens"); return { - id: response.id ?? `chatcmpl_${crypto.randomUUID()}`, + id: recStr(response, "id") ?? `chatcmpl_${crypto.randomUUID()}`, object: "chat.completion", created: Math.floor(Date.now() / 1000), model, @@ -212,7 +214,7 @@ export function fromAnthropicResponseToChat(response: any, model: string): Recor prompt_tokens: inputTokens, completion_tokens: outputTokens, total_tokens: inputTokens + outputTokens, - ...(cached !== undefined && cached !== null ? { prompt_tokens_details: { cached_tokens: Number(cached) } } : {}), + ...(cached === undefined ? {} : { prompt_tokens_details: { cached_tokens: cached } }), }, }; } diff --git a/src/convert/chat.ts b/src/convert/chat.ts index dc1afb3..5cb5fd6 100644 --- a/src/convert/chat.ts +++ b/src/convert/chat.ts @@ -4,85 +4,99 @@ * Completions protocol. */ import type { AnthropicMessage, AnthropicRequest } from "./shared.js"; -import { chatControlParams, imageDataUri, parse, samplingParams } from "./shared.js"; +import type { JsonRecord, JsonValue } from "../json.js"; +import { isRecord, parse, recNum, recObj, recObjs, recStr } from "../json.js"; +import { chatControlParams, collapseAnthropicContent, imageDataUri, samplingParams } from "./shared.js"; import { isDeepSeekLongContextModel } from "../providers/registry.js"; +/** One Chat Completions message as sent upstream (or received back). */ +type ChatMessage = JsonRecord; + export function toChatRequest(input: AnthropicRequest, model: string, provider?: string) { - const messages: any[] = []; + const messages: ChatMessage[] = []; if (input.system) messages.push({ role: "system", content: typeof input.system === "string" ? input.system : input.system.map((part) => part.text ?? "").join("\n") }); const deepSeek = provider === "deepseek" || isDeepSeekLongContextModel(model); for (const message of input.messages) messages.push(...toChatMessages(message, deepSeek)); - return { model, messages, ...(input.max_tokens === undefined ? {} : { max_tokens: input.max_tokens }), ...(input.stream ? { stream: true } : {}), ...samplingParams(input as any), ...chatControlParams(input, deepSeek), ...(input.tools ? { tools: input.tools.map((tool) => ({ type: "function", function: { name: tool.name, description: tool.description, parameters: tool.input_schema } })) } : {}) }; + return { model, messages, ...(input.max_tokens === undefined ? {} : { max_tokens: input.max_tokens }), ...(input.stream ? { stream: true } : {}), ...samplingParams(input), ...chatControlParams(input, deepSeek), ...(input.tools ? { tools: input.tools.map((tool) => ({ type: "function", function: { name: tool.name, description: tool.description, parameters: tool.input_schema } })) } : {}) }; } -function toChatImagePart(part: any): any | undefined { - const url = imageDataUri(part?.source); +function toChatImagePart(part: JsonRecord | undefined): ChatMessage | undefined { + const url = imageDataUri(recObj(part, "source")); return url ? { type: "image_url", image_url: { url } } : undefined; } -function toChatMessages(message: AnthropicMessage, preserveReasoning = false): any[] { +function toChatMessages(message: AnthropicMessage, preserveReasoning = false): ChatMessage[] { if (!Array.isArray(message.content)) return [{ role: message.role, content: message.content ?? "" }]; - const parts: any[] = []; - const toolCalls: any[] = []; - const toolResults: any[] = []; + const parts: ChatMessage[] = []; + const toolCalls: ChatMessage[] = []; + const toolResults: ChatMessage[] = []; let reasoning = ""; const pushText = (text: string) => { if (!text) return; const last = parts[parts.length - 1]; - if (last?.type === "text") last.text += text; + if (last?.type === "text") last.text = `${recStr(last, "text") ?? ""}${text}`; else parts.push({ type: "text", text }); }; - for (const part of message.content) { + for (const raw of message.content) { + if (!isRecord(raw)) continue; + const part = raw; if (part.type === "tool_use") { - toolCalls.push({ id: part.id, type: "function", function: { name: part.name, arguments: JSON.stringify(part.input ?? {}) } }); + toolCalls.push({ id: recStr(part, "id"), type: "function", function: { name: recStr(part, "name"), arguments: JSON.stringify(part.input ?? {}) } }); } else if (part.type === "tool_result") { toolResults.push({ role: "tool", tool_call_id: part.tool_use_id, content: typeof part.content === "string" ? part.content : JSON.stringify(part.content ?? "") }); } else if (part.type === "text") { - pushText(part.text ?? ""); + pushText(recStr(part, "text") ?? ""); } else if (part.type === "thinking" && preserveReasoning && message.role === "assistant") { - if (typeof part.thinking === "string") reasoning += part.thinking; + const thinking = recStr(part, "thinking"); + if (thinking) reasoning += thinking; } else if (part.type === "image") { const image = toChatImagePart(part); if (image) parts.push(image); } } - const output: any[] = []; + const output: ChatMessage[] = []; if (message.role === "assistant" && (parts.length || toolCalls.length || reasoning)) { - const assistant: any = { + output.push({ role: "assistant", - content: parts.length - ? parts.every((part) => part.type === "text") ? parts.map((part) => part.text).join("") : parts - : null, + content: parts.length ? collapseAnthropicContent(parts) : null, ...(reasoning ? { reasoning_content: reasoning } : {}), ...(toolCalls.length ? { tool_calls: toolCalls } : {}), - }; - output.push(assistant); + }); } else if (parts.length) { - output.push({ role: message.role, content: parts.every((part) => part.type === "text") ? parts.map((part) => part.text).join("") : parts }); + output.push({ role: message.role, content: collapseAnthropicContent(parts) }); } output.push(...toolResults); return output; } /** Plain text of a chat message content, ignoring non-text parts (images etc.). */ -function chatText(content: any): string { +function chatText(content: unknown): string { if (typeof content === "string") return content; - if (Array.isArray(content)) return content.filter((part) => part?.type === "text").map((part) => part.text ?? "").join(""); + if (Array.isArray(content)) return content.filter(isRecord).filter((part) => part.type === "text").map((part) => recStr(part, "text") ?? "").join(""); return ""; } -export function fromChatResponse(response: any, model: string): Record { - const choice = response.choices?.[0] ?? {}; const message = choice.message ?? {}; const content = []; - if (message.reasoning_content) content.push({ type: "thinking", thinking: message.reasoning_content }); +/** Nested usage details of a Chat Completions response (`prompt_tokens_details`, …). */ +function chatUsageDetails(response: JsonRecord, usageKey: string, detailKey: string, field: string): number | undefined { + return recNum(recObj(recObj(response, usageKey), detailKey), field); +} + +export function fromChatResponse(response: JsonRecord, model: string): JsonRecord { + const choice = recObjs(response, "choices")[0] ?? {}; const message = recObj(choice, "message") ?? {}; const content: JsonRecord[] = []; + const reasoningContent = recStr(message, "reasoning_content"); + if (reasoningContent) content.push({ type: "thinking", thinking: reasoningContent }); const text = chatText(message.content); if (text) content.push({ type: "text", text }); - for (const call of message.tool_calls ?? []) content.push({ type: "tool_use", id: call.id, name: call.function?.name, input: parse(call.function?.arguments) }); + for (const call of recObjs(message, "tool_calls")) content.push({ type: "tool_use", id: recStr(call, "id"), name: recStr(recObj(call, "function"), "name"), input: parse(recStr(recObj(call, "function"), "arguments")) }); const finishReason = choice.finish_reason; - return { id: response.id ?? `msg_${crypto.randomUUID()}`, type: "message", role: "assistant", model, content, stop_reason: message.tool_calls?.length ? "tool_use" : finishReason === "length" ? "max_tokens" : finishReason === "stop" || finishReason === undefined ? "end_turn" : "max_tokens", stop_sequence: null, usage: { input_tokens: response.usage?.prompt_tokens ?? 0, output_tokens: response.usage?.completion_tokens ?? 0, cache_creation_input_tokens: 0, cache_read_input_tokens: response.usage?.prompt_tokens_details?.cached_tokens ?? 0 } }; + const usage = recObj(response, "usage"); + const toolCallCount = recObjs(message, "tool_calls").length; + return { id: recStr(response, "id") ?? `msg_${crypto.randomUUID()}`, type: "message", role: "assistant", model, content, stop_reason: toolCallCount ? "tool_use" : finishReason === "length" ? "max_tokens" : finishReason === "stop" || finishReason === undefined ? "end_turn" : "max_tokens", stop_sequence: null, usage: { input_tokens: recNum(usage, "prompt_tokens") ?? 0, output_tokens: recNum(usage, "completion_tokens") ?? 0, cache_creation_input_tokens: 0, cache_read_input_tokens: chatUsageDetails(response, "usage", "prompt_tokens_details", "cached_tokens") ?? 0 } }; } /** Return a failure for a provider completion that is not a normal stop. */ -export function chatResponseFailure(response: any): string | undefined { - const reason = response?.choices?.[0]?.finish_reason; +export function chatResponseFailure(response: unknown): string | undefined { + if (!isRecord(response)) return undefined; + const reason = recObjs(response, "choices")[0]?.finish_reason; if (reason === undefined || reason === null || reason === "stop" || reason === "length" || reason === "tool_calls" || reason === "function_call") return undefined; if (reason === "insufficient_system_resource") return "The upstream model stopped because inference resources were insufficient."; if (reason === "content_filter") return "The upstream model stopped because the response was filtered."; @@ -97,22 +111,26 @@ export function chatResponseFailure(response: any): string | undefined { * dropped rather than forwarded as nameless function entries, which strict * upstreams reject as invalid parameters. */ -function toChatTools(tools: unknown): any[] { - return Array.isArray(tools) ? tools.flatMap((tool: any) => { - if (Array.isArray(tool?.tools)) return toChatTools(tool.tools); +function toChatTools(tools: unknown): JsonRecord[] { + return Array.isArray(tools) ? tools.flatMap((raw: unknown) => { + if (!isRecord(raw)) return []; + if (Array.isArray(raw.tools)) return toChatTools(raw.tools); // Server-side built-ins (typed, no nested function) have no chat // representation; everything else must resolve to a named function. - if (tool?.type !== undefined && tool.type !== "function" && !tool.function) return []; - const source = tool?.function ?? tool; - if (!source || typeof source.name !== "string" || !source.name) return []; - return [{ type: "function", function: { name: source.name, ...(source.description === undefined ? {} : { description: source.description }), ...(source.parameters === undefined ? {} : { parameters: source.parameters }) } }]; + if (raw.type !== undefined && raw.type !== "function" && !raw.function) return []; + const source = recObj(raw, "function") ?? raw; + const name = recStr(source, "name"); + if (!name) return []; + return [{ type: "function", function: { name, ...(source.description === undefined ? {} : { description: source.description }), ...(source.parameters === undefined ? {} : { parameters: source.parameters }) } }]; }) : []; } -export function toChatCompletionsRequest(input: any, model: string, provider?: string) { - const messages: any[] = []; - if (input.instructions) messages.push({ role: "system", content: input.instructions }); - const items = typeof input.input === "string" ? [{ role: "user", content: input.input }] : input.input ?? []; +export function toChatCompletionsRequest(input: JsonRecord, model: string, provider?: string) { + const messages: ChatMessage[] = []; + const instructions = recStr(input, "instructions"); + if (instructions) messages.push({ role: "system", content: instructions }); + const rawInput = input.input; + const items: JsonRecord[] = typeof rawInput === "string" ? [{ role: "user", content: rawInput }] : Array.isArray(rawInput) ? rawInput.filter(isRecord) : []; // reasoning_content is a DeepSeek-specific extension; forwarding it to other // chat-completions upstreams risks a strict schema rejecting the request. const deepSeek = provider === "deepseek" || isDeepSeekLongContextModel(model); @@ -121,12 +139,12 @@ export function toChatCompletionsRequest(input: any, model: string, provider?: s if (item.type === "reasoning") { if (deepSeek) pendingReasoning += responseReasoningText(item); } else if (item.type === "function_call") { - const call = { id: item.call_id ?? item.id, type: "function", function: { name: item.name, arguments: item.arguments ?? "{}" } }; + const call = { id: recStr(item, "call_id") ?? recStr(item, "id"), type: "function", function: { name: recStr(item, "name"), arguments: recStr(item, "arguments") ?? "{}" } }; messages.push({ role: "assistant", content: null, ...(pendingReasoning ? { reasoning_content: pendingReasoning } : {}), tool_calls: [call] }); pendingReasoning = ""; } else if (item.type === "function_call_output") { messages.push({ role: "tool", tool_call_id: item.call_id, content: typeof item.output === "string" ? item.output : JSON.stringify(item.output ?? "") }); - } else if (item.role) { + } else if (typeof item.role === "string") { const role = item.role === "developer" ? "system" : item.role; messages.push({ role, content: responseContent(item.content), ...(role === "assistant" && pendingReasoning ? { reasoning_content: pendingReasoning } : {}) }); pendingReasoning = ""; @@ -137,34 +155,39 @@ export function toChatCompletionsRequest(input: any, model: string, provider?: s return { model, messages, ...(input.max_output_tokens === undefined ? {} : { max_tokens: input.max_output_tokens }), ...(input.stream ? { stream: true } : {}), ...samplingParams(input), ...chatControlParams(input, deepSeek), ...(tools.length ? { tools } : {}) }; } -function responseReasoningText(item: any): string { - return (item.summary ?? []) - .filter((part: any) => part?.type === "summary_text" && typeof part.text === "string") - .map((part: any) => part.text) +function responseReasoningText(item: JsonRecord): string { + return recObjs(item, "summary") + .filter((part) => part.type === "summary_text" && typeof part.text === "string") + .map((part) => recStr(part, "text") ?? "") .join(""); } -export function fromChatResponseToResponses(response: any, model: string): Record { - const choice = response.choices?.[0] ?? {}; const message = choice.message ?? {}; const output: any[] = []; - if (message.reasoning_content) output.push({ type: "reasoning", id: `rs_${crypto.randomUUID()}`, summary: [{ type: "summary_text", text: message.reasoning_content }] }); +export function fromChatResponseToResponses(response: JsonRecord, model: string): JsonRecord { + const choice = recObjs(response, "choices")[0] ?? {}; const message = recObj(choice, "message") ?? {}; const output: JsonRecord[] = []; + const reasoningContent = recStr(message, "reasoning_content"); + if (reasoningContent) output.push({ type: "reasoning", id: `rs_${crypto.randomUUID()}`, summary: [{ type: "summary_text", text: reasoningContent }] }); const text = chatText(message.content); if (text) output.push({ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text }] }); - for (const call of message.tool_calls ?? []) output.push({ type: "function_call", call_id: call.id, name: call.function?.name, arguments: call.function?.arguments ?? "{}", status: "completed" }); - const cached = response.usage?.prompt_tokens_details?.cached_tokens; - const reasoning = response.usage?.completion_tokens_details?.reasoning_tokens; + for (const call of recObjs(message, "tool_calls")) output.push({ type: "function_call", call_id: recStr(call, "id"), name: recStr(recObj(call, "function"), "name"), arguments: recStr(recObj(call, "function"), "arguments") ?? "{}", status: "completed" }); + const usage = recObj(response, "usage"); + const cached = chatUsageDetails(response, "usage", "prompt_tokens_details", "cached_tokens"); + const reasoningTokens = chatUsageDetails(response, "usage", "completion_tokens_details", "reasoning_tokens"); const incomplete = choice.finish_reason === "length"; - return { id: response.id ?? `resp_${crypto.randomUUID()}`, object: "response", status: incomplete ? "incomplete" : "completed", model, output, usage: { input_tokens: response.usage?.prompt_tokens ?? 0, output_tokens: response.usage?.completion_tokens ?? 0, total_tokens: response.usage?.total_tokens ?? 0, ...(cached !== undefined && cached !== null ? { input_tokens_details: { cached_tokens: Number(cached) } } : {}), ...(reasoning !== undefined && reasoning !== null ? { output_tokens_details: { reasoning_tokens: Number(reasoning) } } : {}) }, ...(incomplete ? { incomplete_details: { reason: "max_output_tokens" } } : {}) }; + return { id: recStr(response, "id") ?? `resp_${crypto.randomUUID()}`, object: "response", status: incomplete ? "incomplete" : "completed", model, output, usage: { input_tokens: recNum(usage, "prompt_tokens") ?? 0, output_tokens: recNum(usage, "completion_tokens") ?? 0, total_tokens: recNum(usage, "total_tokens") ?? 0, ...(cached !== undefined ? { input_tokens_details: { cached_tokens: Number(cached) } } : {}), ...(reasoningTokens !== undefined ? { output_tokens_details: { reasoning_tokens: Number(reasoningTokens) } } : {}) }, ...(incomplete ? { incomplete_details: { reason: "max_output_tokens" } } : {}) }; } -function responseContent(content: any): any { +function responseContent(content: JsonValue): JsonValue { if (!Array.isArray(content)) return content ?? ""; - const parts = content.map((part) => { - if (part == null || part === "") return undefined; - if (part.type === "input_text" || part.type === "output_text") return part.text ?? "" ? { type: "text", text: part.text ?? "" } : undefined; - if (part.type === "input_image" && part.image_url) return { type: "image_url", image_url: { url: part.image_url } }; - return typeof part === "string" ? { type: "text", text: part } : part; - }).filter((part) => part !== undefined); - if (!parts.length) return ""; - // Plain text stays a string; anything structured keeps the content array. - return parts.every((part) => part.type === "text") ? parts.map((part) => part.text).join("") : parts; + const parts: JsonRecord[] = []; + for (const raw of content) { + // Bare strings are the common shorthand for a text part. + if (typeof raw === "string") { if (raw) parts.push({ type: "text", text: raw }); continue; } + if (!isRecord(raw)) continue; + const text = recStr(raw, "text") ?? ""; + if (raw.type === "input_text" || raw.type === "output_text") { if (text) parts.push({ type: "text", text }); continue; } + const url = recStr(raw, "image_url"); + if (raw.type === "input_image" && url) { parts.push({ type: "image_url", image_url: { url } }); continue; } + parts.push(raw); + } + return collapseAnthropicContent(parts); } diff --git a/src/convert/responses.ts b/src/convert/responses.ts index 25261f9..fe4b7ae 100644 --- a/src/convert/responses.ts +++ b/src/convert/responses.ts @@ -1,24 +1,27 @@ /** Anthropic Messages API <-> Responses API: the direction whose upstream speaks the Responses protocol. */ -import type { AnthropicMessage, AnthropicRequest } from "./shared.js"; -import { chatThinking, imageDataUri, parse, reasoningEffort, responsesToolChoice } from "./shared.js"; +import type { AnthropicMessage, AnthropicRequest, ResponsesItem } from "./shared.js"; +import type { JsonRecord, JsonValue } from "../json.js"; +import { asRecords, isRecord, parse, recNum, recObj, recObjs, recStr } from "../json.js"; +import { chatThinking, imageDataUri, reasoningEffort, responsesToolChoice } from "./shared.js"; import { fromAnthropicResponseToChat, toAnthropicRequestFromChat } from "./anthropic.js"; -function convertContent(content: any, role: string): any { - if (!Array.isArray(content)) return content; - return content.map((part) => { - if (part.type === "text") return { type: role === "assistant" ? "output_text" : "input_text", text: part.text ?? "" }; - if (part.type === "image") { const url = imageDataUri(part.source); return url ? { type: "input_image", image_url: url } : null; } - if (part.type === "tool_result") return { type: "function_call_output", call_id: part.tool_use_id, output: typeof part.content === "string" ? part.content : JSON.stringify(part.content) }; - if (part.type === "tool_use") return { type: "function_call", call_id: part.id, name: part.name, arguments: JSON.stringify(part.input ?? {}) }; - return part; - }).filter((part) => part !== null); +function convertContent(content: JsonValue[], assistant: boolean): JsonValue[] { + return content.flatMap((raw) => { + if (!isRecord(raw)) return []; + const part = raw; + if (part.type === "text") return [{ type: assistant ? "output_text" : "input_text", text: recStr(part, "text") ?? "" }]; + if (part.type === "image") { const url = imageDataUri(recObj(part, "source")); return url ? [{ type: "input_image", image_url: url }] : []; } + if (part.type === "tool_result") return [{ type: "function_call_output", call_id: part.tool_use_id, output: typeof part.content === "string" ? part.content : JSON.stringify(part.content) }]; + if (part.type === "tool_use") return [{ type: "function_call", call_id: recStr(part, "id"), name: recStr(part, "name"), arguments: JSON.stringify(part.input ?? {}) }]; + return [part]; + }); } -export function toResponsesRequest(input: AnthropicRequest, model: string): Record { +export function toResponsesRequest(input: AnthropicRequest, model: string): JsonRecord { const thinking = chatThinking(input); const effort = reasoningEffort(input); const toolChoice = responsesToolChoice(input.tool_choice); - const body: Record = { + const body: JsonRecord = { model, input: toResponsesInput(input.messages), ...(input.max_tokens === undefined ? {} : { max_output_tokens: input.max_tokens }), ...(input.stream ? { stream: true } : {}), @@ -37,64 +40,73 @@ export function toResponsesRequest(input: AnthropicRequest, model: string): Reco } /** Thinking blocks are local-only reasoning echoes from Claude Code; upstream providers never accept them. */ -function isThinkingPart(part: any): boolean { return part?.type === "thinking" || part?.type === "redacted_thinking"; } +function isThinkingPart(part: JsonRecord): boolean { return part.type === "thinking" || part.type === "redacted_thinking"; } -function toResponsesInput(messages: AnthropicMessage[]): any[] { - const output: any[] = []; +function toResponsesInput(messages: AnthropicMessage[]): ResponsesItem[] { + const output: ResponsesItem[] = []; for (const message of messages) { - if (!Array.isArray(message.content)) { output.push({ ...message, content: convertContent(message.content, message.role) }); continue; } - const textParts: any[] = []; - for (const part of message.content as any[]) { + const assistant = message.role === "assistant"; + if (!Array.isArray(message.content)) { output.push({ ...message }); continue; } + const textParts: ResponsesItem[] = []; + for (const raw of message.content) { + if (!isRecord(raw)) continue; + const part = raw; if (isThinkingPart(part)) continue; - if (part.type === "tool_use") { output.push({ type: "function_call", call_id: part.id, name: part.name, arguments: JSON.stringify(part.input ?? {}) }); continue; } + if (part.type === "tool_use") { output.push({ type: "function_call", call_id: recStr(part, "id"), name: recStr(part, "name"), arguments: JSON.stringify(part.input ?? {}) }); continue; } if (part.type === "tool_result") { output.push({ type: "function_call_output", call_id: part.tool_use_id, output: typeof part.content === "string" ? part.content : JSON.stringify(part.content ?? "") }); continue; } - const converted = convertContent([part], message.role)[0]; if (converted) textParts.push(converted); + const converted = convertContent([part], assistant)[0]; + if (isRecord(converted)) textParts.push(converted); } if (textParts.length) output.push({ role: message.role, content: textParts }); } return output; } -function reasoningText(output: any[]): string { - return (output ?? []) - .filter((item: any) => item.type === "reasoning") - .flatMap((item: any) => [ - ...(item.summary ?? []).map((part: any) => part.text ?? ""), - ...(item.content ?? []).filter((part: any) => part.type === "reasoning_text").map((part: any) => part.text ?? "") +function reasoningText(output: ResponsesItem[]): string { + return output + .filter((item) => item.type === "reasoning") + .flatMap((item) => [ + ...recObjs(item, "summary").map((part) => recStr(part, "text") ?? ""), + ...recObjs(item, "content").filter((part) => part.type === "reasoning_text").map((part) => recStr(part, "text") ?? ""), ]) .join(""); } -export function fromResponsesResponse(response: any, model: string): Record { - const output = response.output ?? []; +export function fromResponsesResponse(response: JsonRecord, model: string): JsonRecord { + const output = asRecords(response.output); const text = output - .filter((item: any) => item.type === "message") - .flatMap((item: any) => item.content ?? []) - .filter((part: any) => part.type === "output_text") - .map((part: any) => part.text ?? "").join(""); - const toolUses = output.filter((item: any) => item.type === "function_call").map((item: any) => ({ type: "tool_use", id: item.call_id ?? item.id, name: item.name, input: parse(item.arguments) })); + .filter((item) => item.type === "message") + .flatMap((item) => asRecords(item.content)) + .filter((part) => part.type === "output_text") + .map((part) => recStr(part, "text") ?? "").join(""); + const toolUses = output.filter((item) => item.type === "function_call").map((item) => ({ type: "tool_use", id: recStr(item, "call_id") ?? recStr(item, "id"), name: recStr(item, "name"), input: parse(recStr(item, "arguments")) })); const thinking = reasoningText(output); + const usage = recObj(response, "usage"); + const cached = recNum(recObj(usage, "input_tokens_details"), "cached_tokens"); return { - id: response.id ?? `msg_${crypto.randomUUID()}`, type: "message", role: "assistant", model, + id: recStr(response, "id") ?? `msg_${crypto.randomUUID()}`, type: "message", role: "assistant", model, content: [...(thinking ? [{ type: "thinking", thinking }] : []), ...(text ? [{ type: "text", text }] : []), ...toolUses], stop_reason: toolUses.length ? "tool_use" : response.status === "incomplete" ? "max_tokens" : "end_turn", stop_sequence: null, usage: { - input_tokens: response.usage?.input_tokens ?? 0, - output_tokens: response.usage?.output_tokens ?? 0, + input_tokens: recNum(usage, "input_tokens") ?? 0, + output_tokens: recNum(usage, "output_tokens") ?? 0, cache_creation_input_tokens: 0, - cache_read_input_tokens: response.usage?.input_tokens_details?.cached_tokens ?? 0 + cache_read_input_tokens: cached ?? 0 } }; } /** Return a failure for a Responses result that must not look like a turn end. */ -export function responsesResponseFailure(response: any): string | undefined { - if (response?.status === "failed") { - return response.error?.message ?? response.error ?? "The upstream response failed."; +export function responsesResponseFailure(response: unknown): string | undefined { + if (!isRecord(response)) return undefined; + if (response.status === "failed") { + const error = response.error; + return (isRecord(error) ? recStr(error, "message") : undefined) ?? (typeof error === "string" ? error : undefined) ?? "The upstream response failed."; } - if (response?.status === "incomplete" && response.incomplete_details?.reason !== "max_output_tokens") { - return `The upstream response is incomplete (${String(response.incomplete_details?.reason ?? "unknown")}).`; + if (response.status === "incomplete") { + const reason = recStr(recObj(response, "incomplete_details"), "reason"); + if (reason !== "max_output_tokens") return `The upstream response is incomplete (${String(reason ?? "unknown")}).`; } return undefined; } @@ -105,11 +117,11 @@ export function responsesResponseFailure(response: any): string | undefined { * `toAnthropicRequestFromChat`/`toResponsesRequest` instead of writing a * separate chat->Responses request mapping. */ -export function toResponsesRequestFromChat(input: any, model: string): Record { - return toResponsesRequest(toAnthropicRequestFromChat(input, model) as unknown as AnthropicRequest, model); +export function toResponsesRequestFromChat(input: JsonRecord, model: string): JsonRecord { + return toResponsesRequest(toAnthropicRequestFromChat(input, model), model); } /** Reuses `fromResponsesResponse`/`fromAnthropicResponseToChat` instead of writing a separate Responses->chat response mapping. */ -export function fromResponsesResponseToChat(response: any, model: string): Record { +export function fromResponsesResponseToChat(response: JsonRecord, model: string): JsonRecord { return fromAnthropicResponseToChat(fromResponsesResponse(response, model), model); } diff --git a/src/convert/shared.ts b/src/convert/shared.ts index 65cd69b..147b383 100644 --- a/src/convert/shared.ts +++ b/src/convert/shared.ts @@ -1,35 +1,82 @@ /** * Helpers shared across every conversion direction (chat.ts / responses.ts / - * anthropic.ts): the Anthropic request shape they all consume, image/effort/ - * thinking/tool-choice field mapping, and small parsing utilities. + * anthropic.ts): the Anthropic request shape they all consume and the + * image/effort/thinking/tool-choice field mapping between dialects. The + * dynamic-JSON accessors they read payloads with live in ../json.ts. */ +import type { JsonRecord, JsonValue } from "../json.js"; +import { asRecords, isRecord, recObj, recStr } from "../json.js"; -export interface AnthropicMessage { role: string; content: unknown; } -export interface AnthropicThinking { type?: string; budget_tokens?: number; [key: string]: unknown; } -export interface AnthropicRequest { - model?: string; system?: string | Array<{ type: string; text?: string }>; +/** + * The Anthropic Messages request shape the converters consume: a message + * list plus the few fields whose structure they rely on. `AnthropicMessage` + * is a plain record because a client's messages carry whatever blocks they + * carry; each is read through the accessors from ../json.ts. + */ +export type AnthropicMessage = JsonRecord; +export interface AnthropicThinking extends JsonRecord { type?: string; budget_tokens?: number; } +export interface AnthropicRequest extends JsonRecord { + system?: string | Array<{ type: string; text?: string }>; max_tokens?: number; messages: AnthropicMessage[]; stream?: boolean; temperature?: number; top_p?: number; stop_sequences?: string[]; - tools?: Array<{ name: string; description?: string; input_schema: unknown }>; - thinking?: AnthropicThinking; - output_config?: { effort?: string; [key: string]: unknown }; - tool_choice?: unknown; - metadata?: unknown; - context_management?: unknown; + tools?: Array<{ name: string; description?: string; input_schema: JsonValue }>; +} + +/** + * Interpret a payload from the Anthropic-shaped `/v1/messages` endpoint as an + * `AnthropicRequest`. The endpoint's contract *is* that shape, so this is the + * one place it is asserted rather than re-checked; `messages` is still + * filtered to its object entries so converters can iterate it safely, and a + * body that violates the rest fails at the upstream. + */ +export function asAnthropicRequest(value: JsonRecord): AnthropicRequest { + return { ...value, messages: asRecords(value.messages) } as AnthropicRequest; +} + +/** + * One top-level entry of a Responses request's `input` (message, function_call, + * reasoning, function_call_output…). Raw client JSON, so fields are read + * through the accessors above rather than declared here. + */ +export type ResponsesItem = JsonRecord; + +/** Build an Anthropic image content block from a Responses `input_image.image_url`, which may be a real URL or a data: URI. */ +export function toAnthropicImageSource(url: string | undefined): { type: "url"; url: string } | { type: "base64"; media_type: string; data: string } | undefined { + if (!url) return undefined; + const dataMatch = /^data:([^;]+);base64,(.+)$/.exec(url); + if (dataMatch) { + const [, media_type = "", data = ""] = dataMatch; + return { type: "base64", media_type, data }; + } + return { type: "url", url }; +} + +/** Shared collapse rule for content arrays: pure-text arrays flatten to a joined string; mixed arrays stay blocks; empty becomes "". */ +export function collapseAnthropicContent(parts: JsonRecord[]): JsonValue { + if (!parts.length) return ""; + return parts.every((part) => part.type === "text") ? parts.map((part) => recStr(part, "text") ?? "").join("") : parts; +} + +/** Plain text of an Anthropic content array for the given block type, ignoring everything else. */ +export function textOfBlocks(content: JsonRecord[], type: "text" | "thinking"): string { + const key = type === "text" ? "text" : "thinking"; + return content.filter((part) => part.type === type).map((part) => recStr(part, key) ?? "").join(""); } /** Build a data URI (or pass through remote URLs) from an Anthropic image source. */ -export function imageDataUri(source: any): string | undefined { +export function imageDataUri(source: JsonRecord | undefined): string | undefined { if (!source) return undefined; - if (source.type === "url" && typeof source.url === "string") return source.url; - if (source.type === "base64" && typeof source.data === "string") return `data:${source.media_type ?? "image/png"};base64,${source.data}`; + if (source.type === "url") return recStr(source, "url"); + if (source.type === "base64") { + const data = recStr(source, "data"); + return data ? `data:${recStr(source, "media_type") ?? "image/png"};base64,${data}` : undefined; + } return undefined; } /** Normalize Claude/DeepSeek/Codex effort names to the Chat Completions values. */ -export function reasoningEffort(input: any): "low" | "high" | "max" | undefined { - const value = input?.output_config?.effort ?? input?.reasoning?.effort; - if (typeof value !== "string") return undefined; +export function reasoningEffort(input: JsonRecord | undefined): "low" | "high" | "max" | undefined { + const value = recStr(recObj(input, "output_config"), "effort") ?? recStr(recObj(input, "reasoning"), "effort"); if (value === "minimal" || value === "low") return "low"; if (value === "medium" || value === "high" || value === "xhigh" || value === "ultracode") return "high"; if (value === "max" || value === "ultra") return "max"; @@ -37,60 +84,68 @@ export function reasoningEffort(input: any): "low" | "high" | "max" | undefined } /** Convert Anthropic thinking controls to DeepSeek's OpenAI-shaped control. */ -export function chatThinking(input: any): { type: "enabled" | "disabled" } | undefined { - const type = input?.thinking?.type; +export function chatThinking(input: JsonRecord | undefined): { type: "enabled" | "disabled" } | undefined { + const type = recStr(recObj(input, "thinking"), "type"); if (type === "disabled") return { type: "disabled" }; if (type === "enabled" || type === "adaptive") return { type: "enabled" }; - if (input?.reasoning?.effort === "none") return { type: "disabled" }; - if (input?.reasoning?.effort !== undefined) return { type: "enabled" }; + const effort = recStr(recObj(input, "reasoning"), "effort"); + if (effort === "none") return { type: "disabled" }; + if (effort !== undefined) return { type: "enabled" }; return undefined; } /** Convert Anthropic tool-choice variants to Chat Completions variants. */ -export function chatToolChoice(value: unknown): unknown { +export function chatToolChoice(value: JsonValue): JsonValue | undefined { if (typeof value === "string") { if (value === "none" || value === "auto") return value; if (value === "any" || value === "required") return "required"; return undefined; } - if (!value || typeof value !== "object") return undefined; - const choice = value as { type?: string; name?: string; function?: { name?: string } }; - if (choice.type === "none" || choice.type === "auto") return choice.type; - if (choice.type === "any" || choice.type === "required") return "required"; - if (choice.type === "tool" && typeof choice.name === "string") { - return { type: "function", function: { name: choice.name } }; - } - if (choice.type === "function" && typeof choice.function?.name === "string") return value; - return undefined; + if (!isRecord(value)) return undefined; + const type = recStr(value, "type"); + if (type === "none" || type === "auto") return type; + if (type === "any" || type === "required") return "required"; + const name = forcedToolName(value); + return name ? { type: "function", function: { name } } : undefined; } /** Convert Anthropic tool-choice variants to Responses API variants. */ -export function responsesToolChoice(value: unknown): unknown { +export function responsesToolChoice(value: JsonValue): JsonValue | undefined { if (typeof value === "string") { if (value === "none" || value === "auto" || value === "required") return value; if (value === "any") return "required"; return undefined; } - if (!value || typeof value !== "object") return undefined; - const choice = value as { type?: string; name?: string; function?: { name?: string } }; - if (choice.type === "none" || choice.type === "auto" || choice.type === "required") return choice.type; - if (choice.type === "any") return "required"; - if (choice.type === "tool" && typeof choice.name === "string") return { type: "function", name: choice.name }; - if (choice.type === "function" && typeof choice.function?.name === "string") return { type: "function", name: choice.function.name }; - return undefined; + if (!isRecord(value)) return undefined; + const type = recStr(value, "type"); + if (type === "none" || type === "auto" || type === "required") return type; + if (type === "any") return "required"; + const name = forcedToolName(value); + return name ? { type: "function", name } : undefined; } -/** Convert Responses tool-choice variants to Anthropic's object-shaped tool_choice ({"type": "auto"|"any"|"none"|"tool", name?}). */ -export function anthropicToolChoice(value: unknown): unknown { +/** Name of the tool a `{"type": "tool"|"function"}` choice forces, in either the Anthropic (top-level `name`) or Chat Completions (`function.name`) dialect. */ +function forcedToolName(choice: JsonRecord): string | undefined { + const type = recStr(choice, "type"); + if (type !== "tool" && type !== "function") return undefined; + return recStr(choice, "name") ?? recStr(recObj(choice, "function"), "name"); +} + +/** + * Convert a Responses or Chat Completions tool-choice variant to Anthropic's + * object-shaped tool_choice ({"type": "auto"|"any"|"none"|"tool", name?}). The + * two dialects name the forced tool differently — Responses uses a top-level + * `name`, Chat Completions nests it under `function` — and both are accepted. + */ +export function anthropicToolChoice(value: unknown): JsonRecord | undefined { if (typeof value === "string") { if (value === "none" || value === "auto") return { type: value }; if (value === "required") return { type: "any" }; return undefined; } - if (!value || typeof value !== "object") return undefined; - const choice = value as { type?: string; name?: string }; - if (choice.type === "function" && typeof choice.name === "string") return { type: "tool", name: choice.name }; - return undefined; + if (!isRecord(value) || value.type !== "function") return undefined; + const name = recStr(value, "name") ?? recStr(recObj(value, "function"), "name"); + return name ? { type: "tool", name } : undefined; } /** @@ -112,7 +167,7 @@ export function anthropicThinking(effort: unknown): AnthropicThinking | undefine } /** Sampling knobs shared by both upstream protocols (undefined drops the key). */ -export function samplingParams(input: Record): Record { +export function samplingParams(input: JsonRecord): JsonRecord { return { ...(input.temperature === undefined ? {} : { temperature: input.temperature }), ...(input.top_p === undefined ? {} : { top_p: input.top_p }), @@ -120,7 +175,7 @@ export function samplingParams(input: Record): Record { +export function chatControlParams(input: JsonRecord, deepSeek: boolean): JsonRecord { const thinking = deepSeek ? chatThinking(input) : undefined; const effort = deepSeek ? reasoningEffort(input) : undefined; const toolChoice = chatToolChoice(input.tool_choice); @@ -130,8 +185,3 @@ export function chatControlParams(input: any, deepSeek: boolean): Record; type MetadataMap = Map; -function parseModelsDevMetadata(payload: any): MetadataSections { +/** The only modalities the rest of the codebase distinguishes; anything else is dropped. */ +function knownModalities(value: JsonValue | undefined): string[] { + return (Array.isArray(value) ? value : []).filter((item): item is "text" | "image" => item === "text" || item === "image"); +} + +function parseModelsDevMetadata(payload: JsonRecord): MetadataSections { const sections: MetadataSections = new Map(); - for (const [sectionId, definition] of Object.entries((payload ?? {}) as Record)) { + for (const [sectionId, definitionValue] of Object.entries(payload)) { const table: MetadataMap = new Map(); - for (const [id, entry] of Object.entries(definition?.models ?? {}) as Array<[string, any]>) { - const input = (entry?.modalities?.input ?? []).filter((item: unknown) => item === "text" || item === "image"); + for (const [id, entryValue] of Object.entries(recObj(jsonRecord(definitionValue), "models") ?? {})) { + const entry = jsonRecord(entryValue); + const limit = recObj(entry, "limit"); + const contextWindow = recNum(limit, "context"); + const maxOutputTokens = recNum(limit, "output"); + const input = knownModalities(recObj(entry, "modalities")?.input); table.set(id, { - ...(Number.isFinite(entry?.limit?.context) ? { contextWindow: Number(entry.limit.context) } : {}), - ...(Number.isFinite(entry?.limit?.output) ? { maxOutputTokens: Number(entry.limit.output) } : {}), + ...(contextWindow === undefined ? {} : { contextWindow }), + ...(maxOutputTokens === undefined ? {} : { maxOutputTokens }), ...(input.length ? { modalities: input } : {}), }); } @@ -66,17 +77,20 @@ function parseModelsDevMetadata(payload: any): MetadataSections { } /** Flatten OpenRouter's public model list into a single table keyed by its vendor-prefixed id. */ -function parseOpenRouterMetadata(payload: any): MetadataMap { +function parseOpenRouterMetadata(payload: JsonRecord): MetadataMap { const table: MetadataMap = new Map(); - for (const entry of ((payload?.data ?? []) as Array)) { - if (!entry?.id) continue; - const input = (entry?.architecture?.input_modalities ?? []).filter((item: unknown) => item === "text" || item === "image"); + for (const entry of recObjs(payload, "data")) { + const id = recStr(entry, "id"); + if (!id) continue; + const input = knownModalities(recObj(entry, "architecture")?.input_modalities); + const contextWindow = recNum(entry, "context_length"); + const maxOutputTokens = recNum(recObj(entry, "top_provider"), "max_completion_tokens"); const meta: ModelMetadata = { - ...(Number.isFinite(entry?.context_length) ? { contextWindow: Number(entry.context_length) } : {}), - ...(Number.isFinite(entry?.top_provider?.max_completion_tokens) ? { maxOutputTokens: Number(entry.top_provider.max_completion_tokens) } : {}), + ...(contextWindow === undefined ? {} : { contextWindow }), + ...(maxOutputTokens === undefined ? {} : { maxOutputTokens }), ...(input.length ? { modalities: input } : {}), }; - if (Object.keys(meta).length) table.set(entry.id, meta); + if (Object.keys(meta).length) table.set(id, meta); } return table; } @@ -133,7 +147,7 @@ export function setOpenRouterCatalogIds(ids: string[]): void { const preferred = process.env.OPENROUTER_MODEL ?? (ids.length ? ids[0] : undefined); const current = provider.models[0]?.model; if (preferred && preferred !== current) { - provider.models = [models("openrouter", `${openRouterBase}/chat/completions`, [preferred], "chat-completions")[0]]; + provider.models = [{ provider: "openrouter", model: preferred, protocol: "chat-completions", endpoint: `${openRouterBase}/chat/completions` }]; rebuildAllModels(); } } @@ -149,9 +163,9 @@ export async function fetchOpenRouterModels( try { const response = await fetcher(openRouterModelsUrl, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(10000) }); if (!response.ok) return cachedOpenRouterIds; - const payload = await response.json(); - const ids = ((payload?.data ?? []) as Array<{ id?: string }>) - .map((entry) => entry.id) + const payload = jsonRecord(await response.json()); + const ids = recObjs(payload, "data") + .map((entry) => recStr(entry, "id")) .filter((id): id is string => Boolean(id)); if (!ids.length) return cachedOpenRouterIds; cachedOpenRouterIds = ids; @@ -305,6 +319,9 @@ export function isPlaceholderModel(model: Pick entry.id === base && entry.custom); let id = base; @@ -333,7 +350,7 @@ export function registerCustomProvider(input: CustomProviderInput): ProviderDefi while (providerRegistry.some((entry) => entry.id === id)) id = `${base}-${suffix++}`; } const model: ProviderModel = { provider: id, model: input.model ?? CUSTOM_PROVIDER_PLACEHOLDER_MODEL, protocol: input.protocol, endpoint: customProviderEndpoint(input.baseUrl, input.protocol) }; - const definition: ProviderDefinition = { id, name: input.name, apiKeyEnv: envKeyFor(id), models: [model], custom: true }; + const definition: CustomProviderDefinition = { id, name: input.name, apiKeyEnv: envKeyFor(id), models: [model], custom: true }; const index = providerRegistry.findIndex((entry) => entry.id === id); if (index >= 0) providerRegistry[index] = definition; else providerRegistry.push(definition); @@ -379,19 +396,19 @@ export async function refreshProviderCatalog( const [list, sections, openRouter] = await Promise.all([ needList ? fetcher(`${openCodeBase}/models`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(5000) }) - .then((response) => (response.ok ? response.json() : null)).catch(() => null) + .then((response) => (response.ok ? jsonRecord(response.json()) : null)).catch((): JsonRecord | null => null) : Promise.resolve(null), needMetadata ? fetcher(modelsDevUrl, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(10000) }) - .then((response) => (response.ok ? response.json() : null)).then(parseModelsDevMetadata) + .then((response) => (response.ok ? response.json() : null)).then((payload) => parseModelsDevMetadata(jsonRecord(payload))) .catch((): MetadataSections => new Map()) : Promise.resolve(new Map()), needMetadata ? fetcher(openRouterModelsUrl, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(10000) }) - .then((response) => (response.ok ? response.json() : null)).then(parseOpenRouterMetadata) + .then((response) => (response.ok ? response.json() : null)).then((payload) => parseOpenRouterMetadata(jsonRecord(payload))) .catch((): MetadataMap => new Map()) : Promise.resolve(new Map()), - ]) as [any, MetadataSections, MetadataMap]; + ]); let refreshedMetadata = false; if (needMetadata) { @@ -411,7 +428,7 @@ export async function refreshProviderCatalog( let refreshedList = false; if (!list) return { list: false, metadata: refreshedMetadata }; - const ids = ((list.data ?? []) as Array<{ id?: string }>).map((item) => item.id).filter((id): id is string => Boolean(id)); + const ids = recObjs(list, "data").map((item) => recStr(item, "id")).filter((id): id is string => Boolean(id)); if (ids.length) { const openCode = providerRegistry.find((provider) => provider.id === "opencode"); if (openCode) { diff --git a/src/providers/usage/anthropic.ts b/src/providers/usage/anthropic.ts index f71cd0c..6b4dc77 100644 --- a/src/providers/usage/anthropic.ts +++ b/src/providers/usage/anthropic.ts @@ -1,4 +1,6 @@ import type { TokenUsage, UsageContext } from "../../usage/types.js"; +import type { JsonRecord } from "../../json.js"; +import { recCount, recObj } from "../../json.js"; /** * Anthropic Messages API usage fields: { input_tokens, output_tokens, @@ -6,25 +8,26 @@ import type { TokenUsage, UsageContext } from "../../usage/types.js"; * the streaming pipes in src/streaming/common.ts can reuse this exact field * list to pull cache tokens out of a raw usage chunk. */ -export function mapAnthropicUsage(usage: any, ctx: UsageContext): TokenUsage | null { - if (!usage || typeof usage !== "object") return null; - const inputTokens = Number(usage.input_tokens ?? 0); - const outputTokens = Number(usage.output_tokens ?? 0); - const result: TokenUsage = { +export function mapAnthropicUsage(usage: JsonRecord | undefined, ctx: UsageContext): TokenUsage | null { + if (!usage) return null; + const inputTokens = recCount(usage, "input_tokens") ?? 0; + const outputTokens = recCount(usage, "output_tokens") ?? 0; + const cachedInputTokens = recCount(usage, "cache_read_input_tokens"); + const cacheWriteTokens = recCount(usage, "cache_creation_input_tokens"); + return { provider: ctx.provider ?? "unknown", model: ctx.model ?? "unknown", inputTokens, outputTokens, totalTokens: inputTokens + outputTokens, ...(ctx.sessionId === undefined ? {} : { sessionId: ctx.sessionId }), - ...(ctx.timestamp === undefined ? {} : { timestamp: ctx.timestamp }) + ...(ctx.timestamp === undefined ? {} : { timestamp: ctx.timestamp }), + ...(cachedInputTokens === undefined ? {} : { cachedInputTokens }), + ...(cacheWriteTokens === undefined ? {} : { cacheWriteTokens }), }; - if (usage.cache_read_input_tokens !== undefined && usage.cache_read_input_tokens !== null) result.cachedInputTokens = Number(usage.cache_read_input_tokens); - if (usage.cache_creation_input_tokens !== undefined && usage.cache_creation_input_tokens !== null) result.cacheWriteTokens = Number(usage.cache_creation_input_tokens); - return result; } /** Anthropic Messages API usage: { usage: { input_tokens, output_tokens, cache_*_tokens } } */ -export function extractAnthropicUsage(response: any, ctx: UsageContext): TokenUsage | null { - return mapAnthropicUsage(response?.usage, ctx); +export function extractAnthropicUsage(response: JsonRecord, ctx: UsageContext): TokenUsage | null { + return mapAnthropicUsage(recObj(response, "usage"), ctx); } diff --git a/src/providers/usage/index.ts b/src/providers/usage/index.ts index 47780d3..9788b5a 100644 --- a/src/providers/usage/index.ts +++ b/src/providers/usage/index.ts @@ -1,5 +1,6 @@ import type { ProviderModel } from "../types.js"; import type { TokenUsage, UsageContext } from "../../usage/types.js"; +import type { JsonRecord } from "../../json.js"; import { extractResponsesUsage, extractChatUsage, mapResponsesUsage, mapChatUsage } from "./openai.js"; import { extractAnthropicUsage, mapAnthropicUsage } from "./anthropic.js"; @@ -9,7 +10,7 @@ export type { TokenUsage, UsageContext } from "../../usage/types.js"; // own copy of these field names. export { mapResponsesUsage, mapChatUsage, mapAnthropicUsage }; -export function extractUsage(response: any, model: ProviderModel, ctx: UsageContext): TokenUsage | null { +export function extractUsage(response: JsonRecord, model: ProviderModel, ctx: UsageContext): TokenUsage | null { const context = { ...ctx, provider: model.provider, model: model.model }; switch (model.protocol) { case "responses": return extractResponsesUsage(response, context); diff --git a/src/providers/usage/openai.ts b/src/providers/usage/openai.ts index c2e6ab5..0548435 100644 --- a/src/providers/usage/openai.ts +++ b/src/providers/usage/openai.ts @@ -1,7 +1,8 @@ import type { TokenUsage, UsageContext } from "../../usage/types.js"; +import type { JsonRecord } from "../../json.js"; +import { recCount, recObj } from "../../json.js"; -function base(usage: any, ctx: UsageContext): TokenUsage | null { - if (!usage || typeof usage !== "object") return null; +function base(ctx: UsageContext): TokenUsage { return { provider: ctx.provider ?? "unknown", model: ctx.model ?? "unknown", @@ -21,37 +22,37 @@ function base(usage: any, ctx: UsageContext): TokenUsage | null { * cache/reasoning tokens out of a raw usage chunk, instead of keeping a * second, independently-maintained copy of these field names. */ -function mapUsage(usage: any, ctx: UsageContext, names: { +function mapUsage(usage: JsonRecord | undefined, ctx: UsageContext, names: { input: string; output: string; cached: string; reasoning: string; }): TokenUsage | null { - const result = base(usage, ctx); - if (!result) return null; - result.inputTokens = Number(usage[names.input] ?? 0); - result.outputTokens = Number(usage[names.output] ?? 0); - result.totalTokens = Number(usage.total_tokens ?? (result.inputTokens + result.outputTokens)); - const cached = usage[names.cached]?.cached_tokens; - if (cached !== undefined && cached !== null) result.cachedInputTokens = Number(cached); - const reasoning = usage[names.reasoning]?.reasoning_tokens; - if (reasoning !== undefined && reasoning !== null) result.reasoningTokens = Number(reasoning); + if (!usage) return null; + const result = base(ctx); + result.inputTokens = recCount(usage, names.input) ?? 0; + result.outputTokens = recCount(usage, names.output) ?? 0; + result.totalTokens = recCount(usage, "total_tokens") ?? (result.inputTokens + result.outputTokens); + const cached = recCount(recObj(usage, names.cached), "cached_tokens"); + if (cached !== undefined) result.cachedInputTokens = cached; + const reasoning = recCount(recObj(usage, names.reasoning), "reasoning_tokens"); + if (reasoning !== undefined) result.reasoningTokens = reasoning; return result; } /** OpenAI Responses API usage fields: { input_tokens, output_tokens, ... }. Takes the bare usage object, not a response envelope. */ -export function mapResponsesUsage(usage: any, ctx: UsageContext): TokenUsage | null { +export function mapResponsesUsage(usage: JsonRecord | undefined, ctx: UsageContext): TokenUsage | null { return mapUsage(usage, ctx, { input: "input_tokens", output: "output_tokens", cached: "input_tokens_details", reasoning: "output_tokens_details" }); } /** OpenAI Chat Completions usage fields: { prompt_tokens, completion_tokens, ... }. Takes the bare usage object, not a response envelope. */ -export function mapChatUsage(usage: any, ctx: UsageContext): TokenUsage | null { +export function mapChatUsage(usage: JsonRecord | undefined, ctx: UsageContext): TokenUsage | null { return mapUsage(usage, ctx, { input: "prompt_tokens", output: "completion_tokens", cached: "prompt_tokens_details", reasoning: "completion_tokens_details" }); } /** OpenAI Responses API usage: { input_tokens, output_tokens, ... } */ -export function extractResponsesUsage(response: any, ctx: UsageContext): TokenUsage | null { - return mapResponsesUsage(response?.usage, ctx); +export function extractResponsesUsage(response: JsonRecord, ctx: UsageContext): TokenUsage | null { + return mapResponsesUsage(recObj(response, "usage"), ctx); } /** OpenAI Chat Completions usage: { prompt_tokens, completion_tokens, ... } */ -export function extractChatUsage(response: any, ctx: UsageContext): TokenUsage | null { - return mapChatUsage(response?.usage, ctx); +export function extractChatUsage(response: JsonRecord, ctx: UsageContext): TokenUsage | null { + return mapChatUsage(recObj(response, "usage"), ctx); } diff --git a/src/quota.ts b/src/quota.ts index e73c277..4ddb528 100644 --- a/src/quota.ts +++ b/src/quota.ts @@ -1,3 +1,5 @@ +import { jsonRecord, recCount, recObj, recStr, recObjs } from "./json.js"; +import type { JsonRecord } from "./json.js"; import { providerById } from "./providers/registry.js"; import { resolveCredential } from "./credentials.js"; @@ -13,17 +15,17 @@ export interface UsageResult { message?: string; } -export function parseDeepSeekBalance(payload: any): UsageResult { - const balance = payload?.balance_infos?.[0]; +export function parseDeepSeekBalance(payload: JsonRecord): UsageResult { + const balance = recObjs(payload, "balance_infos")[0]; if (!balance) return { provider: "deepseek", supported: true, success: false, message: "DeepSeek returned no balance information." }; - return { provider: "deepseek", supported: true, success: true, remaining: Number(balance.total_balance ?? 0), unit: balance.currency ?? "CNY" }; + return { provider: "deepseek", supported: true, success: true, remaining: recCount(balance, "total_balance") ?? 0, unit: recStr(balance, "currency") ?? "CNY" }; } -export function parseOpenRouterKey(payload: any): UsageResult { - const data = payload?.data ?? {}; - const used = Number(data.usage ?? 0); - const limit = data.limit == null ? undefined : Number(data.limit); - const remaining = data.limit_remaining == null ? (limit === undefined ? undefined : limit - used) : Number(data.limit_remaining); +export function parseOpenRouterKey(payload: JsonRecord): UsageResult { + const data = recObj(payload, "data") ?? {}; + const used = recCount(data, "usage") ?? 0; + const limit = recCount(data, "limit"); + const remaining = recCount(data, "limit_remaining") ?? (limit === undefined ? undefined : limit - used); return { provider: "openrouter", supported: true, success: true, used, ...(limit === undefined ? {} : { total: limit }), ...(remaining === undefined ? {} : { remaining }), unit: "USD" }; } @@ -38,8 +40,8 @@ export async function queryProviderUsage(providerId: string, apiKey: string): Pr } catch (error) { return { provider: providerId, supported: true, success: false, message: `Failed to reach ${providerId}: ${error instanceof Error ? error.message : error}` }; } - const payload = await response.json().catch(() => ({})); - if (!response.ok) return { provider: providerId, supported: true, success: false, message: payload?.error?.message ?? `Provider returned HTTP ${response.status}.` }; + const payload = jsonRecord(await response.json().catch(() => ({}))); + if (!response.ok) return { provider: providerId, supported: true, success: false, message: recStr(recObj(payload, "error"), "message") ?? `Provider returned HTTP ${response.status}.` }; return providerId === "deepseek" ? parseDeepSeekBalance(payload) : parseOpenRouterKey(payload); } diff --git a/src/runtime.ts b/src/runtime.ts index 422b09a..e02a6a7 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -290,7 +290,7 @@ export async function saveSessionRecord(sessionId: string, record: SessionRecord const sessions: Record = { ...state.sessions, [sessionId]: record }; const ids = Object.keys(sessions); if (ids.length > MAX_REMEMBERED_SESSIONS) { - const oldest = ids.sort((a, b) => sessions[a].recordedAt - sessions[b].recordedAt).slice(0, ids.length - MAX_REMEMBERED_SESSIONS); + const oldest = ids.sort((a, b) => (sessions[a]?.recordedAt ?? 0) - (sessions[b]?.recordedAt ?? 0)).slice(0, ids.length - MAX_REMEMBERED_SESSIONS); for (const id of oldest) delete sessions[id]; } state.sessions = sessions; diff --git a/src/server.ts b/src/server.ts index f45fed5..4d0781b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,7 +1,9 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; import type { Config } from "./config.js"; -import { chatResponseFailure, fromAnthropicResponse, fromAnthropicResponseToChat, fromChatResponse, fromChatResponseToResponses, fromResponsesResponse, fromResponsesResponseToChat, responsesResponseFailure, toAnthropicRequest, toAnthropicRequestFromChat, toChatCompletionsRequest, toChatRequest, toResponsesRequest, toResponsesRequestFromChat } from "./convert/index.js"; +import { asAnthropicRequest, chatResponseFailure, fromAnthropicResponse, fromAnthropicResponseToChat, fromChatResponse, fromChatResponseToResponses, fromResponsesResponse, fromResponsesResponseToChat, responsesResponseFailure, toAnthropicRequest, toAnthropicRequestFromChat, toChatCompletionsRequest, toChatRequest, toResponsesRequest, toResponsesRequestFromChat } from "./convert/index.js"; +import type { JsonRecord } from "./json.js"; +import { jsonRecord, recStr } from "./json.js"; import { pipeAnthropicPassthrough, pipeAnthropicStreamToChat, pipeAnthropicStreamToResponses, pipeChatPassthrough, pipeChatStreamToResponses, pipeResponsesPassthrough, pipeResponsesStream, pipeResponsesStreamToChat, type StreamUsageOptions } from "./streaming/index.js"; import type { ProviderModel, ProviderProtocol } from "./providers/types.js"; import type { TokenUsage } from "./usage/types.js"; @@ -188,16 +190,16 @@ async function proxyRequest( response: ServerResponse, token: string, fallbackModel: string, - payload: (input: any, model: string, provider: ProviderModel) => unknown, + payload: (input: JsonRecord, model: string, provider: ProviderModel) => unknown, maxBodyBytes = MAX_BODY_BYTES, -): Promise<{ input: any; model: string; provider: ProviderModel; watched: Response } | undefined> { +): Promise<{ input: JsonRecord; model: string; provider: ProviderModel; watched: Response } | undefined> { if (!authorized(request, token)) { json(response, 401, { error: { message: "Invalid API key", type: "authentication_error" } }); return undefined; } try { - const input = JSON.parse(await body(request, maxBodyBytes)); + const input = jsonRecord(JSON.parse(await body(request, maxBodyBytes))); // The endpoint decides which model id counts as "configured"; clients // that echo a preferred model (e.g. Codex sending OPENAI_MODEL=auto back) // are honored per honorRequestedModel's rules. - const model = honorRequestedModel(input.model, fallbackModel, config.provider); + const model = honorRequestedModel(recStr(input, "model"), fallbackModel, config.provider); debug(config, `POST ${request.url} model=${model}`); const provider = providerFor(model, config.provider); const apiKey = apiKeyFor(provider, config.apiKey); const payloadBody = payload(input, model, provider); @@ -224,12 +226,12 @@ async function proxyRequest( * to the Anthropic pipe, never the Responses one). */ interface EndpointSpec { - payloads: Record unknown>; + payloads: Record unknown>; pipes: Record Promise>; /** Non-stream JSON translation; an upstream-reported failure maps to a 502 instead. */ - finish(upstream: unknown, provider: ProviderModel, model: string): unknown; + finish(upstream: JsonRecord, provider: ProviderModel, model: string): unknown; /** In-band failure message carried by a non-OK-shaped 200 payload, if any. */ - failure?(upstream: unknown, provider: ProviderModel): string | undefined; + failure?(upstream: JsonRecord, provider: ProviderModel): string | undefined; } const ENDPOINT_SPECS: Record<"responses" | "chat" | "messages", EndpointSpec> = { @@ -267,8 +269,8 @@ const ENDPOINT_SPECS: Record<"responses" | "chat" | "messages", EndpointSpec> = }, messages: { payloads: { - "responses": (input, model) => toResponsesRequest(input, model), - "chat-completions": (input, model, provider) => toChatRequest(input, model, provider.provider), + "responses": (input, model) => toResponsesRequest(asAnthropicRequest(input), model), + "chat-completions": (input, model, provider) => toChatRequest(asAnthropicRequest(input), model, provider.provider), // already Anthropic-shaped; zero conversion "anthropic": (input, model) => ({ ...input, model }), }, @@ -295,10 +297,10 @@ export async function startAdapter(config: Config, options: AdapterOptions = {}) const maxBodyBytes = options.maxBodyBytes ?? MAX_BODY_BYTES; const closeGraceMs = options.closeGraceMs ?? 3_000; const sessionId = randomUUID(); - const sessionFor = (input: any) => typeof input?.session_id === "string" ? input.session_id : sessionId; + const sessionFor = (input: JsonRecord) => recStr(input, "session_id") ?? sessionId; /** Record one usage row, swallowing persistence errors into debug logs. */ const safeRecord = (usage: TokenUsage) => { void collector.record(usage).catch((error) => debug(config, `usage record error=${error instanceof Error ? error.message : "unknown"}`)); }; - const recordUsage = (value: any, provider: ProviderModel, session: string) => { const usage = extractUsage(value, provider, { sessionId: session }); if (usage) safeRecord(usage); }; + const recordUsage = (value: JsonRecord, provider: ProviderModel, session: string) => { const usage = extractUsage(value, provider, { sessionId: session }); if (usage) safeRecord(usage); }; const handleRequest = async (request: IncomingMessage, response: ServerResponse): Promise => { debug(config, `${request.method} ${request.url}`); const url = new URL(request.url ?? "/", "http://localhost"); diff --git a/src/sessions.ts b/src/sessions.ts index 59c8d21..c300b57 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -20,7 +20,7 @@ function uuidOrUndefined(value: string | undefined): string | undefined { export function resumeSessionId(client: "claude" | "codex", commandArgs: string[]): string | undefined { if (client === "claude") { for (let i = 0; i < commandArgs.length; i++) { - const arg = commandArgs[i]; + const arg = commandArgs[i] ?? ""; if (arg === "--resume" || arg === "-r") return uuidOrUndefined(commandArgs[i + 1]); if (arg.startsWith("--resume=")) return uuidOrUndefined(arg.slice("--resume=".length)); } diff --git a/src/shell-profile.ts b/src/shell-profile.ts index 0734db2..e2cc990 100644 --- a/src/shell-profile.ts +++ b/src/shell-profile.ts @@ -45,6 +45,7 @@ export function parseCredentialExports(content: string): Map { const block = /# >>> agentx credentials: ([A-Za-z_][A-Za-z0-9_]*) >>>\nexport ([A-Za-z_][A-Za-z0-9_]*)=('(?:[^']|'\\'')*')\n# <<< agentx credentials: ([A-Za-z_][A-Za-z0-9_]*) << { const capture = newUsageCapture(); const usage = usageCapture(capture, options?.protocol ?? "anthropic", !!options); @@ -22,11 +23,11 @@ export async function pipeAnthropicPassthrough(upstream: Response, response: Ser const value = dataLine(line); if (!value) return; try { - const item = JSON.parse(value); - if (item.type === "message_start" && item.message?.usage) usage.usage(item.message.usage); + const item = jsonRecord(JSON.parse(value)); + if (item.type === "message_start") { const startUsage = recObj(recObj(item, "message"), "usage"); if (startUsage) usage.usage(startUsage); } // message_delta is the turn's final usage; presence of options gates // capture exactly as before (no options → nothing to report to). - if (item.type === "message_delta" && item.usage && options) usage.usage(item.usage); + if (item.type === "message_delta") { const deltaUsage = recObj(item, "usage"); if (deltaUsage && options) usage.usage(deltaUsage); } } catch { /* Ignore incomplete provider events. */ } }; try { diff --git a/src/streaming/anthropic-to-chat.ts b/src/streaming/anthropic-to-chat.ts index 7493531..bb84550 100644 --- a/src/streaming/anthropic-to-chat.ts +++ b/src/streaming/anthropic-to-chat.ts @@ -1,9 +1,10 @@ import type { ServerResponse } from "node:http"; import { - createChatEmitter, dataLine, drain, emitAnthropicError, emitChatError, emitChatToolDelta, + createChatEmitter, dataLine, drain, emitChatError, emitChatToolDelta, failureMessage, newUsageCapture, reportUsage, usageCapture, withSsePipe, UpstreamFailure, type ChatToolCallState, type StreamUsageOptions, } from "./common.js"; +import { jsonRecord, recNum, recObj, recStr } from "../json.js"; /** * Pipe a native Anthropic Messages SSE stream into OpenAI-style @@ -28,25 +29,33 @@ export async function pipeAnthropicStreamToChat(upstream: Response, response: Se const value = dataLine(line); if (!value) return; try { - const item = JSON.parse(value); + const item = jsonRecord(JSON.parse(value)); const failure = failureMessage(item); if (failure) throw new UpstreamFailure(failure); - if (item.type === "message_start" && item.message?.usage) usage.usage(item.message.usage); - if (item.type === "content_block_start" && item.content_block?.type === "tool_use") { - calls.set(item.index, { id: item.content_block.id ?? `call_${item.index}`, name: item.content_block.name ?? "", announced: false }); + const index = recNum(item, "index") ?? 0; + const startUsage = recObj(recObj(item, "message"), "usage"); + if (item.type === "message_start" && startUsage) usage.usage(startUsage); + const contentBlock = recObj(item, "content_block"); + if (item.type === "content_block_start" && contentBlock?.type === "tool_use") { + calls.set(index, { id: recStr(contentBlock, "id") ?? `call_${index}`, name: recStr(contentBlock, "name") ?? "", announced: false }); } + const delta = recObj(item, "delta"); if (item.type === "content_block_delta") { - const delta = item.delta ?? {}; - if (delta.type === "text_delta" && typeof delta.text === "string") { capture.output++; chunk({ content: delta.text }); } - else if (delta.type === "thinking_delta" && typeof delta.thinking === "string") { chunk({ reasoning_content: delta.thinking }); } - else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") { - const call = calls.get(item.index); - if (call) emitChatToolDelta(chunk, call, item.index, delta.partial_json); + const deltaType = recStr(delta, "type"); + const text = recStr(delta, "text"); + const thinking = recStr(delta, "thinking"); + const partialJson = recStr(delta, "partial_json"); + if (deltaType === "text_delta" && text !== undefined) { capture.output++; chunk({ content: text }); } + else if (deltaType === "thinking_delta" && thinking !== undefined) { chunk({ reasoning_content: thinking }); } + else if (deltaType === "input_json_delta" && partialJson !== undefined) { + const call = calls.get(index); + if (call) emitChatToolDelta(chunk, call, index, partialJson); } } if (item.type === "message_delta") { - if (item.delta?.stop_reason === "max_tokens") truncated = true; - if (item.usage) usage.usage(item.usage); + if (recStr(recObj(item, "delta"), "stop_reason") === "max_tokens") truncated = true; + const deltaUsage = recObj(item, "usage"); + if (deltaUsage) usage.usage(deltaUsage); } if (item.type === "message_stop") sawMessageStop = true; } catch (error) { diff --git a/src/streaming/anthropic-to-responses.ts b/src/streaming/anthropic-to-responses.ts index ae37c26..9e9c476 100644 --- a/src/streaming/anthropic-to-responses.ts +++ b/src/streaming/anthropic-to-responses.ts @@ -5,6 +5,7 @@ import { newUsageCapture, reportUsage, usageCapture, withSsePipe, UpstreamFailure, type ReasoningState, type StreamUsageOptions, } from "./common.js"; +import { jsonRecord, recNum, recObj, recStr } from "../json.js"; /** * Pipe a native Anthropic Messages SSE stream into Codex-style Responses @@ -34,39 +35,47 @@ export async function pipeAnthropicStreamToResponses(upstream: Response, respons const value = dataLine(line); if (!value) return; try { - const item = JSON.parse(value); + const item = jsonRecord(JSON.parse(value)); const failure = failureMessage(item); if (failure) throw new UpstreamFailure(failure); - if (item.type === "message_start" && item.message?.usage) usage.usage(item.message.usage); + const startUsage = recObj(recObj(item, "message"), "usage"); + if (item.type === "message_start" && startUsage) usage.usage(startUsage); + const index = recNum(item, "index") ?? 0; + const contentBlock = recObj(item, "content_block"); if (item.type === "content_block_start") { - const blockType = item.content_block?.type; + const blockType = recStr(contentBlock, "type"); if (blockType === "tool_use") { - const call = { id: item.content_block.id ?? `call_${item.index}`, name: item.content_block.name ?? "", arguments: "" }; - calls.set(item.index, call); - event(response, "response.output_item.added", { type: "response.output_item.added", output_index: item.index, item: { type: "function_call", id: `fc_${item.index}`, call_id: call.id, name: call.name, arguments: "", status: "in_progress" } }); + const call = { id: recStr(contentBlock, "id") ?? `call_${index}`, name: recStr(contentBlock, "name") ?? "", arguments: "" }; + calls.set(index, call); + event(response, "response.output_item.added", { type: "response.output_item.added", output_index: index, item: { type: "function_call", id: `fc_${index}`, call_id: call.id, name: call.name, arguments: "", status: "in_progress" } }); } else if (blockType === "thinking") { announceReasoning(response, reasoning); } } + const delta = recObj(item, "delta"); if (item.type === "content_block_delta") { - const delta = item.delta ?? {}; - if (delta.type === "text_delta" && typeof delta.text === "string") { + const deltaType = recStr(delta, "type"); + const textDelta = recStr(delta, "text"); + const thinkingDelta = recStr(delta, "thinking"); + const partialJson = recStr(delta, "partial_json"); + if (deltaType === "text_delta" && textDelta !== undefined) { announceMessageItem(response, msgId, messageAnnounced); - text += delta.text; capture.output++; - emitTextDelta(response, msgId, delta.text); - } else if (delta.type === "thinking_delta" && typeof delta.thinking === "string") { - emitReasoningDelta(response, reasoning, delta.thinking); - } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") { - const call = calls.get(item.index); + text += textDelta; capture.output++; + emitTextDelta(response, msgId, textDelta); + } else if (deltaType === "thinking_delta" && thinkingDelta !== undefined) { + emitReasoningDelta(response, reasoning, thinkingDelta); + } else if (deltaType === "input_json_delta" && partialJson !== undefined) { + const call = calls.get(index); if (call) { - call.arguments += delta.partial_json; - emitFunctionCallDelta(response, { itemId: `fc_${item.index}`, outputIndex: item.index, callId: call.id, delta: delta.partial_json }); + call.arguments += partialJson; + emitFunctionCallDelta(response, { itemId: `fc_${index}`, outputIndex: index, callId: call.id, delta: partialJson }); } } } if (item.type === "message_delta") { - if (item.delta?.stop_reason === "max_tokens") truncated = true; - if (item.usage) usage.usage(item.usage); + if (recStr(recObj(item, "delta"), "stop_reason") === "max_tokens") truncated = true; + const deltaUsage = recObj(item, "usage"); + if (deltaUsage) usage.usage(deltaUsage); } if (item.type === "message_stop") sawMessageStop = true; } catch (error) { diff --git a/src/streaming/chat-passthrough.ts b/src/streaming/chat-passthrough.ts index 865ca86..8bbe5f9 100644 --- a/src/streaming/chat-passthrough.ts +++ b/src/streaming/chat-passthrough.ts @@ -3,6 +3,7 @@ import { createChatEmitter, dataLine, drain, emitChatError, newUsageCapture, reportUsage, usageCapture, withSsePipe, type StreamUsageOptions, } from "./common.js"; +import { jsonRecord, recObj, recObjs, recStr } from "../json.js"; /** * Forward a Chat Completions SSE stream byte-for-byte. Used for the local @@ -24,10 +25,11 @@ export async function pipeChatPassthrough(upstream: Response, response: ServerRe const value = dataLine(line); if (!value || value === "[DONE]") return; try { - const item = JSON.parse(value); - const delta = item.choices?.[0]?.delta?.content; - if (typeof delta === "string" && delta) countedOutput++; - if (item.usage) usage.usage(item.usage); + const item = jsonRecord(JSON.parse(value)); + const delta = recStr(recObj(recObjs(item, "choices")[0], "delta"), "content"); + if (delta) countedOutput++; + const itemUsage = recObj(item, "usage"); + if (itemUsage) usage.usage(itemUsage); } catch { /* Ignore incomplete provider events. */ } }; try { diff --git a/src/streaming/chat-to-responses.ts b/src/streaming/chat-to-responses.ts index a0a3535..72c823b 100644 --- a/src/streaming/chat-to-responses.ts +++ b/src/streaming/chat-to-responses.ts @@ -1,11 +1,12 @@ import type { ServerResponse } from "node:http"; import { chatResponseFailure } from "../convert/index.js"; import { - announceMessageItem, announceReasoning, dataLine, drain, emitChatError, emitFunctionCallDelta, + announceMessageItem, announceReasoning, dataLine, drain, emitFunctionCallDelta, emitReasoningDelta, emitResponsesCompleted, emitResponsesError, emitTextDelta, event, failureMessage, newUsageCapture, reasoningDeltaOf, reportUsage, usageCapture, withSsePipe, UpstreamFailure, type ReasoningState, type StreamUsageOptions, } from "./common.js"; +import { jsonRecord, recNum, recObj, recObjs, recStr } from "../json.js"; /** * Pipe a Chat Completions SSE upstream into Codex-style Responses events. Used @@ -30,39 +31,47 @@ export async function pipeChatStreamToResponses(upstream: Response, response: Se if (!value) return; if (value === "[DONE]") { sawDone = true; return; } try { - const item = JSON.parse(value); + const item = jsonRecord(JSON.parse(value)); const failure = failureMessage(item); if (failure) throw new UpstreamFailure(failure); - const choice = item.choices?.[0]; const delta = choice?.delta?.content; - if (choice?.finish_reason != null) { + const choice = recObjs(item, "choices")[0]; + const delta = recObj(choice, "delta"); + const finishReason = recStr(choice, "finish_reason"); + if (finishReason !== undefined) { sawFinishReason = true; const chatFailure = chatResponseFailure(item); - if (chatFailure) { options?.onDiagnostic?.(`chat completions finish_reason=${choice.finish_reason}: ${chatFailure}`); throw new UpstreamFailure(chatFailure); } + if (chatFailure) { options?.onDiagnostic?.(`chat completions finish_reason=${finishReason}: ${chatFailure}`); throw new UpstreamFailure(chatFailure); } } - if (choice?.finish_reason === "length") truncated = true; - if (typeof delta === "string" && delta) { + if (finishReason === "length") truncated = true; + const content = recStr(delta, "content"); + if (content) { announceMessageItem(response, msgId, messageAnnounced); - text += delta; capture.output++; emitTextDelta(response, msgId, delta); + text += content; capture.output++; emitTextDelta(response, msgId, content); } const reasoningDelta = reasoningDeltaOf(item); if (reasoningDelta) { announceReasoning(response, reasoning); emitReasoningDelta(response, reasoning, reasoningDelta); } - for (const tool of choice?.delta?.tool_calls ?? []) { - const index = tool.index ?? 0; + for (const tool of recObjs(delta, "tool_calls")) { + const index = recNum(tool, "index") ?? 0; + const toolId = recStr(tool, "id"); // Announce each call exactly once even when providers repeat id/name in deltas. - const call = calls.get(index) ?? { id: tool.id ?? `call_${index}`, name: "", arguments: "", announced: false }; - if (tool.id) call.id = tool.id; - if (tool.function?.name) call.name = tool.function.name; + const call = calls.get(index) ?? { id: toolId ?? `call_${index}`, name: "", arguments: "", announced: false }; + if (toolId) call.id = toolId; + const toolFunction = recObj(tool, "function"); + const toolName = recStr(toolFunction, "name"); + if (toolName) call.name = toolName; calls.set(index, call); if (!call.announced) { event(response, "response.output_item.added", { type: "response.output_item.added", output_index: index, item: { type: "function_call", id: `fc_${index}`, call_id: call.id, name: call.name, arguments: "", status: "in_progress" } }); call.announced = true; } - if (tool.function?.arguments) { - call.arguments += tool.function.arguments; - emitFunctionCallDelta(response, { itemId: `fc_${index}`, outputIndex: index, callId: call.id, delta: tool.function.arguments }); + const partialJson = recStr(toolFunction, "arguments"); + if (partialJson) { + call.arguments += partialJson; + emitFunctionCallDelta(response, { itemId: `fc_${index}`, outputIndex: index, callId: call.id, delta: partialJson }); } } - if (item.usage) usage.usage(item.usage); + const itemUsage = recObj(item, "usage"); + if (itemUsage) usage.usage(itemUsage); } catch (error) { // In-band upstream failures must end the stream; only parse noise is ignored. if (error instanceof UpstreamFailure) throw error; diff --git a/src/streaming/common.ts b/src/streaming/common.ts index c6ae5eb..4d40bb4 100644 --- a/src/streaming/common.ts +++ b/src/streaming/common.ts @@ -1,6 +1,8 @@ import type { ServerResponse } from "node:http"; import type { TokenUsage } from "../usage/types.js"; import type { ProviderProtocol } from "../providers/types.js"; +import type { JsonRecord } from "../json.js"; +import { recCount, recObj, recObjs, recStr } from "../json.js"; import { mapAnthropicUsage, mapChatUsage, mapResponsesUsage } from "../providers/usage/index.js"; export const SSE_HEADERS = { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" }; @@ -65,11 +67,11 @@ export function dataLine(line: string): string | undefined { export class UpstreamFailure extends Error {} /** Message of an in-band upstream error payload, if the parsed event carries one. */ -export function failureMessage(item: any): string | undefined { - if (item.type === "response.failed") return item.response?.error?.message ?? item.response?.error ?? "Upstream response failed"; - if (item.type === "error") return item.error?.message ?? (typeof item.message === "string" ? item.message : undefined) ?? "Upstream stream failed"; - if (typeof item.error?.message === "string") return item.error.message; - return undefined; +export function failureMessage(item: JsonRecord): string | undefined { + const response = recObj(item, "response"); + if (item.type === "response.failed") return recStr(recObj(response, "error"), "message") ?? recStr(response, "error") ?? "Upstream response failed"; + if (item.type === "error") return recStr(recObj(item, "error"), "message") ?? recStr(item, "message") ?? "Upstream stream failed"; + return recStr(recObj(item, "error"), "message"); } export function event(response: ServerResponse, type: string, data: unknown) { @@ -129,12 +131,11 @@ export async function drain(reader: ReadableStreamDefaultReader, con * and keeps the first hit; each mapper's own field list is the single source * of truth (providers/usage/*.ts), not duplicated here. */ -export function cacheTokensOf(usage: any): { cached?: number; reasoning?: number } { +export function cacheTokensOf(usage: JsonRecord | undefined): { cached?: number; reasoning?: number } { const chat = mapChatUsage(usage, {}); const responses = mapResponsesUsage(usage, {}); const anthropic = mapAnthropicUsage(usage, {}); - const cached = chat?.cachedInputTokens ?? responses?.cachedInputTokens ?? anthropic?.cachedInputTokens - ?? (usage?.cached_tokens === undefined || usage?.cached_tokens === null ? undefined : Number(usage.cached_tokens)); + const cached = chat?.cachedInputTokens ?? responses?.cachedInputTokens ?? anthropic?.cachedInputTokens ?? recCount(usage, "cached_tokens"); const reasoning = chat?.reasoningTokens ?? responses?.reasoningTokens; return { ...(cached === undefined ? {} : { cached }), @@ -143,7 +144,7 @@ export function cacheTokensOf(usage: any): { cached?: number; reasoning?: number } /** Attach captured cache/reasoning tokens to a usage record when present. */ -export function withCacheTokens(usage: TokenUsage, source: any): TokenUsage { +export function withCacheTokens(usage: TokenUsage, source: JsonRecord | undefined): TokenUsage { const { cached, reasoning } = cacheTokensOf(source); if (cached !== undefined) usage.cachedInputTokens = cached; if (reasoning !== undefined) usage.reasoningTokens = reasoning; @@ -155,7 +156,7 @@ export interface UsageCapture { input: number; output: number; /** The most recent raw upstream usage payload, for protocol-shape-dependent emission (e.g. Anthropic cache_read). */ - raw?: any; + raw?: JsonRecord; sawUsage: boolean; /** Cache/reasoning fields mapped from the latest raw usage via the provider field mappers. */ cached?: number; @@ -167,8 +168,7 @@ export function newUsageCapture(): UsageCapture { return { input: 0, output: 0, sawUsage: false }; } -function mapRawUsage(raw: any, protocol: ProviderProtocol): TokenUsage | null { - if (!raw || typeof raw !== "object") return null; +function mapRawUsage(raw: JsonRecord | undefined, protocol: ProviderProtocol): TokenUsage | null { switch (protocol) { // Bare-usage field lists live in providers/usage/*; the core never parses // provider payloads itself (CLAUDE.md architecture rule). @@ -187,7 +187,7 @@ function mapRawUsage(raw: any, protocol: ProviderProtocol): TokenUsage | null { * `reportUsage`'s fallback then reports. */ export function usageCapture(capture: UsageCapture, protocol: ProviderProtocol, enabled = true): { - usage(raw: any): void; + usage(raw: JsonRecord | undefined): void; total(): number; } { return { @@ -204,7 +204,7 @@ export function usageCapture(capture: UsageCapture, protocol: ProviderProtocol, if (mapped.cacheWriteTokens !== undefined) capture.cacheWrite = mapped.cacheWriteTokens; }, total() { - return Number(capture.raw?.total_tokens ?? capture.input + capture.output); + return recCount(capture.raw, "total_tokens") ?? capture.input + capture.output; }, }; } @@ -214,13 +214,13 @@ function estimatedUsage(provider: string, model: string, inputTokens: number, ou } /** Reasoning text shared by Responses (`reasoning_summary_text`/`reasoning_text`) and chat (`reasoning_content`/`reasoning`) deltas. */ -export function reasoningDeltaOf(item: any): string | undefined { - const delta = item.choices?.[0]?.delta; - const chat = delta?.reasoning_content ?? delta?.reasoning; - if (typeof chat === "string" && chat) return chat; - if (item.type === "response.reasoning_summary_text.delta" || item.type === "response.reasoning_text.delta") { - const value = item.delta; - if (typeof value === "string" && value) return value; +export function reasoningDeltaOf(item: JsonRecord): string | undefined { + const delta = recObj(recObjs(item, "choices")[0], "delta"); + const chat = recStr(delta, "reasoning_content") ?? recStr(delta, "reasoning"); + if (chat) return chat; + const type = recStr(item, "type"); + if (type === "response.reasoning_summary_text.delta" || type === "response.reasoning_text.delta") { + return recStr(item, "delta") || undefined; } return undefined; } diff --git a/src/streaming/responses-passthrough.ts b/src/streaming/responses-passthrough.ts index 7d15fca..14e4394 100644 --- a/src/streaming/responses-passthrough.ts +++ b/src/streaming/responses-passthrough.ts @@ -1,8 +1,9 @@ import type { ServerResponse } from "node:http"; import { - dataLine, drain, emitResponsesError, event, newUsageCapture, reportUsage, + dataLine, drain, emitResponsesError, newUsageCapture, reportUsage, usageCapture, withSsePipe, type StreamUsageOptions, } from "./common.js"; +import { jsonRecord, recObj, recStr } from "../json.js"; /** * Forward a Responses-protocol SSE stream byte-for-byte. Usage is captured from @@ -18,9 +19,10 @@ export async function pipeResponsesPassthrough(upstream: Response, response: Ser const value = dataLine(line); if (!value || value === "[DONE]") return; try { - const item = JSON.parse(value); - if (item.type === "response.output_text.delta" && typeof item.delta === "string") outputTokens++; - if (item.response?.usage) usage.usage(item.response.usage); + const item = jsonRecord(JSON.parse(value)); + if (item.type === "response.output_text.delta" && recStr(item, "delta") !== undefined) outputTokens++; + const completedUsage = recObj(recObj(item, "response"), "usage"); + if (completedUsage) usage.usage(completedUsage); } catch { /* Ignore incomplete provider events. */ } }; try { diff --git a/src/streaming/responses-to-chat.ts b/src/streaming/responses-to-chat.ts index eeea63b..0f9c50e 100644 --- a/src/streaming/responses-to-chat.ts +++ b/src/streaming/responses-to-chat.ts @@ -1,4 +1,5 @@ import type { ServerResponse } from "node:http"; +import { jsonRecord, recObj, recStr } from "../json.js"; import { createChatEmitter, dataLine, drain, emitChatError, emitChatToolDelta, failureMessage, newUsageCapture, reasoningDeltaOf, reportUsage, usageCapture, withSsePipe, UpstreamFailure, @@ -25,26 +26,30 @@ export async function pipeResponsesStreamToChat(upstream: Response, response: Se const value = dataLine(line); if (!value) return; try { - const item = JSON.parse(value); + const item = jsonRecord(JSON.parse(value)); const failure = failureMessage(item); if (failure) throw new UpstreamFailure(failure); - if (item.type === "response.output_text.delta" && typeof item.delta === "string") { capture.output++; chunk({ content: item.delta }); } + const delta = recStr(item, "delta"); + if (item.type === "response.output_text.delta" && delta !== undefined) { capture.output++; chunk({ content: delta }); } const reasoning = reasoningDeltaOf(item); - if (typeof reasoning === "string") chunk({ reasoning_content: reasoning }); - if (item.type === "response.output_item.added" && item.item?.type === "function_call") { - const key = item.item.call_id ?? item.item.id ?? `call_${calls.size}`; - calls.set(key, { index: calls.size, id: key, name: item.item.name ?? "", announced: false }); + if (reasoning) chunk({ reasoning_content: reasoning }); + const addedItem = recObj(item, "item"); + if (item.type === "response.output_item.added" && addedItem?.type === "function_call") { + const key = recStr(addedItem, "call_id") ?? recStr(addedItem, "id") ?? `call_${calls.size}`; + calls.set(key, { index: calls.size, id: key, name: recStr(addedItem, "name") ?? "", announced: false }); } - if (item.type === "response.function_call_arguments.delta" && typeof item.delta === "string") { - const key = item.call_id ?? item.item_id ?? ""; - const call = calls.get(key) ?? { index: calls.size, id: key, name: item.name ?? "", announced: false }; + if (item.type === "response.function_call_arguments.delta" && delta !== undefined) { + const key = recStr(item, "call_id") ?? recStr(item, "item_id") ?? ""; + const call = calls.get(key) ?? { index: calls.size, id: key, name: recStr(item, "name") ?? "", announced: false }; calls.set(key, call); - emitChatToolDelta(chunk, call, call.index, item.delta); + emitChatToolDelta(chunk, call, call.index, delta); } if (item.type === "response.completed") { sawCompleted = true; - if (item.response?.status === "incomplete") truncated = true; - if (item.response?.usage) usage.usage(item.response.usage); + const completed = recObj(item, "response"); + if (completed?.status === "incomplete") truncated = true; + const completedUsage = recObj(completed, "usage"); + if (completedUsage) usage.usage(completedUsage); } } catch (error) { // In-band upstream failures must end the stream; only parse noise is ignored. diff --git a/src/streaming/to-anthropic.ts b/src/streaming/to-anthropic.ts index 47fb8b9..b6547a4 100644 --- a/src/streaming/to-anthropic.ts +++ b/src/streaming/to-anthropic.ts @@ -1,5 +1,6 @@ import type { ServerResponse } from "node:http"; import { chatResponseFailure } from "../convert/index.js"; +import { jsonRecord, recNum, recObj, recObjs, recStr } from "../json.js"; import { cacheTokensOf, dataLine, drain, emitAnthropicError, event, failureMessage, newUsageCapture, reasoningDeltaOf, reportUsage, usageCapture, withSsePipe, UpstreamFailure, type StreamUsageOptions, @@ -57,49 +58,59 @@ export async function pipeResponsesStream(upstream: Response, response: ServerRe if (!value) return; if (value === "[DONE]") { sawDone = true; return; } try { - const item = JSON.parse(value); + const item = jsonRecord(JSON.parse(value)); const failure = failureMessage(item); if (failure) throw new UpstreamFailure(failure); - const choice = item.choices?.[0]; + const choice = recObjs(item, "choices")[0]; + const delta = recObj(choice, "delta"); + const finishReason = recStr(choice, "finish_reason"); if (Array.isArray(item.choices)) { sawChatShape = true; - if (choice?.finish_reason != null) { + if (finishReason !== undefined) { sawFinishReason = true; const chatFailure = chatResponseFailure(item); - if (chatFailure) { options?.onDiagnostic?.(`chat completions finish_reason=${choice.finish_reason}: ${chatFailure}`); throw new UpstreamFailure(chatFailure); } + if (chatFailure) { options?.onDiagnostic?.(`chat completions finish_reason=${finishReason}: ${chatFailure}`); throw new UpstreamFailure(chatFailure); } } } - if (choice?.finish_reason === "length") truncated = true; + if (finishReason === "length") truncated = true; const reasoning = reasoningDeltaOf(item); - if (typeof reasoning === "string") { startThinking(); event(response, "content_block_delta", { type: "content_block_delta", index: blockIndex, delta: { type: "thinking_delta", thinking: reasoning } }); } - const text = item.type === "response.output_text.delta" - ? item.delta - : choice?.delta?.content; - if (typeof text === "string" && text) { startText(); capture.output++; event(response, "content_block_delta", { type: "content_block_delta", index: blockIndex, delta: { type: "text_delta", text } }); } - const itemTool = item.type === "response.output_item.added" && item.item?.type === "function_call" ? item.item : undefined; - const responseArgs = item.type === "response.function_call_arguments.delta" ? item.delta : undefined; - for (const tool of choice?.delta?.tool_calls ?? []) { - const key = tool.index ?? 0; - const call = calls.get(key) ?? { id: tool.id ?? `call_${key}`, name: "" }; - if (tool.id) call.id = tool.id; - if (tool.function?.name) call.name = tool.function.name; + if (reasoning) { startThinking(); event(response, "content_block_delta", { type: "content_block_delta", index: blockIndex, delta: { type: "thinking_delta", thinking: reasoning } }); } + const text = item.type === "response.output_text.delta" ? recStr(item, "delta") : recStr(delta, "content"); + if (text) { startText(); capture.output++; event(response, "content_block_delta", { type: "content_block_delta", index: blockIndex, delta: { type: "text_delta", text } }); } + const addedItem = recObj(item, "item"); + const itemTool = item.type === "response.output_item.added" && addedItem?.type === "function_call" ? addedItem : undefined; + const responseArgs = item.type === "response.function_call_arguments.delta" ? recStr(item, "delta") : undefined; + for (const tool of recObjs(delta, "tool_calls")) { + const key = recNum(tool, "index") ?? 0; + const call = calls.get(key) ?? { id: recStr(tool, "id") ?? `call_${key}`, name: "" }; + const toolId = recStr(tool, "id"); + if (toolId) call.id = toolId; + const toolFunction = recObj(tool, "function"); + const toolName = recStr(toolFunction, "name"); + if (toolName) call.name = toolName; calls.set(key, call); openTool(key, call.id, call.name); - if (tool.function?.arguments) event(response, "content_block_delta", { type: "content_block_delta", index: blockIndex, delta: { type: "input_json_delta", partial_json: tool.function.arguments } }); + const partialJson = recStr(toolFunction, "arguments"); + if (partialJson) event(response, "content_block_delta", { type: "content_block_delta", index: blockIndex, delta: { type: "input_json_delta", partial_json: partialJson } }); } if (itemTool) { - const key = itemTool.call_id ?? itemTool.id ?? `call_${blockIndex}`; - calls.set(key, { id: key, name: itemTool.name ?? "" }); - openTool(key, key, itemTool.name ?? ""); + const key = recStr(itemTool, "call_id") ?? recStr(itemTool, "id") ?? `call_${blockIndex}`; + const name = recStr(itemTool, "name") ?? ""; + calls.set(key, { id: key, name }); + openTool(key, key, name); } - if (typeof responseArgs === "string") { - const key = item.call_id ?? item.item_id ?? activeTool ?? ""; + if (responseArgs) { + const key = recStr(item, "call_id") ?? recStr(item, "item_id") ?? activeTool ?? ""; const known = calls.get(key); - openTool(key, key, item.name ?? known?.name ?? ""); + // Anthropic tool-use ids are strings; a Responses event that carries + // none falls back to a numeric chat index, which must not leak through. + openTool(key, String(key), recStr(item, "name") ?? known?.name ?? ""); event(response, "content_block_delta", { type: "content_block_delta", index: blockIndex, delta: { type: "input_json_delta", partial_json: responseArgs } }); } - if (item.response?.usage) responses.usage(item.response.usage); - if (item.usage) chat.usage(item.usage); + const responseUsage = recObj(recObj(item, "response"), "usage"); + if (responseUsage) responses.usage(responseUsage); + const itemUsage = recObj(item, "usage"); + if (itemUsage) chat.usage(itemUsage); } catch (error) { // In-band upstream failures must end the stream; only parse noise is ignored. if (error instanceof UpstreamFailure) throw error; diff --git a/src/ui.ts b/src/ui.ts index a24584b..5101b61 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -436,7 +436,7 @@ async function selectModel(provider: string, current: string): Promise SqliteDatabase; } +type SqlValue = string | number | bigint | null; +/** node:sqlite returns one object per row; our aggregates alias every numeric column to its `TokenUsageRow` field name. */ +type SqlRow = { [column: string]: SqlValue }; +interface SqliteStatement { + run(...params: SqlValue[]): void; + get(...params: SqlValue[]): SqlRow | undefined; + all(...params: SqlValue[]): SqlRow[]; +} interface SqliteDatabase { exec(sql: string): void; - prepare(sql: string): { run(...args: any[]): void; get(...args: any[]): any; all(...args: any[]): any[] }; + prepare(sql: string): SqliteStatement; close(): void; } +/** Sums arrive as numbers, or as BigInts once a column outgrows the safe double range. */ +function num(value: SqlValue | undefined): number { return Number(value ?? 0); } + let sqlitePromise: Promise | undefined; async function loadSqlite(): Promise { if (sqlitePromise === undefined) sqlitePromise = import("node:sqlite").then((mod) => mod as unknown as SqliteApi).catch(() => undefined); @@ -30,7 +41,7 @@ export function sqliteAvailable(): Promise { return loadSqlite().then(( export class SqliteUsageStore implements UsageStore { private readonly db: SqliteDatabase; - private readonly insert: { run(...args: any[]): void }; + private readonly insert: SqliteStatement; constructor(db: SqliteDatabase) { this.db = db; @@ -49,7 +60,7 @@ export class SqliteUsageStore implements UsageStore { created_at INTEGER NOT NULL )`); // Older databases predate the cache_write_tokens column. - const columns = (this.db.prepare(`PRAGMA table_info(token_usage)`).all() as any[]).map((column) => column.name); + const columns = this.db.prepare(`PRAGMA table_info(token_usage)`).all().map((column) => String(column.name)); if (!columns.includes("cache_write_tokens")) { this.db.exec(`ALTER TABLE token_usage ADD COLUMN cache_write_tokens INTEGER NOT NULL DEFAULT 0`); } @@ -71,26 +82,26 @@ export class SqliteUsageStore implements UsageStore { } async sessionTotals(sessionId: string): Promise { - const row = this.db.prepare(`SELECT COALESCE(SUM(input_tokens),0) AS inputTokens, COALESCE(SUM(output_tokens),0) AS outputTokens, COALESCE(SUM(total_tokens),0) AS totalTokens FROM token_usage WHERE session_id = ?`).get(sessionId) as any; - return { inputTokens: Number(row.inputTokens), outputTokens: Number(row.outputTokens), totalTokens: Number(row.totalTokens) }; + const row = this.db.prepare(`SELECT COALESCE(SUM(input_tokens),0) AS inputTokens, COALESCE(SUM(output_tokens),0) AS outputTokens, COALESCE(SUM(total_tokens),0) AS totalTokens FROM token_usage WHERE session_id = ?`).get(sessionId); + return { inputTokens: num(row?.inputTokens), outputTokens: num(row?.outputTokens), totalTokens: num(row?.totalTokens) }; } async providerStats(period?: UsagePeriod): Promise { const { clause, args } = this.where(period); - const rows = this.db.prepare(`SELECT provider, COALESCE(SUM(total_tokens),0) AS tokens, COUNT(*) AS requests FROM token_usage ${clause} GROUP BY provider ORDER BY tokens DESC, provider ASC`).all(...args) as any[]; - return rows.map((row) => ({ provider: row.provider, tokens: Number(row.tokens), requests: Number(row.requests) })); + const rows = this.db.prepare(`SELECT provider, COALESCE(SUM(total_tokens),0) AS tokens, COUNT(*) AS requests FROM token_usage ${clause} GROUP BY provider ORDER BY tokens DESC, provider ASC`).all(...args); + return rows.map((row) => ({ provider: String(row.provider), tokens: num(row.tokens), requests: num(row.requests) })); } async modelStats(period?: UsagePeriod): Promise { const { clause, args } = this.where(period); - const rows = this.db.prepare(`SELECT provider, model, COALESCE(SUM(input_tokens),0) AS inputTokens, COALESCE(SUM(output_tokens),0) AS outputTokens, COALESCE(SUM(cached_tokens),0) AS cachedTokens, COALESCE(SUM(cache_write_tokens),0) AS cacheWriteTokens, COALESCE(SUM(reasoning_tokens),0) AS reasoningTokens, COALESCE(SUM(total_tokens),0) AS tokens, COUNT(*) AS requests FROM token_usage ${clause} GROUP BY provider, model ORDER BY tokens DESC, provider ASC, model ASC`).all(...args) as any[]; - return rows.map((row) => ({ provider: row.provider, model: row.model, inputTokens: Number(row.inputTokens), outputTokens: Number(row.outputTokens), cachedTokens: Number(row.cachedTokens), cacheWriteTokens: Number(row.cacheWriteTokens), reasoningTokens: Number(row.reasoningTokens), tokens: Number(row.tokens), requests: Number(row.requests) })); + const rows = this.db.prepare(`SELECT provider, model, COALESCE(SUM(input_tokens),0) AS inputTokens, COALESCE(SUM(output_tokens),0) AS outputTokens, COALESCE(SUM(cached_tokens),0) AS cachedTokens, COALESCE(SUM(cache_write_tokens),0) AS cacheWriteTokens, COALESCE(SUM(reasoning_tokens),0) AS reasoningTokens, COALESCE(SUM(total_tokens),0) AS tokens, COUNT(*) AS requests FROM token_usage ${clause} GROUP BY provider, model ORDER BY tokens DESC, provider ASC, model ASC`).all(...args); + return rows.map((row) => ({ provider: String(row.provider), model: String(row.model), inputTokens: num(row.inputTokens), outputTokens: num(row.outputTokens), cachedTokens: num(row.cachedTokens), cacheWriteTokens: num(row.cacheWriteTokens), reasoningTokens: num(row.reasoningTokens), tokens: num(row.tokens), requests: num(row.requests) })); } async totals(period?: UsagePeriod): Promise { const { clause, args } = this.where(period); - const row = this.db.prepare(`SELECT COALESCE(SUM(input_tokens),0) AS inputTokens, COALESCE(SUM(output_tokens),0) AS outputTokens, COALESCE(SUM(total_tokens),0) AS totalTokens FROM token_usage ${clause}`).get(...args) as any; - return { inputTokens: Number(row.inputTokens), outputTokens: Number(row.outputTokens), totalTokens: Number(row.totalTokens) }; + const row = this.db.prepare(`SELECT COALESCE(SUM(input_tokens),0) AS inputTokens, COALESCE(SUM(output_tokens),0) AS outputTokens, COALESCE(SUM(total_tokens),0) AS totalTokens FROM token_usage ${clause}`).get(...args); + return { inputTokens: num(row?.inputTokens), outputTokens: num(row?.outputTokens), totalTokens: num(row?.totalTokens) }; } async close(): Promise { this.db.close(); } diff --git a/test/cli-commands.test.ts b/test/cli-commands.test.ts index 111e3c3..8ca57c7 100644 --- a/test/cli-commands.test.ts +++ b/test/cli-commands.test.ts @@ -109,7 +109,7 @@ test("runConfigCommand lists every provider with its credential status in non-in test("runConfigCommand --base-url registers and persists a custom provider without launching", async () => { await runConfigCommand(["--provider", "Config CLI LLM", "--base-url", "http://config.local", "--protocol", "responses", "--model", "cfg-model"]); try { - assert.equal(providerById("config-cli-llm").models[0].endpoint, "http://config.local/responses"); + assert.equal(providerById("config-cli-llm").models[0]?.endpoint, "http://config.local/responses"); assert.deepEqual((await loadCustomProviders())["config-cli-llm"], { name: "Config CLI LLM", baseUrl: "http://config.local", protocol: "responses", model: "cfg-model" }); const output = logs.join("\n"); assert.match(output, /Config CLI LLM configured/); @@ -229,7 +229,7 @@ test("exec --base-url registers and persists a custom provider, reusable without const deps = { runCommand: async (_cmd: string, _args: string[], env: NodeJS.ProcessEnv) => { capturedEnv = env; return 0; } }; try { await runClientLaunch("exec", ["--provider", "Bench Exec", "--base-url", "http://bench.local", "--protocol", "responses", "--model", "m1", "--", "some-command"], deps); - assert.equal(providerById("bench-exec").models[0].endpoint, "http://bench.local/responses"); + assert.equal(providerById("bench-exec").models[0]?.endpoint, "http://bench.local/responses"); const persisted = await loadCustomProviders(); assert.deepEqual(persisted["bench-exec"], { name: "Bench Exec", baseUrl: "http://bench.local", protocol: "responses", model: "m1" }); assert.ok(capturedEnv?.ANTHROPIC_AUTH_TOKEN, "the launch itself still proceeds through the adapter as normal"); diff --git a/test/codex-catalog.test.ts b/test/codex-catalog.test.ts index 428ae3c..7f365ea 100644 --- a/test/codex-catalog.test.ts +++ b/test/codex-catalog.test.ts @@ -57,7 +57,7 @@ test("includes a custom provider's free-form model id so Codex can resolve it", registerCustomProvider({ name: "Catalog Custom", baseUrl: "http://catalog.invalid", protocol: "chat-completions" }); const models = catalogModels({ provider: "catalog-custom", model: "deepseek-flash" }, []); assert.deepEqual(models.map((item) => item.model), ["deepseek-flash"]); - assert.equal(models[0].provider, "catalog-custom"); + assert.equal(models[0]?.provider, "catalog-custom"); } finally { unregisterCustomProvider("catalog-custom"); } }); diff --git a/test/providers-registry.test.ts b/test/providers-registry.test.ts index e614f4f..3508e49 100644 --- a/test/providers-registry.test.ts +++ b/test/providers-registry.test.ts @@ -386,8 +386,8 @@ test("registerCustomProvider avoids clobbering a built-in provider id, but is id registerCustomProvider({ name: "Repeat Me", baseUrl: "http://b", protocol: "responses", model: "two" }); const matches = providerRegistry.filter((entry) => entry.id === "repeat-me"); assert.equal(matches.length, 1); - assert.equal(matches[0].models[0].endpoint, "http://b/responses"); - assert.equal(matches[0].models[0].model, "two"); + assert.equal(matches[0]?.models[0]?.endpoint, "http://b/responses"); + assert.equal(matches[0]?.models[0]?.model, "two"); } finally { if (shadowedId) unregisterCustomProvider(shadowedId); unregisterCustomProvider("repeat-me"); } }); diff --git a/test/streaming.test.ts b/test/streaming.test.ts index 0b40a15..d715d0c 100644 --- a/test/streaming.test.ts +++ b/test/streaming.test.ts @@ -258,8 +258,8 @@ test("pipeAnthropicPassthrough forwards a native Anthropic SSE stream byte-for-b await pipeAnthropicPassthrough(upstream, output as never, "claude-x", { provider: "custom", model: "claude-x", protocol: "anthropic", onUsage: (usage) => usages.push(usage) }); // Byte-faithful: every upstream chunk appears verbatim in the forwarded output. for (const chunk of chunks) assert.ok(output.text.includes(chunk)); - assert.equal(usages[0].inputTokens, 10); - assert.equal(usages[0].outputTokens, 5); + assert.equal(usages[0]?.inputTokens, 10); + assert.equal(usages[0]?.outputTokens, 5); }); test("pipeAnthropicPassthrough emits an Anthropic error event on upstream failure", async () => { @@ -331,9 +331,9 @@ test("pipeAnthropicStreamToResponses reports input/output/cached usage from mess const output = sink(); const usages: TokenUsage[] = []; await pipeAnthropicStreamToResponses(upstream, output as never, "claude-x", { provider: "custom", model: "claude-x", protocol: "anthropic", onUsage: (usage) => usages.push(usage) }); - assert.equal(usages[0].inputTokens, 50); - assert.equal(usages[0].outputTokens, 9); - assert.equal(usages[0].cachedInputTokens, 40); + assert.equal(usages[0]?.inputTokens, 50); + assert.equal(usages[0]?.outputTokens, 9); + assert.equal(usages[0]?.cachedInputTokens, 40); }); test("pipeAnthropicStreamToResponses emits response.failed when the upstream stream fails mid-flight", async () => { diff --git a/test/ui.test.ts b/test/ui.test.ts index c4ccbaf..dd178d6 100644 --- a/test/ui.test.ts +++ b/test/ui.test.ts @@ -118,8 +118,8 @@ test("Add custom provider: registers it, persists connection metadata only, and try { assert.equal(result, "my-local-llm"); assert.equal(providerById("my-local-llm").custom, true); - assert.equal(providerById("my-local-llm").models[0].protocol, "anthropic"); - assert.equal(providerById("my-local-llm").models[0].endpoint, "http://localhost:11434/v1/messages"); + assert.equal(providerById("my-local-llm").models[0]?.protocol, "anthropic"); + assert.equal(providerById("my-local-llm").models[0]?.endpoint, "http://localhost:11434/v1/messages"); const persisted = await loadCustomProviders(); assert.deepEqual(persisted["my-local-llm"], { name: "My Local LLM", baseUrl: "http://localhost:11434", protocol: "anthropic", model: "custom-model" }); // Never anything key-shaped in the persisted record. @@ -146,8 +146,8 @@ test("Add custom provider: the single Protocol screen maps each choice to the up const result = await resultPromise; try { assert.equal(result, "openai-responses"); - assert.equal(providerById("openai-responses").models[0].protocol, "responses"); - assert.equal(providerById("openai-responses").models[0].endpoint, "https://api.openai.com/v1/responses"); + assert.equal(providerById("openai-responses").models[0]?.protocol, "responses"); + assert.equal(providerById("openai-responses").models[0]?.endpoint, "https://api.openai.com/v1/responses"); } finally { unregisterCustomProvider("openai-responses"); } @@ -195,8 +195,8 @@ test("runProviderManager: adds a custom provider, prints its key guidance, and e await manager; try { - assert.equal(providerById("config-test-llm").models[0].protocol, "chat-completions"); - assert.equal(providerById("config-test-llm").models[0].endpoint, "http://localhost:11434/chat/completions"); + assert.equal(providerById("config-test-llm").models[0]?.protocol, "chat-completions"); + assert.equal(providerById("config-test-llm").models[0]?.endpoint, "http://localhost:11434/chat/completions"); assert.ok((await loadCustomProviders())["config-test-llm"]); // No launch follows, so the manager must print how to persist the key. assert.match(tty.text, /AGENTX_CONFIG_TEST_LLM_API_KEY/); diff --git a/test/usage-storage.test.ts b/test/usage-storage.test.ts index 7ea0d59..47a2fea 100644 --- a/test/usage-storage.test.ts +++ b/test/usage-storage.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { createUsageStore, defaultUsageStore, defaultUsageLocation, periodStart, sqliteAvailable } from "../src/usage/storage.js"; +import { createUsageStore, defaultUsageStore, periodStart, sqliteAvailable } from "../src/usage/storage.js"; import { TokenUsageCollector, normalizeUsage } from "../src/usage/collector.js"; import type { TokenUsage } from "../src/usage/types.js"; @@ -109,7 +109,7 @@ test("json file store handles concurrent reads after a write", async () => { const [models, totals] = await Promise.all([store.modelStats("all"), store.totals("all")]); assert.equal(totals.inputTokens, 100); assert.equal(totals.outputTokens, 25); - assert.equal(models[0].tokens, 125); + assert.equal(models[0]?.tokens, 125); await store.close(); } finally { await rm(dir, { recursive: true, force: true }); } }); @@ -139,7 +139,7 @@ test("cache write tokens survive normalization, storage, and stats", async () => await store.record(normalizeUsage(sample({ provider: "anthropic", model: "claude-sonnet-4", cachedInputTokens: 500, cacheWriteTokens: 200 }))); await store.record(normalizeUsage(sample({ provider: "anthropic", model: "claude-sonnet-4", cachedInputTokens: 100 }))); const models = await store.modelStats("all"); - assert.equal(models[0].cachedTokens, 600); - assert.equal(models[0].cacheWriteTokens, 200); + assert.equal(models[0]?.cachedTokens, 600); + assert.equal(models[0]?.cacheWriteTokens, 200); await store.close(); }); diff --git a/test/usage-streaming.test.ts b/test/usage-streaming.test.ts index 0574c3e..6d413f7 100644 --- a/test/usage-streaming.test.ts +++ b/test/usage-streaming.test.ts @@ -54,8 +54,8 @@ test("pipeResponsesStream estimates usage when the provider sends none", async ( let text = ""; const output = { writeHead() {}, write(value: string) { text += value; }, end() {}, on() {} }; const opts = collect({ provider: "opencode", model: "gpt-5.6-luna", protocol: "responses" }); await pipeResponsesStream(upstream, output as never, "gpt-5.6-luna", opts); - assert.equal(opts.usages[0].estimated, true); - assert.equal(opts.usages[0].outputTokens, 2); + assert.equal(opts.usages[0]?.estimated, true); + assert.equal(opts.usages[0]?.outputTokens, 2); }); test("pipeChatStreamToResponses reports usage from the final chunk", async () => { @@ -119,8 +119,8 @@ test("pipeResponsesPassthrough estimates usage from deltas when the provider sen const output = { writeHead() {}, write() {}, end() {}, on() {} }; const usages: TokenUsage[] = []; await pipeResponsesPassthrough(upstream, output as never, "gpt-5.6-luna", { provider: "opencode", model: "gpt-5.6-luna", protocol: "responses", onUsage: (usage) => usages.push(usage) }); - assert.equal(usages[0].estimated, true); - assert.equal(usages[0].outputTokens, 2); + assert.equal(usages[0]?.estimated, true); + assert.equal(usages[0]?.outputTokens, 2); }); test("renders usage statistics for the CLI", () => { diff --git a/tsconfig.json b/tsconfig.json index 4bf5f14..78abb91 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,9 @@ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "dist", "rootDir": ".", "strict": true, "esModuleInterop": true, - "skipLibCheck": true + "skipLibCheck": true, + "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitOverride": true, + "noUncheckedIndexedAccess": true }, "include": ["src/**/*.ts", "test/**/*.ts"] } From 2b270523ccaa053b1fa7e13aa55c6af1e65a1b2a Mon Sep 17 00:00:00 2001 From: tzzs Date: Sun, 20 Sep 2026 22:01:57 +0800 Subject: [PATCH 6/9] chore(tooling): add eslint, typecheck and single-test entry points Lint and type-check were the two gates a change could slip through without: `npm test` only builds and runs the suite. Adds an ESLint flat config whose rules cover what tsc can't see (a reintroduced `any` at a wire boundary, dead code), a `typecheck` script, `test:one` for running one file, and a CI lint job so the checks gate PRs. macOS joins the CI matrix because it is the developer platform whose paths and shell-profile handling differ from Linux. Also replaces the unused `text` accumulators in the streaming usage tests with a shared fake response that the tests now assert forwarded content against. --- .github/workflows/ci.yml | 26 +- Makefile | 17 +- eslint.config.js | 27 + package-lock.json | 1304 +++++++++++++++++++++++++++++++++- package.json | 10 +- test/usage-streaming.test.ts | 126 ++-- 6 files changed, 1434 insertions(+), 76 deletions(-) create mode 100644 eslint.config.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c82c1ee..de807fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,13 +13,35 @@ permissions: contents: read jobs: + lint: + name: Lint and type-check + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - name: Install dependencies + run: npm ci + - name: Lint + run: npm run lint + - name: Type-check + run: npm run typecheck + - name: Verify package contents + run: npm pack --dry-run + test: name: Node.js ${{ matrix.node-version }} on ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] + # macOS is the primary developer platform and the one whose paths, + # shell profile handling and node:sqlite build differ from Linux. + os: [ubuntu-latest, macos-latest, windows-latest] node-version: [20, 22, 24] steps: - name: Check out repository @@ -33,5 +55,3 @@ jobs: run: npm ci - name: Run tests run: npm test - - name: Verify package contents - run: npm pack --dry-run diff --git a/Makefile b/Makefile index 62b9581..2a9a0c7 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ CLI := node dist/src/cli.js .DEFAULT_GOAL := help -.PHONY: help install build test check ci pack install-local link unlink \ +.PHONY: help install build test test-one lint typecheck check ci pack install-local link unlink \ claude codex proxy doctor version clean help: ## Show available targets @@ -26,10 +26,21 @@ build: node_modules/.package-lock.json ## Compile TypeScript into dist/ test: node_modules/.package-lock.json ## Build and run the test suite $(NPM) test -check: test ## Run tests and verify the npm package contents +# make test-one F=test/streaming — a single test file, quoted so a bare +# `make test-one` doesn't expand to every file in the directory. +test-one: node_modules/.package-lock.json ## Build and run one test file (F=test/) + $(NPM) run test:one -- "dist/$(F).test.js" + +lint: node_modules/.package-lock.json ## Lint the repository + $(NPM) run lint + +typecheck: node_modules/.package-lock.json ## Type-check src/ and test/ without emitting + $(NPM) run typecheck + +check: test lint ## Run tests, lint and verify the npm package contents $(NPM) pack --dry-run -ci: install test ## Reproduce the GitHub Actions verification locally +ci: install lint test ## Reproduce the GitHub Actions verification locally pack: build ## Build and create an npm tarball $(NPM) pack diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..5f1292c --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,27 @@ +import tseslint from "typescript-eslint"; + +/** + * The type checker carries most of the safety here (strict mode plus + * noUncheckedIndexedAccess), so ESLint keeps only what tsc can't see: an + * accidental `any` reintroduced at a wire boundary, and dead code. + */ +export default tseslint.config( + { ignores: ["dist/**", "node_modules/**"] }, + ...tseslint.configs.recommended, + { + files: ["**/*.ts"], + rules: { + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-unused-vars": ["error", { args: "all", argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], + eqeqeq: ["error", "always", { null: "ignore" }], + "no-var": "error", + "prefer-const": "error", + }, + }, + { + // Tests stub providers with loosely-shaped JSON, and an assertion's shape + // is exactly what a test is allowed to leave imprecise. + files: ["test/**/*.ts"], + rules: { "@typescript-eslint/no-explicit-any": "off" }, + }, +); diff --git a/package-lock.json b/package-lock.json index 3e50d7e..f24b02b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,12 +16,38 @@ }, "devDependencies": { "@types/node": "^22.0.0", - "typescript": "^5.6.0" + "eslint": "^10.11.0", + "typescript": "^5.6.0", + "typescript-eslint": "^8.70.0" }, "engines": { "node": ">=20" } }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, "node_modules/@clack/core": { "version": "1.4.3", "resolved": "https://registry.npmmirror.com/@clack/core/-/core-1.4.3.tgz", @@ -47,19 +73,773 @@ "sisteransi": "^1.0.5" }, "engines": { - "node": ">= 20.12.0" + "node": ">= 20.12.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmmirror.com/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmmirror.com/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmmirror.com/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmmirror.com/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmmirror.com/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmmirror.com/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", + "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/type-utils": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.70.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.9", + "resolved": "https://registry.npmmirror.com/ignore/-/ignore-7.0.9.tgz", + "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.70.0.tgz", + "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", + "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.70.0", + "@typescript-eslint/types": "^8.70.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", + "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", + "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz", + "integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.70.0.tgz", + "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", + "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.70.0", + "@typescript-eslint/tsconfig-utils": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.70.0.tgz", + "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", + "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.11.0", + "resolved": "https://registry.npmmirror.com/eslint/-/eslint-10.11.0.tgz", + "integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmmirror.com/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" } }, - "node_modules/@types/node": { - "version": "22.20.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", - "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-string-truncated-width": { "version": "3.0.3", "resolved": "https://registry.npmmirror.com/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", @@ -84,12 +864,445 @@ "fast-string-width": "^3.0.2" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmmirror.com/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmmirror.com/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmmirror.com/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmmirror.com/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmmirror.com/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmmirror.com/sisteransi/-/sisteransi-1.0.5.tgz", "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -104,12 +1317,85 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.70.0.tgz", + "integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.70.0", + "@typescript-eslint/parser": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 4d50d63..227eece 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,11 @@ }, "scripts": { "build": "tsc", - "test": "npm run build && cd dist && node --test" + "typecheck": "tsc --noEmit", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test": "npm run build && cd dist && node --test", + "test:one": "npm run build && node --test" }, "files": [ "dist/src", @@ -30,7 +34,9 @@ ], "devDependencies": { "@types/node": "^22.0.0", - "typescript": "^5.6.0" + "eslint": "^10.11.0", + "typescript": "^5.6.0", + "typescript-eslint": "^8.70.0" }, "dependencies": { "@clack/prompts": "^1.7.0" diff --git a/test/usage-streaming.test.ts b/test/usage-streaming.test.ts index 6d413f7..6576b9f 100644 --- a/test/usage-streaming.test.ts +++ b/test/usage-streaming.test.ts @@ -9,116 +9,125 @@ function collect(options: NonNullable[3]> return { ...options, onUsage: (usage: TokenUsage) => { usages.push(usage); }, usages }; } +/** A stand-in `ServerResponse` whose `writes()` returns everything the pipe emitted. */ +function fakeResponse() { + const chunks: string[] = []; + const response = { writeHead() {}, write(value: string) { chunks.push(value); }, end() {}, on() {} }; + return { response, writes: () => chunks.join("") }; +} + +/** An upstream `Response` replaying `chunks` as its SSE body. */ +function sseUpstream(chunks: string[]) { + return new Response(new ReadableStream({ start(controller) { for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)); controller.close(); } })); +} + test("pipeResponsesStream reports usage from the final chunk", async () => { - const chunks = [ + const upstream = sseUpstream([ 'data: {"type":"response.output_text.delta","delta":"OK"}\n\n', 'data: {"type":"response.completed","response":{"usage":{"input_tokens":100,"output_tokens":20}}}\n\n', - "data: [DONE]\n\n" - ]; - const upstream = new Response(new ReadableStream({ start(controller) { for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)); controller.close(); } })); - let text = ""; const output = { writeHead() {}, write(value: string) { text += value; }, end() {}, on() {} }; + "data: [DONE]\n\n", + ]); + const { response, writes } = fakeResponse(); const opts = collect({ provider: "opencode", model: "gpt-5.6-luna", protocol: "responses" }); - await pipeResponsesStream(upstream, output as never, "gpt-5.6-luna", opts); + await pipeResponsesStream(upstream, response as never, "gpt-5.6-luna", opts); assert.equal(opts.usages.length, 1); assert.deepEqual(opts.usages[0], { provider: "opencode", model: "gpt-5.6-luna", inputTokens: 100, outputTokens: 20, totalTokens: 120 }); + assert.match(writes(), /"type":"text_delta","text":"OK"/); }); test("pipeResponsesStream records cached and reasoning tokens from usage details", async () => { - const chunks = [ + const upstream = sseUpstream([ 'data: {"type":"response.output_text.delta","delta":"OK"}\n\n', - 'data: {"type":"response.completed","response":{"usage":{"input_tokens":100,"output_tokens":20,"input_tokens_details":{"cached_tokens":80},"output_tokens_details":{"reasoning_tokens":12}}}}\n\n' - ]; - const upstream = new Response(new ReadableStream({ start(controller) { for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)); controller.close(); } })); - let text = ""; const output = { writeHead() {}, write(value: string) { text += value; }, end() {}, on() {} }; + 'data: {"type":"response.completed","response":{"usage":{"input_tokens":100,"output_tokens":20,"input_tokens_details":{"cached_tokens":80},"output_tokens_details":{"reasoning_tokens":12}}}}\n\n', + ]); + const { response } = fakeResponse(); const opts = collect({ provider: "opencode", model: "gpt-5.6-luna", protocol: "responses" }); - await pipeResponsesStream(upstream, output as never, "gpt-5.6-luna", opts); + await pipeResponsesStream(upstream, response as never, "gpt-5.6-luna", opts); assert.deepEqual(opts.usages[0], { provider: "opencode", model: "gpt-5.6-luna", inputTokens: 100, outputTokens: 20, totalTokens: 120, cachedInputTokens: 80, reasoningTokens: 12 }); }); test("pipeResponsesStream reads top-level chat completions usage for chat upstreams", async () => { - const chunks = [ + const upstream = sseUpstream([ 'data: {"choices":[{"delta":{"content":"OK"}}]}\n\n', 'data: {"usage":{"prompt_tokens":50,"completion_tokens":10,"prompt_tokens_details":{"cached_tokens":32}}}\n\n', - "data: [DONE]\n\n" - ]; - const upstream = new Response(new ReadableStream({ start(controller) { for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)); controller.close(); } })); - let text = ""; const output = { writeHead() {}, write(value: string) { text += value; }, end() {}, on() {} }; + "data: [DONE]\n\n", + ]); + const { response } = fakeResponse(); const opts = collect({ provider: "deepseek", model: "deepseek-v4-pro", protocol: "chat-completions" }); - await pipeResponsesStream(upstream, output as never, "deepseek-v4-pro", opts); + await pipeResponsesStream(upstream, response as never, "deepseek-v4-pro", opts); assert.deepEqual(opts.usages[0], { provider: "deepseek", model: "deepseek-v4-pro", inputTokens: 50, outputTokens: 10, totalTokens: 60, cachedInputTokens: 32 }); }); test("pipeResponsesStream estimates usage when the provider sends none", async () => { - const chunks = ['data: {"type":"response.output_text.delta","delta":"A"}\n\n', 'data: {"type":"response.output_text.delta","delta":"B"}\n\n', "data: [DONE]\n\n"]; - const upstream = new Response(new ReadableStream({ start(controller) { for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)); controller.close(); } })); - let text = ""; const output = { writeHead() {}, write(value: string) { text += value; }, end() {}, on() {} }; + const upstream = sseUpstream([ + 'data: {"type":"response.output_text.delta","delta":"A"}\n\n', + 'data: {"type":"response.output_text.delta","delta":"B"}\n\n', + "data: [DONE]\n\n", + ]); + const { response } = fakeResponse(); const opts = collect({ provider: "opencode", model: "gpt-5.6-luna", protocol: "responses" }); - await pipeResponsesStream(upstream, output as never, "gpt-5.6-luna", opts); + await pipeResponsesStream(upstream, response as never, "gpt-5.6-luna", opts); assert.equal(opts.usages[0]?.estimated, true); assert.equal(opts.usages[0]?.outputTokens, 2); }); test("pipeChatStreamToResponses reports usage from the final chunk", async () => { - const chunks = [ + const upstream = sseUpstream([ 'data: {"choices":[{"delta":{"content":"OK"}}]}\n\n', 'data: {"usage":{"prompt_tokens":50,"completion_tokens":10,"total_tokens":60}}\n\n', - "data: [DONE]\n\n" - ]; - const upstream = new Response(new ReadableStream({ start(controller) { for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)); controller.close(); } })); - let text = ""; const output = { writeHead() {}, write(value: string) { text += value; }, end() {}, on() {} }; + "data: [DONE]\n\n", + ]); + const { response, writes } = fakeResponse(); const usages: TokenUsage[] = []; - await pipeChatStreamToResponses(upstream, output as never, "deepseek-v4-pro", { provider: "deepseek", model: "deepseek-v4-pro", protocol: "chat-completions", onUsage: (usage) => usages.push(usage) }); + await pipeChatStreamToResponses(upstream, response as never, "deepseek-v4-pro", { provider: "deepseek", model: "deepseek-v4-pro", protocol: "chat-completions", onUsage: (usage) => usages.push(usage) }); assert.deepEqual(usages[0], { provider: "deepseek", model: "deepseek-v4-pro", inputTokens: 50, outputTokens: 10, totalTokens: 60 }); + assert.match(writes(), /"text":"OK"/); }); test("pipeChatStreamToResponses records cached and reasoning tokens when provided", async () => { - const chunks = [ + const upstream = sseUpstream([ 'data: {"choices":[{"delta":{"content":"OK"}}]}\n\n', 'data: {"usage":{"prompt_tokens":50,"completion_tokens":10,"total_tokens":60,"prompt_tokens_details":{"cached_tokens":40},"completion_tokens_details":{"reasoning_tokens":6}}}\n\n', - "data: [DONE]\n\n" - ]; - const upstream = new Response(new ReadableStream({ start(controller) { for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)); controller.close(); } })); - let text = ""; const output = { writeHead() {}, write(value: string) { text += value; }, end() {}, on() {} }; + "data: [DONE]\n\n", + ]); + const { response } = fakeResponse(); const usages: TokenUsage[] = []; - await pipeChatStreamToResponses(upstream, output as never, "deepseek-v4-pro", { provider: "deepseek", model: "deepseek-v4-pro", protocol: "chat-completions", onUsage: (usage) => usages.push(usage) }); + await pipeChatStreamToResponses(upstream, response as never, "deepseek-v4-pro", { provider: "deepseek", model: "deepseek-v4-pro", protocol: "chat-completions", onUsage: (usage) => usages.push(usage) }); assert.deepEqual(usages[0], { provider: "deepseek", model: "deepseek-v4-pro", inputTokens: 50, outputTokens: 10, totalTokens: 60, cachedInputTokens: 40, reasoningTokens: 6 }); }); test("pipeResponsesPassthrough forwards chunks and captures usage", async () => { - const chunks = [ + const upstream = sseUpstream([ 'data: {"type":"response.output_text.delta","delta":"Hi"}\n\n', 'data: {"type":"response.completed","response":{"usage":{"input_tokens":5,"output_tokens":2}}}\n\n', - "data: [DONE]\n\n" - ]; - const upstream = new Response(new ReadableStream({ start(controller) { for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)); controller.close(); } })); - let text = ""; const output = { writeHead() {}, write(value: string) { text += value; }, end() {}, on() {} }; + "data: [DONE]\n\n", + ]); + const { response, writes } = fakeResponse(); const usages: TokenUsage[] = []; - await pipeResponsesPassthrough(upstream, output as never, "gpt-5.6-luna", { provider: "opencode", model: "gpt-5.6-luna", protocol: "responses", onUsage: (usage) => usages.push(usage) }); - assert.equal(text.includes("output_text"), true); + await pipeResponsesPassthrough(upstream, response as never, "gpt-5.6-luna", { provider: "opencode", model: "gpt-5.6-luna", protocol: "responses", onUsage: (usage) => usages.push(usage) }); + assert.equal(writes().includes("output_text"), true); assert.deepEqual(usages[0], { provider: "opencode", model: "gpt-5.6-luna", inputTokens: 5, outputTokens: 2, totalTokens: 7 }); }); test("pipeResponsesPassthrough records cached and reasoning tokens from usage details", async () => { - const chunks = [ - 'data: {"type":"response.completed","response":{"usage":{"input_tokens":90,"output_tokens":8,"input_tokens_details":{"cached_tokens":70},"output_tokens_details":{"reasoning_tokens":5}}}}\n\n' - ]; - const upstream = new Response(new ReadableStream({ start(controller) { for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)); controller.close(); } })); - const output = { writeHead() {}, write() {}, end() {}, on() {} }; + const upstream = sseUpstream([ + 'data: {"type":"response.completed","response":{"usage":{"input_tokens":90,"output_tokens":8,"input_tokens_details":{"cached_tokens":70},"output_tokens_details":{"reasoning_tokens":5}}}}\n\n', + ]); + const { response } = fakeResponse(); const usages: TokenUsage[] = []; - await pipeResponsesPassthrough(upstream, output as never, "gpt-5.6-luna", { provider: "opencode", model: "gpt-5.6-luna", protocol: "responses", onUsage: (usage) => usages.push(usage) }); + await pipeResponsesPassthrough(upstream, response as never, "gpt-5.6-luna", { provider: "opencode", model: "gpt-5.6-luna", protocol: "responses", onUsage: (usage) => usages.push(usage) }); assert.deepEqual(usages[0], { provider: "opencode", model: "gpt-5.6-luna", inputTokens: 90, outputTokens: 8, totalTokens: 98, cachedInputTokens: 70, reasoningTokens: 5 }); }); test("pipeResponsesPassthrough estimates usage from deltas when the provider sends none", async () => { - const chunks = [ + const upstream = sseUpstream([ 'data: {"type":"response.output_text.delta","delta":"A"}\n\n', 'data: {"type":"response.output_text.delta","delta":"B"}\n\n', - "data: [DONE]\n\n" - ]; - const upstream = new Response(new ReadableStream({ start(controller) { for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)); controller.close(); } })); - const output = { writeHead() {}, write() {}, end() {}, on() {} }; + "data: [DONE]\n\n", + ]); + const { response } = fakeResponse(); const usages: TokenUsage[] = []; - await pipeResponsesPassthrough(upstream, output as never, "gpt-5.6-luna", { provider: "opencode", model: "gpt-5.6-luna", protocol: "responses", onUsage: (usage) => usages.push(usage) }); + await pipeResponsesPassthrough(upstream, response as never, "gpt-5.6-luna", { provider: "opencode", model: "gpt-5.6-luna", protocol: "responses", onUsage: (usage) => usages.push(usage) }); assert.equal(usages[0]?.estimated, true); assert.equal(usages[0]?.outputTokens, 2); }); @@ -146,14 +155,13 @@ test("hides the Cached column when no model has cached tokens", () => { }); test("stream pipes capture cache and reasoning tokens from final usage", async () => { - const chunks = [ + const upstream = sseUpstream([ 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\n', 'data: {"usage":{"prompt_tokens":100,"completion_tokens":5,"prompt_tokens_details":{"cached_tokens":80},"completion_tokens_details":{"reasoning_tokens":3}}}\n\n', - "data: [DONE]\n\n" - ]; - const upstream = new Response(new ReadableStream({ start(c) { for (const ch of chunks) c.enqueue(new TextEncoder().encode(ch)); c.close(); } })); - let text = ""; const output = { writeHead() {}, write(v: string) { text += v; }, end() {}, on() {} }; + "data: [DONE]\n\n", + ]); + const { response } = fakeResponse(); const usages: TokenUsage[] = []; - await pipeChatStreamToResponses(upstream, output as never, "gpt-4o", { provider: "openai", model: "gpt-4o", protocol: "chat-completions", onUsage: (u) => usages.push(u) }); + await pipeChatStreamToResponses(upstream, response as never, "gpt-4o", { provider: "openai", model: "gpt-4o", protocol: "chat-completions", onUsage: (usage) => usages.push(usage) }); assert.deepEqual(usages[0], { provider: "openai", model: "gpt-4o", inputTokens: 100, outputTokens: 5, totalTokens: 105, cachedInputTokens: 80, reasoningTokens: 3 }); }); From 36f3e17744da2026c3d2aceaa43aac24e35e9689 Mon Sep 17 00:00:00 2001 From: tzzs Date: Sun, 20 Sep 2026 22:12:19 +0800 Subject: [PATCH 7/9] test: cover the JSON accessors, atomic writes and shared converters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three modules had no test of their own: src/json.ts (the accessors every wire payload is read through), src/fsutil.ts (atomicWriteFile, which is what keeps a crashed run from leaving a torn state file behind), and the shared Anthropic convert helpers. Their edge cases — a counter that arrives as "123", an image source that isn't a URL, DeepSeek-only controls leaking to other providers — are exactly the ones a caller can't see from the endpoint tests above them. Also covers runUsageStats end to end against a temporary usage dir, and moves lint, type-check and the package-content check into a CI job that runs once instead of six times, with macOS joining the test matrix. --- test/convert-shared.test.ts | 108 ++++++++++++++++++++++++++++++++++++ test/fsutil.test.ts | 57 +++++++++++++++++++ test/json.test.ts | 65 ++++++++++++++++++++++ test/usage-storage.test.ts | 28 ++++++++++ 4 files changed, 258 insertions(+) create mode 100644 test/convert-shared.test.ts create mode 100644 test/fsutil.test.ts create mode 100644 test/json.test.ts diff --git a/test/convert-shared.test.ts b/test/convert-shared.test.ts new file mode 100644 index 0000000..09140da --- /dev/null +++ b/test/convert-shared.test.ts @@ -0,0 +1,108 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + anthropicThinking, anthropicToolChoice, chatControlParams, chatThinking, chatToolChoice, + collapseAnthropicContent, imageDataUri, reasoningEffort, responsesToolChoice, samplingParams, + textOfBlocks, toAnthropicImageSource, +} from "../src/convert/shared.js"; + +test("collapseAnthropicContent joins pure-text blocks and keeps mixed arrays as blocks", () => { + assert.equal(collapseAnthropicContent([]), ""); + assert.equal(collapseAnthropicContent([{ type: "text", text: "a" }, { type: "text", text: "b" }]), "ab"); + // A text-only block with no text still counts as text: it joins as "". + assert.equal(collapseAnthropicContent([{ type: "text" }]), ""); + assert.deepEqual(collapseAnthropicContent([{ type: "text", text: "a" }, { type: "image" }]), [{ type: "text", text: "a" }, { type: "image" }]); +}); + +test("textOfBlocks reads one block type and ignores the rest", () => { + const content = [{ type: "text", text: "hello" }, { type: "thinking", thinking: "hm" }, { type: "tool_use", id: "t1" }]; + assert.equal(textOfBlocks(content, "text"), "hello"); + assert.equal(textOfBlocks(content, "thinking"), "hm"); + assert.equal(textOfBlocks([], "text"), ""); + assert.equal(textOfBlocks([{ type: "text" }], "text"), ""); +}); + +test("imageDataUri builds a data URI, passes a remote URL through, and drops what it cannot represent", () => { + assert.equal(imageDataUri({ type: "url", url: "https://x/y.png" }), "https://x/y.png"); + assert.equal(imageDataUri({ type: "base64", media_type: "image/jpeg", data: "AA" }), "data:image/jpeg;base64,AA"); + assert.equal(imageDataUri({ type: "base64", data: "AA" }), "data:image/png;base64,AA"); + assert.equal(imageDataUri({ type: "base64" }), undefined); + assert.equal(imageDataUri({ type: "file", path: "/x" }), undefined); + assert.equal(imageDataUri(undefined), undefined); +}); + +test("toAnthropicImageSource reads a data URI into base64 and leaves other URLs as url sources", () => { + assert.equal(toAnthropicImageSource(undefined), undefined); + assert.equal(toAnthropicImageSource(""), undefined); + assert.deepEqual(toAnthropicImageSource("https://x/y.png"), { type: "url", url: "https://x/y.png" }); + assert.deepEqual(toAnthropicImageSource("data:image/gif;base64,R0lG"), { type: "base64", media_type: "image/gif", data: "R0lG" }); +}); + +test("reasoningEffort collapses vendor effort names onto the Chat Completions scale", () => { + for (const value of ["minimal", "low"]) assert.equal(reasoningEffort({ reasoning: { effort: value } }), "low"); + for (const value of ["medium", "high", "xhigh", "ultracode"]) assert.equal(reasoningEffort({ reasoning: { effort: value } }), "high"); + for (const value of ["max", "ultra"]) assert.equal(reasoningEffort({ reasoning: { effort: value } }), "max"); + assert.equal(reasoningEffort({ reasoning: { effort: "telepathic" } }), undefined); + assert.equal(reasoningEffort({ reasoning: { effort: 5 } }), undefined); + assert.equal(reasoningEffort(undefined), undefined); + // output_config wins over reasoning when both are present. + assert.equal(reasoningEffort({ output_config: { effort: "max" }, reasoning: { effort: "low" } }), "max"); +}); + +test("chatThinking maps Anthropic thinking controls and effort names, and stays absent otherwise", () => { + assert.deepEqual(chatThinking({ thinking: { type: "disabled" } }), { type: "disabled" }); + assert.deepEqual(chatThinking({ thinking: { type: "enabled" } }), { type: "enabled" }); + assert.deepEqual(chatThinking({ thinking: { type: "adaptive" } }), { type: "enabled" }); + assert.deepEqual(chatThinking({ reasoning: { effort: "none" } }), { type: "disabled" }); + assert.deepEqual(chatThinking({ reasoning: { effort: "high" } }), { type: "enabled" }); + assert.equal(chatThinking({ thinking: { type: "sometimes" } }), undefined); + assert.equal(chatThinking({}), undefined); + assert.equal(chatThinking(undefined), undefined); +}); + +test("tool choice converts to each dialect, including both names of a forced tool", () => { + // The two upstream dialects name a forced tool differently: Responses puts + // `name` on the choice, Chat Completions nests it under `function`. + for (const choice of [{ type: "function", name: "bash" }, { type: "function", function: { name: "bash" } }] as const) { + assert.deepEqual(chatToolChoice(choice), { type: "function", function: { name: "bash" } }); + assert.deepEqual(responsesToolChoice(choice), { type: "function", name: "bash" }); + assert.deepEqual(anthropicToolChoice(choice), { type: "tool", name: "bash" }); + } + assert.equal(chatToolChoice("required"), "required"); + assert.equal(responsesToolChoice("any"), "required"); + assert.deepEqual(anthropicToolChoice("required"), { type: "any" }); + assert.deepEqual(anthropicToolChoice("auto"), { type: "auto" }); + // A forced tool with no name is not a choice we can forward, and Anthropic's + // own `{type:"tool"}` shape is not one of the source dialects. + assert.equal(chatToolChoice({ type: "function" }), undefined); + assert.equal(anthropicToolChoice({ type: "function", name: 7 }), undefined); + assert.equal(anthropicToolChoice({ type: "tool", name: "bash" }), undefined); + assert.equal(anthropicToolChoice("gpt-4o"), undefined); + assert.equal(chatToolChoice(42), undefined); + assert.equal(responsesToolChoice(null), undefined); +}); + +test("samplingParams forwards only the knobs the client set", () => { + assert.deepEqual(samplingParams({}), {}); + assert.deepEqual(samplingParams({ temperature: 0, top_p: 1, stop_sequences: ["\n"] }), { temperature: 0, top_p: 1, stop: ["\n"] }); + // An explicit null is a value, not an absent knob. + assert.deepEqual(samplingParams({ temperature: null }), { temperature: null }); +}); + +test("chatControlParams gates DeepSeek-only controls behind the provider check", () => { + const input = { thinking: { type: "enabled" }, reasoning: { effort: "high" }, tool_choice: "required" }; + assert.deepEqual(chatControlParams(input, true), { thinking: { type: "enabled" }, reasoning_effort: "high", tool_choice: "required" }); + assert.deepEqual(chatControlParams(input, false), { tool_choice: "required" }); + assert.deepEqual(chatControlParams({}, false), {}); +}); + +test("anthropicThinking gives each effort a concrete budget, and nothing an unknown one", () => { + assert.deepEqual(anthropicThinking("none"), { type: "disabled" }); + assert.deepEqual(anthropicThinking("minimal"), { type: "enabled", budget_tokens: 1024 }); + assert.deepEqual(anthropicThinking("low"), { type: "enabled", budget_tokens: 4096 }); + assert.deepEqual(anthropicThinking("high"), { type: "enabled", budget_tokens: 16000 }); + assert.deepEqual(anthropicThinking("max"), { type: "enabled", budget_tokens: 32000 }); + assert.equal(anthropicThinking("sometimes"), undefined); + assert.equal(anthropicThinking(undefined), undefined); + assert.equal(anthropicThinking(3), undefined); +}); diff --git a/test/fsutil.test.ts b/test/fsutil.test.ts new file mode 100644 index 0000000..5453578 --- /dev/null +++ b/test/fsutil.test.ts @@ -0,0 +1,57 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { atomicWriteFile } from "../src/fsutil.js"; + +async function tempDir() { + const dir = await mkdtemp(join(tmpdir(), "agentx-fsutil-")); + return { dir, cleanup: () => rm(dir, { recursive: true, force: true }) }; +} + +test("atomicWriteFile creates the parent directories it needs", async () => { + const { dir, cleanup } = await tempDir(); + try { + const file = join(dir, "nested", "deeper", "state.json"); + await atomicWriteFile(file, '{"ok":true}'); + assert.equal(await readFile(file, "utf8"), '{"ok":true}'); + } finally { await cleanup(); } +}); + +test("atomicWriteFile replaces existing contents and leaves no temp files behind", async () => { + const { dir, cleanup } = await tempDir(); + try { + const file = join(dir, "state.json"); + await atomicWriteFile(file, "first"); + await atomicWriteFile(file, "second"); + assert.equal(await readFile(file, "utf8"), "second"); + assert.deepEqual(await readdir(dir), ["state.json"]); + } finally { await cleanup(); } +}); + +test("atomicWriteFile writes state files owner-only, wherever they live", { + // Windows has no POSIX permission bits, so the mode it reports proves nothing there. + skip: process.platform === "win32" && "file modes are not enforced on Windows", +}, async () => { + const { dir, cleanup } = await tempDir(); + try { + // Runtime state can carry a provider API key, so a group- or world-readable + // copy of it is a leak even on a single-user machine. + await writeFile(join(dir, "seed.txt"), ""); + const file = join(dir, "seed.txt"); + await atomicWriteFile(file, "secret"); + const info = await stat(file); + assert.equal(info.mode & 0o077, 0, `state file is group/world readable: ${(info.mode & 0o777).toString(8)}`); + } finally { await cleanup(); } +}); + +test("concurrent atomicWriteFile calls all land without racing each other's temp file", async () => { + const { dir, cleanup } = await tempDir(); + try { + const file = join(dir, "shared.json"); + await Promise.all(Array.from({ length: 8 }, (_, index) => atomicWriteFile(file, String(index)))); + assert.match(await readFile(file, "utf8"), /^[0-7]$/); + assert.deepEqual(await readdir(dir), ["shared.json"]); + } finally { await cleanup(); } +}); diff --git a/test/json.test.ts b/test/json.test.ts new file mode 100644 index 0000000..a35d434 --- /dev/null +++ b/test/json.test.ts @@ -0,0 +1,65 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { asRecords, isRecord, jsonRecord, parse, recCount, recNum, recObj, recObjs, recStr } from "../src/json.js"; + +test("isRecord accepts objects and rejects arrays, null and primitives", () => { + assert.equal(isRecord({}), true); + assert.equal(isRecord({ a: 1 }), true); + assert.equal(isRecord([]), false); + assert.equal(isRecord(null), false); + assert.equal(isRecord("x"), false); + assert.equal(isRecord(7), false); +}); + +test("jsonRecord narrows any parsed payload to an object to read from", () => { + assert.deepEqual(jsonRecord({ a: 1 }), { a: 1 }); + assert.deepEqual(jsonRecord(null), {}); + assert.deepEqual(jsonRecord([1, 2]), {}); + assert.deepEqual(jsonRecord("text"), {}); + assert.deepEqual(jsonRecord(undefined), {}); +}); + +test("asRecords keeps the object members of an array", () => { + assert.deepEqual(asRecords([{ a: 1 }, "skip", null, { b: 2 }]), [{ a: 1 }, { b: 2 }]); + assert.deepEqual(asRecords(undefined), []); + assert.deepEqual(asRecords("not an array"), []); +}); + +test("string accessors read only their own type", () => { + const payload = { name: "bash", count: 3, flag: true, missing: null }; + assert.equal(recStr(payload, "name"), "bash"); + assert.equal(recStr(payload, "count"), undefined); + assert.equal(recStr(payload, "missing"), undefined); + assert.equal(recStr(undefined, "name"), undefined); + assert.equal(recNum(payload, "count"), 3); + assert.equal(recNum(payload, "name"), undefined); + // A counter is not a number: JSON payloads from several providers carry "123". + assert.equal(recNum({ count: "123" }, "count"), undefined); + assert.equal(recCount({ count: "123" }, "count"), 123); +}); + +test("recCount treats absent, null and non-numeric values as no count", () => { + assert.equal(recCount({ a: null }, "a"), undefined); + assert.equal(recCount({ a: "" }, "a"), undefined); + assert.equal(recCount({ a: "abc" }, "a"), undefined); + assert.equal(recCount({ a: NaN }, "a"), undefined); + assert.equal(recCount({}, "a"), undefined); + assert.equal(recCount({ a: 0 }, "a"), 0); +}); + +test("object accessors read nested payloads and drop the wrong shapes", () => { + const payload = { usage: { input_tokens: 5 }, items: [{ id: "a" }, "skip"], list: [1, 2] }; + assert.deepEqual(recObj(payload, "usage"), { input_tokens: 5 }); + assert.equal(recObj(payload, "items"), undefined); + assert.equal(recObj(payload, "missing"), undefined); + assert.deepEqual(recObjs(payload, "items"), [{ id: "a" }]); + assert.deepEqual(recObjs(payload, "list"), []); + assert.deepEqual(recObjs(payload, "usage"), []); +}); + +test("parse is best-effort and never throws", () => { + assert.deepEqual(parse('{"a":1}'), { a: 1 }); + assert.deepEqual(parse({ a: 1 }), { a: 1 }); + assert.deepEqual(parse("not json"), {}); + assert.deepEqual(parse(undefined), {}); +}); diff --git a/test/usage-storage.test.ts b/test/usage-storage.test.ts index 47a2fea..77ef9c2 100644 --- a/test/usage-storage.test.ts +++ b/test/usage-storage.test.ts @@ -4,6 +4,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createUsageStore, defaultUsageStore, periodStart, sqliteAvailable } from "../src/usage/storage.js"; +import { runUsageStats } from "../src/usage/cli.js"; import { TokenUsageCollector, normalizeUsage } from "../src/usage/collector.js"; import type { TokenUsage } from "../src/usage/types.js"; @@ -134,6 +135,33 @@ test("closing a memory store keeps recorded stats readable", async () => { assert.equal((await store.totals("all")).totalTokens, 150); }); +test("runUsageStats renders the rows the adapter server recorded", async () => { + const originalDir = process.env.AGENTX_USAGE_DIR; + const originalBackend = process.env.AGENTX_USAGE_BACKEND; + const dir = await mkdtemp(join(tmpdir(), "agentx-usage-report-")); + try { + process.env.AGENTX_USAGE_DIR = dir; + process.env.AGENTX_USAGE_BACKEND = "json"; + const store = await defaultUsageStore(); + await store.record(normalizeUsage(sample({ provider: "anthropic", model: "claude-sonnet-4", inputTokens: 400, outputTokens: 100, totalTokens: 500, cachedInputTokens: 120 }))); + await store.record(normalizeUsage(sample({ provider: "anthropic", model: "claude-sonnet-4", inputTokens: 100, outputTokens: 20, totalTokens: 120 }))); + await store.close(); + + const all = await runUsageStats("all"); + assert.match(all, /Token Usage \(All time\)/); + assert.match(all, /anthropic/); + assert.match(all, /claude-sonnet-4/); + // 400+100+100+20 recorded tokens, and only the first row carried a cache read. + assert.match(all, /620/); + assert.match(all, /120/); + assert.match(await runUsageStats("today"), /Token Usage/); + } finally { + if (originalDir === undefined) delete process.env.AGENTX_USAGE_DIR; else process.env.AGENTX_USAGE_DIR = originalDir; + if (originalBackend === undefined) delete process.env.AGENTX_USAGE_BACKEND; else process.env.AGENTX_USAGE_BACKEND = originalBackend; + await rm(dir, { recursive: true, force: true }); + } +}); + test("cache write tokens survive normalization, storage, and stats", async () => { const store = await createUsageStore({ backend: "memory" }); await store.record(normalizeUsage(sample({ provider: "anthropic", model: "claude-sonnet-4", cachedInputTokens: 500, cacheWriteTokens: 200 }))); From 18b44c8526529c4eeb628839b274052af0f7b159 Mon Sep 17 00:00:00 2001 From: tzzs Date: Sun, 20 Sep 2026 22:21:59 +0800 Subject: [PATCH 8/9] docs: record the typed JSON layer and retire the shipped original spec The root-level spec was the brief the project was built from, not something a contributor reads today, so it moves to docs/plans/ marked as shipped with the current state pointed at docs/architecture.md. Documents what the last batch actually changed: src/json.ts as the only way to read a wire payload, lint/typecheck/single-test commands, the CI gates, and the completed auto-install removal. Also drops a code comment pointing at a todo item that no longer exists. --- CLAUDE.md | 10 +++++--- README.md | 7 +++++- README.zh-CN.md | 7 +++++- docs/architecture.md | 9 +++++-- .../plans/initial-product-spec.md | 8 +++++- docs/remaining-simplification-todos.md | 25 +++---------------- src/server.ts | 3 +-- 7 files changed, 37 insertions(+), 32 deletions(-) rename agentx.md => docs/plans/initial-product-spec.md (98%) diff --git a/CLAUDE.md b/CLAUDE.md index 9b65702..7ed3f01 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,10 +12,12 @@ npm ci # 按 package-lock.json 安装精确依赖 npm run build # 编译 TypeScript 到 dist/(使用 tsc) npm test # 先构建,再执行 node --test(在 dist/ 下自动发现测试文件;Windows 的 cmd.exe 不做 glob 展开,故用目录自动发现而非 dist/test/*.test.js) +npm run lint # ESLint(flat config,见 eslint.config.js) +npm run typecheck # tsc --noEmit,同时检查 src/ 与 test/ ``` -运行单个测试文件:`npm run build && node --test dist/test/catalog.test.js` -运行完整的本地 CI 检查(安装 + 测试 + 包校验):`make check` +运行单个测试文件:`npm run test:one -- dist/test/catalog.test.js`(或 `make test-one F=test/catalog`) +运行完整的本地 CI 检查(安装 + 测试 + lint + 包校验):`make check` `Makefile` 提供了一些便捷目标,运行 `make` 可查看完整列表。常见的有:`make doctor`、`make test`、`make build`、`make version`。 @@ -23,6 +25,8 @@ npm test # 先构建,再执行 node --test(在 dist/ 下自动发 本包声明了 `"type": "module"` 并使用 `rootDir: "."` 编译,因此**所有 import 都使用 `.js` 后缀,且所有路径以仓库根目录为基准**(例如 `import { loadConfig } from "./config.js"`,测试位于 `test/` 下,导入如 `"../src/catalog.js"`)。 +`src/` 中不使用 `any`:客户端请求、Provider 响应与 SSE 事件这类无法预先验证的 JSON 一律建模为 `src/json.ts` 的 `JsonRecord`,通过 `recStr`/`recNum`/`recCount`/`recObj`/`recObjs`/`parse` 读取。字段名写错或漏掉非对象检查会直接编译失败,而不是变成一次运行时 undefined。tsconfig 开启了 `noUncheckedIndexedAccess` 等严格标志,ESLint 只保留 tsc 看不见的规则(见 `eslint.config.js`)。 + ### 两套协议转换路径 适配器对外暴露三个请求端点,在三种 API 格式之间进行转换。本地面向客户端(client-facing)的端点是: @@ -34,7 +38,7 @@ npm test # 先构建,再执行 node --test(在 dist/ 下自动发 转换函数集中在 `src/convert/`,按**上游协议**(而非转换方向)拆分为四个文件: -- `shared.ts` — 跨方向 helper:图片/effort/thinking/三套 tool-choice 转换、采样参数、JSON 解析等 +- `shared.ts` — 跨方向 helper:图片/effort/thinking/三套 tool-choice 转换、采样参数(JSON 字段的读取统一走 `src/json.ts`) - `chat.ts` — 上游 = Chat Completions 的全部方向:`toChatRequest` / `fromChatResponse`(Anthropic ↔ Chat Completions)、`toChatCompletionsRequest` / `fromChatResponseToResponses`(Responses ↔ Chat Completions) - `responses.ts` — 上游 = Responses:`toResponsesRequest` / `fromResponsesResponse`(Anthropic ↔ Responses);`toResponsesRequestFromChat` / `fromResponsesResponseToChat`(本地 Chat Completions ↔ Responses;分别复用 `toResponsesRequest`/`fromResponsesResponse` 加 `anthropic.ts` 的两个新函数拼出,不重复写转换逻辑) - `anthropic.ts` — 上游 = Anthropic(自定义 Provider 声明 `protocol: "anthropic"` 时专属):`toAnthropicRequest` / `fromAnthropicResponse`(Responses ↔ Anthropic;Codex 只会看到本地 Responses 端点,仍需经此转换才能到达一个原生 Anthropic 上游);`toAnthropicRequestFromChat` / `fromAnthropicResponseToChat`(本地 Chat Completions ↔ Anthropic,供 `/v1/chat/completions` 使用,只映射主流字段,不映射 DeepSeek 的 `thinking`/`reasoning_effort` 扩展) diff --git a/README.md b/README.md index 9f2223f..fda5a27 100644 --- a/README.md +++ b/README.md @@ -484,16 +484,21 @@ git clone https://github.com/tzzs/agentx.git cd agentx npm ci npm test +npm run lint npm run build ``` The project uses TypeScript, native Node.js `fetch`, Node.js ESM, and the built-in `node:test` runner. Tests are compiled into `dist/test` before execution. +`npm run lint` runs ESLint (flat config in `eslint.config.js`), `npm run typecheck` runs `tsc --noEmit` over `src/` and `test/`, and `make check` runs both plus the package check. `src/` is free of `any`: wire payloads are read through the typed accessors in `src/json.ts`. + +To run one test file: `npm run test:one -- dist/test/catalog.test.js` (or `make test-one F=test/catalog`). + The test suite covers request/response conversion, system instructions, streaming events, tool calls, provider routing, chat-completion conversion, token usage adapters, storage, and the usage query API. Tests do not require an API key or network access. ## CI and Publishing -GitHub Actions runs the build, tests, and package dry-run for every push and pull request against `main`. Release Please is configured in `.github/workflows/release-please.yml` and creates a release PR from conventional commits. Publishing is configured in `.github/workflows/publish.yml`: +GitHub Actions runs lint and type-check once per push, and the test suite on Node.js 20/22/24 across Linux, macOS and Windows, for every push and pull request against `main`. Release Please is configured in `.github/workflows/release-please.yml` and creates a release PR from conventional commits. Publishing is configured in `.github/workflows/publish.yml`: 1. Add an `NPM_TOKEN` secret to the `npm` GitHub environment. 2. Push a tag matching `v*.*.*`, or manually run **Publish package**. diff --git a/README.zh-CN.md b/README.zh-CN.md index 8302f17..5d7fda4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -470,16 +470,21 @@ git clone https://github.com/tzzs/agentx.git cd agentx npm ci npm test +npm run lint npm run build ``` 项目使用 TypeScript、Node.js 原生 `fetch`、Node.js ESM 和内置 `node:test` 测试运行器。测试会先编译到 `dist/test`,再执行编译后的测试。 +`npm run lint` 运行 ESLint(flat config 位于 `eslint.config.js`),`npm run typecheck` 对 `src/` 与 `test/` 执行 `tsc --noEmit`,`make check` 则会连同包校验一起跑完这些检查。`src/` 中不使用 `any`:wire payload 统一通过 `src/json.ts` 的带类型访问器读取。 + +只跑单个测试文件:`npm run test:one -- dist/test/catalog.test.js`(或 `make test-one F=test/catalog`)。 + 测试覆盖请求/响应转换、system instructions、流式事件、工具调用、Provider 路由、Chat Completions 转换,以及 Token 用量适配器、存储和用量查询 API。测试不需要 API Key,也不依赖网络。 ## CI 与发布 -GitHub Actions 会在每次 push 和针对 `main` 的 Pull Request 中运行构建、测试和 npm 包内容检查。`.github/workflows/release-please.yml` 会根据 Conventional Commits 创建版本发布 PR。发布配置位于 `.github/workflows/publish.yml`: +GitHub Actions 会在每次 push 和针对 `main` 的 Pull Request 中运行一次 lint 与类型检查,并在 Linux、macOS、Windows 三套系统的 Node.js 20/22/24 矩阵上跑测试。`.github/workflows/release-please.yml` 会根据 Conventional Commits 创建版本发布 PR。发布配置位于 `.github/workflows/publish.yml`: 1. 在 GitHub 的 `npm` environment 中添加 `NPM_TOKEN` Secret。 2. 推送匹配 `v*.*.*` 的版本标签,或手动运行 **Publish package** 工作流。 diff --git a/docs/architecture.md b/docs/architecture.md index d885fc2..8d5b318 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -73,7 +73,9 @@ Claude Code (Anthropic Messages API) └─ 流式: streaming/anthropic-passthrough.ts, streaming/anthropic-to-responses.ts ``` -`src/convert/` 是转换函数所在的模块化目录:`shared.ts`(跨方向 helper:图片/effort/thinking/tool-choice 映射、采样参数、JSON 解析)、`chat.ts`(上游 = Chat Completions 的全部方向)、`responses.ts`(上游 = Responses)、`anthropic.ts`(上游 = Anthropic,自定义 Provider 专属)、`index.ts`(barrel 导出,公共函数名不变)。`src/catalog.ts` 只保留 `providers`/`providerFor`/`honorRequestedModel` 路由函数。 +`src/convert/` 是转换函数所在的模块化目录:`shared.ts`(跨方向 helper:图片/effort/thinking/tool-choice 映射、采样参数)、`chat.ts`(上游 = Chat Completions 的全部方向)、`responses.ts`(上游 = Responses)、`anthropic.ts`(上游 = Anthropic,自定义 Provider 专属)、`index.ts`(barrel 导出,公共函数名不变)。`src/catalog.ts` 只保留 `providers`/`providerFor`/`honorRequestedModel` 路由函数。 + +所有 wire payload(客户端请求、Provider 响应、SSE 事件)经 `src/json.ts` 的 `JsonRecord` + 字段访问器(`recStr`/`recNum`/`recCount`/`recObj`/`recObjs`/`parse`)读取,代码库里不使用 `any`:字段名写错或漏掉非对象检查由编译器拦下,而不是留成一次 undefined 运行时错误。 `src/streaming/` 同样是拆分后的模块化目录(`common.ts` 收敛公共 SSE 写入/heartbeat/usage capture 逻辑,每条协议转换路径各占一个文件),不是单一的 `streaming.ts`。 @@ -119,7 +121,10 @@ Claude Code (Anthropic Messages API) | `src/server.ts` | HTTP Adapter、认证、路由、重试、端口回退 | | `src/catalog.ts` | 模型路由(`providers`/`providerFor`/`honorRequestedModel`),不含转换函数 | | `src/convert/` | 协议转换函数,按上游协议拆分:`shared.ts`(跨方向 helper)、`chat.ts`(上游 = Chat Completions)、`responses.ts`(上游 = Responses)、`anthropic.ts`(上游 = Anthropic,自定义 Provider 专属)、`index.ts`(barrel) | +| `src/json.ts` | wire payload 的类型词汇表:`JsonRecord` 与字段访问器,替代 `any` | | `src/streaming/` | SSE 流式协议转换(按上游协议拆分成多个文件) | +| `src/fsutil.ts` | `atomicWriteFile`:临时文件 + rename,避免崩溃留下截断的状态文件 | +| `src/usage/` | `TokenUsage` 类型、存储后端(sqlite/json/memory)、统计渲染 | | `src/providers/registry.ts` + `types.ts` | Provider 目录、模型注册表、自定义 Provider 注册/移除 | | `src/process.ts` | 子进程启动/环境注入/stdio 转发/清理/`--native` 环境清洗 | | `src/credentials.ts` | 凭据查找(CLI > `AGENTX_` 前缀环境变量 > 旧环境变量 > 交互输入) | @@ -127,4 +132,4 @@ Claude Code (Anthropic Messages API) | `src/quota.ts` | Provider 远程额度查询(`agentx quota`) | | `src/doctor.ts` / `src/ui.ts` | 诊断 / 交互式启动器(含自定义 Provider 的 Add/Remove 流程) | -> 注:相比 agentx.md 中建议的 `src/commands/`、`src/proxy/`、`src/runtime/` 目录结构,实际实现是扁平化的 `src/*.ts`(server.ts 取代了 proxy/anthropic/responses/chat-completions/auth 分层,转换逻辑集中在 `src/convert/`,按上游协议而非方向拆分)。 +> 注:相比 `docs/plans/initial-product-spec.md` 中建议的 `src/commands/`、`src/proxy/`、`src/runtime/` 目录结构,实际实现是扁平化的 `src/*.ts`(server.ts 取代了 proxy/anthropic/responses/chat-completions/auth 分层,转换逻辑集中在 `src/convert/`,按上游协议而非方向拆分)。 diff --git a/agentx.md b/docs/plans/initial-product-spec.md similarity index 98% rename from agentx.md rename to docs/plans/initial-product-spec.md index af26517..1601f9d 100644 --- a/agentx.md +++ b/docs/plans/initial-product-spec.md @@ -1,4 +1,10 @@ -# 项目:agentx +# Plan: AgentX 初始产品规格 + +Status: Shipped — 这是项目从零开始时的规格说明,保留作为设计初衷的历史记录; +与现状的偏差(命令集、目录结构、provider 范围)以 `docs/architecture.md` 为准。 +Date: 2026-08-18 + +## 原始规格 请实现一个生产可用的 npm CLI 工具 **`agentx`**。 diff --git a/docs/remaining-simplification-todos.md b/docs/remaining-simplification-todos.md index a55c212..a40faf1 100644 --- a/docs/remaining-simplification-todos.md +++ b/docs/remaining-simplification-todos.md @@ -1,7 +1,7 @@ # AgentX 剩余精简待办 -> 更新时间:2026-08-30 -> 背景:已完成 runtime/profile 状态合并、Codex catalog 收窄、移除 `--model auto` 隐式路由、模型目录按需刷新、凭据 shell profile 自动写入移除、`doctor` 按 client/`--offline` 范围控制、`streaming.ts` 拆分为模块化目录、cost estimation 删除、`agentx quota` 从 `usage` 拆分、未接入的 Google usage/pricing 及整个 capabilities 抽象删除。本文档记录后续建议继续执行的精简项——已完成的条目直接从列表移除,不在此保留变更记录(改动历史见 git log)。 +> 更新时间:2026-09-20 +> 背景:已完成 runtime/profile 状态合并、Codex catalog 收窄、移除 `--model auto` 隐式路由、模型目录按需刷新、凭据 shell profile 自动写入移除、`doctor` 按 client/`--offline` 范围控制、`streaming.ts` 拆分为模块化目录、cost estimation 删除、`agentx quota` 从 `usage` 拆分、未接入的 Google usage/pricing 及整个 capabilities 抽象删除、客户端自动安装流程移除(改为纯提示)。本文档记录后续建议继续执行的精简项——已完成的条目直接从列表移除,不在此保留变更记录(改动历史见 git log)。 > > 同一批改造里还新增了自定义 Provider(含原生 Anthropic Messages API 协议支持)。这是功能增补,不是本文档要精简的对象;`Anthropic` 的 usage 适配器(`src/providers/usage/anthropic.ts`)因此从死代码变为活代码,不再适用于"未接入协议应删除"的判断。 @@ -9,27 +9,8 @@ | 级别 | 待办 | 主要收益 | | --- | --- | --- | -| G | 删除客户端自动安装流程 | 缩小 launcher 职责 | | K | 同账号多实例请求协调 | 减少并发排队造成的感知延迟 | -## G. 删除客户端自动安装流程 - -找不到 `claude` / `codex` 时,launcher 会交互式询问是否执行全局 npm 安装。这个功能方便,但会让 AgentX 额外承担外部安装器的职责,也增加错误处理路径。 - -### 建议方案 - -退化为清晰的提示: - -```bash -✗ Codex not found. -Install: - npm install -g @openai/codex -Then re-run: - agentx codex -``` - -保留 `doctor` 检测客户端是否可用即可。 - --- ## K. 同账号多实例请求协调 @@ -48,4 +29,4 @@ Then re-run: ## 建议下一批执行顺序 -G 和 H 都是需要用户拍板的方向性选择(是保留便利功能、收窄支持范围,还是移除),不建议在没有明确意见的情况下单方面执行。K 已经结论为"暂不实施",除非诊断层面的轻量方案被明确需要。 +K 是唯一剩余的待办,且已结论为"暂不实施",除非诊断层面的轻量方案被明确需要。 diff --git a/src/server.ts b/src/server.ts index 4d0781b..9dee826 100644 --- a/src/server.ts +++ b/src/server.ts @@ -307,8 +307,7 @@ export async function startAdapter(config: Config, options: AdapterOptions = {}) const pathname = url.pathname; if (pathname === "/health" && request.method === "GET") return json(response, 200, { status: "ok" }); // Usage statistics are read through `agentx usage` (direct storage access); - // the former unauthenticated /usage/* HTTP endpoints were removed — see - // docs/remaining-simplification-todos.md item B. + // the former unauthenticated /usage/* HTTP endpoints were removed. if (pathname === "/v1/models" && request.method === "GET") return json(response, 200, { data: providers.filter((item) => (!config.provider || item.provider === config.provider) && !isPlaceholderModel(item)).map((item) => ({ id: item.model, object: "model", owned_by: item.provider })) }); From 4160069c6eef95b5de7b607a0e961143c6cd1377 Mon Sep 17 00:00:00 2001 From: tzzs Date: Sun, 20 Sep 2026 22:44:42 +0800 Subject: [PATCH 9/9] fix: retry a locked state-file rename on Windows Replacing a destination another handle still holds surfaces a transient EPERM rather than a real failure, so back off and retry instead of dropping the write. A failed rename now also removes its temp file, which can otherwise leave a secret-bearing state payload on disk. --- src/fsutil.ts | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/fsutil.ts b/src/fsutil.ts index ab00365..b3192b1 100644 --- a/src/fsutil.ts +++ b/src/fsutil.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { mkdir, rename, writeFile } from "node:fs/promises"; +import { mkdir, rename, rm, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; /** @@ -12,5 +12,29 @@ export async function atomicWriteFile(file: string, contents: string): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + return await rename(temporary, file); + } catch (error) { + // Unlike POSIX, Windows rename cannot displace an open destination: an + // antivirus scan or a sibling writer holding the file yields a transient + // permission error rather than a real failure, so back off and retry. + if (attempt === 4 || !LOCKED.has((error as NodeJS.ErrnoException).code ?? "")) throw error; + await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 20)); + } + } }