mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-04 20:17:24 +00:00
fix(proxy): shape direct OpenAI reasoning chat payloads
This commit is contained in:
@@ -2,13 +2,17 @@ import * as http from 'http';
|
|||||||
import type { Dispatcher } from 'undici';
|
import type { Dispatcher } from 'undici';
|
||||||
import type { OpenAICompatProfileConfig } from '../profile-router';
|
import type { OpenAICompatProfileConfig } from '../profile-router';
|
||||||
import { resolveProxyRequestRoute } from '../request-router';
|
import { resolveProxyRequestRoute } from '../request-router';
|
||||||
import { ProxyRequestTransformer } from '../transformers/request-transformer';
|
import {
|
||||||
|
ProxyRequestTransformer,
|
||||||
|
type ProxyOpenAIRequest,
|
||||||
|
} from '../transformers/request-transformer';
|
||||||
import { ProxySseStreamTransformer } from '../transformers/sse-stream-transformer';
|
import { ProxySseStreamTransformer } from '../transformers/sse-stream-transformer';
|
||||||
import { resolveOpenAIChatCompletionsUrl } from '../upstream-url';
|
import { resolveOpenAIChatCompletionsUrl } from '../upstream-url';
|
||||||
import { createLogger } from '../../services/logging';
|
import { createLogger } from '../../services/logging';
|
||||||
import { pipeWebResponseToNode, readJsonBody, writeJson } from './http-helpers';
|
import { pipeWebResponseToNode, readJsonBody, writeJson } from './http-helpers';
|
||||||
|
|
||||||
const REQUEST_TIMEOUT_MS = 600_000;
|
const REQUEST_TIMEOUT_MS = 600_000;
|
||||||
|
const DIRECT_OPENAI_REASONING_CHAT_MODEL = /^(?:gpt-5|o[134])(?:[-.]|$)/;
|
||||||
const logger = createLogger('proxy:openai-compat:messages');
|
const logger = createLogger('proxy:openai-compat:messages');
|
||||||
|
|
||||||
class ProxyInputError extends Error {
|
class ProxyInputError extends Error {
|
||||||
@@ -26,11 +30,46 @@ function buildUpstreamHeaders(profile: OpenAICompatProfileConfig): Record<string
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDirectOpenAIReasoningChatModel(
|
||||||
|
profile: OpenAICompatProfileConfig,
|
||||||
|
model: string | undefined
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
profile.provider === 'openai' &&
|
||||||
|
typeof model === 'string' &&
|
||||||
|
DIRECT_OPENAI_REASONING_CHAT_MODEL.test(model.trim().toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shapeUpstreamChatPayload(
|
||||||
|
payload: ProxyOpenAIRequest,
|
||||||
|
profile: OpenAICompatProfileConfig
|
||||||
|
): ProxyOpenAIRequest {
|
||||||
|
if (!isDirectOpenAIReasoningChatModel(profile, payload.model)) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shaped = { ...payload };
|
||||||
|
|
||||||
|
if (shaped.max_tokens !== undefined) {
|
||||||
|
shaped.max_completion_tokens = shaped.max_tokens;
|
||||||
|
delete shaped.max_tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete shaped.metadata;
|
||||||
|
|
||||||
|
if ((shaped.tools?.length ?? 0) > 0) {
|
||||||
|
delete shaped.reasoning_effort;
|
||||||
|
}
|
||||||
|
|
||||||
|
return shaped;
|
||||||
|
}
|
||||||
|
|
||||||
function buildUpstreamRequest(
|
function buildUpstreamRequest(
|
||||||
profile: OpenAICompatProfileConfig,
|
profile: OpenAICompatProfileConfig,
|
||||||
rawBody: unknown
|
rawBody: unknown
|
||||||
): { body: string; route: ReturnType<typeof resolveProxyRequestRoute> } {
|
): { body: string; route: ReturnType<typeof resolveProxyRequestRoute> } {
|
||||||
let transformed;
|
let transformed: ProxyOpenAIRequest;
|
||||||
try {
|
try {
|
||||||
const transformer = new ProxyRequestTransformer();
|
const transformer = new ProxyRequestTransformer();
|
||||||
transformed = transformer.transform(rawBody);
|
transformed = transformer.transform(rawBody);
|
||||||
@@ -39,11 +78,14 @@ function buildUpstreamRequest(
|
|||||||
throw new ProxyInputError(message);
|
throw new ProxyInputError(message);
|
||||||
}
|
}
|
||||||
const route = resolveProxyRequestRoute(profile, transformed);
|
const route = resolveProxyRequestRoute(profile, transformed);
|
||||||
const body = {
|
const body = shapeUpstreamChatPayload(
|
||||||
...transformed,
|
{
|
||||||
model: route.model || route.profile.model,
|
...transformed,
|
||||||
stream: transformed.stream === true,
|
model: route.model || route.profile.model,
|
||||||
};
|
stream: transformed.stream === true,
|
||||||
|
},
|
||||||
|
route.profile
|
||||||
|
);
|
||||||
return { body: JSON.stringify(body), route };
|
return { body: JSON.stringify(body), route };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ export interface ProxyOpenAIRequest {
|
|||||||
parallel_tool_calls?: boolean;
|
parallel_tool_calls?: boolean;
|
||||||
messages: OpenAIMessage[];
|
messages: OpenAIMessage[];
|
||||||
max_tokens?: number;
|
max_tokens?: number;
|
||||||
|
max_completion_tokens?: number;
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
top_p?: number;
|
top_p?: number;
|
||||||
stop?: string[];
|
stop?: string[];
|
||||||
|
|||||||
@@ -353,4 +353,70 @@ describe('openai proxy request routing', () => {
|
|||||||
});
|
});
|
||||||
expect((bodies[0]?.body as { reasoning?: unknown } | undefined)?.reasoning).toBeUndefined();
|
expect((bodies[0]?.body as { reasoning?: unknown } | undefined)?.reasoning).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shapes direct OpenAI reasoning-model chat payloads after route resolution', async () => {
|
||||||
|
const hits: string[] = [];
|
||||||
|
const bodies: Array<{ label: string; body: unknown }> = [];
|
||||||
|
const upstreamPort = await startMockUpstream('openai', hits, bodies);
|
||||||
|
|
||||||
|
const settingsPath = writeSettings('openai', {
|
||||||
|
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'openai_token',
|
||||||
|
ANTHROPIC_MODEL: 'gpt-5.4',
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tempDir, '.ccs', 'config.json'),
|
||||||
|
JSON.stringify({ profiles: { openai: settingsPath } }, null, 2),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const profile: OpenAICompatProfileConfig = {
|
||||||
|
profileName: 'openai',
|
||||||
|
settingsPath,
|
||||||
|
baseUrl: `http://127.0.0.1:${upstreamPort}`,
|
||||||
|
apiKey: 'openai_token',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
};
|
||||||
|
proxyServer = startOpenAICompatProxyServer({
|
||||||
|
profile,
|
||||||
|
port: 0,
|
||||||
|
authToken: 'test-proxy-token',
|
||||||
|
});
|
||||||
|
proxyPort = await waitForServerListening(proxyServer);
|
||||||
|
|
||||||
|
const response = await requestProxy({
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
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 openai' }],
|
||||||
|
});
|
||||||
|
expect(hits).toEqual(['openai']);
|
||||||
|
|
||||||
|
const body = bodies[0]?.body as {
|
||||||
|
max_tokens?: number;
|
||||||
|
max_completion_tokens?: number;
|
||||||
|
metadata?: unknown;
|
||||||
|
reasoning_effort?: string;
|
||||||
|
tools?: unknown[];
|
||||||
|
};
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user