mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 04:14:57 +00:00
feat: support Codex fast service-tier aliases
* feat: support Codex fast service-tier aliases * fix: send Codex fast tier as priority
This commit is contained in:
1 parent
a8d412a0eb
commit
74d73748ee
17 files changed
+471
-86
No files matched your search
@@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic
|
|||||||
|
|
||||||
### Recent Fixes
|
### Recent Fixes
|
||||||
|
|
||||||
|
- **2026-05-07**: **#760** Codex GPT fast mode is now a first-class CLIProxy model tuning suffix. CCS accepts `gpt-5.4-fast`, `gpt-5.4-high-fast`, and equivalent canonicalized forms in raw env configs, CLI variant creation, and the dashboard model picker; runtime requests now send the base upstream model with `reasoning.effort` plus `service_tier: "priority"` instead of leaking the suffixed alias to CLIProxy upstream routing.
|
||||||
- **2026-05-07**: **#1103** GitHub Copilot is now treated as a deprecated compatibility bridge. The dashboard moves Copilot out of the active Identity & Access section and into Deprecated, quick setup no longer offers `ghcp` for new onboarding, CLI/help/config copy marks Copilot as deprecated, and existing `ccs copilot` / `ghcp` compatibility paths remain available for current setups.
|
- **2026-05-07**: **#1103** GitHub Copilot is now treated as a deprecated compatibility bridge. The dashboard moves Copilot out of the active Identity & Access section and into Deprecated, quick setup no longer offers `ghcp` for new onboarding, CLI/help/config copy marks Copilot as deprecated, and existing `ccs copilot` / `ghcp` compatibility paths remain available for current setups.
|
||||||
- **2026-05-07**: **#1189** Headless settings-profile delegation now preserves native Claude passthrough args without a Claude flag allowlist. Explicit `--channels` values reach Claude Code, future native flags can carry multiple adjacent values, malformed CCS-owned flags no longer swallow the next native flag, and `--prompt=<text>` routes through headless delegation consistently with `--prompt <text>`.
|
- **2026-05-07**: **#1189** Headless settings-profile delegation now preserves native Claude passthrough args without a Claude flag allowlist. Explicit `--channels` values reach Claude Code, future native flags can carry multiple adjacent values, malformed CCS-owned flags no longer swallow the next native flag, and `--prompt=<text>` routes through headless delegation consistently with `--prompt <text>`.
|
||||||
- **2026-05-03**: **#1172** Local CLIProxy config generation now keeps the CPAMC management dashboard aligned with backend selection. `backend: original` points to the upstream dashboard, `backend: plus` points to the CCS-maintained CPAMC fork, `cliproxy.management_panel_repository` lets advanced users override the panel repository, and stale generated configs are regenerated when the expected panel source changes.
|
- **2026-05-03**: **#1172** Local CLIProxy config generation now keeps the CPAMC management dashboard aligned with backend selection. `backend: original` points to the upstream dashboard, `backend: plus` points to the CCS-maintained CPAMC fork, `cliproxy.management_panel_repository` lets advanced users override the panel repository, and stale generated configs are regenerated when the expected panel source changes.
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ describe('codex plan compatibility', () => {
|
|||||||
it('maps paid-only free-plan models to safe fallbacks', () => {
|
it('maps paid-only free-plan models to safe fallbacks', () => {
|
||||||
expect(getFreePlanFallbackCodexModel('gpt-5.5')).toBe('gpt-5.4');
|
expect(getFreePlanFallbackCodexModel('gpt-5.5')).toBe('gpt-5.4');
|
||||||
expect(getFreePlanFallbackCodexModel('gpt-5.5-xhigh')).toBe('gpt-5.4');
|
expect(getFreePlanFallbackCodexModel('gpt-5.5-xhigh')).toBe('gpt-5.4');
|
||||||
|
expect(getFreePlanFallbackCodexModel('gpt-5.5-high-fast')).toBe('gpt-5.4');
|
||||||
|
expect(getFreePlanFallbackCodexModel('gpt-5.5-fast-high')).toBe('gpt-5.4');
|
||||||
expect(getFreePlanFallbackCodexModel('gpt-5.3-codex')).toBe('gpt-5.4');
|
expect(getFreePlanFallbackCodexModel('gpt-5.3-codex')).toBe('gpt-5.4');
|
||||||
expect(getFreePlanFallbackCodexModel('gpt-5.3-codex-xhigh')).toBe('gpt-5.4');
|
expect(getFreePlanFallbackCodexModel('gpt-5.3-codex-xhigh')).toBe('gpt-5.4');
|
||||||
expect(getFreePlanFallbackCodexModel('gpt-5.3-codex(high)')).toBe('gpt-5.4');
|
expect(getFreePlanFallbackCodexModel('gpt-5.3-codex(high)')).toBe('gpt-5.4');
|
||||||
|
|||||||
@@ -147,6 +147,60 @@ describe('CodexReasoningProxy extended-context compatibility', () => {
|
|||||||
expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBe('high');
|
expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBe('high');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('translates codex fast model suffixes into service_tier', async () => {
|
||||||
|
const capturedBodies: JsonRecord[] = [];
|
||||||
|
|
||||||
|
const upstream = http.createServer((req, res) => {
|
||||||
|
let rawBody = '';
|
||||||
|
req.setEncoding('utf8');
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
rawBody += chunk;
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
capturedBodies.push(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.4',
|
||||||
|
},
|
||||||
|
defaultEffort: 'medium',
|
||||||
|
});
|
||||||
|
|
||||||
|
const proxyPort = await proxy.start();
|
||||||
|
const highFastResponse = await postJson(
|
||||||
|
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
|
||||||
|
{
|
||||||
|
model: 'gpt-5.4-high-fast',
|
||||||
|
messages: [],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const fastHighResponse = await postJson(
|
||||||
|
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
|
||||||
|
{
|
||||||
|
model: 'gpt-5.4-fast-high',
|
||||||
|
messages: [],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
proxy.stop();
|
||||||
|
|
||||||
|
expect(highFastResponse.statusCode).toBe(200);
|
||||||
|
expect(fastHighResponse.statusCode).toBe(200);
|
||||||
|
expect(capturedBodies[0]?.model).toBe('gpt-5.4');
|
||||||
|
expect((capturedBodies[0]?.reasoning as JsonRecord | undefined)?.effort).toBe('high');
|
||||||
|
expect(capturedBodies[0]?.service_tier).toBe('priority');
|
||||||
|
expect(capturedBodies[1]?.model).toBe('gpt-5.4');
|
||||||
|
expect((capturedBodies[1]?.reasoning as JsonRecord | undefined)?.effort).toBe('high');
|
||||||
|
expect(capturedBodies[1]?.service_tier).toBe('priority');
|
||||||
|
});
|
||||||
|
|
||||||
it('skips reasoning injection when disableEffort is enabled', async () => {
|
it('skips reasoning injection when disableEffort is enabled', async () => {
|
||||||
let capturedBody: JsonRecord | null = null;
|
let capturedBody: JsonRecord | null = null;
|
||||||
|
|
||||||
@@ -189,6 +243,49 @@ describe('CodexReasoningProxy extended-context compatibility', () => {
|
|||||||
expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBeUndefined();
|
expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps fast service tier when disableEffort is enabled', async () => {
|
||||||
|
let capturedBody: JsonRecord | null = null;
|
||||||
|
|
||||||
|
const upstream = http.createServer((req, res) => {
|
||||||
|
let rawBody = '';
|
||||||
|
req.setEncoding('utf8');
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
rawBody += chunk;
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
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.4',
|
||||||
|
},
|
||||||
|
disableEffort: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const proxyPort = await proxy.start();
|
||||||
|
const response = await postJson(
|
||||||
|
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
|
||||||
|
{
|
||||||
|
model: 'gpt-5.4-high-fast',
|
||||||
|
messages: [],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
proxy.stop();
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(capturedBody?.model).toBe('gpt-5.4');
|
||||||
|
expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBeUndefined();
|
||||||
|
expect(capturedBody?.service_tier).toBe('priority');
|
||||||
|
});
|
||||||
|
|
||||||
it('does not strip unknown model ids that merely end with "-high"', async () => {
|
it('does not strip unknown model ids that merely end with "-high"', async () => {
|
||||||
let capturedBody: JsonRecord | null = null;
|
let capturedBody: JsonRecord | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -123,10 +123,17 @@ describe('model-id-normalizer', () => {
|
|||||||
expect(normalizeCodexLegacyModelAliases('gpt-5-codex')).toBe('gpt-5.4');
|
expect(normalizeCodexLegacyModelAliases('gpt-5-codex')).toBe('gpt-5.4');
|
||||||
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-mini[1m]')).toBe('gpt-5.4-mini[1m]');
|
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-mini[1m]')).toBe('gpt-5.4-mini[1m]');
|
||||||
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-high')).toBe('gpt-5.4-high');
|
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-high')).toBe('gpt-5.4-high');
|
||||||
|
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-fast-high')).toBe('gpt-5.4-high-fast');
|
||||||
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-high[1m]')).toBe('gpt-5.4-high[1m]');
|
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-high[1m]')).toBe('gpt-5.4-high[1m]');
|
||||||
|
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-high-fast[1m]')).toBe(
|
||||||
|
'gpt-5.4-high-fast[1m]'
|
||||||
|
);
|
||||||
expect(normalizeModelIdForProvider('gpt-5.2-codex', 'codex')).toBe('gpt-5.2');
|
expect(normalizeModelIdForProvider('gpt-5.2-codex', 'codex')).toBe('gpt-5.2');
|
||||||
expect(normalizeModelIdForProvider('gpt-5.1-codex-mini', 'codex')).toBe('gpt-5.4-mini');
|
expect(normalizeModelIdForProvider('gpt-5.1-codex-mini', 'codex')).toBe('gpt-5.4-mini');
|
||||||
expect(canonicalizeModelIdForProvider('gpt-5-codex-high', 'codex')).toBe('gpt-5.4-high');
|
expect(canonicalizeModelIdForProvider('gpt-5-codex-high', 'codex')).toBe('gpt-5.4-high');
|
||||||
|
expect(canonicalizeModelIdForProvider('gpt-5-codex-fast-high', 'codex')).toBe(
|
||||||
|
'gpt-5.4-high-fast'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ export type CodexPlanType = CodexQuotaResult['planType'];
|
|||||||
|
|
||||||
const FREE_SAFE_DEFAULT_MODEL = 'gpt-5.4';
|
const FREE_SAFE_DEFAULT_MODEL = 'gpt-5.4';
|
||||||
const FREE_SAFE_FAST_MODEL = 'gpt-5.4-mini';
|
const FREE_SAFE_FAST_MODEL = 'gpt-5.4-mini';
|
||||||
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
|
const CODEX_TUNING_SUFFIX_REGEX =
|
||||||
|
/(?:-(?:xhigh|high|medium)(?:-fast)?|-fast(?:-(?:xhigh|high|medium))?)$/i;
|
||||||
const CODEX_PAREN_SUFFIX_REGEX = /\((xhigh|high|medium)\)$/i;
|
const CODEX_PAREN_SUFFIX_REGEX = /\((xhigh|high|medium)\)$/i;
|
||||||
const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i;
|
const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i;
|
||||||
const KNOWN_CODEX_MODELS = new Set(
|
const KNOWN_CODEX_MODELS = new Set(
|
||||||
@@ -57,7 +58,7 @@ export function normalizeCodexModelId(model: string): string {
|
|||||||
.trim()
|
.trim()
|
||||||
.replace(EXTENDED_CONTEXT_SUFFIX_REGEX, '')
|
.replace(EXTENDED_CONTEXT_SUFFIX_REGEX, '')
|
||||||
.replace(CODEX_PAREN_SUFFIX_REGEX, '')
|
.replace(CODEX_PAREN_SUFFIX_REGEX, '')
|
||||||
.replace(CODEX_EFFORT_SUFFIX_REGEX, '')
|
.replace(CODEX_TUNING_SUFFIX_REGEX, '')
|
||||||
.trim();
|
.trim();
|
||||||
return normalizeModelIdForProvider(stripped, 'codex').trim().toLowerCase();
|
return normalizeModelIdForProvider(stripped, 'codex').trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
import { getModelMaxLevel } from '../model-catalog';
|
import { getModelMaxLevel } from '../model-catalog';
|
||||||
|
|
||||||
export type CodexReasoningEffort = 'medium' | 'high' | 'xhigh';
|
export type CodexReasoningEffort = 'medium' | 'high' | 'xhigh';
|
||||||
|
export type CodexServiceTier = 'fast';
|
||||||
|
type CodexServiceTierRequestValue = 'priority';
|
||||||
|
|
||||||
export interface CodexReasoningModelMap {
|
export interface CodexReasoningModelMap {
|
||||||
opusModel?: string;
|
opusModel?: string;
|
||||||
@@ -41,10 +43,15 @@ interface ForwardJsonContext {
|
|||||||
requestedModel: string | null;
|
requestedModel: string | null;
|
||||||
attemptedUpstreamModel: string | null;
|
attemptedUpstreamModel: string | null;
|
||||||
effort: CodexReasoningEffort | null;
|
effort: CodexReasoningEffort | null;
|
||||||
|
serviceTier: CodexServiceTier | null;
|
||||||
retryCount: number;
|
retryCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i;
|
const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i;
|
||||||
|
const CODEX_TUNING_SUFFIX_TOKEN_REGEX = /-(xhigh|high|medium|fast)$/i;
|
||||||
|
const CODEX_SERVICE_TIER_REQUEST_VALUE: Record<CodexServiceTier, CodexServiceTierRequestValue> = {
|
||||||
|
fast: 'priority',
|
||||||
|
};
|
||||||
|
|
||||||
function stripExtendedContextSuffix(model: string): string {
|
function stripExtendedContextSuffix(model: string): string {
|
||||||
return model.replace(EXTENDED_CONTEXT_SUFFIX_REGEX, '').trim();
|
return model.replace(EXTENDED_CONTEXT_SUFFIX_REGEX, '').trim();
|
||||||
@@ -58,16 +65,35 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseModelEffortSuffix(
|
function parseModelTuningSuffix(model: string): {
|
||||||
model: string
|
upstreamModel: string;
|
||||||
): { upstreamModel: string; effort: CodexReasoningEffort } | null {
|
effort: CodexReasoningEffort | null;
|
||||||
|
serviceTier: CodexServiceTier | null;
|
||||||
|
} | null {
|
||||||
const normalizedModel = stripExtendedContextSuffix(model);
|
const normalizedModel = stripExtendedContextSuffix(model);
|
||||||
const match = normalizedModel.match(/^(.*)-(xhigh|high|medium)$/i);
|
let upstreamModel = normalizedModel;
|
||||||
if (!match) return null;
|
let effort: CodexReasoningEffort | null = null;
|
||||||
const upstreamModel = match[1]?.trim();
|
let serviceTier: CodexServiceTier | null = null;
|
||||||
const effort = match[2]?.toLowerCase() as CodexReasoningEffort;
|
|
||||||
|
for (let consumed = 0; consumed < 2; consumed += 1) {
|
||||||
|
const match = upstreamModel.match(CODEX_TUNING_SUFFIX_TOKEN_REGEX);
|
||||||
|
if (!match?.[1]) break;
|
||||||
|
|
||||||
|
const token = match[1].toLowerCase();
|
||||||
|
if (token === 'fast') {
|
||||||
|
if (serviceTier) break;
|
||||||
|
serviceTier = 'fast';
|
||||||
|
} else {
|
||||||
|
if (effort) break;
|
||||||
|
effort = token as CodexReasoningEffort;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstreamModel = upstreamModel.slice(0, -match[0].length).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!effort && !serviceTier) return null;
|
||||||
if (!upstreamModel) return null;
|
if (!upstreamModel) return null;
|
||||||
return { upstreamModel, effort };
|
return { upstreamModel, effort, serviceTier };
|
||||||
}
|
}
|
||||||
|
|
||||||
function isKnownCodexModelId(
|
function isKnownCodexModelId(
|
||||||
@@ -154,19 +180,36 @@ export function getEffortForModel(
|
|||||||
export function injectReasoningEffortIntoBody(
|
export function injectReasoningEffortIntoBody(
|
||||||
body: unknown,
|
body: unknown,
|
||||||
effort: CodexReasoningEffort
|
effort: CodexReasoningEffort
|
||||||
|
): unknown {
|
||||||
|
return injectCodexRequestTuningIntoBody(body, { effort, serviceTier: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function injectCodexRequestTuningIntoBody(
|
||||||
|
body: unknown,
|
||||||
|
tuning: {
|
||||||
|
effort: CodexReasoningEffort | null;
|
||||||
|
serviceTier: CodexServiceTier | null;
|
||||||
|
}
|
||||||
): unknown {
|
): unknown {
|
||||||
if (!isRecord(body)) return body;
|
if (!isRecord(body)) return body;
|
||||||
|
|
||||||
// OpenAI Responses API knob: reasoning: { effort: "..." }
|
// OpenAI Responses API knob: reasoning: { effort: "..." }
|
||||||
// Always override effort (user expectation).
|
// Always override effort (user expectation).
|
||||||
const existingReasoning = isRecord(body.reasoning) ? body.reasoning : {};
|
const existingReasoning = isRecord(body.reasoning) ? body.reasoning : {};
|
||||||
return {
|
const tunedBody = { ...body };
|
||||||
...body,
|
|
||||||
reasoning: {
|
if (tuning.effort) {
|
||||||
|
tunedBody.reasoning = {
|
||||||
...existingReasoning,
|
...existingReasoning,
|
||||||
effort,
|
effort: tuning.effort,
|
||||||
},
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
|
if (tuning.serviceTier) {
|
||||||
|
tunedBody.service_tier = CODEX_SERVICE_TIER_REQUEST_VALUE[tuning.serviceTier];
|
||||||
|
}
|
||||||
|
|
||||||
|
return tunedBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CodexReasoningProxy {
|
export class CodexReasoningProxy {
|
||||||
@@ -191,7 +234,8 @@ export class CodexReasoningProxy {
|
|||||||
at: string;
|
at: string;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
upstreamModel: string | null;
|
upstreamModel: string | null;
|
||||||
effort: CodexReasoningEffort;
|
effort: CodexReasoningEffort | null;
|
||||||
|
serviceTier: CodexServiceTier | null;
|
||||||
path: string;
|
path: string;
|
||||||
}> = [];
|
}> = [];
|
||||||
private readonly counts: Record<CodexReasoningEffort, number> = { medium: 0, high: 0, xhigh: 0 };
|
private readonly counts: Record<CodexReasoningEffort, number> = { medium: 0, high: 0, xhigh: 0 };
|
||||||
@@ -226,14 +270,21 @@ export class CodexReasoningProxy {
|
|||||||
private buildForwardBody(
|
private buildForwardBody(
|
||||||
body: unknown,
|
body: unknown,
|
||||||
upstreamModel: string | null,
|
upstreamModel: string | null,
|
||||||
effort: CodexReasoningEffort | null
|
tuning: {
|
||||||
|
effort: CodexReasoningEffort | null;
|
||||||
|
serviceTier: CodexServiceTier | null;
|
||||||
|
}
|
||||||
): unknown {
|
): unknown {
|
||||||
const withUpstreamModel =
|
const withUpstreamModel =
|
||||||
upstreamModel && isRecord(body) ? { ...body, model: upstreamModel } : body;
|
upstreamModel && isRecord(body) ? { ...body, model: upstreamModel } : body;
|
||||||
if (this.config.disableEffort || !effort) {
|
const effort = this.config.disableEffort ? null : tuning.effort;
|
||||||
|
if (!effort && !tuning.serviceTier) {
|
||||||
return withUpstreamModel;
|
return withUpstreamModel;
|
||||||
}
|
}
|
||||||
return injectReasoningEffortIntoBody(withUpstreamModel, effort);
|
return injectCodexRequestTuningIntoBody(withUpstreamModel, {
|
||||||
|
effort,
|
||||||
|
serviceTier: tuning.serviceTier,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendBufferedResponse(
|
private sendBufferedResponse(
|
||||||
@@ -247,14 +298,17 @@ export class CodexReasoningProxy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Treat trailing "-high/-medium/-xhigh" as an effort alias only for known codex models.
|
* Treat trailing "-high/-medium/-xhigh" and "-fast" as Codex tuning aliases
|
||||||
|
* only for known codex models.
|
||||||
* Prevents stripping legitimate upstream model IDs that happen to end with those tokens.
|
* Prevents stripping legitimate upstream model IDs that happen to end with those tokens.
|
||||||
*/
|
*/
|
||||||
private parseEffortAlias(
|
private parseTuningAlias(model: string | null): {
|
||||||
model: string | null
|
upstreamModel: string;
|
||||||
): { upstreamModel: string; effort: CodexReasoningEffort } | null {
|
effort: CodexReasoningEffort | null;
|
||||||
|
serviceTier: CodexServiceTier | null;
|
||||||
|
} | null {
|
||||||
if (!model) return null;
|
if (!model) return null;
|
||||||
const parsed = parseModelEffortSuffix(model);
|
const parsed = parseModelTuningSuffix(model);
|
||||||
if (!parsed) return null;
|
if (!parsed) return null;
|
||||||
if (!isKnownCodexModelId(parsed.upstreamModel, this.modelEffort)) {
|
if (!isKnownCodexModelId(parsed.upstreamModel, this.modelEffort)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -296,11 +350,21 @@ export class CodexReasoningProxy {
|
|||||||
private record(
|
private record(
|
||||||
model: string | null,
|
model: string | null,
|
||||||
upstreamModel: string | null,
|
upstreamModel: string | null,
|
||||||
effort: CodexReasoningEffort,
|
effort: CodexReasoningEffort | null,
|
||||||
|
serviceTier: CodexServiceTier | null,
|
||||||
path: string
|
path: string
|
||||||
): void {
|
): void {
|
||||||
this.counts[effort] += 1;
|
if (effort) {
|
||||||
this.recent.push({ at: new Date().toISOString(), model, upstreamModel, effort, path });
|
this.counts[effort] += 1;
|
||||||
|
}
|
||||||
|
this.recent.push({
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
model,
|
||||||
|
upstreamModel,
|
||||||
|
effort,
|
||||||
|
serviceTier,
|
||||||
|
path,
|
||||||
|
});
|
||||||
if (this.recent.length > 50) this.recent.shift();
|
if (this.recent.length > 50) this.recent.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,12 +482,13 @@ export class CodexReasoningProxy {
|
|||||||
? stripExtendedContextSuffix(originalModel)
|
? stripExtendedContextSuffix(originalModel)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Support "model aliases" like `gpt-5.2-codex-xhigh` by translating to:
|
// Support "model aliases" like `gpt-5.4-high-fast` by translating to:
|
||||||
// - upstream model: `gpt-5.2-codex`
|
// - upstream model: `gpt-5.4`
|
||||||
// - reasoning.effort: `xhigh`
|
// - reasoning.effort: `high`
|
||||||
|
// - service_tier: `priority` (Codex request value for fast mode)
|
||||||
//
|
//
|
||||||
// This allows tier→effort mapping without inventing upstream model IDs.
|
// This allows tier/speed mapping without inventing upstream model IDs.
|
||||||
const suffixParsed = this.parseEffortAlias(normalizedRequestModel);
|
const suffixParsed = this.parseTuningAlias(normalizedRequestModel);
|
||||||
const requestedUpstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel;
|
const requestedUpstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel;
|
||||||
const rememberedFallback = this.getRememberedFallback(requestedUpstreamModel);
|
const rememberedFallback = this.getRememberedFallback(requestedUpstreamModel);
|
||||||
const upstreamModel = rememberedFallback ?? requestedUpstreamModel;
|
const upstreamModel = rememberedFallback ?? requestedUpstreamModel;
|
||||||
@@ -436,14 +501,15 @@ export class CodexReasoningProxy {
|
|||||||
: !this.config.disableEffort
|
: !this.config.disableEffort
|
||||||
? requestedEffort
|
? requestedEffort
|
||||||
: null;
|
: null;
|
||||||
const rewritten = this.buildForwardBody(parsed, upstreamModel, effort);
|
const serviceTier = suffixParsed?.serviceTier ?? null;
|
||||||
|
const rewritten = this.buildForwardBody(parsed, upstreamModel, { effort, serviceTier });
|
||||||
|
|
||||||
if (effort) {
|
if (effort || serviceTier) {
|
||||||
this.record(originalModel, upstreamModel, effort, requestPath);
|
this.record(originalModel, upstreamModel, effort, serviceTier, requestPath);
|
||||||
this.trace(
|
this.trace(
|
||||||
`[${new Date().toISOString()}] model=${originalModel ?? 'null'} upstreamModel=${
|
`[${new Date().toISOString()}] model=${originalModel ?? 'null'} upstreamModel=${
|
||||||
upstreamModel ?? 'null'
|
upstreamModel ?? 'null'
|
||||||
} effort=${effort} path=${requestPath}`
|
} effort=${effort ?? 'null'} serviceTier=${serviceTier ?? 'null'} path=${requestPath}`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.log(`[disabled] model=${originalModel ?? 'null'} -> passthrough (no reasoning)`);
|
this.log(`[disabled] model=${originalModel ?? 'null'} -> passthrough (no reasoning)`);
|
||||||
@@ -458,6 +524,7 @@ export class CodexReasoningProxy {
|
|||||||
requestedModel: requestedUpstreamModel,
|
requestedModel: requestedUpstreamModel,
|
||||||
attemptedUpstreamModel: upstreamModel,
|
attemptedUpstreamModel: upstreamModel,
|
||||||
effort,
|
effort,
|
||||||
|
serviceTier,
|
||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -630,7 +697,10 @@ export class CodexReasoningProxy {
|
|||||||
!this.config.disableEffort && context.effort
|
!this.config.disableEffort && context.effort
|
||||||
? capEffortAtModelMax(fallbackModel, context.effort)
|
? capEffortAtModelMax(fallbackModel, context.effort)
|
||||||
: null;
|
: null;
|
||||||
const retryBody = this.buildForwardBody(body, fallbackModel, retryEffort);
|
const retryBody = this.buildForwardBody(body, fallbackModel, {
|
||||||
|
effort: retryEffort,
|
||||||
|
serviceTier: context.serviceTier,
|
||||||
|
});
|
||||||
|
|
||||||
this.log(
|
this.log(
|
||||||
`Upstream rejected model "${context.attemptedUpstreamModel}". Retrying ${context.requestPath} with "${fallbackModel}".`
|
`Upstream rejected model "${context.attemptedUpstreamModel}". Retrying ${context.requestPath} with "${fallbackModel}".`
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const DENIED_ANTIGRAVITY_OPUS_45_REGEX =
|
|||||||
const DENIED_ANTIGRAVITY_SONNET_45_REGEX =
|
const DENIED_ANTIGRAVITY_SONNET_45_REGEX =
|
||||||
/claude-sonnet-4(?:[.-])5(?:-thinking)?(?=(?:$|[^a-z0-9]))/gi;
|
/claude-sonnet-4(?:[.-])5(?:-thinking)?(?=(?:$|[^a-z0-9]))/gi;
|
||||||
const CANONICAL_ANTIGRAVITY_OPUS_46_MODEL = 'claude-opus-4-6-thinking';
|
const CANONICAL_ANTIGRAVITY_OPUS_46_MODEL = 'claude-opus-4-6-thinking';
|
||||||
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
|
const CODEX_TUNING_SUFFIX_TOKEN_REGEX = /-(xhigh|high|medium|fast)$/i;
|
||||||
const CODEX_LEGACY_MODEL_ALIASES: Readonly<Record<string, string>> = Object.freeze({
|
const CODEX_LEGACY_MODEL_ALIASES: Readonly<Record<string, string>> = Object.freeze({
|
||||||
'gpt-5-codex': 'gpt-5.4',
|
'gpt-5-codex': 'gpt-5.4',
|
||||||
'gpt-5-codex-mini': 'gpt-5.4-mini',
|
'gpt-5-codex-mini': 'gpt-5.4-mini',
|
||||||
@@ -65,15 +65,31 @@ function splitBaseModelAndSuffix(model: string): { baseModel: string; suffix: st
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitCodexEffortSuffix(model: string): { baseModel: string; suffix: string } {
|
function splitCodexTuningSuffix(model: string): { baseModel: string; suffix: string } {
|
||||||
const match = model.match(CODEX_EFFORT_SUFFIX_REGEX);
|
let baseModel = model;
|
||||||
if (!match?.[0]) {
|
let effort: string | null = null;
|
||||||
return { baseModel: model, suffix: '' };
|
let serviceTier: 'fast' | null = null;
|
||||||
|
|
||||||
|
for (let consumed = 0; consumed < 2; consumed += 1) {
|
||||||
|
const match = baseModel.match(CODEX_TUNING_SUFFIX_TOKEN_REGEX);
|
||||||
|
if (!match?.[1]) break;
|
||||||
|
|
||||||
|
const token = match[1].toLowerCase();
|
||||||
|
if (token === 'fast') {
|
||||||
|
if (serviceTier) break;
|
||||||
|
serviceTier = 'fast';
|
||||||
|
} else {
|
||||||
|
if (effort) break;
|
||||||
|
effort = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
baseModel = baseModel.slice(0, -match[0].length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const suffix = [effort, serviceTier].filter(Boolean).join('-');
|
||||||
return {
|
return {
|
||||||
baseModel: model.slice(0, -match[0].length),
|
baseModel,
|
||||||
suffix: match[0],
|
suffix: suffix ? `-${suffix}` : '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,11 +125,11 @@ export function isIFlowProvider(provider: ProviderLike): boolean {
|
|||||||
return provider.trim().toLowerCase() === 'iflow';
|
return provider.trim().toLowerCase() === 'iflow';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Strip Codex effort suffixes while preserving trailing config suffixes. */
|
/** Strip Codex effort/service-tier suffixes while preserving trailing config suffixes. */
|
||||||
export function stripCodexEffortSuffix(model: string): string {
|
export function stripCodexEffortSuffix(model: string): string {
|
||||||
const trimmed = trimModelId(model);
|
const trimmed = trimModelId(model);
|
||||||
const { baseModel, suffix } = splitBaseModelAndSuffix(trimmed);
|
const { baseModel, suffix } = splitBaseModelAndSuffix(trimmed);
|
||||||
const { baseModel: withoutEffort } = splitCodexEffortSuffix(baseModel);
|
const { baseModel: withoutEffort } = splitCodexTuningSuffix(baseModel);
|
||||||
return `${withoutEffort}${suffix}`;
|
return `${withoutEffort}${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,12 +137,12 @@ export function stripCodexEffortSuffix(model: string): string {
|
|||||||
export function normalizeCodexLegacyModelAliases(model: string): string {
|
export function normalizeCodexLegacyModelAliases(model: string): string {
|
||||||
const trimmed = trimModelId(model);
|
const trimmed = trimModelId(model);
|
||||||
const { baseModel, suffix } = splitBaseModelAndSuffix(trimmed);
|
const { baseModel, suffix } = splitBaseModelAndSuffix(trimmed);
|
||||||
const { baseModel: baseWithoutEffort, suffix: effortSuffix } = splitCodexEffortSuffix(baseModel);
|
const { baseModel: baseWithoutEffort, suffix: tuningSuffix } = splitCodexTuningSuffix(baseModel);
|
||||||
const replacement = CODEX_LEGACY_MODEL_ALIASES[baseWithoutEffort.trim().toLowerCase()];
|
const replacement = CODEX_LEGACY_MODEL_ALIASES[baseWithoutEffort.trim().toLowerCase()];
|
||||||
if (!replacement) {
|
if (!replacement) {
|
||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
return `${replacement}${effortSuffix}${suffix}`;
|
return `${replacement}${tuningSuffix}${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,9 +16,15 @@ import { getThinkingConfig } from '../../config/config-loader-facade';
|
|||||||
/** Model tier types for thinking budget defaults */
|
/** Model tier types for thinking budget defaults */
|
||||||
export type ModelTier = 'opus' | 'sonnet' | 'haiku';
|
export type ModelTier = 'opus' | 'sonnet' | 'haiku';
|
||||||
|
|
||||||
|
const CODEX_EFFORT_REGEX = /^(medium|high|xhigh)$/i;
|
||||||
|
const CODEX_FAST_TUNING_VALUE_REGEX =
|
||||||
|
/^(?:(medium|high|xhigh)-fast|fast-(medium|high|xhigh)|fast)$/i;
|
||||||
|
const CODEX_TUNING_SUFFIX_REGEX =
|
||||||
|
/(?:-(?:xhigh|high|medium)(?:-fast)?|-fast(?:-(?:xhigh|high|medium))?)$/i;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize model ID for provider capability lookup.
|
* Normalize model ID for provider capability lookup.
|
||||||
* Codex may carry effort suffixes in either legacy "(high)" or current "-high" forms.
|
* Codex may carry effort/service-tier suffixes in legacy "(high)" or current "-high-fast" forms.
|
||||||
*/
|
*/
|
||||||
function normalizeModelForThinkingLookup(model: string, provider: CLIProxyProvider): string {
|
function normalizeModelForThinkingLookup(model: string, provider: CLIProxyProvider): string {
|
||||||
const withoutExtendedContext = model.replace(/\[1m\]$/i, '').trim();
|
const withoutExtendedContext = model.replace(/\[1m\]$/i, '').trim();
|
||||||
@@ -26,8 +32,10 @@ function normalizeModelForThinkingLookup(model: string, provider: CLIProxyProvid
|
|||||||
|
|
||||||
if (provider !== 'codex') return providerNormalized;
|
if (provider !== 'codex') return providerNormalized;
|
||||||
|
|
||||||
// New codex suffix form: gpt-5.3-codex-high -> gpt-5.3-codex
|
// New codex suffix forms: gpt-5.4-high-fast, gpt-5.4-fast-high -> gpt-5.4
|
||||||
const codexSuffixMatch = providerNormalized.match(/^(.*)-(xhigh|high|medium)$/i);
|
const codexSuffixMatch = providerNormalized.match(
|
||||||
|
/^(.*?)(?:-(?:xhigh|high|medium)(?:-fast)?|-fast(?:-(?:xhigh|high|medium))?)$/i
|
||||||
|
);
|
||||||
if (codexSuffixMatch?.[1]) {
|
if (codexSuffixMatch?.[1]) {
|
||||||
return codexSuffixMatch[1].trim();
|
return codexSuffixMatch[1].trim();
|
||||||
}
|
}
|
||||||
@@ -82,8 +90,6 @@ function applyThinkingSuffixForProvider(
|
|||||||
thinkingValue: string | number,
|
thinkingValue: string | number,
|
||||||
provider?: CLIProxyProvider
|
provider?: CLIProxyProvider
|
||||||
): string {
|
): string {
|
||||||
const codexEffortRegex = /^(medium|high|xhigh)$/i;
|
|
||||||
const codexModelSuffixRegex = /-(medium|high|xhigh)$/i;
|
|
||||||
const parenthesizedSuffixMatch = model.match(/\(([^)]+)\)$/);
|
const parenthesizedSuffixMatch = model.match(/\(([^)]+)\)$/);
|
||||||
|
|
||||||
// Existing parenthesized suffix:
|
// Existing parenthesized suffix:
|
||||||
@@ -93,7 +99,7 @@ function applyThinkingSuffixForProvider(
|
|||||||
if (parenthesizedSuffixMatch) {
|
if (parenthesizedSuffixMatch) {
|
||||||
if (provider === 'codex') {
|
if (provider === 'codex') {
|
||||||
const normalizedParensValue = parenthesizedSuffixMatch[1]?.trim().toLowerCase() || '';
|
const normalizedParensValue = parenthesizedSuffixMatch[1]?.trim().toLowerCase() || '';
|
||||||
if (codexEffortRegex.test(normalizedParensValue)) {
|
if (CODEX_EFFORT_REGEX.test(normalizedParensValue)) {
|
||||||
return model.replace(/\([^)]+\)$/, `-${normalizedParensValue}`);
|
return model.replace(/\([^)]+\)$/, `-${normalizedParensValue}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,11 +116,11 @@ function applyThinkingSuffixForProvider(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (provider === 'codex') {
|
if (provider === 'codex') {
|
||||||
if (codexModelSuffixRegex.test(model)) {
|
if (CODEX_TUNING_SUFFIX_REGEX.test(model)) {
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
const normalized = String(thinkingValue).trim().toLowerCase();
|
const normalized = String(thinkingValue).trim().toLowerCase();
|
||||||
if (codexEffortRegex.test(normalized)) {
|
if (CODEX_EFFORT_REGEX.test(normalized) || CODEX_FAST_TUNING_VALUE_REGEX.test(normalized)) {
|
||||||
return `${model}-${normalized}`;
|
return `${model}-${normalized}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export interface ModelEntry {
|
|||||||
extendedContext?: boolean;
|
extendedContext?: boolean;
|
||||||
/** Whether model can read image inputs natively without the Image transformer */
|
/** Whether model can read image inputs natively without the Image transformer */
|
||||||
nativeImageInput?: boolean;
|
nativeImageInput?: boolean;
|
||||||
|
/** Additional Codex service-tier suffixes supported by this model. */
|
||||||
|
codexServiceTiers?: Array<'fast'>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -200,6 +202,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
|||||||
maxLevel: 'xhigh',
|
maxLevel: 'xhigh',
|
||||||
dynamicAllowed: false,
|
dynamicAllowed: false,
|
||||||
},
|
},
|
||||||
|
codexServiceTiers: ['fast'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gpt-5.4',
|
id: 'gpt-5.4',
|
||||||
@@ -211,6 +214,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
|||||||
maxLevel: 'xhigh',
|
maxLevel: 'xhigh',
|
||||||
dynamicAllowed: false,
|
dynamicAllowed: false,
|
||||||
},
|
},
|
||||||
|
codexServiceTiers: ['fast'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gpt-5.4-mini',
|
id: 'gpt-5.4-mini',
|
||||||
|
|||||||
@@ -130,6 +130,64 @@ function formatModelOption(model: ModelEntry): string {
|
|||||||
return `${model.name}${tierBadge}`;
|
return `${model.name}${tierBadge}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CODEX_EFFORTS_IN_ORDER = ['medium', 'high', 'xhigh'] as const;
|
||||||
|
type CodexSelectableEffort = (typeof CODEX_EFFORTS_IN_ORDER)[number];
|
||||||
|
|
||||||
|
function getCodexSelectableEfforts(model: ModelEntry): CodexSelectableEffort[] {
|
||||||
|
const maxLevel = model.thinking?.maxLevel;
|
||||||
|
const maxIndex = CODEX_EFFORTS_IN_ORDER.findIndex((effort) => effort === maxLevel);
|
||||||
|
if (maxIndex < 0) return [];
|
||||||
|
return CODEX_EFFORTS_IN_ORDER.slice(0, maxIndex + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCodexModelOptionValues(model: ModelEntry, modelId: string): string[] {
|
||||||
|
const serviceTiers = model.codexServiceTiers ?? [];
|
||||||
|
const optionValues: string[] = [modelId];
|
||||||
|
|
||||||
|
for (const serviceTier of serviceTiers) {
|
||||||
|
optionValues.push(`${modelId}-${serviceTier}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const effort of getCodexSelectableEfforts(model)) {
|
||||||
|
const effortModelId = `${modelId}-${effort}`;
|
||||||
|
optionValues.push(effortModelId);
|
||||||
|
for (const serviceTier of serviceTiers) {
|
||||||
|
optionValues.push(`${effortModelId}-${serviceTier}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return optionValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModelOptionsForProvider(
|
||||||
|
provider: CLIProxyProvider,
|
||||||
|
catalog: NonNullable<ReturnType<typeof getProviderCatalog>>,
|
||||||
|
routing: CliproxyProviderRoutingHints | undefined
|
||||||
|
): Array<{ id: string; label: string }> {
|
||||||
|
return catalog.models.flatMap((model) => {
|
||||||
|
const modelId = getSelectableModelId(model.id, routing);
|
||||||
|
const optionValues =
|
||||||
|
provider === 'codex' ? getCodexModelOptionValues(model, modelId) : [modelId];
|
||||||
|
return optionValues.map((optionValue) => ({
|
||||||
|
id: optionValue,
|
||||||
|
label:
|
||||||
|
provider === 'codex'
|
||||||
|
? `${optionValue} (${formatModelOption(model)})`
|
||||||
|
: formatModelOption(model),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultModelOptionIndex(
|
||||||
|
modelOptions: Array<{ id: string }>,
|
||||||
|
catalog: NonNullable<ReturnType<typeof getProviderCatalog>>,
|
||||||
|
routing: CliproxyProviderRoutingHints | undefined
|
||||||
|
): number {
|
||||||
|
const defaultModelId = getSelectableModelId(catalog.defaultModel, routing);
|
||||||
|
const defaultIdx = modelOptions.findIndex((option) => option.id === defaultModelId);
|
||||||
|
return defaultIdx >= 0 ? defaultIdx : 0;
|
||||||
|
}
|
||||||
|
|
||||||
function getSelectableModelId(
|
function getSelectableModelId(
|
||||||
modelId: string,
|
modelId: string,
|
||||||
routing: CliproxyProviderRoutingHints | undefined
|
routing: CliproxyProviderRoutingHints | undefined
|
||||||
@@ -207,13 +265,13 @@ async function selectTierConfig(
|
|||||||
const routing = (await getCatalogRoutingSnapshot()).routing[provider as CLIProxyProvider];
|
const routing = (await getCatalogRoutingSnapshot()).routing[provider as CLIProxyProvider];
|
||||||
const catalog = getProviderCatalog(provider as CLIProxyProvider);
|
const catalog = getProviderCatalog(provider as CLIProxyProvider);
|
||||||
if (catalog) {
|
if (catalog) {
|
||||||
const modelOptions = catalog.models.map((m) => ({
|
const modelOptions = getModelOptionsForProvider(
|
||||||
id: getSelectableModelId(m.id, routing),
|
provider as CLIProxyProvider,
|
||||||
label: formatModelOption(m),
|
catalog,
|
||||||
}));
|
routing
|
||||||
const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel);
|
);
|
||||||
model = await InteractivePrompt.selectFromList(`Model for ${tierName}:`, modelOptions, {
|
model = await InteractivePrompt.selectFromList(`Model for ${tierName}:`, modelOptions, {
|
||||||
defaultIndex: defaultIdx >= 0 ? defaultIdx : 0,
|
defaultIndex: getDefaultModelOptionIndex(modelOptions, catalog, routing),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -468,13 +526,13 @@ export async function handleCreate(
|
|||||||
const routing = (await getCatalogRoutingSnapshot()).routing[provider as CLIProxyProvider];
|
const routing = (await getCatalogRoutingSnapshot()).routing[provider as CLIProxyProvider];
|
||||||
const catalog = getProviderCatalog(provider as CLIProxyProvider);
|
const catalog = getProviderCatalog(provider as CLIProxyProvider);
|
||||||
if (catalog) {
|
if (catalog) {
|
||||||
const modelOptions = catalog.models.map((m) => ({
|
const modelOptions = getModelOptionsForProvider(
|
||||||
id: getSelectableModelId(m.id, routing),
|
provider as CLIProxyProvider,
|
||||||
label: formatModelOption(m),
|
catalog,
|
||||||
}));
|
routing
|
||||||
const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel);
|
);
|
||||||
model = await InteractivePrompt.selectFromList('Select model:', modelOptions, {
|
model = await InteractivePrompt.selectFromList('Select model:', modelOptions, {
|
||||||
defaultIndex: defaultIdx >= 0 ? defaultIdx : 0,
|
defaultIndex: getDefaultModelOptionIndex(modelOptions, catalog, routing),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,8 +229,8 @@ export function ModelConfigSection({
|
|||||||
{provider === 'codex' && (
|
{provider === 'codex' && (
|
||||||
<p className="text-[11px] text-muted-foreground mb-3 rounded-md border bg-muted/30 px-2.5 py-2">
|
<p className="text-[11px] text-muted-foreground mb-3 rounded-md border bg-muted/30 px-2.5 py-2">
|
||||||
Codex tip: suffixes <code>-medium</code>, <code>-high</code>, and <code>-xhigh</code>{' '}
|
Codex tip: suffixes <code>-medium</code>, <code>-high</code>, and <code>-xhigh</code>{' '}
|
||||||
pin reasoning effort. Select a suffixed model to pin effort; unsuffixed models use
|
pin reasoning effort. <code>-fast</code> enables the Codex fast service tier on models
|
||||||
Thinking settings.
|
that support it, including combined forms such as <code>gpt-5.4-high-fast</code>.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -12,8 +12,12 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { SearchableSelect } from '@/components/ui/searchable-select';
|
import { SearchableSelect } from '@/components/ui/searchable-select';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import type { CliproxyProviderRoutingHints } from '@/lib/api-client';
|
import type { CliproxyProviderRoutingHints } from '@/lib/api-client';
|
||||||
import type { CodexEffort } from '@/lib/codex-effort';
|
import type { CodexEffort, CodexServiceTier } from '@/lib/codex-effort';
|
||||||
import { getCodexEffortDisplay, getCodexEffortVariants } from '@/lib/codex-effort';
|
import {
|
||||||
|
getCodexEffortDisplay,
|
||||||
|
getCodexEffortVariants,
|
||||||
|
parseCodexServiceTier,
|
||||||
|
} from '@/lib/codex-effort';
|
||||||
import { getResolvedCatalogModels, getSupplementalCatalogModels } from '@/lib/model-catalogs';
|
import { getResolvedCatalogModels, getSupplementalCatalogModels } from '@/lib/model-catalogs';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -38,6 +42,8 @@ export interface ModelEntry {
|
|||||||
};
|
};
|
||||||
/** Highest codex reasoning-effort suffix this model supports in the dashboard UI. */
|
/** Highest codex reasoning-effort suffix this model supports in the dashboard UI. */
|
||||||
codexMaxEffort?: CodexEffort;
|
codexMaxEffort?: CodexEffort;
|
||||||
|
/** Additional Codex service-tier suffixes supported by this model in the dashboard UI. */
|
||||||
|
codexServiceTiers?: CodexServiceTier[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Provider catalog */
|
/** Provider catalog */
|
||||||
@@ -112,6 +118,17 @@ function CodexEffortBadge({ modelId }: { modelId: string | undefined }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CodexServiceTierBadge({ modelId }: { modelId: string | undefined }) {
|
||||||
|
const serviceTier = parseCodexServiceTier(modelId);
|
||||||
|
if (!serviceTier) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary" className="text-[9px] h-4 px-1 uppercase">
|
||||||
|
{serviceTier}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ProviderModelSelector({
|
export function ProviderModelSelector({
|
||||||
catalog,
|
catalog,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -318,10 +335,13 @@ function getPreferredOptionValue(
|
|||||||
|
|
||||||
function getModelOptionValues(
|
function getModelOptionValues(
|
||||||
codexMaxEffort: CodexEffort | undefined,
|
codexMaxEffort: CodexEffort | undefined,
|
||||||
|
codexServiceTiers: readonly CodexServiceTier[] | undefined,
|
||||||
optionValue: string,
|
optionValue: string,
|
||||||
isCodexProvider: boolean
|
isCodexProvider: boolean
|
||||||
): string[] {
|
): string[] {
|
||||||
return isCodexProvider ? getCodexEffortVariants(optionValue, codexMaxEffort) : [optionValue];
|
return isCodexProvider
|
||||||
|
? getCodexEffortVariants(optionValue, codexMaxEffort, codexServiceTiers)
|
||||||
|
: [optionValue];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FlexibleModelSelector({
|
export function FlexibleModelSelector({
|
||||||
@@ -356,6 +376,7 @@ export function FlexibleModelSelector({
|
|||||||
resolvedCatalogModels.flatMap((model) =>
|
resolvedCatalogModels.flatMap((model) =>
|
||||||
getModelOptionValues(
|
getModelOptionValues(
|
||||||
model.codexMaxEffort,
|
model.codexMaxEffort,
|
||||||
|
model.codexServiceTiers,
|
||||||
getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())),
|
getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())),
|
||||||
isCodexProvider
|
isCodexProvider
|
||||||
)
|
)
|
||||||
@@ -372,6 +393,7 @@ export function FlexibleModelSelector({
|
|||||||
const routingHint = routingHints.get(model.id.toLowerCase());
|
const routingHint = routingHints.get(model.id.toLowerCase());
|
||||||
const optionValues = getModelOptionValues(
|
const optionValues = getModelOptionValues(
|
||||||
model.codexMaxEffort,
|
model.codexMaxEffort,
|
||||||
|
model.codexServiceTiers,
|
||||||
getPreferredOptionValue(model.id, routingHint),
|
getPreferredOptionValue(model.id, routingHint),
|
||||||
isCodexProvider
|
isCodexProvider
|
||||||
);
|
);
|
||||||
@@ -390,6 +412,7 @@ export function FlexibleModelSelector({
|
|||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
{isCodexProvider && <CodexEffortBadge modelId={optionValue} />}
|
{isCodexProvider && <CodexEffortBadge modelId={optionValue} />}
|
||||||
|
{isCodexProvider && <CodexServiceTierBadge modelId={optionValue} />}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
itemContent: (
|
itemContent: (
|
||||||
@@ -407,6 +430,7 @@ export function FlexibleModelSelector({
|
|||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
{isCodexProvider && <CodexEffortBadge modelId={optionValue} />}
|
{isCodexProvider && <CodexEffortBadge modelId={optionValue} />}
|
||||||
|
{isCodexProvider && <CodexServiceTierBadge modelId={optionValue} />}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
@@ -423,6 +447,7 @@ export function FlexibleModelSelector({
|
|||||||
.flatMap((model) => {
|
.flatMap((model) => {
|
||||||
const routingHint = routingHints.get(model.id.toLowerCase());
|
const routingHint = routingHints.get(model.id.toLowerCase());
|
||||||
const optionValues = getModelOptionValues(
|
const optionValues = getModelOptionValues(
|
||||||
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
getPreferredOptionValue(model.id, routingHint),
|
getPreferredOptionValue(model.id, routingHint),
|
||||||
isCodexProvider
|
isCodexProvider
|
||||||
@@ -442,6 +467,7 @@ export function FlexibleModelSelector({
|
|||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
{isCodexProvider && <CodexEffortBadge modelId={optionValue} />}
|
{isCodexProvider && <CodexEffortBadge modelId={optionValue} />}
|
||||||
|
{isCodexProvider && <CodexServiceTierBadge modelId={optionValue} />}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
itemContent: (
|
itemContent: (
|
||||||
@@ -458,6 +484,7 @@ export function FlexibleModelSelector({
|
|||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
{isCodexProvider && <CodexEffortBadge modelId={optionValue} />}
|
{isCodexProvider && <CodexEffortBadge modelId={optionValue} />}
|
||||||
|
{isCodexProvider && <CodexServiceTierBadge modelId={optionValue} />}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|||||||
+56
-12
@@ -1,37 +1,69 @@
|
|||||||
export type CodexEffort = 'medium' | 'high' | 'xhigh';
|
export type CodexEffort = 'medium' | 'high' | 'xhigh';
|
||||||
|
export type CodexServiceTier = 'fast';
|
||||||
|
|
||||||
const CODEX_EFFORT_SUFFIX_REGEX = /-(medium|high|xhigh)$/i;
|
const CODEX_TUNING_SUFFIX_TOKEN_REGEX = /-(medium|high|xhigh|fast)$/i;
|
||||||
const CODEX_EFFORTS_IN_ORDER: readonly CodexEffort[] = ['medium', 'high', 'xhigh'];
|
const CODEX_EFFORTS_IN_ORDER: readonly CodexEffort[] = ['medium', 'high', 'xhigh'];
|
||||||
|
|
||||||
function trimModelId(modelId: string | undefined): string {
|
function trimModelId(modelId: string | undefined): string {
|
||||||
return modelId?.trim() ?? '';
|
return modelId?.trim() ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseCodexTuningSuffix(modelId: string | undefined): {
|
||||||
|
baseModel: string;
|
||||||
|
effort: CodexEffort | undefined;
|
||||||
|
serviceTier: CodexServiceTier | undefined;
|
||||||
|
} {
|
||||||
|
let baseModel = trimModelId(modelId);
|
||||||
|
let effort: CodexEffort | undefined;
|
||||||
|
let serviceTier: CodexServiceTier | undefined;
|
||||||
|
|
||||||
|
for (let consumed = 0; consumed < 2; consumed += 1) {
|
||||||
|
const match = baseModel.match(CODEX_TUNING_SUFFIX_TOKEN_REGEX);
|
||||||
|
if (!match?.[1]) break;
|
||||||
|
|
||||||
|
const token = match[1].toLowerCase();
|
||||||
|
if (token === 'fast') {
|
||||||
|
if (serviceTier) break;
|
||||||
|
serviceTier = 'fast';
|
||||||
|
} else {
|
||||||
|
if (effort) break;
|
||||||
|
effort = token as CodexEffort;
|
||||||
|
}
|
||||||
|
|
||||||
|
baseModel = baseModel.slice(0, -match[0].length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { baseModel, effort, serviceTier };
|
||||||
|
}
|
||||||
|
|
||||||
export function parseCodexEffort(modelId: string | undefined): CodexEffort | undefined {
|
export function parseCodexEffort(modelId: string | undefined): CodexEffort | undefined {
|
||||||
if (!modelId) return undefined;
|
return parseCodexTuningSuffix(modelId).effort;
|
||||||
const match = trimModelId(modelId).match(CODEX_EFFORT_SUFFIX_REGEX);
|
}
|
||||||
if (!match?.[1]) return undefined;
|
|
||||||
return match[1].toLowerCase() as CodexEffort;
|
export function parseCodexServiceTier(modelId: string | undefined): CodexServiceTier | undefined {
|
||||||
|
return parseCodexTuningSuffix(modelId).serviceTier;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stripCodexEffortSuffix(modelId: string | undefined): string {
|
export function stripCodexEffortSuffix(modelId: string | undefined): string {
|
||||||
return trimModelId(modelId).replace(CODEX_EFFORT_SUFFIX_REGEX, '');
|
return parseCodexTuningSuffix(modelId).baseModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyCodexEffortSuffix(
|
export function applyCodexEffortSuffix(
|
||||||
modelId: string | undefined,
|
modelId: string | undefined,
|
||||||
effort: CodexEffort | undefined
|
effort: CodexEffort | undefined
|
||||||
): string {
|
): string {
|
||||||
const normalizedModelId = stripCodexEffortSuffix(modelId);
|
const parsed = parseCodexTuningSuffix(modelId);
|
||||||
|
const normalizedModelId = parsed.baseModel;
|
||||||
if (!normalizedModelId || !effort) {
|
if (!normalizedModelId || !effort) {
|
||||||
return normalizedModelId;
|
return parsed.serviceTier ? `${normalizedModelId}-${parsed.serviceTier}` : normalizedModelId;
|
||||||
}
|
}
|
||||||
return `${normalizedModelId}-${effort}`;
|
return [normalizedModelId, effort, parsed.serviceTier].filter(Boolean).join('-');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCodexEffortVariants(
|
export function getCodexEffortVariants(
|
||||||
modelId: string,
|
modelId: string,
|
||||||
maxEffort: CodexEffort | undefined
|
maxEffort: CodexEffort | undefined,
|
||||||
|
serviceTiers: readonly CodexServiceTier[] = []
|
||||||
): string[] {
|
): string[] {
|
||||||
if (!maxEffort) {
|
if (!maxEffort) {
|
||||||
const explicitEffort = parseCodexEffort(modelId);
|
const explicitEffort = parseCodexEffort(modelId);
|
||||||
@@ -39,10 +71,22 @@ export function getCodexEffortVariants(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalizedModelId = stripCodexEffortSuffix(modelId);
|
const normalizedModelId = stripCodexEffortSuffix(modelId);
|
||||||
const variantIds = [normalizedModelId];
|
const variantIds: string[] = [];
|
||||||
|
|
||||||
|
const appendVariantsForEffort = (effort: CodexEffort | undefined) => {
|
||||||
|
const effortModel = effort
|
||||||
|
? applyCodexEffortSuffix(normalizedModelId, effort)
|
||||||
|
: normalizedModelId;
|
||||||
|
variantIds.push(effortModel);
|
||||||
|
for (const serviceTier of serviceTiers) {
|
||||||
|
variantIds.push(`${effortModel}-${serviceTier}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
appendVariantsForEffort(undefined);
|
||||||
|
|
||||||
for (const effort of CODEX_EFFORTS_IN_ORDER) {
|
for (const effort of CODEX_EFFORTS_IN_ORDER) {
|
||||||
variantIds.push(applyCodexEffortSuffix(normalizedModelId, effort));
|
appendVariantsForEffort(effort);
|
||||||
if (effort === maxEffort) {
|
if (effort === maxEffort) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
|||||||
description:
|
description:
|
||||||
'Newest Codex-released GPT-5 family model; falls back to GPT-5.4 on free plans',
|
'Newest Codex-released GPT-5 family model; falls back to GPT-5.4 on free plans',
|
||||||
codexMaxEffort: 'xhigh',
|
codexMaxEffort: 'xhigh',
|
||||||
|
codexServiceTiers: ['fast'],
|
||||||
presetMapping: {
|
presetMapping: {
|
||||||
default: 'gpt-5.5',
|
default: 'gpt-5.5',
|
||||||
opus: 'gpt-5.5',
|
opus: 'gpt-5.5',
|
||||||
@@ -278,6 +279,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
|||||||
name: 'GPT-5.4',
|
name: 'GPT-5.4',
|
||||||
description: 'Recommended Codex default for most coding and agentic tasks',
|
description: 'Recommended Codex default for most coding and agentic tasks',
|
||||||
codexMaxEffort: 'xhigh',
|
codexMaxEffort: 'xhigh',
|
||||||
|
codexServiceTiers: ['fast'],
|
||||||
presetMapping: {
|
presetMapping: {
|
||||||
default: 'gpt-5.4',
|
default: 'gpt-5.4',
|
||||||
opus: 'gpt-5.4',
|
opus: 'gpt-5.4',
|
||||||
|
|||||||
@@ -123,11 +123,34 @@ describe('FlexibleModelSelector', () => {
|
|||||||
|
|
||||||
expect(screen.getByText('gpt-5.3-codex-high')).toBeInTheDocument();
|
expect(screen.getByText('gpt-5.3-codex-high')).toBeInTheDocument();
|
||||||
expect(screen.getByText('gpt-5.3-codex-xhigh')).toBeInTheDocument();
|
expect(screen.getByText('gpt-5.3-codex-xhigh')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('gpt-5.4-high-fast')).toBeInTheDocument();
|
||||||
|
|
||||||
await userEvent.click(screen.getByText('gpt-5.3-codex-high'));
|
await userEvent.click(screen.getByText('gpt-5.3-codex-high'));
|
||||||
expect(onChange).toHaveBeenCalledWith('gpt-5.3-codex-high');
|
expect(onChange).toHaveBeenCalledWith('gpt-5.3-codex-high');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('offers codex fast variants without relegating saved fast selections', async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FlexibleModelSelector
|
||||||
|
label="Primary model"
|
||||||
|
value="gpt-5.4-high-fast"
|
||||||
|
onChange={onChange}
|
||||||
|
catalog={MODEL_CATALOGS.codex}
|
||||||
|
allModels={[]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /gpt-5\.4-high-fast/i })).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /gpt-5\.4-high-fast/i }));
|
||||||
|
|
||||||
|
expect(screen.queryByText('Current value')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('gpt-5.4-fast')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('gpt-5.4-high-fast').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not relegate saved codex effort variants to the legacy current-value fallback', async () => {
|
it('does not relegate saved codex effort variants to the legacy current-value fallback', async () => {
|
||||||
render(
|
render(
|
||||||
<FlexibleModelSelector
|
<FlexibleModelSelector
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import {
|
|||||||
getCodexEffortDisplay,
|
getCodexEffortDisplay,
|
||||||
getCodexEffortVariants,
|
getCodexEffortVariants,
|
||||||
parseCodexEffort,
|
parseCodexEffort,
|
||||||
|
parseCodexServiceTier,
|
||||||
stripCodexEffortSuffix,
|
stripCodexEffortSuffix,
|
||||||
} from '@/lib/codex-effort';
|
} from '@/lib/codex-effort';
|
||||||
|
|
||||||
describe('parseCodexEffort', () => {
|
describe('parseCodexEffort', () => {
|
||||||
it('parses lowercase suffixes', () => {
|
it('parses lowercase suffixes', () => {
|
||||||
expect(parseCodexEffort('gpt-5.3-codex-high')).toBe('high');
|
expect(parseCodexEffort('gpt-5.3-codex-high')).toBe('high');
|
||||||
|
expect(parseCodexEffort('gpt-5.4-high-fast')).toBe('high');
|
||||||
|
expect(parseCodexEffort('gpt-5.4-fast-high')).toBe('high');
|
||||||
expect(parseCodexEffort('gpt-5.3-codex-xhigh')).toBe('xhigh');
|
expect(parseCodexEffort('gpt-5.3-codex-xhigh')).toBe('xhigh');
|
||||||
expect(parseCodexEffort('gpt-5-mini-medium')).toBe('medium');
|
expect(parseCodexEffort('gpt-5-mini-medium')).toBe('medium');
|
||||||
});
|
});
|
||||||
@@ -21,10 +24,19 @@ describe('parseCodexEffort', () => {
|
|||||||
it('returns undefined for unsuffixed or unsupported values', () => {
|
it('returns undefined for unsuffixed or unsupported values', () => {
|
||||||
expect(parseCodexEffort('gpt-5.3-codex')).toBeUndefined();
|
expect(parseCodexEffort('gpt-5.3-codex')).toBeUndefined();
|
||||||
expect(parseCodexEffort('gpt-5.3-codex-low')).toBeUndefined();
|
expect(parseCodexEffort('gpt-5.3-codex-low')).toBeUndefined();
|
||||||
|
expect(parseCodexEffort('gpt-5.4-fast')).toBeUndefined();
|
||||||
expect(parseCodexEffort(undefined)).toBeUndefined();
|
expect(parseCodexEffort(undefined)).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parseCodexServiceTier', () => {
|
||||||
|
it('parses fast service-tier suffixes', () => {
|
||||||
|
expect(parseCodexServiceTier('gpt-5.4-fast')).toBe('fast');
|
||||||
|
expect(parseCodexServiceTier('gpt-5.4-high-fast')).toBe('fast');
|
||||||
|
expect(parseCodexServiceTier('gpt-5.4-fast-high')).toBe('fast');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getCodexEffortDisplay', () => {
|
describe('getCodexEffortDisplay', () => {
|
||||||
it('returns pinned label for suffixed models', () => {
|
it('returns pinned label for suffixed models', () => {
|
||||||
expect(getCodexEffortDisplay('gpt-5.3-codex-high')).toEqual({
|
expect(getCodexEffortDisplay('gpt-5.3-codex-high')).toEqual({
|
||||||
@@ -48,7 +60,9 @@ describe('getCodexEffortDisplay', () => {
|
|||||||
describe('codex effort helpers', () => {
|
describe('codex effort helpers', () => {
|
||||||
it('strips and reapplies codex effort suffixes', () => {
|
it('strips and reapplies codex effort suffixes', () => {
|
||||||
expect(stripCodexEffortSuffix('gpt-5.3-codex-high')).toBe('gpt-5.3-codex');
|
expect(stripCodexEffortSuffix('gpt-5.3-codex-high')).toBe('gpt-5.3-codex');
|
||||||
|
expect(stripCodexEffortSuffix('gpt-5.4-fast-high')).toBe('gpt-5.4');
|
||||||
expect(applyCodexEffortSuffix('gpt-5.3-codex-high', 'xhigh')).toBe('gpt-5.3-codex-xhigh');
|
expect(applyCodexEffortSuffix('gpt-5.3-codex-high', 'xhigh')).toBe('gpt-5.3-codex-xhigh');
|
||||||
|
expect(applyCodexEffortSuffix('gpt-5.4-fast', 'high')).toBe('gpt-5.4-high-fast');
|
||||||
expect(applyCodexEffortSuffix('gpt-5.3-codex', undefined)).toBe('gpt-5.3-codex');
|
expect(applyCodexEffortSuffix('gpt-5.3-codex', undefined)).toBe('gpt-5.3-codex');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,4 +79,15 @@ describe('codex effort helpers', () => {
|
|||||||
'gpt-5.4-mini-high',
|
'gpt-5.4-mini-high',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('builds fast variants for models with a fast service tier', () => {
|
||||||
|
expect(getCodexEffortVariants('gpt-5.4', 'high', ['fast'])).toEqual([
|
||||||
|
'gpt-5.4',
|
||||||
|
'gpt-5.4-fast',
|
||||||
|
'gpt-5.4-medium',
|
||||||
|
'gpt-5.4-medium-fast',
|
||||||
|
'gpt-5.4-high',
|
||||||
|
'gpt-5.4-high-fast',
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -14,8 +14,10 @@ describe('codex model catalog defaults', () => {
|
|||||||
expect(codex55?.tier).toBe('paid');
|
expect(codex55?.tier).toBe('paid');
|
||||||
expect(codex55?.presetMapping?.haiku).toBe('gpt-5.4-mini');
|
expect(codex55?.presetMapping?.haiku).toBe('gpt-5.4-mini');
|
||||||
expect(codex55?.codexMaxEffort).toBe('xhigh');
|
expect(codex55?.codexMaxEffort).toBe('xhigh');
|
||||||
|
expect(codex55?.codexServiceTiers).toEqual(['fast']);
|
||||||
expect(codex54?.tier).toBeUndefined();
|
expect(codex54?.tier).toBeUndefined();
|
||||||
expect(codex54?.presetMapping?.haiku).toBe('gpt-5.4-mini');
|
expect(codex54?.presetMapping?.haiku).toBe('gpt-5.4-mini');
|
||||||
|
expect(codex54?.codexServiceTiers).toEqual(['fast']);
|
||||||
expect(codex53?.presetMapping?.haiku).toBe('gpt-5.4-mini');
|
expect(codex53?.presetMapping?.haiku).toBe('gpt-5.4-mini');
|
||||||
expect(codex52?.presetMapping?.haiku).toBe('gpt-5.4-mini');
|
expect(codex52?.presetMapping?.haiku).toBe('gpt-5.4-mini');
|
||||||
expect(codex53?.codexMaxEffort).toBe('xhigh');
|
expect(codex53?.codexMaxEffort).toBe('xhigh');
|
||||||
|
|||||||
Reference in new issue
Block a user