Skip to content

Commit 09201ee

Browse files
Merge pull request #1296 from DavidShawa/feat/openai-prompt-cache-and-chatgpt-models
feat(openai): sticky prompt cache, usage mapping, and GPT-5.6 models
2 parents 1338b50 + 7652e6f commit 09201ee

13 files changed

Lines changed: 397 additions & 46 deletions

File tree

src/commands/effort/effort.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, arg
156156

157157
if (COMMON_HELP_ARGS.includes(args)) {
158158
onDone(
159-
'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',
159+
'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',
160160
);
161161
return;
162162
}

src/services/api/openai/__tests__/queryModelOpenAI.isolated.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -222,20 +222,20 @@ mock.module('@ant/model-provider', () => ({
222222
anthropicToolChoiceToOpenAI: () => undefined,
223223
}))
224224

225-
mock.module('../../../../utils/envUtils.js', () => ({
226-
isEnvTruthy: (value: string | undefined) =>
227-
value === '1' || value === 'true' || value === 'yes' || value === 'on',
228-
isEnvDefinedFalsy: (value: string | undefined) =>
229-
value === '0' || value === 'false' || value === 'no' || value === 'off',
230-
}))
231-
232225
mock.module('../../../../services/analytics/growthbook.js', () => ({
233226
getFeatureValue_CACHED_MAY_BE_STALE: (_key: string, fallback: unknown) =>
234227
fallback,
228+
checkStatsigFeatureGate_CACHED_MAY_BE_STALE: () => false,
229+
getFeatureValue_CACHED_WITH_REFRESH: (_key: string, fallback: unknown) =>
230+
fallback,
235231
}))
236232

