diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 82f664c1d0..12e6f9af89 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -1328,12 +1328,12 @@ test('uses OpenAI-compatible responses endpoint with text chunk types when OPENA test('uses correct empty input fallback schema for standard responses and responses_compat', async () => { let capturedBody: Record | undefined - globalThis.fetch = (async (input, init) => { + globalThis.fetch = (async (_input, init) => { capturedBody = JSON.parse(String(init?.body)) as Record return new Response(JSON.stringify({ id: 'resp-1', model: 'test', - output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'ok' }] }] + output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'ok' }] }], }), { headers: { 'Content-Type': 'application/json' } }) }) as unknown as FetchType @@ -1369,6 +1369,7 @@ test('uses correct empty input fallback schema for standard responses and respon }, ]) }) + // openaiShim test extraction seam 005 end diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 535560b44c..8e981879d9 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -83,6 +83,10 @@ import { type AnthropicUsage, type ShimCreateParams, } from './codexShim.js' +import { + createRequestBodyPlanner, + hydrateOpenAIShimCompatibilityEnv as hydrateRequestPlanningEnv, +} from './openaiShim/requestPlanner.js' import { buildAnthropicUsageFromRawUsage } from './cacheMetrics.js' import { convertOpenAIStreamUsage, @@ -712,49 +716,10 @@ function isGeminiMode(): boolean { function hydrateOpenAIShimCompatibilityEnv( processEnv: NodeJS.ProcessEnv = process.env, ): void { - // Provider selection, base URL defaults, and model defaults now flow - // through resolveProviderRequest(). The shim still needs a few legacy - // credential aliases because downstream auth/header paths read OPENAI_*. - if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_GEMINI)) { - const geminiApiKey = - processEnv.GEMINI_API_KEY ?? processEnv.GOOGLE_API_KEY - if (geminiApiKey && !processEnv.OPENAI_API_KEY) { - processEnv.OPENAI_API_KEY = geminiApiKey - } - return - } - - if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_MISTRAL)) { - if (processEnv.MISTRAL_API_KEY && !processEnv.OPENAI_API_KEY) { - processEnv.OPENAI_API_KEY = processEnv.MISTRAL_API_KEY - } - return - } - - if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_GITHUB)) { - processEnv.OPENAI_API_KEY = - processEnv.GITHUB_COPILOT_KEY ?? - processEnv.OPENAI_API_KEY ?? - processEnv.GITHUB_TOKEN ?? - processEnv.GH_TOKEN ?? - '' - return - } - - if (processEnv.BANKR_BASE_URL && !processEnv.OPENAI_BASE_URL) { - processEnv.OPENAI_BASE_URL = processEnv.BANKR_BASE_URL - } - if (processEnv.BANKR_MODEL && !processEnv.OPENAI_MODEL) { - processEnv.OPENAI_MODEL = processEnv.BANKR_MODEL - } - - const routeCredential = resolveRouteCredentialValue({ - processEnv, - baseUrl: processEnv.OPENAI_BASE_URL ?? processEnv.OPENAI_API_BASE, + hydrateRequestPlanningEnv(processEnv, { + isEnvTruthy, + resolveRouteCredentialValue, }) - if (routeCredential && !processEnv.OPENAI_API_KEY) { - processEnv.OPENAI_API_KEY = routeCredential - } } function convertMessages( @@ -1486,244 +1451,14 @@ class OpenAIShimMessages { anthropic: false, gemini: false, } - const buildResponsesBody = (): Record => { - const responsesBody: Record = { - model: request.resolvedModel, - input: getResponsesInput(), - stream: params.stream ?? false, - store: false, - } - - if (shouldStripResponsesStore) { - delete responsesBody.store - } - - if (!Array.isArray(responsesBody.input) || responsesBody.input.length === 0) { - responsesBody.input = [ - { - type: 'message', - role: 'user', - content: [{ type: effectiveTransport === 'responses_compat' ? 'text' : 'input_text', text: '' }], - }, - ] - } - - const systemText = convertSystemPrompt(params.system) - if (systemText) { - responsesBody.instructions = systemText - } - - if (body.max_tokens !== undefined) { - responsesBody.max_output_tokens = body.max_tokens - } else if (body.max_completion_tokens !== undefined) { - responsesBody.max_output_tokens = body.max_completion_tokens - } - - if (params.temperature !== undefined) responsesBody.temperature = params.temperature - if (params.top_p !== undefined) responsesBody.top_p = params.top_p - if (reasoningRequestPlan.wireFormat === 'reasoning_effort' && reasoningRequestPlan.reasoningEffort) { - responsesBody.reasoning = { - effort: reasoningRequestPlan.reasoningEffort, - summary: 'auto', - } - responsesBody.include = ['reasoning.encrypted_content'] - } - - if (!omitTools.responses && params.tools && params.tools.length > 0) { - const convertedTools = convertToolsToResponsesTools( - params.tools as Array<{ - name?: string - description?: string - input_schema?: Record - }>, - ) - if (convertedTools.length > 0) { - responsesBody.tools = convertedTools - } - } - - for (const field of shimConfig.removeBodyFields ?? []) { - delete responsesBody[field] - } - - return responsesBody - } - - // Anthropic Messages API body — used when endpointPath is /messages. - // params.messages, params.tools, etc. are already in Anthropic format - // (they originate from the Anthropic SDK). We pass them through directly, - // only adding the top-level system (as string or content-block array) - // and max_tokens. - const buildAnthropicMessagesBody = (): Record => { - const anthropicBody: Record = { - model: request.resolvedModel, - messages: params.messages, - max_tokens: params.max_tokens, - stream: params.stream ?? false, - } - - // Pass system through in native format. The Anthropic Messages API - // accepts either a string or an array of content blocks (with optional - // cache_control markers). Only filter the billing header block. - if (Array.isArray(params.system)) { - const filtered = (params.system as Array<{ type?: string; text?: string }>) - .filter(block => !(block.type === 'text' && (block.text ?? '').startsWith('x-anthropic-billing-header'))) - if (filtered.length > 0) anthropicBody.system = filtered - } else if (params.system) { - const text = typeof params.system === 'string' ? params.system : String(params.system) - if (text && !text.startsWith('x-anthropic-billing-header')) anthropicBody.system = text - } - - if (!omitTools.anthropic && params.tools && params.tools.length > 0) { - anthropicBody.tools = params.tools - } - if (!omitTools.anthropic && params.tool_choice) { - anthropicBody.tool_choice = params.tool_choice - } - - if (request.reasoning?.effort) { - // Shim receives OpenAI effort levels (xhigh) from client.ts, but - // Anthropic API expects 'max' not 'xhigh'. Convert for the effort field. - const effort = request.reasoning.effort === 'xhigh' ? 'max' : request.reasoning.effort - const modelLower = request.resolvedModel.toLowerCase() - const isAdaptive = modelLower.includes('opus-4-7') || modelLower.includes('opus-4-6') || - modelLower.includes('opus-4-8') || - modelLower.includes('opus-4.6') || modelLower.includes('opus-4.7') || - modelLower.includes('opus-4.8') || - modelLower.includes('sonnet-4-6') || modelLower.includes('sonnet-4.6') - const isOpus45 = modelLower.includes('opus-4-5') || modelLower.includes('opus-4.5') - - if (isAdaptive) { - anthropicBody.thinking = { type: 'adaptive' } - anthropicBody.effort = effort - } else if (isOpus45) { - anthropicBody.effort = effort - } else if (effort === 'high' || effort === 'max') { - anthropicBody.thinking = { - type: 'enabled', - budgetTokens: effort === 'max' ? 31_999 : 16_000, - } - } - } - - return anthropicBody - } - - // Google AI SDK body — used when endpointPath is /models/gemini-*. - // Converts Anthropic-format params to Google AI SDK format. - const buildGeminiBody = (): Record => { - const contents: Array<{ role: string; parts: Array> }> = [] - - // Build a lookup from tool_use_id → function name so tool_result - // blocks can emit the correct functionResponse.name (Gemini requires - // the function name, not the Anthropic tool_use_id). - const toolUseIdToName = new Map() - const messages = params.messages as Array<{ - role?: string - content?: unknown - }> - for (const msg of messages) { - if (!Array.isArray(msg.content)) continue - for (const block of msg.content as Array<{ type?: string; id?: string; name?: string }>) { - if (block.type === 'tool_use' && block.id && block.name) { - toolUseIdToName.set(block.id, block.name) - } - } - } - - for (const msg of messages) { - const role = msg.role === 'assistant' ? 'model' : 'user' - const parts: Array> = [] - - if (typeof msg.content === 'string') { - parts.push({ text: msg.content }) - } else if (Array.isArray(msg.content)) { - for (const block of msg.content as Array<{ type?: string; text?: string; id?: string; name?: string; input?: unknown; tool_use_id?: string; content?: unknown; is_error?: boolean }>) { - if (block.type === 'text' && block.text) { - parts.push({ text: block.text }) - } else if (block.type === 'tool_use' && block.id && block.name) { - parts.push({ - functionCall: { - name: block.name, - args: block.input ?? {}, - }, - }) - } else if (block.type === 'tool_result' && block.tool_use_id) { - const funcName = toolUseIdToName.get(block.tool_use_id) ?? block.tool_use_id - let resultContent = typeof block.content === 'string' - ? block.content - : Array.isArray(block.content) - ? (block.content as Array<{ type?: string; text?: string }>) - .filter(b => b.type === 'text') - .map(b => b.text ?? '') - .join('\n') - : '' - if (block.is_error) { - resultContent = `Error: ${resultContent}` - } - parts.push({ - functionResponse: { - name: funcName, - response: { - name: funcName, - content: resultContent, - }, - }, - }) - } - } - } - - if (parts.length > 0) { - contents.push({ role, parts }) - } - } - - const geminiBody: Record = { contents } - - // System instruction - const systemText = convertSystemPrompt(params.system) - if (systemText) { - geminiBody.systemInstruction = { parts: [{ text: systemText }] } - } - - // Generation config - const genConfig: Record = {} - if (params.max_tokens !== undefined) { - genConfig.maxOutputTokens = params.max_tokens - } else if (maxTokensValue !== undefined) { - genConfig.maxOutputTokens = maxTokensValue - } else if (maxCompletionTokensValue !== undefined) { - genConfig.maxOutputTokens = maxCompletionTokensValue - } - if (params.temperature !== undefined) genConfig.temperature = params.temperature - if (params.top_p !== undefined) genConfig.topP = params.top_p - if (request.reasoning?.effort) { - const level = request.reasoning.effort === 'xhigh' ? 'high' : request.reasoning.effort - genConfig.thinkingConfig = { includeThoughts: true, thinkingLevel: level } - } - if (Object.keys(genConfig).length > 0) { - geminiBody.generationConfig = genConfig - } - - // Tools — convert Anthropic tool format to Google functionDeclarations - if (!omitTools.gemini && params.tools && params.tools.length > 0) { - const functionDeclarations = (params.tools as Array<{ - name?: string - description?: string - input_schema?: Record - }>).map(tool => ({ - name: tool.name ?? '', - description: tool.description ?? '', - ...(tool.input_schema ? { parameters: tool.input_schema } : {}), - })) - if (functionDeclarations.length > 0) { - geminiBody.tools = [{ functionDeclarations }] - } - } - - return geminiBody - } + const planner = createRequestBodyPlanner({ + request, params, effectiveTransport, shouldStripResponsesStore, body, + reasoningRequestPlan, shimConfig, getResponsesInput, convertSystemPrompt, + convertToolsToResponsesTools, maxTokensValue, maxCompletionTokensValue, + getOllamaNumCtx, normalizeOllamaNativeMessages, useNativeOllamaChat, + fastPath, stableStringifyJson, omitTools, + }) + const { buildResponsesBody, serializeBody } = planner // Extraction boundary: request planning | request execution. // The prepared body builders above are executor inputs, not executor-owned logic. @@ -2097,52 +1832,7 @@ class OpenAIShimMessages { return hasImages } - // Extraction boundary: executor preparation | request serialization. - // Native Ollama/body serialization remains request-planner-owned. - // Keep this marker stable so executor and planner extractions stay disjoint. - // WHY: byte-identity required for implicit prefix caching in - // OpenAI/Kimi/DeepSeek. stableStringify sorts object keys at every - // depth so spurious insertion-order differences across rebuilds of - // `body` (spread-merge, conditional assignments above) don't bust - // the provider's prefix hash. - // - // Local backends do not implement prefix caching, so the deep key-sort - // is pure CPU overhead per request (issue #1016). Drop to the native - // `JSON.stringify` fast path when the fast-path config opts out. - const buildOllamaChatBody = (): Record => { - const options: Record = { - num_ctx: getOllamaNumCtx(), - } - if (body.max_tokens !== undefined) { - options.num_predict = body.max_tokens - } else if (body.max_completion_tokens !== undefined) { - options.num_predict = body.max_completion_tokens - } - if (params.temperature !== undefined) options.temperature = params.temperature - if (params.top_p !== undefined) options.top_p = params.top_p - - return { - model: request.resolvedModel, - messages: normalizeOllamaNativeMessages(body.messages), - stream: params.stream ?? false, - options, - ...(body.tools ? { tools: body.tools } : {}), - } - } - - const serializeBody = (): string => { - const payload = - useNativeOllamaChat ? buildOllamaChatBody() - : effectiveTransport === 'responses' || effectiveTransport === 'responses_compat' ? buildResponsesBody() - : effectiveTransport === 'anthropic_messages' ? buildAnthropicMessagesBody() - : effectiveTransport === 'gemini' ? buildGeminiBody() - : body - return fastPath.skipStableStringify - ? JSON.stringify(payload) - : stableStringifyJson(payload) - } - // Extraction boundary: request serialization | executor attempt loop. - // The executor consumes the serialized body through a lazy rebuild callback. + // Extraction boundary: the executor lazily rebuilds planner-owned request bodies. // Keep this marker stable so executor and planner extractions stay disjoint. serializedBody = serializeBody() diff --git a/src/services/api/openaiShim/requestPlanner.test.ts b/src/services/api/openaiShim/requestPlanner.test.ts new file mode 100644 index 0000000000..db53a2fbb9 --- /dev/null +++ b/src/services/api/openaiShim/requestPlanner.test.ts @@ -0,0 +1,425 @@ +import { describe, expect, test } from 'bun:test' +import { + createRequestBodyPlanner, + hydrateOpenAIShimCompatibilityEnv, +} from './requestPlanner.js' + +const envDependencies = (credential?: string) => ({ + isEnvTruthy: (value: string | undefined) => value === '1', + resolveRouteCredentialValue: () => credential, +}) + +function createPlanner( + overrides: Partial[0]> = {}, +) { + const context: Parameters[0] = { + request: { resolvedModel: 'test-model' }, + params: { + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 128, + stream: true, + }, + effectiveTransport: 'chat_completions', + shouldStripResponsesStore: false, + body: { + model: 'test-model', + messages: [{ role: 'user', content: 'hello' }], + }, + reasoningRequestPlan: {}, + shimConfig: {}, + getResponsesInput: () => context.params.messages, + convertSystemPrompt: (system) => (typeof system === 'string' ? system : ''), + convertToolsToResponsesTools: (tools) => + tools.map((tool) => ({ type: 'function', name: tool.name })), + getOllamaNumCtx: () => 32_768, + normalizeOllamaNativeMessages: (messages) => messages, + useNativeOllamaChat: false, + fastPath: { skipStableStringify: false }, + stableStringifyJson: (value) => JSON.stringify(value), + omitTools: { responses: false, anthropic: false, gemini: false }, + ...overrides, + } + return createRequestBodyPlanner(context) +} + +describe('compatibility environment hydration', () => { + test('hydrates provider aliases without replacing explicit OpenAI credentials', () => { + const gemini: NodeJS.ProcessEnv = { + CLAUDE_CODE_USE_GEMINI: '1', + GEMINI_API_KEY: 'gemini-key', + } + hydrateOpenAIShimCompatibilityEnv(gemini, envDependencies()) + expect(gemini.OPENAI_API_KEY).toBe('gemini-key') + + const mistral: NodeJS.ProcessEnv = { + CLAUDE_CODE_USE_MISTRAL: '1', + MISTRAL_API_KEY: 'mistral-key', + OPENAI_API_KEY: 'explicit-key', + } + hydrateOpenAIShimCompatibilityEnv(mistral, envDependencies()) + expect(mistral.OPENAI_API_KEY).toBe('explicit-key') + }) + + test('uses GitHub credential precedence and Bankr route defaults', () => { + const github: NodeJS.ProcessEnv = { + CLAUDE_CODE_USE_GITHUB: '1', + GITHUB_TOKEN: 'github-token', + GH_TOKEN: 'gh-token', + } + hydrateOpenAIShimCompatibilityEnv(github, envDependencies()) + expect(github.OPENAI_API_KEY).toBe('github-token') + + const bankr: NodeJS.ProcessEnv = { + BANKR_BASE_URL: 'https://bankr.test/v1', + BANKR_MODEL: 'bankr-model', + } + let resolvedBaseUrl: string | undefined + hydrateOpenAIShimCompatibilityEnv(bankr, { + isEnvTruthy: (value) => value === '1', + resolveRouteCredentialValue: ({ baseUrl }) => { + resolvedBaseUrl = baseUrl + return 'route-key' + }, + }) + expect(bankr).toMatchObject({ + OPENAI_BASE_URL: 'https://bankr.test/v1', + OPENAI_MODEL: 'bankr-model', + OPENAI_API_KEY: 'route-key', + }) + expect(resolvedBaseUrl).toBe('https://bankr.test/v1') + }) +}) + +describe('Responses API body planning', () => { + test('builds the fallback input, system, token, sampling, and reasoning fields', () => { + const planner = createPlanner({ + effectiveTransport: 'responses_compat', + params: { + messages: [], + system: 'system prompt', + temperature: 0.3, + top_p: 0.8, + tools: [{ name: 'Read' }], + }, + body: { max_completion_tokens: 256 }, + reasoningRequestPlan: { + wireFormat: 'reasoning_effort', + reasoningEffort: 'high', + }, + getResponsesInput: () => [], + }) + + expect(planner.buildResponsesBody()).toEqual({ + model: 'test-model', + input: [ + { + type: 'message', + role: 'user', + content: [{ type: 'text', text: '' }], + }, + ], + stream: false, + store: false, + instructions: 'system prompt', + max_output_tokens: 256, + temperature: 0.3, + top_p: 0.8, + reasoning: { effort: 'high', summary: 'auto' }, + include: ['reasoning.encrypted_content'], + tools: [{ type: 'function', name: 'Read' }], + }) + }) + + test('applies store and configured-field removal on every rebuild', () => { + const planner = createPlanner({ + effectiveTransport: 'responses', + shouldStripResponsesStore: true, + body: { max_tokens: 64 }, + shimConfig: { removeBodyFields: ['temperature'] }, + params: { + messages: [{ role: 'user', content: 'hello' }], + temperature: 1, + }, + }) + + expect(planner.buildResponsesBody()).toEqual({ + model: 'test-model', + input: [{ role: 'user', content: 'hello' }], + stream: false, + max_output_tokens: 64, + }) + }) +}) + +describe('Anthropic Messages body planning', () => { + test('filters only the billing system block and preserves native tools', () => { + const planner = createPlanner({ + effectiveTransport: 'anthropic_messages', + params: { + messages: [{ role: 'user', content: 'hello' }], + system: [ + { type: 'text', text: 'x-anthropic-billing-header: hidden' }, + { type: 'text', text: 'keep me' }, + ], + max_tokens: 512, + tools: [{ name: 'Read', input_schema: { type: 'object' } }], + tool_choice: { type: 'auto' }, + }, + }) + + expect(planner.buildAnthropicMessagesBody()).toEqual({ + model: 'test-model', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 512, + stream: false, + system: [{ type: 'text', text: 'keep me' }], + tools: [{ name: 'Read', input_schema: { type: 'object' } }], + tool_choice: { type: 'auto' }, + }) + }) + + test('maps reasoning effort for adaptive and fixed-budget models', () => { + const adaptive = createPlanner({ + request: { + resolvedModel: 'claude-opus-4-6', + reasoning: { effort: 'xhigh' }, + }, + effectiveTransport: 'anthropic_messages', + }).buildAnthropicMessagesBody() + expect(adaptive).toMatchObject({ + thinking: { type: 'adaptive' }, + effort: 'max', + }) + + const fixed = createPlanner({ + request: { + resolvedModel: 'claude-sonnet-4', + reasoning: { effort: 'high' }, + }, + effectiveTransport: 'anthropic_messages', + }).buildAnthropicMessagesBody() + expect(fixed.thinking).toEqual({ type: 'enabled', budgetTokens: 16_000 }) + }) +}) + +describe('Gemini body planning', () => { + test('converts text, tool calls, and tool results with the original function name', () => { + const planner = createPlanner({ + effectiveTransport: 'gemini', + params: { + messages: [ + { + role: 'assistant', + content: [ + { type: 'text', text: 'working' }, + { + type: 'tool_use', + id: 'call-1', + name: 'Read', + input: { path: 'a' }, + }, + ], + }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-1', + content: [{ type: 'text', text: 'result' }], + }, + ], + }, + ], + }, + }) + + expect(planner.buildGeminiBody().contents).toEqual([ + { + role: 'model', + parts: [ + { text: 'working' }, + { functionCall: { name: 'Read', args: { path: 'a' } } }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'Read', + response: { name: 'Read', content: 'result' }, + }, + }, + ], + }, + ]) + }) + + test('builds system, generation, reasoning, and function declaration fields', () => { + const planner = createPlanner({ + request: { + resolvedModel: 'gemini-model', + reasoning: { effort: 'xhigh' }, + }, + effectiveTransport: 'gemini', + params: { + messages: [], + system: 'system prompt', + temperature: 0.2, + top_p: 0.7, + tools: [ + { + name: 'Read', + description: 'read a file', + input_schema: { type: 'object' }, + }, + ], + }, + maxCompletionTokensValue: 321, + }) + + expect(planner.buildGeminiBody()).toEqual({ + contents: [], + systemInstruction: { parts: [{ text: 'system prompt' }] }, + generationConfig: { + maxOutputTokens: 321, + temperature: 0.2, + topP: 0.7, + thinkingConfig: { includeThoughts: true, thinkingLevel: 'high' }, + }, + tools: [ + { + functionDeclarations: [ + { + name: 'Read', + description: 'read a file', + parameters: { type: 'object' }, + }, + ], + }, + ], + }) + }) +}) + +describe('serialization and retry state', () => { + test('builds native Ollama options and uses the stable serializer', () => { + let stableValue: unknown + const planner = createPlanner({ + request: { resolvedModel: 'llama' }, + params: { + messages: [], + stream: true, + temperature: 0.4, + top_p: 0.6, + }, + body: { + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 42, + tools: [{ type: 'function' }], + }, + useNativeOllamaChat: true, + stableStringifyJson: (value) => { + stableValue = value + return 'stable-body' + }, + }) + + expect(planner.serializeBody()).toBe('stable-body') + expect(stableValue).toEqual({ + model: 'llama', + messages: [{ role: 'user', content: 'hello' }], + stream: true, + options: { + num_ctx: 32_768, + num_predict: 42, + temperature: 0.4, + top_p: 0.6, + }, + tools: [{ type: 'function' }], + }) + }) + + test('shared omit flags rebuild transport bodies without tools', () => { + const planner = createPlanner({ + effectiveTransport: 'responses', + params: { + messages: [], + tools: [{ name: 'Read' }], + tool_choice: { type: 'tool', name: 'Read' }, + }, + }) + expect(planner.buildResponsesBody()).toHaveProperty('tools') + planner.omitTools.responses = true + expect(planner.buildResponsesBody()).not.toHaveProperty('tools') + planner.omitTools.anthropic = true + expect(planner.buildAnthropicMessagesBody()).not.toHaveProperty('tools') + expect(planner.buildAnthropicMessagesBody()).not.toHaveProperty('tool_choice') + planner.omitTools.gemini = true + expect(planner.buildGeminiBody()).not.toHaveProperty('tools') + }) + + test('serializeBody routes transport bodies and honors omit flags on rebuild', () => { + const payloads: unknown[] = [] + const stableStringifyJson = (value: unknown) => { + payloads.push(value) + return JSON.stringify(value) + } + + const responsesPlanner = createPlanner({ + effectiveTransport: 'responses', + params: { messages: [], tools: [{ name: 'Read' }] }, + stableStringifyJson, + }) + responsesPlanner.serializeBody() + expect(payloads.at(-1)).toHaveProperty('tools') + responsesPlanner.omitTools.responses = true + responsesPlanner.serializeBody() + expect(payloads.at(-1)).not.toHaveProperty('tools') + + const anthropicPlanner = createPlanner({ + effectiveTransport: 'anthropic_messages', + params: { + messages: [{ role: 'user', content: 'hello' }], + tools: [{ name: 'Read' }], + tool_choice: { type: 'auto' }, + }, + stableStringifyJson, + }) + anthropicPlanner.serializeBody() + expect(payloads.at(-1)).toMatchObject({ + tools: [{ name: 'Read' }], + tool_choice: { type: 'auto' }, + }) + anthropicPlanner.omitTools.anthropic = true + anthropicPlanner.serializeBody() + expect(payloads.at(-1)).not.toHaveProperty('tools') + expect(payloads.at(-1)).not.toHaveProperty('tool_choice') + + const geminiPlanner = createPlanner({ + effectiveTransport: 'gemini', + params: { + messages: [], + tools: [{ name: 'Read', description: 'read' }], + }, + stableStringifyJson, + }) + geminiPlanner.serializeBody() + expect(payloads.at(-1)).toHaveProperty('tools') + geminiPlanner.omitTools.gemini = true + geminiPlanner.serializeBody() + expect(payloads.at(-1)).not.toHaveProperty('tools') + }) + + test('uses native JSON serialization only when the fast path opts out', () => { + const planner = createPlanner({ + body: { z: 1, a: 2 }, + fastPath: { skipStableStringify: true }, + stableStringifyJson: () => { + throw new Error('stable serializer should not run') + }, + }) + expect(planner.serializeBody()).toBe('{"z":1,"a":2}') + }) +}) diff --git a/src/services/api/openaiShim/requestPlanner.ts b/src/services/api/openaiShim/requestPlanner.ts new file mode 100644 index 0000000000..98eda33e1c --- /dev/null +++ b/src/services/api/openaiShim/requestPlanner.ts @@ -0,0 +1,461 @@ +export function hydrateOpenAIShimCompatibilityEnv( + processEnv: NodeJS.ProcessEnv, + dependencies: { + isEnvTruthy: (value: string | undefined) => boolean + resolveRouteCredentialValue: (input: { + processEnv: NodeJS.ProcessEnv + baseUrl?: string + }) => string | undefined + }, +): void { + if (dependencies.isEnvTruthy(processEnv.CLAUDE_CODE_USE_GEMINI)) { + const key = processEnv.GEMINI_API_KEY ?? processEnv.GOOGLE_API_KEY + if (key && !processEnv.OPENAI_API_KEY) processEnv.OPENAI_API_KEY = key + return + } + if (dependencies.isEnvTruthy(processEnv.CLAUDE_CODE_USE_MISTRAL)) { + if (processEnv.MISTRAL_API_KEY && !processEnv.OPENAI_API_KEY) + processEnv.OPENAI_API_KEY = processEnv.MISTRAL_API_KEY + return + } + if (dependencies.isEnvTruthy(processEnv.CLAUDE_CODE_USE_GITHUB)) { + processEnv.OPENAI_API_KEY = + processEnv.GITHUB_COPILOT_KEY ?? + processEnv.OPENAI_API_KEY ?? + processEnv.GITHUB_TOKEN ?? + processEnv.GH_TOKEN ?? + '' + return + } + if (processEnv.BANKR_BASE_URL && !processEnv.OPENAI_BASE_URL) + processEnv.OPENAI_BASE_URL = processEnv.BANKR_BASE_URL + if (processEnv.BANKR_MODEL && !processEnv.OPENAI_MODEL) + processEnv.OPENAI_MODEL = processEnv.BANKR_MODEL + const credential = dependencies.resolveRouteCredentialValue({ + processEnv, + baseUrl: processEnv.OPENAI_BASE_URL ?? processEnv.OPENAI_API_BASE, + }) + if (credential && !processEnv.OPENAI_API_KEY) + processEnv.OPENAI_API_KEY = credential +} + +type RequestTransport = + | 'responses' + | 'responses_compat' + | 'anthropic_messages' + | 'gemini' + | (string & {}) + +type ToolDefinition = { + name?: string + description?: string + input_schema?: Record +} + +type ShimRequestParams = { + messages: Array<{ + role?: string + message?: { role?: string; content?: unknown } + content?: unknown + }> + system?: unknown + max_tokens?: number + stream?: boolean + temperature?: number + top_p?: number + tools?: ToolDefinition[] + tool_choice?: unknown +} + +type RequestBodyPlannerContext = { + request: { + resolvedModel: string + reasoning?: { effort?: string } + } + params: ShimRequestParams + effectiveTransport: RequestTransport + shouldStripResponsesStore: boolean + body: Record + reasoningRequestPlan: { + wireFormat?: string + reasoningEffort?: string + } + shimConfig: { removeBodyFields?: string[] } + getResponsesInput: () => unknown + convertSystemPrompt: (system: unknown) => string + convertToolsToResponsesTools: ( + tools: ToolDefinition[], + ) => Array> + maxTokensValue?: number + maxCompletionTokensValue?: number + getOllamaNumCtx: () => number + normalizeOllamaNativeMessages: (messages: unknown) => unknown + useNativeOllamaChat: boolean + fastPath: { skipStableStringify: boolean } + stableStringifyJson: (value: unknown) => string + omitTools: { + responses: boolean + anthropic: boolean + gemini: boolean + } +} + +export function createRequestBodyPlanner(context: RequestBodyPlannerContext) { + const { + request, + params, + effectiveTransport, + shouldStripResponsesStore, + body, + reasoningRequestPlan, + shimConfig, + getResponsesInput, + convertSystemPrompt, + convertToolsToResponsesTools, + maxTokensValue, + maxCompletionTokensValue, + getOllamaNumCtx, + normalizeOllamaNativeMessages, + useNativeOllamaChat, + fastPath, + stableStringifyJson, + omitTools, + } = context + const buildResponsesBody = (): Record => { + const responsesBody: Record = { + model: request.resolvedModel, + input: getResponsesInput(), + stream: params.stream ?? false, + store: false, + } + + if (shouldStripResponsesStore) { + delete responsesBody.store + } + + if ( + !Array.isArray(responsesBody.input) || + responsesBody.input.length === 0 + ) { + responsesBody.input = [ + { + type: 'message', + role: 'user', + content: [ + { + type: + effectiveTransport === 'responses_compat' + ? 'text' + : 'input_text', + text: '', + }, + ], + }, + ] + } + + const systemText = convertSystemPrompt(params.system) + if (systemText) { + responsesBody.instructions = systemText + } + + if (body.max_tokens !== undefined) { + responsesBody.max_output_tokens = body.max_tokens + } else if (body.max_completion_tokens !== undefined) { + responsesBody.max_output_tokens = body.max_completion_tokens + } + + if (params.temperature !== undefined) + responsesBody.temperature = params.temperature + if (params.top_p !== undefined) responsesBody.top_p = params.top_p + if ( + reasoningRequestPlan.wireFormat === 'reasoning_effort' && + reasoningRequestPlan.reasoningEffort + ) { + responsesBody.reasoning = { + effort: reasoningRequestPlan.reasoningEffort, + summary: 'auto', + } + responsesBody.include = ['reasoning.encrypted_content'] + } + + if (!omitTools.responses && params.tools && params.tools.length > 0) { + const convertedTools = convertToolsToResponsesTools(params.tools) + if (convertedTools.length > 0) { + responsesBody.tools = convertedTools + } + } + + for (const field of shimConfig.removeBodyFields ?? []) { + delete responsesBody[field] + } + + return responsesBody + } + + // Anthropic Messages API body — used when endpointPath is /messages. + // params.messages, params.tools, etc. are already in Anthropic format + // (they originate from the Anthropic SDK). We pass them through directly, + // only adding the top-level system (as string or content-block array) + // and max_tokens. + const buildAnthropicMessagesBody = (): Record => { + const anthropicBody: Record = { + model: request.resolvedModel, + messages: params.messages, + max_tokens: params.max_tokens, + stream: params.stream ?? false, + } + + // Pass system through in native format. The Anthropic Messages API + // accepts either a string or an array of content blocks (with optional + // cache_control markers). Only filter the billing header block. + if (Array.isArray(params.system)) { + const filtered = ( + params.system as Array<{ type?: string; text?: string }> + ).filter( + (block) => + !( + block.type === 'text' && + (block.text ?? '').startsWith('x-anthropic-billing-header') + ), + ) + if (filtered.length > 0) anthropicBody.system = filtered + } else if (params.system) { + const text = + typeof params.system === 'string' + ? params.system + : String(params.system) + if (text && !text.startsWith('x-anthropic-billing-header')) + anthropicBody.system = text + } + + if (!omitTools.anthropic && params.tools && params.tools.length > 0) { + anthropicBody.tools = params.tools + } + if (!omitTools.anthropic && params.tool_choice) { + anthropicBody.tool_choice = params.tool_choice + } + + if (request.reasoning?.effort) { + // Shim receives OpenAI effort levels (xhigh) from client.ts, but + // Anthropic API expects 'max' not 'xhigh'. Convert for the effort field. + const effort = + request.reasoning.effort === 'xhigh' ? 'max' : request.reasoning.effort + const modelLower = request.resolvedModel.toLowerCase() + const isAdaptive = + modelLower.includes('opus-4-7') || + modelLower.includes('opus-4-6') || + modelLower.includes('opus-4-8') || + modelLower.includes('opus-4.6') || + modelLower.includes('opus-4.7') || + modelLower.includes('opus-4.8') || + modelLower.includes('sonnet-4-6') || + modelLower.includes('sonnet-4.6') + const isOpus45 = + modelLower.includes('opus-4-5') || modelLower.includes('opus-4.5') + + if (isAdaptive) { + anthropicBody.thinking = { type: 'adaptive' } + anthropicBody.effort = effort + } else if (isOpus45) { + anthropicBody.effort = effort + } else if (effort === 'high' || effort === 'max') { + anthropicBody.thinking = { + type: 'enabled', + budgetTokens: effort === 'max' ? 31_999 : 16_000, + } + } + } + + return anthropicBody + } + + // Google AI SDK body — used when endpointPath is /models/gemini-*. + // Converts Anthropic-format params to Google AI SDK format. + const buildGeminiBody = (): Record => { + const contents: Array<{ + role: string + parts: Array> + }> = [] + + // Build a lookup from tool_use_id → function name so tool_result + // blocks can emit the correct functionResponse.name (Gemini requires + // the function name, not the Anthropic tool_use_id). + const toolUseIdToName = new Map() + const messages = params.messages as Array<{ + role?: string + content?: unknown + }> + for (const msg of messages) { + if (!Array.isArray(msg.content)) continue + for (const block of msg.content as Array<{ + type?: string + id?: string + name?: string + }>) { + if (block.type === 'tool_use' && block.id && block.name) { + toolUseIdToName.set(block.id, block.name) + } + } + } + + for (const msg of messages) { + const role = msg.role === 'assistant' ? 'model' : 'user' + const parts: Array> = [] + + if (typeof msg.content === 'string') { + parts.push({ text: msg.content }) + } else if (Array.isArray(msg.content)) { + for (const block of msg.content as Array<{ + type?: string + text?: string + id?: string + name?: string + input?: unknown + tool_use_id?: string + content?: unknown + is_error?: boolean + }>) { + if (block.type === 'text' && block.text) { + parts.push({ text: block.text }) + } else if (block.type === 'tool_use' && block.id && block.name) { + parts.push({ + functionCall: { + name: block.name, + args: block.input ?? {}, + }, + }) + } else if (block.type === 'tool_result' && block.tool_use_id) { + const funcName = + toolUseIdToName.get(block.tool_use_id) ?? block.tool_use_id + let resultContent = + typeof block.content === 'string' + ? block.content + : Array.isArray(block.content) + ? (block.content as Array<{ type?: string; text?: string }>) + .filter((b) => b.type === 'text') + .map((b) => b.text ?? '') + .join('\n') + : '' + if (block.is_error) { + resultContent = `Error: ${resultContent}` + } + parts.push({ + functionResponse: { + name: funcName, + response: { + name: funcName, + content: resultContent, + }, + }, + }) + } + } + } + + if (parts.length > 0) { + contents.push({ role, parts }) + } + } + + const geminiBody: Record = { contents } + + // System instruction + const systemText = convertSystemPrompt(params.system) + if (systemText) { + geminiBody.systemInstruction = { parts: [{ text: systemText }] } + } + + // Generation config + const genConfig: Record = {} + if (params.max_tokens !== undefined) { + genConfig.maxOutputTokens = params.max_tokens + } else if (maxTokensValue !== undefined) { + genConfig.maxOutputTokens = maxTokensValue + } else if (maxCompletionTokensValue !== undefined) { + genConfig.maxOutputTokens = maxCompletionTokensValue + } + if (params.temperature !== undefined) + genConfig.temperature = params.temperature + if (params.top_p !== undefined) genConfig.topP = params.top_p + if (request.reasoning?.effort) { + const level = + request.reasoning.effort === 'xhigh' ? 'high' : request.reasoning.effort + genConfig.thinkingConfig = { + includeThoughts: true, + thinkingLevel: level, + } + } + if (Object.keys(genConfig).length > 0) { + geminiBody.generationConfig = genConfig + } + + // Tools — convert Anthropic tool format to Google functionDeclarations + if (!omitTools.gemini && params.tools && params.tools.length > 0) { + const functionDeclarations = ( + params.tools as Array<{ + name?: string + description?: string + input_schema?: Record + }> + ).map((tool) => ({ + name: tool.name ?? '', + description: tool.description ?? '', + ...(tool.input_schema ? { parameters: tool.input_schema } : {}), + })) + if (functionDeclarations.length > 0) { + geminiBody.tools = [{ functionDeclarations }] + } + } + + return geminiBody + } + + const buildOllamaChatBody = (): Record => { + const options: Record = { + num_ctx: getOllamaNumCtx(), + } + if (body.max_tokens !== undefined) { + options.num_predict = body.max_tokens + } else if (body.max_completion_tokens !== undefined) { + options.num_predict = body.max_completion_tokens + } + if (params.temperature !== undefined) + options.temperature = params.temperature + if (params.top_p !== undefined) options.top_p = params.top_p + + return { + model: request.resolvedModel, + messages: normalizeOllamaNativeMessages(body.messages), + stream: params.stream ?? false, + options, + ...(body.tools ? { tools: body.tools } : {}), + } + } + + const serializeBody = (): string => { + const payload = useNativeOllamaChat + ? buildOllamaChatBody() + : effectiveTransport === 'responses' || + effectiveTransport === 'responses_compat' + ? buildResponsesBody() + : effectiveTransport === 'anthropic_messages' + ? buildAnthropicMessagesBody() + : effectiveTransport === 'gemini' + ? buildGeminiBody() + : body + // Byte-identical serialization preserves implicit prefix caching for + // OpenAI/Kimi/DeepSeek. Local backends can opt out of the key sort. + return fastPath.skipStableStringify + ? JSON.stringify(payload) + : stableStringifyJson(payload) + } + return { + buildResponsesBody, + buildAnthropicMessagesBody, + buildGeminiBody, + buildOllamaChatBody, + serializeBody, + omitTools, + } +}