diff --git a/src/commands/effort/effort.tsx b/src/commands/effort/effort.tsx index 4967e49432..ebc2373510 100644 --- a/src/commands/effort/effort.tsx +++ b/src/commands/effort/effort.tsx @@ -156,7 +156,7 @@ export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, arg if (COMMON_HELP_ARGS.includes(args)) { onDone( - 'Usage: /effort [low|medium|high|xhigh|max|auto]\n\nEffort levels:\n- low: Quick, straightforward implementation\n- medium: Balanced approach with standard testing\n- high: Comprehensive implementation with extensive testing\n- xhigh: Extended reasoning beyond high, short of max; including ChatGPT Codex models\n- max: Maximum capability with deepest reasoning; maps to xhigh for ChatGPT Codex models\n- auto: Use the default effort level for your model', + 'Usage: /effort [low|medium|high|xhigh|max|auto]\n\nEffort levels:\n- low: Quick, straightforward implementation\n- medium: Balanced approach with standard testing\n- high: Comprehensive implementation with extensive testing\n- xhigh: Extended reasoning beyond high, short of max; including ChatGPT Codex models\n- max: Maximum capability with deepest reasoning\n- auto: Use the default effort level for your model', ); return; } diff --git a/src/services/api/openai/__tests__/queryModelOpenAI.isolated.ts b/src/services/api/openai/__tests__/queryModelOpenAI.isolated.ts index c0e5499764..0aa42a4174 100644 --- a/src/services/api/openai/__tests__/queryModelOpenAI.isolated.ts +++ b/src/services/api/openai/__tests__/queryModelOpenAI.isolated.ts @@ -222,20 +222,20 @@ mock.module('@ant/model-provider', () => ({ anthropicToolChoiceToOpenAI: () => undefined, })) -mock.module('../../../../utils/envUtils.js', () => ({ - isEnvTruthy: (value: string | undefined) => - value === '1' || value === 'true' || value === 'yes' || value === 'on', - isEnvDefinedFalsy: (value: string | undefined) => - value === '0' || value === 'false' || value === 'no' || value === 'off', -})) - mock.module('../../../../services/analytics/growthbook.js', () => ({ getFeatureValue_CACHED_MAY_BE_STALE: (_key: string, fallback: unknown) => fallback, + checkStatsigFeatureGate_CACHED_MAY_BE_STALE: () => false, + getFeatureValue_CACHED_WITH_REFRESH: (_key: string, fallback: unknown) => + fallback, })) -mock.module('src/bootstrap/state.js', () => ({ - isReplBridgeActive: () => false, +// Force Chat Completions path so stream/client mocks apply (not Responses). +// Avoid partial mocks of bootstrap/state and envUtils — incomplete surfaces +// break transitive named imports when this file is run alone. +mock.module('../chatgptAuth.js', () => ({ + isChatGPTAuthEnabled: () => false, + getValidChatGPTAuth: async () => null, })) mock.module('bun:bundle', () => ({ @@ -601,6 +601,8 @@ describe('queryModelOpenAI — max_tokens forwarded to request', () => { expect(_lastCreateArgs).not.toBeNull() expect(_lastCreateArgs!.max_tokens).toBe(8192) + // Process-sticky OpenAI cache routing (not message-derived) + expect(_lastCreateArgs!.prompt_cache_key).toMatch(/^ccb:[0-9a-f-]+$/i) }) }) diff --git a/src/services/api/openai/__tests__/responsesAdapter.test.ts b/src/services/api/openai/__tests__/responsesAdapter.test.ts index 4dc48420d5..5b32633e19 100644 --- a/src/services/api/openai/__tests__/responsesAdapter.test.ts +++ b/src/services/api/openai/__tests__/responsesAdapter.test.ts @@ -1,7 +1,21 @@ import { describe, expect, test } from 'bun:test' -import { buildResponsesRequest } from '../responsesAdapter.js' +import { buildResponsesRequest, extractUsage } from '../responsesAdapter.js' +import { formatOpenAIPromptCacheKey } from '../openaiShared.js' +import { calculateCacheHitRate } from '../../../../utils/cacheWarning.js' describe('buildResponsesRequest', () => { + test('includes max reasoning effort for ChatGPT Responses requests', () => { + const request = buildResponsesRequest({ + model: 'gpt-5.6-sol', + messages: [{ role: 'user', content: 'hello' }], + tools: [], + toolChoice: undefined, + reasoningEffort: 'max', + }) + + expect(request.reasoning).toEqual({ effort: 'max' }) + }) + test('includes reasoning effort for ChatGPT Responses requests', () => { const request = buildResponsesRequest({ model: 'gpt-5.5', @@ -24,4 +38,146 @@ describe('buildResponsesRequest', () => { expect('max_output_tokens' in request).toBe(false) }) + + test('includes stable prompt_cache_key for session-sticky cache routing', () => { + const key = formatOpenAIPromptCacheKey('session-abc-123') + const request = buildResponsesRequest({ + model: 'gpt-5.6-sol', + messages: [{ role: 'user', content: 'hello' }], + tools: [], + toolChoice: undefined, + promptCacheKey: key, + }) + + expect(request.prompt_cache_key).toBe('ccb:session-abc-123') + }) + + test('defaults prompt_cache_key to process-stable fallback when not overridden', () => { + const request = buildResponsesRequest({ + model: 'gpt-5.5', + messages: [{ role: 'user', content: 'hello' }], + tools: [], + toolChoice: undefined, + }) + const again = buildResponsesRequest({ + model: 'gpt-5.5', + messages: [{ role: 'user', content: 'next' }], + tools: [], + toolChoice: undefined, + }) + + expect(request.prompt_cache_key).toMatch(/^ccb:[0-9a-f-]+$/i) + expect(again.prompt_cache_key).toBe(request.prompt_cache_key) + }) + + test('prompt_cache_key is stable across turns (not derived from messages)', () => { + const key = formatOpenAIPromptCacheKey('same-session') + const turn1 = buildResponsesRequest({ + model: 'gpt-5.5', + messages: [{ role: 'user', content: 'first' }], + tools: [], + toolChoice: undefined, + promptCacheKey: key, + }) + const turn2 = buildResponsesRequest({ + model: 'gpt-5.5', + messages: [ + { role: 'user', content: 'first' }, + { role: 'assistant', content: 'ok' }, + { role: 'user', content: 'second' }, + ], + tools: [], + toolChoice: undefined, + promptCacheKey: key, + }) + + expect(turn1.prompt_cache_key).toBe(turn2.prompt_cache_key) + expect(turn1.prompt_cache_key).toBe('ccb:same-session') + }) +}) + +describe('extractUsage (OpenAI Responses → Anthropic usage)', () => { + test('subtracts cached_tokens so hit rate uses OpenAI total as denominator', () => { + const usage = extractUsage({ + usage: { + input_tokens: 30_000, + output_tokens: 100, + input_tokens_details: { cached_tokens: 20_000 }, + }, + }) + + expect(usage).toEqual({ + input_tokens: 10_000, + output_tokens: 100, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 20_000, + }) + + // Was 40% under the double-count bug; correct is 66.7%. + const hitRate = calculateCacheHitRate(usage) + expect(hitRate).toBeCloseTo((20_000 / 30_000) * 100, 5) + }) + + test('full cache hit can report 100% (not capped at 50%)', () => { + const usage = extractUsage({ + usage: { + input_tokens: 30_000, + output_tokens: 50, + input_tokens_details: { cached_tokens: 30_000 }, + }, + }) + + expect(usage.input_tokens).toBe(0) + expect(usage.cache_read_input_tokens).toBe(30_000) + expect(calculateCacheHitRate(usage)).toBe(100) + }) + + test('maps cache_write_tokens to cache_creation without double-counting total', () => { + const usage = extractUsage({ + usage: { + input_tokens: 10_000, + output_tokens: 10, + input_tokens_details: { + cached_tokens: 6_000, + cache_write_tokens: 2_000, + }, + }, + }) + + expect(usage).toEqual({ + input_tokens: 2_000, + output_tokens: 10, + cache_creation_input_tokens: 2_000, + cache_read_input_tokens: 6_000, + }) + // segments sum to OpenAI total + expect( + usage.input_tokens + + usage.cache_creation_input_tokens + + usage.cache_read_input_tokens, + ).toBe(10_000) + expect(calculateCacheHitRate(usage)).toBeCloseTo(60, 5) + }) + + test('clamps overlapping write/read that exceed total input', () => { + const usage = extractUsage({ + usage: { + input_tokens: 5_000, + output_tokens: 0, + input_tokens_details: { + cached_tokens: 4_000, + cache_write_tokens: 4_000, + }, + }, + }) + + expect( + usage.input_tokens + + usage.cache_creation_input_tokens + + usage.cache_read_input_tokens, + ).toBe(5_000) + expect(usage.cache_read_input_tokens).toBe(4_000) + expect(usage.cache_creation_input_tokens).toBe(1_000) + expect(usage.input_tokens).toBe(0) + }) }) diff --git a/src/services/api/openai/__tests__/thinking.test.ts b/src/services/api/openai/__tests__/thinking.test.ts index c147a8dc1a..9ff9d92e38 100644 --- a/src/services/api/openai/__tests__/thinking.test.ts +++ b/src/services/api/openai/__tests__/thinking.test.ts @@ -203,6 +203,8 @@ describe('buildOpenAIRequestBody — thinking params', () => { messages: [{ role: 'user', content: 'hello' }], tools: [] as any[], toolChoice: undefined as any, + // Avoid depending on bootstrap session state in pure request-body tests. + promptCacheKey: 'ccb:test-session', } as any test('includes official DeepSeek API thinking format when enabled', () => { @@ -210,6 +212,15 @@ describe('buildOpenAIRequestBody — thinking params', () => { expect(body.thinking).toEqual({ type: 'enabled' }) }) + test('includes prompt_cache_key for sticky OpenAI cache routing', () => { + const body = buildOpenAIRequestBody({ + ...baseParams, + enableThinking: false, + maxTokens: 1024, + }) + expect(body.prompt_cache_key).toBe('ccb:test-session') + }) + test('includes vLLM/self-hosted thinking format when enabled', () => { const body = buildOpenAIRequestBody({ ...baseParams, enableThinking: true }) expect(body.enable_thinking).toBe(true) diff --git a/src/services/api/openai/index.ts b/src/services/api/openai/index.ts index f165d40260..c201f12992 100644 --- a/src/services/api/openai/index.ts +++ b/src/services/api/openai/index.ts @@ -14,7 +14,7 @@ import type { import type { AgentId } from '../../../types/ids.js' import type { Tools } from '../../../Tool.js' import { getOpenAIClient } from './client.js' -import { updateOpenAIUsage } from './openaiShared.js' +import { getOpenAIPromptCacheKey, updateOpenAIUsage } from './openaiShared.js' import { anthropicMessagesToOpenAI, resolveOpenAIModel, @@ -79,7 +79,8 @@ function convertToResponsesReasoningEffort( if (effortValue === 'low') return 'low' if (effortValue === 'medium') return 'medium' if (effortValue === 'high') return 'high' - if (effortValue === 'xhigh' || effortValue === 'max') return 'xhigh' + if (effortValue === 'xhigh') return 'xhigh' + if (effortValue === 'max') return 'max' if (typeof effortValue === 'number') return 'high' return undefined } @@ -345,8 +346,13 @@ export async function* queryModelOpenAI( options.maxOutputTokensOverride, ) + // Sticky cache routing key: stable for this CCB process so OpenAI can + // co-locate multi-turn requests on the same cache-bearing node. Never hash + // the full message body (that changes every turn and defeats routing). + const promptCacheKey = getOpenAIPromptCacheKey() + logForDebugging( - `[OpenAI] Calling model=${openaiModel}, messages=${openaiMessages.length}, tools=${openaiTools.length}, thinking=${enableThinking}`, + `[OpenAI] Calling model=${openaiModel}, messages=${openaiMessages.length}, tools=${openaiTools.length}, thinking=${enableThinking}, prompt_cache_key=${promptCacheKey}`, ) // 11. Call OpenAI API with streaming. ChatGPT subscription auth uses the @@ -361,6 +367,7 @@ export async function* queryModelOpenAI( tools: openaiTools, toolChoice: openaiToolChoice, reasoningEffort, + promptCacheKey, }), signal, fetchOverride: options.fetchOverride as unknown as typeof fetch, @@ -381,6 +388,7 @@ export async function* queryModelOpenAI( enableThinking, maxTokens, temperatureOverride: options.temperatureOverride, + promptCacheKey, }), { signal }, ), diff --git a/src/services/api/openai/openaiShared.ts b/src/services/api/openai/openaiShared.ts index 6012080a6e..0521c48cdc 100644 --- a/src/services/api/openai/openaiShared.ts +++ b/src/services/api/openai/openaiShared.ts @@ -4,7 +4,47 @@ * Both the OpenAI path (queryModelOpenAI) and Grok path (queryModelGrok) use * the same adapters (openaiStreamAdapter, openaiConvertMessages), so the event * processing logic should be shared rather than duplicated. + * + * Keep this module free of bootstrap/state imports so pure request-body unit + * tests and isolated mocks do not need a full session runtime. + */ +import { randomUUID } from 'crypto' + +/** + * Build a stable OpenAI `prompt_cache_key` for a session. + * + * OpenAI automatic prefix caching benefits from routing sticky keys so multi-turn + * requests land on the same cache-bearing compute node. The key must be stable + * for the whole conversation — never derived from full message bodies (that + * changes every turn and defeats routing). + * + * Format: `ccb:` + */ +export function formatOpenAIPromptCacheKey(sessionId: string): string { + return `ccb:${sessionId}` +} + +/** + * Process-scoped sticky key. OpenAI uses this for cache-node routing, not as a + * content hash — it only needs to be stable across multi-turn requests in the + * same CCB process. Avoids a bootstrap/state import so pure unit tests and + * partial mocks stay isolated. + */ +let processPromptCacheKey: string | null = null + +/** + * Stable OpenAI `prompt_cache_key` for this process. + * Prefer an explicit override (session id) when the caller already has one. */ +export function getOpenAIPromptCacheKey(sessionIdOverride?: string): string { + if (sessionIdOverride) { + return formatOpenAIPromptCacheKey(sessionIdOverride) + } + if (!processPromptCacheKey) { + processPromptCacheKey = formatOpenAIPromptCacheKey(randomUUID()) + } + return processPromptCacheKey +} /** * Merge a delta usage into the accumulated usage, preserving cache-related diff --git a/src/services/api/openai/requestBody.ts b/src/services/api/openai/requestBody.ts index 095332cf57..a31e377667 100644 --- a/src/services/api/openai/requestBody.ts +++ b/src/services/api/openai/requestBody.ts @@ -5,6 +5,7 @@ */ import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions/completions.mjs' import { isEnvTruthy, isEnvDefinedFalsy } from '../../../utils/envUtils.js' +import { getOpenAIPromptCacheKey } from './openaiShared.js' /** * Detect whether thinking mode should be enabled for this model. @@ -75,10 +76,14 @@ export function buildOpenAIRequestBody(params: { enableThinking: boolean maxTokens: number temperatureOverride?: number + /** Override for tests; production uses the current CCB session id. */ + promptCacheKey?: string }): ChatCompletionCreateParamsStreaming & { thinking?: { type: string } enable_thinking?: boolean chat_template_kwargs?: { thinking: boolean; enable_thinking: boolean } + /** OpenAI prompt-cache routing key (not always in SDK types yet). */ + prompt_cache_key?: string } { const { model, @@ -88,6 +93,7 @@ export function buildOpenAIRequestBody(params: { enableThinking, maxTokens, temperatureOverride, + promptCacheKey, } = params return { model, @@ -99,6 +105,9 @@ export function buildOpenAIRequestBody(params: { }), stream: true, stream_options: { include_usage: true }, + // Sticky cache routing for multi-turn OpenAI/compatible endpoints that + // honor prompt_cache_key. Process-stable by default (not message-derived). + prompt_cache_key: promptCacheKey ?? getOpenAIPromptCacheKey(), // Enable chain-of-thought output for DeepSeek and MiMo models. // When active, temperature/top_p/presence_penalty/frequency_penalty are ignored. ...(enableThinking && { diff --git a/src/services/api/openai/responsesAdapter.ts b/src/services/api/openai/responsesAdapter.ts index c074eed5b4..31f9c4c2d8 100644 --- a/src/services/api/openai/responsesAdapter.ts +++ b/src/services/api/openai/responsesAdapter.ts @@ -1,10 +1,16 @@ import { randomUUID } from 'crypto' import type { BetaRawMessageStreamEvent } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' import { getValidChatGPTAuth } from './chatgptAuth.js' +import { getOpenAIPromptCacheKey } from './openaiShared.js' type ResponsesInputItem = Record type ResponsesTool = Record -export type ResponsesReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh' +export type ResponsesReasoningEffort = + | 'low' + | 'medium' + | 'high' + | 'xhigh' + | 'max' type ResponsesRequest = { model: string @@ -16,6 +22,8 @@ type ResponsesRequest = { tool_choice?: unknown reasoning?: { effort: ResponsesReasoningEffort } parallel_tool_calls?: boolean + /** Sticky cache routing key — stable for the CCB session. */ + prompt_cache_key: string } type AnthropicUsage = { @@ -168,6 +176,8 @@ export function buildResponsesRequest(params: { tools: unknown[] toolChoice: unknown reasoningEffort?: ResponsesReasoningEffort + /** Override for tests; production uses the current CCB session id. */ + promptCacheKey?: string }): ResponsesRequest { const { input, instructions } = convertMessagesToResponsesInput( params.messages, @@ -187,6 +197,9 @@ export function buildResponsesRequest(params: { ? { reasoning: { effort: params.reasoningEffort } } : {}), parallel_tool_calls: true, + // Same process/session → same key so OpenAI can sticky-route to a cache node. + // Must not hash the full message list (would change every turn). + prompt_cache_key: params.promptCacheKey ?? getOpenAIPromptCacheKey(), } } @@ -221,23 +234,50 @@ async function* parseSSE( } } -function extractUsage( +/** + * Map OpenAI Responses usage → Anthropic-style mutually exclusive fields. + * + * OpenAI: input_tokens is TOTAL input; cached_tokens ⊆ input_tokens; + * cache_write_tokens (GPT-5.6+) reports tokens written this turn. + * Anthropic: input + cache_creation + cache_read are disjoint and sum to total. + * + * Without subtracting cached from input, cacheWarning hit-rate becomes + * cached/(total+cached) with a hard ceiling of 50%. + */ +export function extractUsage( response: Record | undefined, ): AnthropicUsage { const usage = response?.usage as Record | undefined const inputDetails = usage?.input_tokens_details as | Record | undefined + + const totalInput = + typeof usage?.input_tokens === 'number' ? usage.input_tokens : 0 + const outputTokens = + typeof usage?.output_tokens === 'number' ? usage.output_tokens : 0 + + const cachedRaw = + typeof inputDetails?.cached_tokens === 'number' + ? inputDetails.cached_tokens + : 0 + const writeRaw = + typeof inputDetails?.cache_write_tokens === 'number' + ? inputDetails.cache_write_tokens + : 0 + + // Cap segments so they stay non-negative and do not exceed total input. + // Prefer preserving cache_read for hit-rate accuracy, then cache_creation. + const cacheRead = Math.min(Math.max(0, cachedRaw), Math.max(0, totalInput)) + const remainingAfterRead = Math.max(0, totalInput - cacheRead) + const cacheCreation = Math.min(Math.max(0, writeRaw), remainingAfterRead) + const inputTokens = Math.max(0, remainingAfterRead - cacheCreation) + return { - input_tokens: - typeof usage?.input_tokens === 'number' ? usage.input_tokens : 0, - output_tokens: - typeof usage?.output_tokens === 'number' ? usage.output_tokens : 0, - cache_creation_input_tokens: 0, - cache_read_input_tokens: - typeof inputDetails?.cached_tokens === 'number' - ? inputDetails.cached_tokens - : 0, + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreation, + cache_read_input_tokens: cacheRead, } } diff --git a/src/utils/cacheWarning.ts b/src/utils/cacheWarning.ts index 6558e5440c..1912721c18 100644 --- a/src/utils/cacheWarning.ts +++ b/src/utils/cacheWarning.ts @@ -30,7 +30,7 @@ const cacheWarningStateBySource = new Map() // Evict the oldest entry (by insertion order) when the limit is exceeded. const MAX_SOURCE_ENTRIES = 50 -const DEFAULT_CACHE_THRESHOLD = 80 +const DEFAULT_CACHE_THRESHOLD = 70 /** * 从 settings.json 读取缓存阈值配置 diff --git a/src/utils/context.ts b/src/utils/context.ts index 1c0680bd55..629ab30394 100644 --- a/src/utils/context.ts +++ b/src/utils/context.ts @@ -4,6 +4,10 @@ import { getGlobalConfig } from './config.js' import { isEnvTruthy } from './envUtils.js' import { getCanonicalName } from './model/model.js' import { resolveAntModel } from './model/antModels.js' +import { + CHATGPT_CODEX_MAX_OUTPUT_TOKENS, + getChatGPTModelContextWindow, +} from './model/chatgptModels.js' import { getModelCapability } from './model/modelCapabilities.js' // Model context window size (200k tokens for all models right now) @@ -76,6 +80,20 @@ export function getContextWindowForModel( return 1_000_000 } + // GPT-5.6 family: OAuth/Codex ≈ 272k; API key path ≈ 1.05M (model card). + // Used for UI %, auto-compact thresholds, and local budgeting — not sent + // as a request field (Codex Responses does not take max_input_tokens). + const chatgptContextWindow = getChatGPTModelContextWindow(model) + if (chatgptContextWindow !== undefined) { + if ( + is1mContextDisabled() && + chatgptContextWindow > MODEL_CONTEXT_WINDOW_DEFAULT + ) { + return MODEL_CONTEXT_WINDOW_DEFAULT + } + return chatgptContextWindow + } + const cap = getModelCapability(model) if (cap?.max_input_tokens && cap.max_input_tokens >= 100_000) { if ( @@ -175,7 +193,11 @@ export function getModelMaxOutputTokens(model: string): { const m = getCanonicalName(model) - if (m.includes('opus-4-7')) { + // GPT-5.6 family: official 128k max output (OpenAI model card). + if (getChatGPTModelContextWindow(model) !== undefined) { + defaultTokens = 32_000 + upperLimit = CHATGPT_CODEX_MAX_OUTPUT_TOKENS + } else if (m.includes('opus-4-7')) { defaultTokens = 64_000 upperLimit = 128_000 } else if (m.includes('opus-4-6')) { diff --git a/src/utils/effort.ts b/src/utils/effort.ts index 9d4b641576..df1a756dae 100644 --- a/src/utils/effort.ts +++ b/src/utils/effort.ts @@ -187,19 +187,7 @@ export function resolveAppliedEffort( if (envOverride === null) { return undefined } - const resolved = - envOverride ?? appStateEffortValue ?? getDefaultEffortForModel(model) - // OpenAI Responses uses xhigh as its highest public reasoning effort. - // Keep /effort max usable as a familiar alias in ChatGPT subscription mode. - if ( - resolved === 'max' && - getAPIProvider() === 'openai' && - isChatGPTAuthMode() && - modelSupportsXhighEffort(model) - ) { - return 'xhigh' - } - return resolved + return envOverride ?? appStateEffortValue ?? getDefaultEffortForModel(model) } /** diff --git a/src/utils/model/chatgptModels.ts b/src/utils/model/chatgptModels.ts index 4521b9ebff..89de8ff1cf 100644 --- a/src/utils/model/chatgptModels.ts +++ b/src/utils/model/chatgptModels.ts @@ -4,10 +4,52 @@ export type ChatGPTCodexModelOption = { description: string } -export const CHATGPT_CODEX_DEFAULT_MODEL = 'gpt-5.5' -export const CHATGPT_CODEX_FAST_MODEL = 'gpt-5.4-mini' +/** Default ChatGPT/Codex model (newest frontier). */ +export const CHATGPT_CODEX_DEFAULT_MODEL = 'gpt-5.6-sol' +/** Fast/small default for lighter tasks. */ +export const CHATGPT_CODEX_FAST_MODEL = 'gpt-5.6-luna' +/** + * ChatGPT OAuth / Codex subscription practical context window. + * Codex with ChatGPT login is product-limited to ~272k (not the full API 1.05M). + */ +export const CHATGPT_OAUTH_CONTEXT_WINDOW = 272_000 + +/** + * GPT-5.6 family context window on the OpenAI API model card (API key path). + * Long-context pricing applies above 272k input tokens. + */ +export const CHATGPT_API_CONTEXT_WINDOW = 1_050_000 + +/** @deprecated Use CHATGPT_OAUTH_CONTEXT_WINDOW or CHATGPT_API_CONTEXT_WINDOW. */ +export const CHATGPT_CODEX_CONTEXT_WINDOW = CHATGPT_OAUTH_CONTEXT_WINDOW + +/** Official GPT-5.6 family max output tokens (OpenAI model card). */ +export const CHATGPT_CODEX_MAX_OUTPUT_TOKENS = 128_000 + +/** + * ChatGPT OAuth / Codex model picker options. + * Newest GPT-5.6 family first; previous generation models retained for + * users still on those ids. + */ export const CHATGPT_CODEX_MODEL_OPTIONS: ChatGPTCodexModelOption[] = [ + { + value: 'gpt-5.6-sol', + label: 'gpt-5.6-sol', + description: + 'Frontier model for complex coding, research, and real-world work', + }, + { + value: 'gpt-5.6-terra', + label: 'gpt-5.6-terra', + description: 'Strong model for everyday coding', + }, + { + value: 'gpt-5.6-luna', + label: 'gpt-5.6-luna', + description: + 'Small, fast, and cost-efficient model for simpler coding tasks', + }, { value: 'gpt-5.5', label: 'GPT-5.5', @@ -46,9 +88,42 @@ export function isChatGPTAuthMode(): boolean { return process.env.OPENAI_AUTH_MODE === 'chatgpt' } +function normalizeChatGPTModelId(model: string): string { + return model.toLowerCase().replace(/\[1m\]$/i, '') +} + +/** + * Whether this is a GPT-5.6 family model id (Sol/Terra/Luna or bare `gpt-5.6`). + */ +export function isGpt56FamilyModel(model: string): boolean { + const normalized = normalizeChatGPTModelId(model) + return normalized === 'gpt-5.6' || normalized.startsWith('gpt-5.6-') +} + export function isChatGPTCodexReasoningModel(model: string): boolean { - const normalized = model.toLowerCase().replace(/\[1m\]$/, '') - return CHATGPT_CODEX_MODEL_OPTIONS.some( - option => option.value.toLowerCase() === normalized, + const normalized = normalizeChatGPTModelId(model) + return ( + isGpt56FamilyModel(model) || + CHATGPT_CODEX_MODEL_OPTIONS.some( + option => option.value.toLowerCase() === normalized, + ) ) } + +/** + * Context window for GPT-5.6 models used by CCB for local budgeting + * (status bar %, auto-compact thresholds). Not sent as a request field. + * + * - ChatGPT OAuth / Codex backend: 272k (subscription product limit) + * - API key / OpenAI-compatible using gpt-5.6-*: 1.05M (model card) + */ +export function getChatGPTModelContextWindow( + model: string, +): number | undefined { + if (!isGpt56FamilyModel(model)) { + return undefined + } + return isChatGPTAuthMode() + ? CHATGPT_OAUTH_CONTEXT_WINDOW + : CHATGPT_API_CONTEXT_WINDOW +} diff --git a/src/utils/settings/types.ts b/src/utils/settings/types.ts index 6125911fd1..9e2b365328 100644 --- a/src/utils/settings/types.ts +++ b/src/utils/settings/types.ts @@ -1135,7 +1135,7 @@ export const SettingsSchema = lazySchema(() => .max(100) .optional() .describe( - 'Prompt cache hit rate threshold (0-100). Warnings shown when cache hit rate falls below this percentage. Default: 80.', + 'Prompt cache hit rate threshold (0-100). Warnings shown when cache hit rate falls below this percentage. Default: 70.', ), cacheWarningEnabled: z .boolean()