diff --git a/src/cliproxy/codex-reasoning-proxy.ts b/src/cliproxy/codex-reasoning-proxy.ts index d846cb14..0558c2ab 100644 --- a/src/cliproxy/codex-reasoning-proxy.ts +++ b/src/cliproxy/codex-reasoning-proxy.ts @@ -27,6 +27,12 @@ export interface CodexReasoningProxyConfig { 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 { return typeof value === 'string' && value.trim().length > 0; } @@ -38,10 +44,11 @@ function isRecord(value: unknown): value is Record { function parseModelEffortSuffix( model: string ): { 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; const upstreamModel = match[1]?.trim(); - const effort = match[2] as CodexReasoningEffort; + const effort = match[2]?.toLowerCase() as CodexReasoningEffort; if (!upstreamModel) return null; return { upstreamModel, effort }; } @@ -86,8 +93,10 @@ export function buildCodexModelEffortMap( const upsertMin = (model: string | undefined, effort: CodexReasoningEffort) => { if (!isNonEmptyString(model)) return; - const existing = map.get(model); - map.set(model, existing ? minEffort(existing, effort) : effort); + const normalizedModel = stripExtendedContextSuffix(model); + if (!normalizedModel) return; + const existing = map.get(normalizedModel); + map.set(normalizedModel, existing ? minEffort(existing, effort) : effort); }; upsertMin(models.defaultModel, 'xhigh'); @@ -108,9 +117,10 @@ export function getEffortForModel( defaultEffort: CodexReasoningEffort ): CodexReasoningEffort { 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 - return capEffortAtModelMax(model, effort); + return capEffortAtModelMax(normalizedModel, effort); } export function injectReasoningEffortIntoBody( @@ -316,17 +326,22 @@ export class CodexReasoningProxy { const originalModel = 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: // - upstream model: `gpt-5.2-codex` // - reasoning.effort: `xhigh` // // This allows tier→effort mapping without inventing upstream model IDs. - const suffixParsed = originalModel ? parseModelEffortSuffix(originalModel) : null; - const upstreamModel = suffixParsed?.upstreamModel ?? originalModel; + const suffixParsed = normalizedRequestModel + ? parseModelEffortSuffix(normalizedRequestModel) + : null; + const upstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel; const effort = suffixParsed?.effort ?? - getEffortForModel(originalModel, this.modelEffort, this.config.defaultEffort); + getEffortForModel(normalizedRequestModel, this.modelEffort, this.config.defaultEffort); const withUpstreamModel = upstreamModel && isRecord(parsed) ? { ...parsed, model: upstreamModel } : parsed; diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index 11a74b2a..d4353572 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -164,9 +164,55 @@ function ensureRequiredEnvVars( 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; } +/** 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. * Handles various localhost patterns: 127.0.0.1, localhost, 0.0.0.0 diff --git a/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts b/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts new file mode 100644 index 00000000..1e88c258 --- /dev/null +++ b/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts @@ -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; + +function closeServer(server: http.Server): Promise { + return new Promise((resolve) => { + server.close(() => resolve()); + }); +} + +function listenOnRandomPort(server: http.Server): Promise { + 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'); + }); +}); diff --git a/tests/unit/cliproxy/env-builder-provider-url.test.ts b/tests/unit/cliproxy/env-builder-provider-url.test.ts new file mode 100644 index 00000000..0705638c --- /dev/null +++ b/tests/unit/cliproxy/env-builder-provider-url.test.ts @@ -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'); + }); +}); diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index ab19c98a..f7a6c172 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -79,6 +79,8 @@ export function ProviderEditor({ ); }, [modelsData, modelFilterProvider]); + const providerRoute = (baseProvider || provider).toLowerCase(); + const { data, isLoading, @@ -119,7 +121,7 @@ export function ProviderEditor({ const handleApplyPreset = (updates: Record) => { const effectivePort = port ?? CLIPROXY_DEFAULT_PORT; 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, ...updates, }); @@ -129,7 +131,7 @@ export function ProviderEditor({ const handleCustomPresetApply = (values: ModelMappingValues, presetName?: string) => { const effectivePort = port ?? CLIPROXY_DEFAULT_PORT; 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_MODEL: values.default, ANTHROPIC_DEFAULT_OPUS_MODEL: values.opus,