mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 22:17:14 +00:00
fix(proxy): support opt-in OpenAI reasoning shaping
This commit is contained in:
@@ -11,6 +11,7 @@ export interface OpenAICompatProfileConfig {
|
|||||||
apiKey: string;
|
apiKey: string;
|
||||||
provider: DroidProvider;
|
provider: DroidProvider;
|
||||||
insecure?: boolean;
|
insecure?: boolean;
|
||||||
|
forceOpenAIReasoningModel?: boolean;
|
||||||
model?: string;
|
model?: string;
|
||||||
opusModel?: string;
|
opusModel?: string;
|
||||||
sonnetModel?: string;
|
sonnetModel?: string;
|
||||||
@@ -28,12 +29,18 @@ export interface OpenAICompatProfileEnv {
|
|||||||
ANTHROPIC_SMALL_FAST_MODEL?: string;
|
ANTHROPIC_SMALL_FAST_MODEL?: string;
|
||||||
CCS_DROID_PROVIDER?: string;
|
CCS_DROID_PROVIDER?: string;
|
||||||
CCS_OPENAI_PROXY_INSECURE?: string;
|
CCS_OPENAI_PROXY_INSECURE?: string;
|
||||||
|
CCS_OPENAI_REASONING_MODEL?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isOpenAICompatProvider(provider: DroidProvider | null): provider is DroidProvider {
|
export function isOpenAICompatProvider(provider: DroidProvider | null): provider is DroidProvider {
|
||||||
return provider === 'openai' || provider === 'generic-chat-completion-api';
|
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(
|
export function resolveOpenAICompatProfileConfig(
|
||||||
profileName: string,
|
profileName: string,
|
||||||
settingsPath: string,
|
settingsPath: string,
|
||||||
@@ -63,9 +70,8 @@ export function resolveOpenAICompatProfileConfig(
|
|||||||
baseUrl,
|
baseUrl,
|
||||||
apiKey,
|
apiKey,
|
||||||
provider,
|
provider,
|
||||||
insecure:
|
insecure: isTruthyEnv(env.CCS_OPENAI_PROXY_INSECURE),
|
||||||
env.CCS_OPENAI_PROXY_INSECURE === '1' ||
|
forceOpenAIReasoningModel: isTruthyEnv(env.CCS_OPENAI_REASONING_MODEL),
|
||||||
env.CCS_OPENAI_PROXY_INSECURE?.toLowerCase() === 'true',
|
|
||||||
model: env.ANTHROPIC_MODEL?.trim() || undefined,
|
model: env.ANTHROPIC_MODEL?.trim() || undefined,
|
||||||
opusModel: env.ANTHROPIC_DEFAULT_OPUS_MODEL?.trim() || undefined,
|
opusModel: env.ANTHROPIC_DEFAULT_OPUS_MODEL?.trim() || undefined,
|
||||||
sonnetModel: env.ANTHROPIC_DEFAULT_SONNET_MODEL?.trim() || undefined,
|
sonnetModel: env.ANTHROPIC_DEFAULT_SONNET_MODEL?.trim() || undefined,
|
||||||
|
|||||||
@@ -31,15 +31,21 @@ function buildUpstreamHeaders(profile: OpenAICompatProfileConfig): Record<string
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDirectOpenAIReasoningChatModel(
|
function isKnownOpenAIReasoningChatModel(model: string | undefined): boolean {
|
||||||
|
if (typeof model !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = model.trim().toLowerCase();
|
||||||
|
const modelName = normalized.split('/').pop() || normalized;
|
||||||
|
return DIRECT_OPENAI_REASONING_CHAT_MODEL.test(modelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShapeOpenAIReasoningChatPayload(
|
||||||
profile: OpenAICompatProfileConfig,
|
profile: OpenAICompatProfileConfig,
|
||||||
model: string | undefined
|
model: string | undefined
|
||||||
): boolean {
|
): boolean {
|
||||||
return (
|
return profile.forceOpenAIReasoningModel === true || isKnownOpenAIReasoningChatModel(model);
|
||||||
profile.provider === 'openai' &&
|
|
||||||
typeof model === 'string' &&
|
|
||||||
DIRECT_OPENAI_REASONING_CHAT_MODEL.test(model.trim().toLowerCase())
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMiniMaxOpenAICompatProfile(profile: OpenAICompatProfileConfig): boolean {
|
function isMiniMaxOpenAICompatProfile(profile: OpenAICompatProfileConfig): boolean {
|
||||||
@@ -124,7 +130,7 @@ function shapeUpstreamChatPayload(
|
|||||||
shaped = shapeMiniMaxChatPayload(shaped);
|
shaped = shapeMiniMaxChatPayload(shaped);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isDirectOpenAIReasoningChatModel(profile, shaped.model)) {
|
if (!shouldShapeOpenAIReasoningChatPayload(profile, shaped.model)) {
|
||||||
return shaped;
|
return shaped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -419,4 +419,126 @@ describe('openai proxy request routing', () => {
|
|||||||
expect(body.reasoning_effort).toBeUndefined();
|
expect(body.reasoning_effort).toBeUndefined();
|
||||||
expect(body.tools?.length).toBe(1);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,20 @@ describe('resolveOpenAICompatProfileConfig', () => {
|
|||||||
expect(result?.insecure).toBe(true);
|
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', () => {
|
it('ignores Anthropic-compatible profiles', () => {
|
||||||
const result = resolveOpenAICompatProfileConfig('glm', '/tmp/glm.settings.json', {
|
const result = resolveOpenAICompatProfileConfig('glm', '/tmp/glm.settings.json', {
|
||||||
ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic',
|
ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic',
|
||||||
|
|||||||
Reference in New Issue
Block a user