diff --git a/cli/src/claude/sdk/types.ts b/cli/src/claude/sdk/types.ts index d48fab3d..5af54833 100644 --- a/cli/src/claude/sdk/types.ts +++ b/cli/src/claude/sdk/types.ts @@ -66,6 +66,16 @@ export interface SDKResultMessage extends SDKMessage { cache_read_input_tokens?: number cache_creation_input_tokens?: number } + modelUsage?: Record total_cost_usd: number duration_ms: number duration_api_ms: number diff --git a/cli/src/claude/utils/sdkToLogConverter.test.ts b/cli/src/claude/utils/sdkToLogConverter.test.ts index 2110c709..5ea30327 100644 --- a/cli/src/claude/utils/sdkToLogConverter.test.ts +++ b/cli/src/claude/utils/sdkToLogConverter.test.ts @@ -200,6 +200,116 @@ describe('SDKToLogConverter', () => { }) }) + describe('Context window propagation', () => { + function makeAssistantMessage(): SDKAssistantMessage { + return { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'hi' }], + usage: { + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + service_tier: 'standard' + } + } as any + } + } + + it('infers 1M contextWindow from [1m] suffix on system.init', () => { + const initMsg: SDKSystemMessage = { + type: 'system', + subtype: 'init', + session_id: 'session-1', + model: 'claude-opus-4-7[1m]' + } + converter.convert(initMsg) + + const assistantLog = converter.convert(makeAssistantMessage()) as any + expect(assistantLog?.message?.usage?.context_window).toBe(1_000_000) + }) + + it('infers 200k contextWindow when [1m] suffix is absent', () => { + const initMsg: SDKSystemMessage = { + type: 'system', + subtype: 'init', + session_id: 'session-2', + model: 'claude-sonnet-4-6' + } + converter.convert(initMsg) + + const assistantLog = converter.convert(makeAssistantMessage()) as any + expect(assistantLog?.message?.usage?.context_window).toBe(200_000) + }) + + it('refines contextWindow from result.modelUsage and applies to later assistants', () => { + const initMsg: SDKSystemMessage = { + type: 'system', + subtype: 'init', + session_id: 'session-3', + model: 'claude-opus-4-7[1m]' + } + converter.convert(initMsg) + + // First assistant gets the 1M estimate from the [1m] suffix + const first = converter.convert(makeAssistantMessage()) as any + expect(first?.message?.usage?.context_window).toBe(1_000_000) + + // Result message reports authoritative contextWindow (say, 500k) + const resultMsg: SDKResultMessage = { + type: 'result', + subtype: 'success', + num_turns: 1, + total_cost_usd: 0, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: 'session-3', + modelUsage: { + 'claude-opus-4-7[1m]': { contextWindow: 500_000 } + } + } + converter.convert(resultMsg) + + // Subsequent assistant message uses the refined value + const second = converter.convert(makeAssistantMessage()) as any + expect(second?.message?.usage?.context_window).toBe(500_000) + }) + + it('does not overwrite an explicit context_window already set by upstream', () => { + const initMsg: SDKSystemMessage = { + type: 'system', + subtype: 'init', + session_id: 'session-4', + model: 'claude-opus-4-7[1m]' + } + converter.convert(initMsg) + + const assistantMsg: SDKAssistantMessage = { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'hi' }], + usage: { + input_tokens: 10, + output_tokens: 20, + context_window: 42 + } + } as any + } + + const log = converter.convert(assistantMsg) as any + expect(log?.message?.usage?.context_window).toBe(42) + }) + + it('leaves usage untouched when no system.init was seen', () => { + const log = converter.convert(makeAssistantMessage()) as any + expect(log?.message?.usage?.context_window).toBeUndefined() + }) + }) + describe('Parent-child relationships', () => { it('should track parent UUIDs across messages', () => { const msg1: SDKUserMessage = { diff --git a/cli/src/claude/utils/sdkToLogConverter.ts b/cli/src/claude/utils/sdkToLogConverter.ts index 4b551958..7dcbafb6 100644 --- a/cli/src/claude/utils/sdkToLogConverter.ts +++ b/cli/src/claude/utils/sdkToLogConverter.ts @@ -57,6 +57,8 @@ export class SDKToLogConverter { private context: ConversionContext private responses?: Map private sidechainLastUUID = new Map(); + private resolvedModel: string | null = null + private modelContextWindow: number | null = null constructor( context: Omit, @@ -195,6 +197,13 @@ export class SDKToLogConverter { case 'assistant': { const assistantMsg = sdkMessage as SDKAssistantMessage + const message = assistantMsg.message as Record + if (this.modelContextWindow !== null && message && typeof message.usage === 'object' && message.usage !== null) { + const usage = message.usage as Record + if (usage.context_window === undefined) { + usage.context_window = this.modelContextWindow + } + } logMessage = { ...baseFields, type: 'assistant', @@ -220,6 +229,15 @@ export class SDKToLogConverter { this.updateSessionId(systemMsg.session_id) } + // Capture the resolved model name on init (e.g. "claude-opus-4-7[1m]"). + // The `[1m]` suffix is stripped on per-turn assistant messages, so + // remember the full name here to derive an initial contextWindow + // estimate. The result message later refines it with the real value. + if (systemMsg.subtype === 'init' && typeof systemMsg.model === 'string') { + this.resolvedModel = systemMsg.model + this.modelContextWindow = systemMsg.model.endsWith('[1m]') ? 1_000_000 : 200_000 + } + // System messages are typically not sent to logs // but we can convert them if needed logMessage = { @@ -237,7 +255,17 @@ export class SDKToLogConverter { case 'result': { // Result messages are not converted to log messages // They're SDK-specific messages that indicate session completion - // Not part of the actual conversation log + // Not part of the actual conversation log. + // + // But they carry the authoritative per-model contextWindow, + // which we cache and inject into subsequent assistant messages. + const resultMsg = sdkMessage as SDKResultMessage + if (resultMsg.modelUsage && this.resolvedModel) { + const cw = resultMsg.modelUsage[this.resolvedModel]?.contextWindow + if (typeof cw === 'number' && cw > 0) { + this.modelContextWindow = cw + } + } break } diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index acb8c697..b56333c2 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -274,7 +274,8 @@ function normalizeAssistantOutput( output_tokens: outputTokens, cache_creation_input_tokens: asNumber(usage?.cache_creation_input_tokens) ?? undefined, cache_read_input_tokens: asNumber(usage?.cache_read_input_tokens) ?? undefined, - service_tier: asString(usage?.service_tier) ?? undefined + service_tier: asString(usage?.service_tier) ?? undefined, + context_window: asNumber(usage?.context_window) ?? undefined } : undefined } }