237-
mock.module('src/bootstrap/state.js', () => ({
238-
isReplBridgeActive: () => false,
233+
// Force Chat Completions path so stream/client mocks apply (not Responses).
234+
// Avoid partial mocks of bootstrap/state and envUtils — incomplete surfaces
235+
// break transitive named imports when this file is run alone.
236+
mock.module('../chatgptAuth.js', () => ({
237+
isChatGPTAuthEnabled: () => false,
238+
getValidChatGPTAuth: async () => null,
239239
}))
240240

241241
mock.module('bun:bundle', () => ({
@@ -601,6 +601,8 @@ describe('queryModelOpenAI — max_tokens forwarded to request', () => {
601601

602602
expect(_lastCreateArgs).not.toBeNull()
603603
expect(_lastCreateArgs!.max_tokens).toBe(8192)
604+
// Process-sticky OpenAI cache routing (not message-derived)
605+
expect(_lastCreateArgs!.prompt_cache_key).toMatch(/^ccb:[0-9a-f-]+$/i)
604606
})
605607
})
606608

src/services/api/openai/__tests__/responsesAdapter.test.ts

Lines changed: 157 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,21 @@
11
import { describe, expect, test } from 'bun:test'
2-
import { buildResponsesRequest } from '../responsesAdapter.js'
2+
import { buildResponsesRequest, extractUsage } from '../responsesAdapter.js'
3+
import { formatOpenAIPromptCacheKey } from '../openaiShared.js'
4+
import { calculateCacheHitRate } from '../../../../utils/cacheWarning.js'
35

46
describe('buildResponsesRequest', () => {
7+
test('includes max reasoning effort for ChatGPT Responses requests', () => {
8+
const request = buildResponsesRequest({
9+
model: 'gpt-5.6-sol',
10+
messages: [{ role: 'user', content: 'hello' }],
11+
tools: [],
12+
toolChoice: undefined,
13+
reasoningEffort: 'max',
14+
})
15+
16+
expect(request.reasoning).toEqual({ effort: 'max' })
17+
})
18+
519
test('includes reasoning effort for ChatGPT Responses requests', () => {
620
const request = buildResponsesRequest({
721
model: 'gpt-5.5',
@@ -24,4 +38,146 @@ describe('buildResponsesRequest', () => {
2438

2539
expect('max_output_tokens' in request).toBe(false)
2640
})
41+
42+
test('includes stable prompt_cache_key for session-sticky cache routing', () => {
43+
const key = formatOpenAIPromptCacheKey('session-abc-123')
44+
const request = buildResponsesRequest({
45+
model: 'gpt-5.6-sol',
46+
messages: [{ role: 'user', content: 'hello' }],
47+
tools: [],
48+
toolChoice: undefined,
49+
promptCacheKey: key,
50+
})
51+
52+
expect(request.prompt_cache_key).toBe('ccb:session-abc-123')
53+
})
54+
55+
test('defaults prompt_cache_key to process-stable fallback when not overridden', () => {
56+
const request = buildResponsesRequest({
57+
model: 'gpt-5.5',
58+
messages: [{ role: 'user', content: 'hello' }],
59+
tools: [],
60+
toolChoice: undefined,
61+
})
62+
const again = buildResponsesRequest({
63+
model: 'gpt-5.5',
64+
messages: [{ role: 'user', content: 'next' }],
65+
tools: [],
66+
toolChoice: undefined,
67+
})
68+
69+
expect(request.prompt_cache_key).toMatch(/^ccb:[0-9a-f-]+$/i)
70+
expect(again.prompt_cache_key).toBe(request.prompt_cache_key)
71+
})
72+
73+
test('prompt_cache_key is stable across turns (not derived from messages)', () => {
74+
const key = formatOpenAIPromptCacheKey('same-session')
75+
const turn1 = buildResponsesRequest({
76+
model: 'gpt-5.5',
77+
messages: [{ role: 'user', content: 'first' }],
78+
tools: [],
79+
toolChoice: undefined,
80+
promptCacheKey: key,
81+
})
82+
const turn2 = buildResponsesRequest({
83+
model: 'gpt-5.5',
84+
messages: [
85+
{ role: 'user', content: 'first' },
86+
{ role: 'assistant', content: 'ok' },
87+
{ role: 'user', content: 'second' },
88+
],
89+
tools: [],
90+
toolChoice: undefined,
91+
promptCacheKey: key,
92+
})
93+
94+
expect(turn1.prompt_cache_key).toBe(turn2.prompt_cache_key)
95+
expect(turn1.prompt_cache_key).toBe('ccb:same-session')
96+
})
97+
})
98+
99+
describe('extractUsage (OpenAI Responses → Anthropic usage)', () => {
100+
test('subtracts cached_tokens so hit rate uses OpenAI total as denominator', () => {
101+
const usage = extractUsage({
102+
usage: {
103+
input_tokens: 30_000,
104+
output_tokens: 100,
105+
input_tokens_details: { cached_tokens: 20_000 },
106+
},
107+
})
108+
109+
expect(usage).toEqual({
110+
input_tokens: 10_000,
111+
output_tokens: 100,
112+
cache_creation_input_tokens: 0,
113+
cache_read_input_tokens: 20_000,
114+
})
115+
116+
// Was 40% under the double-count bug; correct is 66.7%.
117+
const hitRate = calculateCacheHitRate(usage)
118+
expect(hitRate).toBeCloseTo((20_000 / 30_000) * 100, 5)
119+
})
120+
121+
test('full cache hit can report 100% (not capped at 50%)', () => {
122+
const usage = extractUsage({
123+
usage: {
124+
input_tokens: 30_000,
125+
output_tokens: 50,
126+
input_tokens_details: { cached_tokens: 30_000 },
127+
},
128+
})
129+
130+
expect(usage.input_tokens).toBe(0)
131+
expect(usage.cache_read_input_tokens).toBe(30_000)
132+
expect(calculateCacheHitRate(usage)).toBe(100)
133+
})
134+
135+
test('maps cache_write_tokens to cache_creation without double-counting total', () => {
136+
const usage = extractUsage({
137+
usage: {
138+
input_tokens: 10_000,
139+
output_tokens: 10,
140+
input_tokens_details: {
141+
cached_tokens: 6_000,
142+
cache_write_tokens: 2_000,
143+
},
144+
},
145+
})
146+
147+
expect(usage).toEqual({
148+
input_tokens: 2_000,
149+
output_tokens: 10,
150+
cache_creation_input_tokens: 2_000,
151+
cache_read_input_tokens: 6_000,
152+
})
153+
// segments sum to OpenAI total
154+
expect(
155+
usage.input_tokens +
156+
usage.cache_creation_input_tokens +
157+
usage.cache_read_input_tokens,
158+
).toBe(10_000)
159+
expect(calculateCacheHitRate(usage)).toBeCloseTo(60, 5)
160+
})
161+
162+
test('clamps overlapping write/read that exceed total input', () => {
163+
const usage = extractUsage({
164+
usage: {
165+
input_tokens: 5_000,
166+
output_tokens: 0,
167+
input_tokens_details: {
168+
cached_tokens: 4_000,
169+
cache_write_tokens: 4_000,
170+
},
171+
},
172+
})
173+
174+
expect(
175+
usage.input_tokens +
176+
usage.cache_creation_input_tokens +
177+
usage.cache_read_input_tokens,
178+
).toBe(5_000)
179+
expect(usage.cache_read_input_tokens).toBe(4_000)
180+
expect(usage.cache_creation_input_tokens).toBe(1_000)
181+
expect(usage.input_tokens).toBe(0)
182+
})
27183
})

src/services/api/openai/__tests__/thinking.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,13 +203,24 @@ describe('buildOpenAIRequestBody — thinking params', () => {
203203
messages: [{ role: 'user', content: 'hello' }],
204204
tools: [] as any[],
205205
toolChoice: undefined as any,
206+
// Avoid depending on bootstrap session state in pure request-body tests.
207+
promptCacheKey: 'ccb:test-session',
206208
} as any
207209

208210
test('includes official DeepSeek API thinking format when enabled', () => {
209211
const body = buildOpenAIRequestBody({ ...baseParams, enableThinking: true })
210212
expect(body.thinking).toEqual({ type: 'enabled' })
211213
})
212214

215+
test('includes prompt_cache_key for sticky OpenAI cache routing', () => {
216+
const body = buildOpenAIRequestBody({
217+
...baseParams,
218+
enableThinking: false,
219+
maxTokens: 1024,
220+
})
221+
expect(body.prompt_cache_key).toBe('ccb:test-session')
222+
})
223+
213224
test('includes vLLM/self-hosted thinking format when enabled', () => {
214225
const body = buildOpenAIRequestBody({ ...baseParams, enableThinking: true })
215226
expect(body.enable_thinking).toBe(true)

src/services/api/openai/index.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type {
1414
import type { AgentId } from '../../../types/ids.js'
1515
import type { Tools } from '../../../Tool.js'
1616
import { getOpenAIClient } from './client.js'
17-
import { updateOpenAIUsage } from './openaiShared.js'
17+
import { getOpenAIPromptCacheKey, updateOpenAIUsage } from './openaiShared.js'
1818
import {
1919
anthropicMessagesToOpenAI,
2020
resolveOpenAIModel,
@@ -79,7 +79,8 @@ function convertToResponsesReasoningEffort(
7979
if (effortValue === 'low') return 'low'
8080
if (effortValue === 'medium') return 'medium'
8181
if (effortValue === 'high') return 'high'
82-
if (effortValue === 'xhigh' || effortValue === 'max') return 'xhigh'
82+
if (effortValue === 'xhigh') return 'xhigh'
83+
if (effortValue === 'max') return 'max'
8384
if (typeof effortValue === 'number') return 'high'
8485
return undefined
8586
}
@@ -345,8 +346,13 @@ export async function* queryModelOpenAI(
345346
options.maxOutputTokensOverride,
346347
)
347348

349+
// Sticky cache routing key: stable for this CCB process so OpenAI can
350+
// co-locate multi-turn requests on the same cache-bearing node. Never hash
351+
// the full message body (that changes every turn and defeats routing).
352+
const promptCacheKey = getOpenAIPromptCacheKey()
353+
348354
logForDebugging(
349-
`[OpenAI] Calling model=${openaiModel}, messages=${openaiMessages.length}, tools=${openaiTools.length}, thinking=${enableThinking}`,
355+
`[OpenAI] Calling model=${openaiModel}, messages=${openaiMessages.length}, tools=${openaiTools.length}, thinking=${enableThinking}, prompt_cache_key=${promptCacheKey}`,
350356
)
351357

352358
// 11. Call OpenAI API with streaming. ChatGPT subscription auth uses the
@@ -361,6 +367,7 @@ export async function* queryModelOpenAI(
361367
tools: openaiTools,
362368
toolChoice: openaiToolChoice,
363369
reasoningEffort,
370+
promptCacheKey,
364371
}),
365372
signal,
366373
fetchOverride: options.fetchOverride as unknown as typeof fetch,
@@ -381,6 +388,7 @@ export async function* queryModelOpenAI(
381388
enableThinking,
382389
maxTokens,
383390
temperatureOverride: options.temperatureOverride,
391+
promptCacheKey,
384392
}),
385393
{ signal },
386394
),

src/services/api/openai/openaiShared.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,47 @@
44
* Both the OpenAI path (queryModelOpenAI) and Grok path (queryModelGrok) use
55
* the same adapters (openaiStreamAdapter, openaiConvertMessages), so the event
66
* processing logic should be shared rather than duplicated.
7+
*
8+
* Keep this module free of bootstrap/state imports so pure request-body unit
9+
* tests and isolated mocks do not need a full session runtime.
10+
*/
11+
import { randomUUID } from 'crypto'
12+
13+
/**
14+
* Build a stable OpenAI `prompt_cache_key` for a session.
15+
*
16+
* OpenAI automatic prefix caching benefits from routing sticky keys so multi-turn
17+
* requests land on the same cache-bearing compute node. The key must be stable
18+
* for the whole conversation — never derived from full message bodies (that
19+
* changes every turn and defeats routing).
20+
*
21+
* Format: `ccb:<sessionId>`
22+
*/
23+
export function formatOpenAIPromptCacheKey(sessionId: string): string {
24+
return `ccb:${sessionId}`
25+
}
26+
27+
/**
28+
* Process-scoped sticky key. OpenAI uses this for cache-node routing, not as a
29+
* content hash — it only needs to be stable across multi-turn requests in the
30+
* same CCB process. Avoids a bootstrap/state import so pure unit tests and
31+
* partial mocks stay isolated.
32+
*/
33+
let processPromptCacheKey: string | null = null
34+
35+
/**
36+
* Stable OpenAI `prompt_cache_key` for this process.
37+
* Prefer an explicit override (session id) when the caller already has one.
738
*/
39+
export function getOpenAIPromptCacheKey(sessionIdOverride?: string): string {
40+
if (sessionIdOverride) {
41+
return formatOpenAIPromptCacheKey(sessionIdOverride)
42+
}
43+
if (!processPromptCacheKey) {
44+
processPromptCacheKey = formatOpenAIPromptCacheKey(randomUUID())
45+
}
46+
return processPromptCacheKey
47+
}
848

949
/**
1050
* Merge a delta usage into the accumulated usage, preserving cache-related

src/services/api/openai/requestBody.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66
import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions/completions.mjs'
77
import { isEnvTruthy, isEnvDefinedFalsy } from '../../../utils/envUtils.js'
8+
import { getOpenAIPromptCacheKey } from './openaiShared.js'
89

910
/**
1011
* Detect whether thinking mode should be enabled for this model.
@@ -75,10 +76,14 @@ export function buildOpenAIRequestBody(params: {
7576
enableThinking: boolean
7677
maxTokens: number
7778
temperatureOverride?: number
79+
/** Override for tests; production uses the current CCB session id. */
80+
promptCacheKey?: string
7881
}): ChatCompletionCreateParamsStreaming & {
7982
thinking?: { type: string }
8083
enable_thinking?: boolean
8184
chat_template_kwargs?: { thinking: boolean; enable_thinking: boolean }
85+
/** OpenAI prompt-cache routing key (not always in SDK types yet). */
86+
prompt_cache_key?: string
8287
} {
8388
const {
8489
model,
@@ -88,6 +93,7 @@ export function buildOpenAIRequestBody(params: {
8893
enableThinking,
8994
maxTokens,
9095
temperatureOverride,
96+
promptCacheKey,
9197
} = params
9298
return {
9399
model,
@@ -99,6 +105,9 @@ export function buildOpenAIRequestBody(params: {
99105
}),
100106
stream: true,
101107
stream_options: { include_usage: true },
108+
// Sticky cache routing for multi-turn OpenAI/compatible endpoints that
109+
// honor prompt_cache_key. Process-stable by default (not message-derived).
110+
prompt_cache_key: promptCacheKey ?? getOpenAIPromptCacheKey(),
102111
// Enable chain-of-thought output for DeepSeek and MiMo models.
103112
// When active, temperature/top_p/presence_penalty/frequency_penalty are ignored.
104113
...(enableThinking && {

0 commit comments

Comments
 (0)