mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(claude): propagate real contextWindow from SDK result to web (#720)
The "Default" model in NewSession sends no --model flag, so Claude CLI picks its own default (e.g. Opus 4.7 [1m] on Pro accounts). The web status bar then falls back to 200K - 10K headroom = 190K because the Claude SDK path never plumbs the real per-model contextWindow through to the wire-level `modelContextWindow`, unlike the ACP/Codex backends. Fix the gap in three places: - cli/src/claude/sdk/types.ts: declare optional `modelUsage` on SDKResultMessage to surface what Claude CLI already emits (`modelUsage[<model>].contextWindow`). - cli/src/claude/utils/sdkToLogConverter.ts: on system.init, capture the resolved model name (full form with `[1m]` suffix) and derive an initial contextWindow from the suffix. On every assistant message, inject the cached contextWindow into `usage.context_window` when absent. On result, refine the cache with the authoritative value from `modelUsage`. - web/src/chat/normalizeAgent.ts: forward `context_window` through the assistant usage normalization, so the existing reducer path (reducer.ts:175 → StatusBar.tsx:175) can render the real window. Closes #719.
This commit is contained in:
@@ -66,6 +66,16 @@ export interface SDKResultMessage extends SDKMessage {
|
||||
cache_read_input_tokens?: number
|
||||
cache_creation_input_tokens?: number
|
||||
}
|
||||
modelUsage?: Record<string, {
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
cacheReadInputTokens?: number
|
||||
cacheCreationInputTokens?: number
|
||||
webSearchRequests?: number
|
||||
costUSD?: number
|
||||
contextWindow?: number
|
||||
maxOutputTokens?: number
|
||||
}>
|
||||
total_cost_usd: number
|
||||
duration_ms: number
|
||||
duration_api_ms: number
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -57,6 +57,8 @@ export class SDKToLogConverter {
|
||||
private context: ConversionContext
|
||||
private responses?: Map<string, PermissionResponse>
|
||||
private sidechainLastUUID = new Map<string, string>();
|
||||
private resolvedModel: string | null = null
|
||||
private modelContextWindow: number | null = null
|
||||
|
||||
constructor(
|
||||
context: Omit<ConversionContext, 'parentUuid'>,
|
||||
@@ -195,6 +197,13 @@ export class SDKToLogConverter {
|
||||
|
||||
case 'assistant': {
|
||||
const assistantMsg = sdkMessage as SDKAssistantMessage
|
||||
const message = assistantMsg.message as Record<string, unknown>
|
||||
if (this.modelContextWindow !== null && message && typeof message.usage === 'object' && message.usage !== null) {
|
||||
const usage = message.usage as Record<string, unknown>
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user