Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/commands/effort/effort.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
20 changes: 11 additions & 9 deletions src/services/api/openai/__tests__/queryModelOpenAI.isolated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -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)
})
})

Expand Down
158 changes: 157 additions & 1 deletion src/services/api/openai/__tests__/responsesAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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)
})
})
11 changes: 11 additions & 0 deletions src/services/api/openai/__tests__/thinking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,13 +203,24 @@ 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', () => {
const body = buildOpenAIRequestBody({ ...baseParams, enableThinking: true })
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)
Expand Down
14 changes: 11 additions & 3 deletions src/services/api/openai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -361,6 +367,7 @@ export async function* queryModelOpenAI(
tools: openaiTools,
toolChoice: openaiToolChoice,
reasoningEffort,
promptCacheKey,
}),
signal,
fetchOverride: options.fetchOverride as unknown as typeof fetch,
Expand All @@ -381,6 +388,7 @@ export async function* queryModelOpenAI(
enableThinking,
maxTokens,
temperatureOverride: options.temperatureOverride,
promptCacheKey,
}),
{ signal },
),
Expand Down
40 changes: 40 additions & 0 deletions src/services/api/openai/openaiShared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<sessionId>`
*/
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
Expand Down
9 changes: 9 additions & 0 deletions src/services/api/openai/requestBody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -88,6 +93,7 @@ export function buildOpenAIRequestBody(params: {
enableThinking,
maxTokens,
temperatureOverride,
promptCacheKey,
} = params
return {
model,
Expand All @@ -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 && {
Expand Down
Loading