mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-06 04:22:40 +00:00
fix(cliproxy): normalize codex model and provider routing
This commit is contained in:
@@ -27,6 +27,12 @@ export interface CodexReasoningProxyConfig {
|
|||||||
stripPathPrefix?: string;
|
stripPathPrefix?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i;
|
||||||
|
|
||||||
|
function stripExtendedContextSuffix(model: string): string {
|
||||||
|
return model.replace(EXTENDED_CONTEXT_SUFFIX_REGEX, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
function isNonEmptyString(value: unknown): value is string {
|
function isNonEmptyString(value: unknown): value is string {
|
||||||
return typeof value === 'string' && value.trim().length > 0;
|
return typeof value === 'string' && value.trim().length > 0;
|
||||||
}
|
}
|
||||||
@@ -38,10 +44,11 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
function parseModelEffortSuffix(
|
function parseModelEffortSuffix(
|
||||||
model: string
|
model: string
|
||||||
): { upstreamModel: string; effort: CodexReasoningEffort } | null {
|
): { upstreamModel: string; effort: CodexReasoningEffort } | null {
|
||||||
const match = model.match(/^(.*)-(xhigh|high|medium)$/);
|
const normalizedModel = stripExtendedContextSuffix(model);
|
||||||
|
const match = normalizedModel.match(/^(.*)-(xhigh|high|medium)$/i);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
const upstreamModel = match[1]?.trim();
|
const upstreamModel = match[1]?.trim();
|
||||||
const effort = match[2] as CodexReasoningEffort;
|
const effort = match[2]?.toLowerCase() as CodexReasoningEffort;
|
||||||
if (!upstreamModel) return null;
|
if (!upstreamModel) return null;
|
||||||
return { upstreamModel, effort };
|
return { upstreamModel, effort };
|
||||||
}
|
}
|
||||||
@@ -86,8 +93,10 @@ export function buildCodexModelEffortMap(
|
|||||||
|
|
||||||
const upsertMin = (model: string | undefined, effort: CodexReasoningEffort) => {
|
const upsertMin = (model: string | undefined, effort: CodexReasoningEffort) => {
|
||||||
if (!isNonEmptyString(model)) return;
|
if (!isNonEmptyString(model)) return;
|
||||||
const existing = map.get(model);
|
const normalizedModel = stripExtendedContextSuffix(model);
|
||||||
map.set(model, existing ? minEffort(existing, effort) : effort);
|
if (!normalizedModel) return;
|
||||||
|
const existing = map.get(normalizedModel);
|
||||||
|
map.set(normalizedModel, existing ? minEffort(existing, effort) : effort);
|
||||||
};
|
};
|
||||||
|
|
||||||
upsertMin(models.defaultModel, 'xhigh');
|
upsertMin(models.defaultModel, 'xhigh');
|
||||||
@@ -108,9 +117,10 @@ export function getEffortForModel(
|
|||||||
defaultEffort: CodexReasoningEffort
|
defaultEffort: CodexReasoningEffort
|
||||||
): CodexReasoningEffort {
|
): CodexReasoningEffort {
|
||||||
if (!model) return defaultEffort;
|
if (!model) return defaultEffort;
|
||||||
const effort = modelEffort.get(model) ?? defaultEffort;
|
const normalizedModel = stripExtendedContextSuffix(model);
|
||||||
|
const effort = modelEffort.get(normalizedModel) ?? defaultEffort;
|
||||||
// Apply model-specific cap from catalog
|
// Apply model-specific cap from catalog
|
||||||
return capEffortAtModelMax(model, effort);
|
return capEffortAtModelMax(normalizedModel, effort);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function injectReasoningEffortIntoBody(
|
export function injectReasoningEffortIntoBody(
|
||||||
@@ -316,17 +326,22 @@ export class CodexReasoningProxy {
|
|||||||
|
|
||||||
const originalModel =
|
const originalModel =
|
||||||
isRecord(parsed) && typeof parsed.model === 'string' ? parsed.model : null;
|
isRecord(parsed) && typeof parsed.model === 'string' ? parsed.model : null;
|
||||||
|
const normalizedRequestModel = originalModel
|
||||||
|
? stripExtendedContextSuffix(originalModel)
|
||||||
|
: null;
|
||||||
|
|
||||||
// Support "model aliases" like `gpt-5.2-codex-xhigh` by translating to:
|
// Support "model aliases" like `gpt-5.2-codex-xhigh` by translating to:
|
||||||
// - upstream model: `gpt-5.2-codex`
|
// - upstream model: `gpt-5.2-codex`
|
||||||
// - reasoning.effort: `xhigh`
|
// - reasoning.effort: `xhigh`
|
||||||
//
|
//
|
||||||
// This allows tier→effort mapping without inventing upstream model IDs.
|
// This allows tier→effort mapping without inventing upstream model IDs.
|
||||||
const suffixParsed = originalModel ? parseModelEffortSuffix(originalModel) : null;
|
const suffixParsed = normalizedRequestModel
|
||||||
const upstreamModel = suffixParsed?.upstreamModel ?? originalModel;
|
? parseModelEffortSuffix(normalizedRequestModel)
|
||||||
|
: null;
|
||||||
|
const upstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel;
|
||||||
const effort =
|
const effort =
|
||||||
suffixParsed?.effort ??
|
suffixParsed?.effort ??
|
||||||
getEffortForModel(originalModel, this.modelEffort, this.config.defaultEffort);
|
getEffortForModel(normalizedRequestModel, this.modelEffort, this.config.defaultEffort);
|
||||||
|
|
||||||
const withUpstreamModel =
|
const withUpstreamModel =
|
||||||
upstreamModel && isRecord(parsed) ? { ...parsed, model: upstreamModel } : parsed;
|
upstreamModel && isRecord(parsed) ? { ...parsed, model: upstreamModel } : parsed;
|
||||||
|
|||||||
@@ -164,9 +164,55 @@ function ensureRequiredEnvVars(
|
|||||||
result.ANTHROPIC_AUTH_TOKEN = defaults.ANTHROPIC_AUTH_TOKEN;
|
result.ANTHROPIC_AUTH_TOKEN = defaults.ANTHROPIC_AUTH_TOKEN;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize local CLIProxy root/wrong-provider URLs to provider-pinned endpoint.
|
||||||
|
// This prevents model-routed "unknown provider" failures for codex effort aliases.
|
||||||
|
if (result.ANTHROPIC_BASE_URL?.trim()) {
|
||||||
|
result.ANTHROPIC_BASE_URL = normalizeLocalProviderBaseUrl(
|
||||||
|
result.ANTHROPIC_BASE_URL,
|
||||||
|
provider,
|
||||||
|
validPort
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Localhost hostnames used for local CLIProxy endpoints */
|
||||||
|
const LOCALHOST_NAMES = new Set(['127.0.0.1', 'localhost', '0.0.0.0']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize local CLIProxy endpoint to the expected provider route.
|
||||||
|
* Only rewrites localhost URLs that target the active local port.
|
||||||
|
*/
|
||||||
|
function normalizeLocalProviderBaseUrl(
|
||||||
|
baseUrl: string,
|
||||||
|
provider: CLIProxyProvider,
|
||||||
|
port: number
|
||||||
|
): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(baseUrl);
|
||||||
|
if (!['http:', 'https:'].includes(parsed.protocol)) return baseUrl;
|
||||||
|
if (!LOCALHOST_NAMES.has(parsed.hostname.toLowerCase())) return baseUrl;
|
||||||
|
|
||||||
|
const effectivePort = parsed.port
|
||||||
|
? Number.parseInt(parsed.port, 10)
|
||||||
|
: parsed.protocol === 'https:'
|
||||||
|
? 443
|
||||||
|
: 80;
|
||||||
|
if (!Number.isFinite(effectivePort) || effectivePort !== port) return baseUrl;
|
||||||
|
|
||||||
|
const expectedPath = `/api/provider/${provider}`;
|
||||||
|
if (parsed.pathname === expectedPath && !parsed.search && !parsed.hash) return baseUrl;
|
||||||
|
|
||||||
|
parsed.pathname = expectedPath;
|
||||||
|
parsed.search = '';
|
||||||
|
parsed.hash = '';
|
||||||
|
return parsed.toString();
|
||||||
|
} catch {
|
||||||
|
return baseUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rewrite localhost URLs to remote server URLs.
|
* Rewrite localhost URLs to remote server URLs.
|
||||||
* Handles various localhost patterns: 127.0.0.1, localhost, 0.0.0.0
|
* Handles various localhost patterns: 127.0.0.1, localhost, 0.0.0.0
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import * as http from 'http';
|
||||||
|
import { afterEach, describe, expect, it } from 'bun:test';
|
||||||
|
import {
|
||||||
|
buildCodexModelEffortMap,
|
||||||
|
CodexReasoningProxy,
|
||||||
|
getEffortForModel,
|
||||||
|
} from '../../../src/cliproxy/codex-reasoning-proxy';
|
||||||
|
|
||||||
|
type JsonRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
function closeServer(server: http.Server): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
server.close(() => resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function listenOnRandomPort(server: http.Server): Promise<number> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', () => {
|
||||||
|
const address = server.address();
|
||||||
|
if (typeof address !== 'object' || !address) {
|
||||||
|
reject(new Error('Failed to resolve server address'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(address.port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function postJson(
|
||||||
|
url: string,
|
||||||
|
body: JsonRecord
|
||||||
|
): Promise<{ statusCode: number; body: JsonRecord }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const payload = JSON.stringify(body);
|
||||||
|
|
||||||
|
const req = http.request(
|
||||||
|
{
|
||||||
|
hostname: parsed.hostname,
|
||||||
|
port: parsed.port,
|
||||||
|
path: parsed.pathname + parsed.search,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(payload),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
let responseBody = '';
|
||||||
|
res.setEncoding('utf8');
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
responseBody += chunk;
|
||||||
|
});
|
||||||
|
res.on('end', () => {
|
||||||
|
let parsedResponse: JsonRecord = {};
|
||||||
|
try {
|
||||||
|
parsedResponse = responseBody ? (JSON.parse(responseBody) as JsonRecord) : {};
|
||||||
|
} catch {
|
||||||
|
parsedResponse = {};
|
||||||
|
}
|
||||||
|
resolve({ statusCode: res.statusCode ?? 0, body: parsedResponse });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
req.on('error', reject);
|
||||||
|
req.write(payload);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CodexReasoningProxy extended-context compatibility', () => {
|
||||||
|
const cleanupServers: http.Server[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
while (cleanupServers.length > 0) {
|
||||||
|
const server = cleanupServers.pop();
|
||||||
|
if (server) {
|
||||||
|
await closeServer(server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes [1m] suffixes in effort map lookups', () => {
|
||||||
|
const map = buildCodexModelEffortMap({
|
||||||
|
defaultModel: 'gpt-5.3-codex-xhigh[1m]',
|
||||||
|
sonnetModel: 'gpt-5.3-codex-high[1m]',
|
||||||
|
haikuModel: 'gpt-5-mini-medium[1m]',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getEffortForModel('gpt-5.3-codex-high', map, 'medium')).toBe('high');
|
||||||
|
expect(getEffortForModel('gpt-5-mini-medium', map, 'high')).toBe('medium');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips [1m] and codex effort suffixes before forwarding upstream', async () => {
|
||||||
|
let capturedBody: JsonRecord | null = null;
|
||||||
|
let capturedPath = '';
|
||||||
|
|
||||||
|
const upstream = http.createServer((req, res) => {
|
||||||
|
let rawBody = '';
|
||||||
|
req.setEncoding('utf8');
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
rawBody += chunk;
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
capturedPath = req.url || '';
|
||||||
|
capturedBody = rawBody ? (JSON.parse(rawBody) as JsonRecord) : {};
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ ok: true }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
cleanupServers.push(upstream);
|
||||||
|
|
||||||
|
const upstreamPort = await listenOnRandomPort(upstream);
|
||||||
|
const proxy = new CodexReasoningProxy({
|
||||||
|
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
|
||||||
|
modelMap: {
|
||||||
|
defaultModel: 'gpt-5.3-codex-xhigh[1m]',
|
||||||
|
opusModel: 'gpt-5.3-codex-xhigh[1m]',
|
||||||
|
sonnetModel: 'gpt-5.3-codex-high[1m]',
|
||||||
|
haikuModel: 'gpt-5-mini-medium[1m]',
|
||||||
|
},
|
||||||
|
defaultEffort: 'medium',
|
||||||
|
});
|
||||||
|
|
||||||
|
const proxyPort = await proxy.start();
|
||||||
|
const response = await postJson(
|
||||||
|
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
|
||||||
|
{
|
||||||
|
model: 'gpt-5.3-codex-high[1m]',
|
||||||
|
messages: [],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
proxy.stop();
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(capturedPath).toBe('/api/provider/codex/v1/messages');
|
||||||
|
expect(capturedBody?.model).toBe('gpt-5.3-codex');
|
||||||
|
expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBe('high');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||||
|
import { getEffectiveEnvVars } from '../../../src/cliproxy/config/env-builder';
|
||||||
|
|
||||||
|
interface EnvSettings {
|
||||||
|
ANTHROPIC_BASE_URL: string;
|
||||||
|
ANTHROPIC_AUTH_TOKEN: string;
|
||||||
|
ANTHROPIC_MODEL: string;
|
||||||
|
ANTHROPIC_DEFAULT_OPUS_MODEL: string;
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: string;
|
||||||
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCodexSettings(settingsPath: string, env: EnvSettings): void {
|
||||||
|
fs.writeFileSync(settingsPath, JSON.stringify({ env }, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('getEffectiveEnvVars local provider URL normalization', () => {
|
||||||
|
let tempHome: string;
|
||||||
|
let settingsPath: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-env-url-'));
|
||||||
|
settingsPath = path.join(tempHome, 'codex.settings.json');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rewrites local root URL to provider endpoint', () => {
|
||||||
|
writeCodexSettings(settingsPath, {
|
||||||
|
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||||
|
ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh',
|
||||||
|
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh',
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high',
|
||||||
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium',
|
||||||
|
});
|
||||||
|
|
||||||
|
const env = getEffectiveEnvVars('codex', 8317, settingsPath);
|
||||||
|
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/codex');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rewrites wrong local provider path to the requested provider', () => {
|
||||||
|
writeCodexSettings(settingsPath, {
|
||||||
|
ANTHROPIC_BASE_URL: 'http://localhost:8317/api/provider/my-codex-variant?debug=1',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||||
|
ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh',
|
||||||
|
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh',
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high',
|
||||||
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium',
|
||||||
|
});
|
||||||
|
|
||||||
|
const env = getEffectiveEnvVars('codex', 8317, settingsPath);
|
||||||
|
expect(env.ANTHROPIC_BASE_URL).toBe('http://localhost:8317/api/provider/codex');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not rewrite localhost URLs targeting non-cliproxy ports', () => {
|
||||||
|
writeCodexSettings(settingsPath, {
|
||||||
|
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||||
|
ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh',
|
||||||
|
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh',
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high',
|
||||||
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium',
|
||||||
|
});
|
||||||
|
|
||||||
|
const env = getEffectiveEnvVars('codex', 8317, settingsPath);
|
||||||
|
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:11434');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -79,6 +79,8 @@ export function ProviderEditor({
|
|||||||
);
|
);
|
||||||
}, [modelsData, modelFilterProvider]);
|
}, [modelsData, modelFilterProvider]);
|
||||||
|
|
||||||
|
const providerRoute = (baseProvider || provider).toLowerCase();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -119,7 +121,7 @@ export function ProviderEditor({
|
|||||||
const handleApplyPreset = (updates: Record<string, string>) => {
|
const handleApplyPreset = (updates: Record<string, string>) => {
|
||||||
const effectivePort = port ?? CLIPROXY_DEFAULT_PORT;
|
const effectivePort = port ?? CLIPROXY_DEFAULT_PORT;
|
||||||
updateEnvValues({
|
updateEnvValues({
|
||||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${provider}`,
|
ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${providerRoute}`,
|
||||||
ANTHROPIC_AUTH_TOKEN: effectiveApiKey,
|
ANTHROPIC_AUTH_TOKEN: effectiveApiKey,
|
||||||
...updates,
|
...updates,
|
||||||
});
|
});
|
||||||
@@ -129,7 +131,7 @@ export function ProviderEditor({
|
|||||||
const handleCustomPresetApply = (values: ModelMappingValues, presetName?: string) => {
|
const handleCustomPresetApply = (values: ModelMappingValues, presetName?: string) => {
|
||||||
const effectivePort = port ?? CLIPROXY_DEFAULT_PORT;
|
const effectivePort = port ?? CLIPROXY_DEFAULT_PORT;
|
||||||
updateEnvValues({
|
updateEnvValues({
|
||||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${provider}`,
|
ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${providerRoute}`,
|
||||||
ANTHROPIC_AUTH_TOKEN: effectiveApiKey,
|
ANTHROPIC_AUTH_TOKEN: effectiveApiKey,
|
||||||
ANTHROPIC_MODEL: values.default,
|
ANTHROPIC_MODEL: values.default,
|
||||||
ANTHROPIC_DEFAULT_OPUS_MODEL: values.opus,
|
ANTHROPIC_DEFAULT_OPUS_MODEL: values.opus,
|
||||||
|
|||||||
Reference in New Issue
Block a user