diff --git a/src/proxy/profile-router.ts b/src/proxy/profile-router.ts index 9fe6b092..ead4efc2 100644 --- a/src/proxy/profile-router.ts +++ b/src/proxy/profile-router.ts @@ -11,6 +11,7 @@ export interface OpenAICompatProfileConfig { apiKey: string; provider: DroidProvider; insecure?: boolean; + forceOpenAIReasoningModel?: boolean; model?: string; opusModel?: string; sonnetModel?: string; @@ -28,12 +29,18 @@ export interface OpenAICompatProfileEnv { ANTHROPIC_SMALL_FAST_MODEL?: string; CCS_DROID_PROVIDER?: string; CCS_OPENAI_PROXY_INSECURE?: string; + CCS_OPENAI_REASONING_MODEL?: string; } export function isOpenAICompatProvider(provider: DroidProvider | null): provider is DroidProvider { return provider === 'openai' || provider === 'generic-chat-completion-api'; } +function isTruthyEnv(value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase(); + return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'; +} + export function resolveOpenAICompatProfileConfig( profileName: string, settingsPath: string, @@ -63,9 +70,8 @@ export function resolveOpenAICompatProfileConfig( baseUrl, apiKey, provider, - insecure: - env.CCS_OPENAI_PROXY_INSECURE === '1' || - env.CCS_OPENAI_PROXY_INSECURE?.toLowerCase() === 'true', + insecure: isTruthyEnv(env.CCS_OPENAI_PROXY_INSECURE), + forceOpenAIReasoningModel: isTruthyEnv(env.CCS_OPENAI_REASONING_MODEL), model: env.ANTHROPIC_MODEL?.trim() || undefined, opusModel: env.ANTHROPIC_DEFAULT_OPUS_MODEL?.trim() || undefined, sonnetModel: env.ANTHROPIC_DEFAULT_SONNET_MODEL?.trim() || undefined, diff --git a/src/proxy/server/messages-route.ts b/src/proxy/server/messages-route.ts index f33ff24f..cf79b027 100644 --- a/src/proxy/server/messages-route.ts +++ b/src/proxy/server/messages-route.ts @@ -31,15 +31,21 @@ function buildUpstreamHeaders(profile: OpenAICompatProfileConfig): Record { expect(body.reasoning_effort).toBeUndefined(); expect(body.tools?.length).toBe(1); }); + + it('keeps generic opaque model payloads unchanged unless reasoning shaping is opted in', async () => { + const hits: string[] = []; + const bodies: Array<{ label: string; body: unknown }> = []; + const upstreamPort = await startMockUpstream('gateway', hits, bodies); + + const settingsPath = writeSettings('gateway', { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${upstreamPort}`, + ANTHROPIC_AUTH_TOKEN: 'gateway_token', + ANTHROPIC_MODEL: 'b3f9a2c7e8d14f60', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + + fs.writeFileSync( + path.join(tempDir, '.ccs', 'config.json'), + JSON.stringify({ profiles: { gateway: settingsPath } }, null, 2), + 'utf8' + ); + + const profile: OpenAICompatProfileConfig = { + profileName: 'gateway', + settingsPath, + baseUrl: `http://127.0.0.1:${upstreamPort}`, + apiKey: 'gateway_token', + provider: 'generic-chat-completion-api', + model: 'b3f9a2c7e8d14f60', + }; + proxyServer = startOpenAICompatProxyServer({ + profile, + port: 0, + authToken: 'test-proxy-token', + }); + proxyPort = await waitForServerListening(proxyServer); + + const response = await requestProxy({ + model: 'b3f9a2c7e8d14f60', + max_tokens: 1024, + metadata: { trace: 'abc' }, + messages: [{ role: 'user', content: 'stay compatible' }], + }); + + expect(response.status).toBe(200); + expect(hits).toEqual(['gateway']); + + const body = bodies[0]?.body as { + max_tokens?: number; + max_completion_tokens?: number; + metadata?: unknown; + }; + expect(body.max_tokens).toBe(1024); + expect(body.max_completion_tokens).toBeUndefined(); + expect(body.metadata).toEqual({ trace: 'abc' }); + }); + + it('shapes generic opaque model payloads when reasoning shaping is opted in', async () => { + const hits: string[] = []; + const bodies: Array<{ label: string; body: unknown }> = []; + const upstreamPort = await startMockUpstream('gateway', hits, bodies); + + const settingsPath = writeSettings('gateway', { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${upstreamPort}`, + ANTHROPIC_AUTH_TOKEN: 'gateway_token', + ANTHROPIC_MODEL: 'b3f9a2c7e8d14f60', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + CCS_OPENAI_REASONING_MODEL: '1', + }); + + fs.writeFileSync( + path.join(tempDir, '.ccs', 'config.json'), + JSON.stringify({ profiles: { gateway: settingsPath } }, null, 2), + 'utf8' + ); + + const profile: OpenAICompatProfileConfig = { + profileName: 'gateway', + settingsPath, + baseUrl: `http://127.0.0.1:${upstreamPort}`, + apiKey: 'gateway_token', + provider: 'generic-chat-completion-api', + forceOpenAIReasoningModel: true, + model: 'b3f9a2c7e8d14f60', + }; + proxyServer = startOpenAICompatProxyServer({ + profile, + port: 0, + authToken: 'test-proxy-token', + }); + proxyPort = await waitForServerListening(proxyServer); + + const response = await requestProxy({ + model: 'b3f9a2c7e8d14f60', + thinking: { type: 'adaptive' }, + output_config: { effort: 'max' }, + max_tokens: 1024, + metadata: { trace: 'abc' }, + tools: [{ name: 'search', description: 'Search docs', input_schema: { type: 'object' } }], + messages: [{ role: 'user', content: 'think with tools' }], + }); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + content: [{ type: 'text', text: 'Reply from gateway' }], + }); + expect(hits).toEqual(['gateway']); + + const body = bodies[0]?.body as { + max_tokens?: number; + max_completion_tokens?: number; + metadata?: unknown; + reasoning_effort?: string; + tools?: unknown[]; + }; + expect(body).toMatchObject({ + model: 'b3f9a2c7e8d14f60', + max_completion_tokens: 1024, + tool_choice: 'auto', + }); + expect(body.max_tokens).toBeUndefined(); + expect(body.metadata).toBeUndefined(); + expect(body.reasoning_effort).toBeUndefined(); + expect(body.tools?.length).toBe(1); + }); }); diff --git a/tests/unit/proxy/profile-router.test.ts b/tests/unit/proxy/profile-router.test.ts index 18f0d398..3608c448 100644 --- a/tests/unit/proxy/profile-router.test.ts +++ b/tests/unit/proxy/profile-router.test.ts @@ -28,6 +28,20 @@ describe('resolveOpenAICompatProfileConfig', () => { expect(result?.insecure).toBe(true); }); + it('supports opt-in reasoning payload shaping for opaque OpenAI-compatible model IDs', () => { + const result = resolveOpenAICompatProfileConfig('gateway', '/tmp/gateway.settings.json', { + ANTHROPIC_BASE_URL: 'https://gateway.example.com/v1', + ANTHROPIC_AUTH_TOKEN: 'gateway-token', + ANTHROPIC_MODEL: 'b3f9a2c7e8d14f60', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + CCS_OPENAI_REASONING_MODEL: 'yes', + }); + + expect(result).not.toBeNull(); + expect(result?.provider).toBe('generic-chat-completion-api'); + expect(result?.forceOpenAIReasoningModel).toBe(true); + }); + it('ignores Anthropic-compatible profiles', () => { const result = resolveOpenAICompatProfileConfig('glm', '/tmp/glm.settings.json', { ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic',