diff --git a/src/server/__tests__/proxy-streaming.test.ts b/src/server/__tests__/proxy-streaming.test.ts index 6172f4bb..edd198c7 100644 --- a/src/server/__tests__/proxy-streaming.test.ts +++ b/src/server/__tests__/proxy-streaming.test.ts @@ -281,6 +281,12 @@ describe('openaiResponsesStreamToAnthropic', () => { const texts = textDeltas.map((e) => (e.data.delta as Record).text) expect(texts).toContain('Hello') expect(texts).toContain(' world') + + const msgDelta = events.find((e) => e.event === 'message_delta')! + expect(msgDelta.data.usage).toEqual({ + input_tokens: 10, + output_tokens: 5, + }) }) test('function call streaming', async () => { diff --git a/src/server/proxy/streaming/openaiResponsesStreamToAnthropic.ts b/src/server/proxy/streaming/openaiResponsesStreamToAnthropic.ts index 560fe05c..66790a17 100644 --- a/src/server/proxy/streaming/openaiResponsesStreamToAnthropic.ts +++ b/src/server/proxy/streaming/openaiResponsesStreamToAnthropic.ts @@ -265,7 +265,10 @@ function processEvent( controller.enqueue(encoder.encode(formatSse('message_delta', { type: 'message_delta', delta: { stop_reason: stopReason, stop_sequence: null }, - usage: { output_tokens: usage?.output_tokens ?? 0 }, + usage: { + input_tokens: usage?.input_tokens ?? 0, + output_tokens: usage?.output_tokens ?? 0, + }, }))) if (!state.messageStopped) { state.messageStopped = true diff --git a/src/services/openaiAuth/models.test.ts b/src/services/openaiAuth/models.test.ts index 2665dcf1..284bf158 100644 --- a/src/services/openaiAuth/models.test.ts +++ b/src/services/openaiAuth/models.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from 'bun:test' import { OPENAI_DEFAULT_MAIN_MODEL, + OPENAI_GPT5_LARGE_CONTEXT_WINDOW, + OPENAI_GPT5_STANDARD_CONTEXT_WINDOW, + getOpenAIContextWindowForModel, isOpenAIResponsesModel, resolveOpenAICodexModel, } from './models.js' @@ -18,4 +21,22 @@ describe('openai auth model resolution', () => { test('maps opus aliases to the OpenAI default model', () => { expect(resolveOpenAICodexModel('opus')).toBe(OPENAI_DEFAULT_MAIN_MODEL) }) + + test('maps large-context GPT-5 models to a 1.05M context window', () => { + expect(getOpenAIContextWindowForModel('gpt-5.5')).toBe( + OPENAI_GPT5_LARGE_CONTEXT_WINDOW, + ) + expect(getOpenAIContextWindowForModel('gpt-5.4')).toBe( + OPENAI_GPT5_LARGE_CONTEXT_WINDOW, + ) + }) + + test('maps standard GPT-5 models to a 400k context window', () => { + expect(getOpenAIContextWindowForModel('gpt-5.3-codex')).toBe( + OPENAI_GPT5_STANDARD_CONTEXT_WINDOW, + ) + expect(getOpenAIContextWindowForModel('gpt-5.4-mini')).toBe( + OPENAI_GPT5_STANDARD_CONTEXT_WINDOW, + ) + }) }) diff --git a/src/services/openaiAuth/models.ts b/src/services/openaiAuth/models.ts index 8eb0b4cb..b5ed2505 100644 --- a/src/services/openaiAuth/models.ts +++ b/src/services/openaiAuth/models.ts @@ -1,6 +1,8 @@ export const OPENAI_DEFAULT_MAIN_MODEL = 'gpt-5.3-codex' export const OPENAI_DEFAULT_SONNET_MODEL = 'gpt-5.4' export const OPENAI_DEFAULT_HAIKU_MODEL = 'gpt-5.4-mini' +export const OPENAI_GPT5_STANDARD_CONTEXT_WINDOW = 400_000 +export const OPENAI_GPT5_LARGE_CONTEXT_WINDOW = 1_050_000 export type OpenAIModelCatalogEntry = { value: string @@ -98,3 +100,38 @@ export function getOpenAIModelDisplayName(model: string): string | null { return null } } + +export function getOpenAIContextWindowForModel(model: string): number | null { + const normalized = model.trim().toLowerCase() + + // Current OpenAI model docs split GPT-5-family context windows into + // standard 400k models and larger 1.05M models. + if ( + normalized === 'gpt-5.5' || + normalized === 'gpt-5.5-pro' || + normalized === 'gpt-5.4' || + normalized === 'gpt-5.4-pro' + ) { + return OPENAI_GPT5_LARGE_CONTEXT_WINDOW + } + + if ( + normalized === 'gpt-5.4-mini' || + normalized === 'gpt-5.4-nano' || + normalized === 'gpt-5.3-codex' || + normalized === 'gpt-5.2' || + normalized === 'gpt-5.2-codex' || + normalized === 'gpt-5.1' || + normalized === 'gpt-5.1-codex' || + normalized === 'gpt-5.1-codex-max' || + normalized === 'gpt-5.1-codex-mini' || + normalized === 'gpt-5-codex' || + normalized === 'gpt-5' || + normalized === 'gpt-5-mini' || + normalized === 'gpt-5-nano' + ) { + return OPENAI_GPT5_STANDARD_CONTEXT_WINDOW + } + + return null +} diff --git a/src/utils/__tests__/tokens.test.ts b/src/utils/__tests__/tokens.test.ts new file mode 100644 index 00000000..990a11b0 --- /dev/null +++ b/src/utils/__tests__/tokens.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from 'bun:test' +import { getCurrentUsage } from '../tokens.js' + +describe('getCurrentUsage', () => { + test('skips zero placeholder usage and returns the latest meaningful usage', () => { + const messages = [ + { + type: 'assistant', + message: { + model: 'gpt-5.5', + content: [{ type: 'text', text: 'older' }], + usage: { + input_tokens: 123, + output_tokens: 45, + cache_creation_input_tokens: 6, + cache_read_input_tokens: 7, + }, + }, + }, + { + type: 'assistant', + message: { + model: 'gpt-5.5', + content: [{ type: 'text', text: 'placeholder' }], + usage: { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + }, + ] as const + + expect(getCurrentUsage(messages as never)).toEqual({ + input_tokens: 123, + output_tokens: 45, + cache_creation_input_tokens: 6, + cache_read_input_tokens: 7, + }) + }) +}) diff --git a/src/utils/context.ts b/src/utils/context.ts index d9714de9..469e3f65 100644 --- a/src/utils/context.ts +++ b/src/utils/context.ts @@ -1,11 +1,12 @@ // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered import { CONTEXT_1M_BETA_HEADER } from '../constants/betas.js' +import { getOpenAIContextWindowForModel } from '../services/openaiAuth/models.js' import { getGlobalConfig } from './config.js' import { isEnvTruthy } from './envUtils.js' import { getCanonicalName } from './model/model.js' import { getModelCapability } from './model/modelCapabilities.js' -// Model context window size (200k tokens for all models right now) +// Default fallback when the model-specific capability is unknown. export const MODEL_CONTEXT_WINDOW_DEFAULT = 200_000 // Maximum output tokens for compact operations @@ -71,6 +72,11 @@ export function getContextWindowForModel( return 1_000_000 } + const openAIContextWindow = getOpenAIContextWindowForModel(model) + if (openAIContextWindow) { + return openAIContextWindow + } + const cap = getModelCapability(model) if (cap?.max_input_tokens && cap.max_input_tokens >= 100_000) { if ( diff --git a/src/utils/tokens.ts b/src/utils/tokens.ts index c56da552..3c3034bb 100644 --- a/src/utils/tokens.ts +++ b/src/utils/tokens.ts @@ -145,12 +145,30 @@ export function getCurrentUsage(messages: Message[]): { const message = messages[i] const usage = message ? getTokenUsage(message) : undefined if (usage) { - return { + const normalizedUsage = { input_tokens: usage.input_tokens, output_tokens: usage.output_tokens, cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0, cache_read_input_tokens: usage.cache_read_input_tokens ?? 0, } + + // Zero-token usage is used in a few recovery/compaction paths as a + // placeholder for "stale or unavailable", not as a real token count. + if ( + normalizedUsage.input_tokens === 0 && + normalizedUsage.output_tokens === 0 && + normalizedUsage.cache_creation_input_tokens === 0 && + normalizedUsage.cache_read_input_tokens === 0 + ) { + continue + } + + return { + input_tokens: normalizedUsage.input_tokens, + output_tokens: normalizedUsage.output_tokens, + cache_creation_input_tokens: normalizedUsage.cache_creation_input_tokens, + cache_read_input_tokens: normalizedUsage.cache_read_input_tokens, + } } } return null