From 1603b28071c965870a40e927387c1989a76624ed Mon Sep 17 00:00:00 2001 From: simonseo Date: Mon, 30 Mar 2026 17:06:01 -0700 Subject: [PATCH 1/2] fix: emit compatible finish metadata for Claude streams --- src/claude-code-language-model.ts | 144 +++++++++++++++++++++++------- 1 file changed, 113 insertions(+), 31 deletions(-) diff --git a/src/claude-code-language-model.ts b/src/claude-code-language-model.ts index cc65276..ff68335 100644 --- a/src/claude-code-language-model.ts +++ b/src/claude-code-language-model.ts @@ -22,6 +22,85 @@ import { } from "./session-manager.js" import { log } from "./logger.js" +function buildUsage( + usage?: ClaudeStreamMessage["usage"], +): LanguageModelV2Usage { + const hasInputTokens = + typeof usage?.input_tokens === "number" || + typeof usage?.cache_read_input_tokens === "number" || + typeof usage?.cache_creation_input_tokens === "number" + const inputTokens = hasInputTokens + ? (usage?.input_tokens ?? 0) + + (usage?.cache_read_input_tokens ?? 0) + + (usage?.cache_creation_input_tokens ?? 0) + : undefined + const outputTokens = usage?.output_tokens + + return { + inputTokens, + outputTokens, + totalTokens: + typeof inputTokens === "number" && typeof outputTokens === "number" + ? inputTokens + outputTokens + : undefined, + cachedInputTokens: usage?.cache_read_input_tokens, + } +} + +function buildProviderMetadata(meta: { + sessionId?: string + costUsd?: number + durationMs?: number + cacheCreationInputTokens?: number +}): Record> | undefined { + const data: Record = {} + const anthropic: Record = {} + + if (typeof meta.sessionId === "string") data.sessionId = meta.sessionId + if (typeof meta.costUsd === "number") data.costUsd = meta.costUsd + if (typeof meta.durationMs === "number") data.durationMs = meta.durationMs + if (typeof meta.cacheCreationInputTokens === "number") { + anthropic.cacheCreationInputTokens = meta.cacheCreationInputTokens + } + + if (Object.keys(data).length === 0 && Object.keys(anthropic).length === 0) { + return undefined + } + + return { + ...(Object.keys(data).length > 0 ? { "claude-code": data } : {}), + ...(Object.keys(anthropic).length > 0 ? { anthropic } : {}), + } +} + +function buildStreamUsage( + usage: LanguageModelV2Usage, + cacheWriteInputTokens?: number, +) { + const cacheReadInputTokens = usage.cachedInputTokens + const noCacheInputTokens = + typeof usage.inputTokens === "number" + ? usage.inputTokens - + (cacheReadInputTokens ?? 0) - + (cacheWriteInputTokens ?? 0) + : undefined + + return { + inputTokens: { + total: usage.inputTokens, + noCache: noCacheInputTokens, + cacheRead: cacheReadInputTokens, + cacheWrite: cacheWriteInputTokens, + }, + outputTokens: { + total: usage.outputTokens, + text: undefined, + reasoning: usage.reasoningTokens, + }, + totalTokens: usage.totalTokens, + } +} + export class ClaudeCodeLanguageModel implements LanguageModelV2 { readonly specificationVersion = "v2" readonly modelId: string @@ -179,7 +258,6 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { const userMsg = getClaudeUserMessage(options.prompt, includeHistoryContext) - // doGenerate always spawns a fresh process, never reuse session ID const cliArgs = buildCliArgs({ sessionKey: sk, skipPermissions: this.config.skipPermissions !== false, @@ -453,19 +531,23 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { controller.enqueue({ type: "text-end", id: textId }) controller.enqueue({ type: "finish", - finishReason: "stop", - usage: { + finishReason: { + unified: "stop", + raw: undefined, + } as any, + rawFinishReason: undefined as any, + usage: buildStreamUsage({ inputTokens: 0, outputTokens: 0, totalTokens: 0, - }, + }) as any, providerMetadata: { "claude-code": { synthetic: true, path: "no-tools", }, }, - }) + } as any) controller.close() }, }) @@ -548,6 +630,7 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { costUsd?: number durationMs?: number usage?: ClaudeStreamMessage["usage"] + cacheCreationInputTokens?: number } = {} const lineHandler = (line: string) => { @@ -969,6 +1052,7 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { costUsd: msg.total_cost_usd, durationMs: msg.duration_ms, usage: msg.usage, + cacheCreationInputTokens: msg.usage?.cache_creation_input_tokens, } log.info("conversation result", { @@ -993,24 +1077,21 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { } } + const usage = buildUsage(msg.usage) + controller.enqueue({ type: "finish", - finishReason: - toolCallMap.size > 0 ? "tool-calls" : "stop", - usage: { - inputTokens: msg.usage?.input_tokens, - outputTokens: msg.usage?.output_tokens, - totalTokens: - msg.usage?.input_tokens && - msg.usage?.output_tokens - ? msg.usage.input_tokens + - msg.usage.output_tokens - : undefined, - }, - providerMetadata: { - "claude-code": resultMeta, - }, - }) + finishReason: { + unified: toolCallMap.size > 0 ? "tool-calls" : "stop", + raw: undefined, + } as any, + rawFinishReason: undefined as any, + usage: buildStreamUsage( + usage, + msg.usage?.cache_creation_input_tokens, + ) as any, + providerMetadata: buildProviderMetadata(resultMeta), + } as any) controllerClosed = true lineEmitter.off("line", lineHandler) @@ -1039,16 +1120,17 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { } controller.enqueue({ type: "finish", - finishReason: "stop", - usage: { - inputTokens: undefined, - outputTokens: undefined, - totalTokens: undefined, - }, - providerMetadata: { - "claude-code": resultMeta, - }, - }) + finishReason: { + unified: "stop", + raw: undefined, + } as any, + rawFinishReason: undefined as any, + usage: buildStreamUsage( + buildUsage(resultMeta.usage), + resultMeta.cacheCreationInputTokens, + ) as any, + providerMetadata: buildProviderMetadata(resultMeta), + } as any) try { controller.close() } catch {} From 3bc8ed369e5bec19a18e85d0c733d05b0028e2f2 Mon Sep 17 00:00:00 2001 From: simonseo Date: Mon, 30 Mar 2026 17:19:15 -0700 Subject: [PATCH 2/2] add claude effort passthrough and effort-scoped sessions --- src/claude-code-language-model.ts | 46 +++++++++++++++++++++++++++++-- src/session-manager.ts | 15 ++++++---- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/src/claude-code-language-model.ts b/src/claude-code-language-model.ts index ff68335..01b3fe5 100644 --- a/src/claude-code-language-model.ts +++ b/src/claude-code-language-model.ts @@ -22,6 +22,8 @@ import { } from "./session-manager.js" import { log } from "./logger.js" +type ClaudeCodeEffort = "low" | "medium" | "high" | "max" + function buildUsage( usage?: ClaudeStreamMessage["usage"], ): LanguageModelV2Usage { @@ -101,6 +103,40 @@ function buildStreamUsage( } } +function getClaudeCodeEffort( + provider: string, + providerOptions: Record | undefined, +): ClaudeCodeEffort | undefined { + const scoped = providerOptions?.[provider] + if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { + return undefined + } + + const raw = + scoped.effort ?? + scoped.reasoningEffort ?? + scoped.thinkingLevel + + if (typeof raw !== "string") { + return undefined + } + + const normalized = raw.toLowerCase() + if (normalized === "low" || normalized === "medium" || normalized === "high" || normalized === "max") { + return normalized + } + + if (normalized === "xhigh") { + return "max" + } + + log.warn("ignoring unsupported claude effort", { + provider, + effort: raw, + }) + return undefined +} + export class ClaudeCodeLanguageModel implements LanguageModelV2 { readonly specificationVersion = "v2" readonly modelId: string @@ -215,7 +251,8 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { const warnings: LanguageModelV2CallWarning[] = [] const cwd = this.config.cwd ?? process.cwd() const scope = this.requestScope(options as any) - const sk = sessionKey(cwd, `${this.modelId}::${scope}`) + const effort = getClaudeCodeEffort(this.provider, options.providerOptions as any) + const sk = sessionKey(cwd, `${this.modelId}::${scope}`, effort) if (scope === "no-tools") { const text = this.synthesizeTitle(options.prompt) @@ -263,11 +300,13 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { skipPermissions: this.config.skipPermissions !== false, includeSessionId: false, model: this.modelId, + effort, }) log.info("doGenerate starting", { cwd, model: this.modelId, + effort, textLength: userMsg.length, includeHistoryContext, }) @@ -514,7 +553,8 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { const cliPath = this.config.cliPath const skipPermissions = this.config.skipPermissions !== false const scope = this.requestScope(options as any) - const sk = sessionKey(cwd, `${this.modelId}::${scope}`) + const effort = getClaudeCodeEffort(this.provider, options.providerOptions as any) + const sk = sessionKey(cwd, `${this.modelId}::${scope}`, effort) if (scope === "no-tools") { const text = this.synthesizeTitle(options.prompt) @@ -578,6 +618,7 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { log.info("doStream starting", { cwd, model: this.modelId, + effort, textLength: userMsg.length, includeHistoryContext, hasActiveProcess, @@ -587,6 +628,7 @@ export class ClaudeCodeLanguageModel implements LanguageModelV2 { sessionKey: sk, skipPermissions, model: this.modelId, + effort, }) const stream = new ReadableStream({ diff --git a/src/session-manager.ts b/src/session-manager.ts index cbf0be0..5edba96 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -107,8 +107,9 @@ export function buildCliArgs(opts: { skipPermissions: boolean includeSessionId?: boolean model?: string + effort?: string }): string[] { - const { sessionKey, skipPermissions, includeSessionId = true, model } = opts + const { sessionKey, skipPermissions, includeSessionId = true, model, effort } = opts const args = [ "--output-format", "stream-json", @@ -121,6 +122,10 @@ export function buildCliArgs(opts: { args.push("--model", model) } + if (effort) { + args.push("--effort", effort) + } + if (includeSessionId) { const sessionId = claudeSessions.get(sessionKey) if (sessionId && !activeProcesses.has(sessionKey)) { @@ -136,9 +141,9 @@ export function buildCliArgs(opts: { } /** - * Build a session key that includes both cwd and model, - * so different models get separate processes. + * Build a session key that includes cwd, model, and runtime options + * that affect Claude session behavior. */ -export function sessionKey(cwd: string, modelId: string): string { - return `${cwd}::${modelId}` +export function sessionKey(cwd: string, modelId: string, effort?: string): string { + return `${cwd}::${modelId}::${effort ?? "default"}` }