mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 16:19:27 +00:00
Merge pull request #759 from kaitranntt/kai/fix/757-gpt54-model-not-supported
fix: recover Codex live-session gpt-5.4 switches
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { getDefaultAccount } from './account-manager';
|
||||
import { getProviderCatalog } from './model-catalog';
|
||||
import { fetchCodexQuota } from './quota-fetcher-codex';
|
||||
import { getCachedQuota, setCachedQuota } from './quota-response-cache';
|
||||
import type { CodexQuotaResult } from './quota-types';
|
||||
@@ -12,6 +13,9 @@ const FREE_SAFE_FAST_MODEL = 'gpt-5-codex-mini';
|
||||
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
|
||||
const CODEX_PAREN_SUFFIX_REGEX = /\((xhigh|high|medium)\)$/i;
|
||||
const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i;
|
||||
const KNOWN_CODEX_MODELS = new Set(
|
||||
(getProviderCatalog('codex')?.models ?? []).map((model) => model.id.toLowerCase())
|
||||
);
|
||||
|
||||
const FREE_PLAN_FALLBACKS = new Map<string, string>([
|
||||
['gpt-5.3-codex', FREE_SAFE_DEFAULT_MODEL],
|
||||
@@ -19,7 +23,29 @@ const FREE_PLAN_FALLBACKS = new Map<string, string>([
|
||||
['gpt-5.4', FREE_SAFE_DEFAULT_MODEL],
|
||||
]);
|
||||
|
||||
function normalizeCodexModelId(model: string): string {
|
||||
export interface CodexRuntimeFallbackModelMap {
|
||||
defaultModel?: string;
|
||||
opusModel?: string;
|
||||
sonnetModel?: string;
|
||||
haikuModel?: string;
|
||||
}
|
||||
|
||||
export interface CodexUnsupportedModelError {
|
||||
message: string | null;
|
||||
code: 'model_not_supported';
|
||||
param: string | null;
|
||||
type: string | null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isKnownCodexModel(model: string): boolean {
|
||||
return KNOWN_CODEX_MODELS.has(model);
|
||||
}
|
||||
|
||||
export function normalizeCodexModelId(model: string): string {
|
||||
return model
|
||||
.trim()
|
||||
.replace(EXTENDED_CONTEXT_SUFFIX_REGEX, '')
|
||||
@@ -37,6 +63,74 @@ export function getFreePlanFallbackCodexModel(model: string): string | null {
|
||||
return FREE_PLAN_FALLBACKS.get(normalizeCodexModelId(model)) ?? null;
|
||||
}
|
||||
|
||||
export function parseCodexUnsupportedModelError(
|
||||
statusCode: number | undefined,
|
||||
responseBody: string
|
||||
): CodexUnsupportedModelError | null {
|
||||
if (statusCode !== 400 || !responseBody.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(responseBody);
|
||||
if (
|
||||
!isRecord(parsed) ||
|
||||
!isRecord(parsed.error) ||
|
||||
parsed.error.code !== 'model_not_supported'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
message: typeof parsed.error.message === 'string' ? parsed.error.message : null,
|
||||
code: 'model_not_supported',
|
||||
param: typeof parsed.error.param === 'string' ? parsed.error.param : null,
|
||||
type: typeof parsed.error.type === 'string' ? parsed.error.type : null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveRuntimeCodexFallbackModel(options: {
|
||||
requestedModel: string;
|
||||
modelMap: CodexRuntimeFallbackModelMap;
|
||||
excludeModels?: string[];
|
||||
}): string | null {
|
||||
const requestedModel = normalizeCodexModelId(options.requestedModel);
|
||||
if (!requestedModel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const excludedModels = new Set(
|
||||
(options.excludeModels ?? []).map((model) => normalizeCodexModelId(model)).filter(Boolean)
|
||||
);
|
||||
const candidates = [
|
||||
options.modelMap.defaultModel,
|
||||
getFreePlanFallbackCodexModel(requestedModel),
|
||||
options.modelMap.opusModel,
|
||||
options.modelMap.sonnetModel,
|
||||
options.modelMap.haikuModel,
|
||||
getDefaultCodexModel(),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue;
|
||||
const normalizedCandidate = normalizeCodexModelId(candidate);
|
||||
if (
|
||||
!normalizedCandidate ||
|
||||
normalizedCandidate === requestedModel ||
|
||||
excludedModels.has(normalizedCandidate) ||
|
||||
!isKnownCodexModel(normalizedCandidate)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
return normalizedCandidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function reconcileCodexModelForActivePlan(options: {
|
||||
settingsPath: string;
|
||||
currentModel: string | undefined;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import { URL } from 'url';
|
||||
import {
|
||||
normalizeCodexModelId,
|
||||
parseCodexUnsupportedModelError,
|
||||
resolveRuntimeCodexFallbackModel,
|
||||
} from './codex-plan-compatibility';
|
||||
import { getModelMaxLevel } from './model-catalog';
|
||||
|
||||
export type CodexReasoningEffort = 'medium' | 'high' | 'xhigh';
|
||||
@@ -29,6 +34,14 @@ export interface CodexReasoningProxyConfig {
|
||||
disableEffort?: boolean;
|
||||
}
|
||||
|
||||
interface ForwardJsonContext {
|
||||
requestPath: string;
|
||||
requestedModel: string | null;
|
||||
attemptedUpstreamModel: string | null;
|
||||
effort: CodexReasoningEffort | null;
|
||||
retryCount: number;
|
||||
}
|
||||
|
||||
const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i;
|
||||
|
||||
function stripExtendedContextSuffix(model: string): string {
|
||||
@@ -170,6 +183,7 @@ export class CodexReasoningProxy {
|
||||
> &
|
||||
Pick<CodexReasoningProxyConfig, 'modelMap' | 'stripPathPrefix'>;
|
||||
private readonly modelEffort: Map<string, CodexReasoningEffort>;
|
||||
private readonly sessionFallbackByModel = new Map<string, string>();
|
||||
private readonly recent: Array<{
|
||||
at: string;
|
||||
model: string | null;
|
||||
@@ -193,6 +207,41 @@ export class CodexReasoningProxy {
|
||||
this.modelEffort = buildCodexModelEffortMap(this.config.modelMap, this.config.defaultEffort);
|
||||
}
|
||||
|
||||
private getRememberedFallback(model: string | null): string | null {
|
||||
if (!model) return null;
|
||||
return this.sessionFallbackByModel.get(normalizeCodexModelId(model)) ?? null;
|
||||
}
|
||||
|
||||
private rememberFallback(requestedModel: string, fallbackModel: string): void {
|
||||
const normalizedRequestedModel = normalizeCodexModelId(requestedModel);
|
||||
const normalizedFallbackModel = normalizeCodexModelId(fallbackModel);
|
||||
if (!normalizedRequestedModel || !normalizedFallbackModel) return;
|
||||
this.sessionFallbackByModel.set(normalizedRequestedModel, normalizedFallbackModel);
|
||||
}
|
||||
|
||||
private buildForwardBody(
|
||||
body: unknown,
|
||||
upstreamModel: string | null,
|
||||
effort: CodexReasoningEffort | null
|
||||
): unknown {
|
||||
const withUpstreamModel =
|
||||
upstreamModel && isRecord(body) ? { ...body, model: upstreamModel } : body;
|
||||
if (this.config.disableEffort || !effort) {
|
||||
return withUpstreamModel;
|
||||
}
|
||||
return injectReasoningEffortIntoBody(withUpstreamModel, effort);
|
||||
}
|
||||
|
||||
private sendBufferedResponse(
|
||||
clientRes: http.ServerResponse,
|
||||
statusCode: number,
|
||||
headers: http.IncomingHttpHeaders,
|
||||
responseBody: string
|
||||
): void {
|
||||
clientRes.writeHead(statusCode, headers);
|
||||
clientRes.end(responseBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* Treat trailing "-high/-medium/-xhigh" as an effort alias only for known codex models.
|
||||
* Prevents stripping legitimate upstream model IDs that happen to end with those tokens.
|
||||
@@ -365,41 +414,48 @@ export class CodexReasoningProxy {
|
||||
? stripExtendedContextSuffix(originalModel)
|
||||
: null;
|
||||
|
||||
// When effort is disabled (thinking mode: off), strip model suffix but don't inject reasoning
|
||||
if (this.config.disableEffort) {
|
||||
const suffixParsed = this.parseEffortAlias(normalizedRequestModel);
|
||||
const upstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel;
|
||||
const forwarded =
|
||||
upstreamModel && isRecord(parsed) ? { ...parsed, model: upstreamModel } : parsed;
|
||||
|
||||
this.log(`[disabled] model=${originalModel ?? 'null'} -> passthrough (no reasoning)`);
|
||||
await this.forwardJson(req, res, fullUpstreamUrl, forwarded);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 = this.parseEffortAlias(normalizedRequestModel);
|
||||
const upstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel;
|
||||
const effort =
|
||||
const requestedUpstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel;
|
||||
const rememberedFallback = this.getRememberedFallback(requestedUpstreamModel);
|
||||
const upstreamModel = rememberedFallback ?? requestedUpstreamModel;
|
||||
const requestedEffort =
|
||||
suffixParsed?.effort ??
|
||||
getEffortForModel(normalizedRequestModel, this.modelEffort, this.config.defaultEffort);
|
||||
const effort =
|
||||
!this.config.disableEffort && upstreamModel
|
||||
? capEffortAtModelMax(upstreamModel, requestedEffort)
|
||||
: !this.config.disableEffort
|
||||
? requestedEffort
|
||||
: null;
|
||||
const rewritten = this.buildForwardBody(parsed, upstreamModel, effort);
|
||||
|
||||
const withUpstreamModel =
|
||||
upstreamModel && isRecord(parsed) ? { ...parsed, model: upstreamModel } : parsed;
|
||||
const rewritten = injectReasoningEffortIntoBody(withUpstreamModel, effort);
|
||||
if (effort) {
|
||||
this.record(originalModel, upstreamModel, effort, requestPath);
|
||||
this.trace(
|
||||
`[${new Date().toISOString()}] model=${originalModel ?? 'null'} upstreamModel=${
|
||||
upstreamModel ?? 'null'
|
||||
} effort=${effort} path=${requestPath}`
|
||||
);
|
||||
} else {
|
||||
this.log(`[disabled] model=${originalModel ?? 'null'} -> passthrough (no reasoning)`);
|
||||
}
|
||||
|
||||
this.record(originalModel, upstreamModel, effort, requestPath);
|
||||
this.trace(
|
||||
`[${new Date().toISOString()}] model=${originalModel ?? 'null'} upstreamModel=${
|
||||
upstreamModel ?? 'null'
|
||||
} effort=${effort} path=${requestPath}`
|
||||
);
|
||||
if (rememberedFallback && rememberedFallback !== requestedUpstreamModel) {
|
||||
this.log(`Using remembered fallback ${requestedUpstreamModel} -> ${rememberedFallback}`);
|
||||
}
|
||||
|
||||
await this.forwardJson(req, res, fullUpstreamUrl, rewritten);
|
||||
await this.forwardJson(req, res, fullUpstreamUrl, rewritten, {
|
||||
requestPath,
|
||||
requestedModel: requestedUpstreamModel,
|
||||
attemptedUpstreamModel: upstreamModel,
|
||||
effort,
|
||||
retryCount: 0,
|
||||
});
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
if (!res.headersSent) {
|
||||
@@ -487,8 +543,9 @@ export class CodexReasoningProxy {
|
||||
originalReq: http.IncomingMessage,
|
||||
clientRes: http.ServerResponse,
|
||||
upstreamUrl: URL,
|
||||
body: unknown
|
||||
): Promise<void> {
|
||||
body: unknown,
|
||||
context: ForwardJsonContext
|
||||
): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const bodyString = JSON.stringify(body);
|
||||
const requestFn = this.getRequestFn(upstreamUrl);
|
||||
@@ -503,9 +560,73 @@ export class CodexReasoningProxy {
|
||||
headers: this.buildForwardHeaders(originalReq.headers, bodyString),
|
||||
},
|
||||
(upstreamRes) => {
|
||||
clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
|
||||
upstreamRes.pipe(clientRes);
|
||||
upstreamRes.on('end', () => resolve());
|
||||
const statusCode = upstreamRes.statusCode || 200;
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
clientRes.writeHead(statusCode, upstreamRes.headers);
|
||||
upstreamRes.pipe(clientRes);
|
||||
upstreamRes.on('end', () => resolve(statusCode));
|
||||
upstreamRes.on('error', reject);
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
upstreamRes.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
upstreamRes.on('end', async () => {
|
||||
try {
|
||||
const responseBody = Buffer.concat(chunks).toString('utf8');
|
||||
const unsupportedError =
|
||||
context.retryCount === 0
|
||||
? parseCodexUnsupportedModelError(statusCode, responseBody)
|
||||
: null;
|
||||
const fallbackModel =
|
||||
unsupportedError && context.requestedModel
|
||||
? resolveRuntimeCodexFallbackModel({
|
||||
requestedModel: context.requestedModel,
|
||||
modelMap: this.config.modelMap,
|
||||
excludeModels: context.attemptedUpstreamModel
|
||||
? [context.attemptedUpstreamModel]
|
||||
: undefined,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (unsupportedError && fallbackModel && context.requestedModel) {
|
||||
const retryEffort =
|
||||
!this.config.disableEffort && context.effort
|
||||
? capEffortAtModelMax(fallbackModel, context.effort)
|
||||
: null;
|
||||
const retryBody = this.buildForwardBody(body, fallbackModel, retryEffort);
|
||||
|
||||
this.log(
|
||||
`Upstream rejected model "${context.attemptedUpstreamModel}". Retrying ${context.requestPath} with "${fallbackModel}".`
|
||||
);
|
||||
|
||||
const retryStatusCode = await this.forwardJson(
|
||||
originalReq,
|
||||
clientRes,
|
||||
upstreamUrl,
|
||||
retryBody,
|
||||
{
|
||||
...context,
|
||||
attemptedUpstreamModel: fallbackModel,
|
||||
effort: retryEffort,
|
||||
retryCount: context.retryCount + 1,
|
||||
}
|
||||
);
|
||||
|
||||
if (retryStatusCode >= 200 && retryStatusCode < 300) {
|
||||
this.rememberFallback(context.requestedModel, fallbackModel);
|
||||
}
|
||||
|
||||
resolve(retryStatusCode);
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendBufferedResponse(clientRes, statusCode, upstreamRes.headers, responseBody);
|
||||
resolve(statusCode);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
upstreamRes.on('error', reject);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -3,6 +3,8 @@ import { getProviderCatalog, getModelMaxLevel } from '../../../src/cliproxy/mode
|
||||
import {
|
||||
getDefaultCodexModel,
|
||||
getFreePlanFallbackCodexModel,
|
||||
parseCodexUnsupportedModelError,
|
||||
resolveRuntimeCodexFallbackModel,
|
||||
} from '../../../src/cliproxy/codex-plan-compatibility';
|
||||
|
||||
describe('codex plan compatibility', () => {
|
||||
@@ -25,6 +27,50 @@ describe('codex plan compatibility', () => {
|
||||
expect(getFreePlanFallbackCodexModel('gpt-5.1-codex-mini')).toBeNull();
|
||||
});
|
||||
|
||||
it('detects upstream Codex model_not_supported responses', () => {
|
||||
expect(
|
||||
parseCodexUnsupportedModelError(
|
||||
400,
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: 'The requested model is not supported.',
|
||||
code: 'model_not_supported',
|
||||
param: 'model',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
message: 'The requested model is not supported.',
|
||||
code: 'model_not_supported',
|
||||
param: 'model',
|
||||
type: 'invalid_request_error',
|
||||
});
|
||||
expect(
|
||||
parseCodexUnsupportedModelError(500, '{"error":{"code":"model_not_supported"}}')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves runtime fallbacks without retrying the rejected model again', () => {
|
||||
expect(
|
||||
resolveRuntimeCodexFallbackModel({
|
||||
requestedModel: 'gpt-5.4',
|
||||
modelMap: { defaultModel: 'gpt-5-codex' },
|
||||
})
|
||||
).toBe('gpt-5-codex');
|
||||
|
||||
expect(
|
||||
resolveRuntimeCodexFallbackModel({
|
||||
requestedModel: 'gpt-5.4',
|
||||
modelMap: {
|
||||
defaultModel: 'gpt-5.4',
|
||||
haikuModel: 'gpt-5-codex-mini',
|
||||
},
|
||||
excludeModels: ['gpt-5-codex'],
|
||||
})
|
||||
).toBe('gpt-5-codex-mini');
|
||||
});
|
||||
|
||||
it('tracks Codex thinking caps for current safe defaults and paid models', () => {
|
||||
expect(getModelMaxLevel('codex', 'gpt-5-codex')).toBe('high');
|
||||
expect(getModelMaxLevel('codex', 'gpt-5-codex-mini')).toBe('high');
|
||||
|
||||
@@ -230,6 +230,90 @@ describe('CodexReasoningProxy extended-context compatibility', () => {
|
||||
expect(capturedBody?.model).toBe('enterprise-internal-high');
|
||||
});
|
||||
|
||||
it('retries unsupported live-session models once and remembers the fallback', async () => {
|
||||
const capturedModels: string[] = [];
|
||||
const capturedEfforts: Array<string | undefined> = [];
|
||||
|
||||
const upstream = http.createServer((req, res) => {
|
||||
let rawBody = '';
|
||||
req.setEncoding('utf8');
|
||||
req.on('data', (chunk) => {
|
||||
rawBody += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
const requestBody = rawBody ? (JSON.parse(rawBody) as JsonRecord) : {};
|
||||
const reasoning = requestBody.reasoning as JsonRecord | undefined;
|
||||
const model = String(requestBody.model ?? '');
|
||||
const effort = typeof reasoning?.effort === 'string' ? reasoning.effort : undefined;
|
||||
|
||||
capturedModels.push(model);
|
||||
capturedEfforts.push(effort);
|
||||
|
||||
if (model === 'gpt-5.4') {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: 'The requested model is not supported.',
|
||||
code: 'model_not_supported',
|
||||
param: 'model',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
model,
|
||||
effort: effort ?? null,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
cleanupServers.push(upstream);
|
||||
|
||||
const upstreamPort = await listenOnRandomPort(upstream);
|
||||
const proxy = new CodexReasoningProxy({
|
||||
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
|
||||
modelMap: {
|
||||
defaultModel: 'gpt-5.4',
|
||||
haikuModel: 'gpt-5-codex-mini',
|
||||
},
|
||||
defaultEffort: 'medium',
|
||||
});
|
||||
|
||||
const proxyPort = await proxy.start();
|
||||
const firstResponse = await postJson(
|
||||
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
|
||||
{
|
||||
model: 'gpt-5.4-xhigh',
|
||||
messages: [],
|
||||
}
|
||||
);
|
||||
const secondResponse = await postJson(
|
||||
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
|
||||
{
|
||||
model: 'gpt-5.4-xhigh',
|
||||
messages: [],
|
||||
}
|
||||
);
|
||||
|
||||
proxy.stop();
|
||||
|
||||
expect(firstResponse.statusCode).toBe(200);
|
||||
expect(secondResponse.statusCode).toBe(200);
|
||||
expect(firstResponse.body.model).toBe('gpt-5-codex');
|
||||
expect(firstResponse.body.effort).toBe('high');
|
||||
expect(secondResponse.body.model).toBe('gpt-5-codex');
|
||||
expect(secondResponse.body.effort).toBe('high');
|
||||
expect(capturedModels).toEqual(['gpt-5.4', 'gpt-5-codex', 'gpt-5-codex']);
|
||||
expect(capturedEfforts).toEqual(['xhigh', 'high', 'high']);
|
||||
});
|
||||
|
||||
it('keeps reasoning enabled when CCS_THINKING=high overrides config off', async () => {
|
||||
let capturedBody: JsonRecord | null = null;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Sparkles, Zap, Star, X, Plus } from 'lucide-react';
|
||||
import { FlexibleModelSelector } from '../provider-model-selector';
|
||||
@@ -12,6 +13,24 @@ import { ExtendedContextToggle } from '../extended-context-toggle';
|
||||
import { stripExtendedContextSuffix } from '@/lib/extended-context-utils';
|
||||
import type { ModelConfigSectionProps } from './types';
|
||||
|
||||
type CatalogPresetModel = NonNullable<ModelConfigSectionProps['catalog']>['models'][number];
|
||||
|
||||
function getPresetUpdates(model: CatalogPresetModel): Record<string, string> {
|
||||
const mapping = model.presetMapping || {
|
||||
default: model.id,
|
||||
opus: model.id,
|
||||
sonnet: model.id,
|
||||
haiku: model.id,
|
||||
};
|
||||
|
||||
return {
|
||||
ANTHROPIC_MODEL: mapping.default,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: mapping.opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: mapping.sonnet,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: mapping.haiku,
|
||||
};
|
||||
}
|
||||
|
||||
export function ModelConfigSection({
|
||||
catalog,
|
||||
savedPresets,
|
||||
@@ -29,8 +48,6 @@ export function ModelConfigSection({
|
||||
onDeletePreset,
|
||||
isDeletePending,
|
||||
}: ModelConfigSectionProps) {
|
||||
const showPresets = (catalog && catalog.models.length > 0) || savedPresets.length > 0;
|
||||
|
||||
// Find current model entry to check for extended context support
|
||||
// Strip [1m] suffix when looking up in catalog since catalog IDs don't have suffix
|
||||
const currentModelEntry = useMemo(() => {
|
||||
@@ -39,6 +56,37 @@ export function ModelConfigSection({
|
||||
return catalog.models.find((m) => m.id === baseModelId);
|
||||
}, [catalog, currentModel]);
|
||||
|
||||
const presetGroups = useMemo(() => {
|
||||
const presetModels = (catalog?.models ?? []).filter((model) => model.presetMapping);
|
||||
if (presetModels.length === 0) return [];
|
||||
|
||||
const hasPaidPresets = presetModels.some((model) => model.tier === 'paid');
|
||||
if (!hasPaidPresets) {
|
||||
return [{ key: 'default', models: presetModels.slice(0, 4) }];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'free',
|
||||
label: 'Free Tier',
|
||||
description: 'Available on free or paid plans',
|
||||
badgeClassName: 'text-[10px] bg-green-100 text-green-700 border-green-200',
|
||||
iconClassName: 'text-green-600',
|
||||
models: presetModels.filter((model) => model.tier !== 'paid'),
|
||||
},
|
||||
{
|
||||
key: 'paid',
|
||||
label: 'Paid Tier',
|
||||
description: 'Requires paid access',
|
||||
badgeClassName: 'text-[10px] bg-amber-100 text-amber-700 border-amber-200',
|
||||
iconClassName: 'text-amber-700',
|
||||
models: presetModels.filter((model) => model.tier === 'paid'),
|
||||
},
|
||||
].filter((group) => group.models.length > 0);
|
||||
}, [catalog]);
|
||||
|
||||
const showPresets = presetGroups.length > 0 || savedPresets.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Quick Presets */}
|
||||
@@ -49,77 +97,81 @@ export function ModelConfigSection({
|
||||
Presets
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mb-3">Apply pre-configured model mappings</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Recommended presets from catalog */}
|
||||
{catalog?.models.slice(0, 4).map((model) => (
|
||||
<Button
|
||||
key={model.id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-7 gap-1"
|
||||
onClick={() => {
|
||||
const mapping = model.presetMapping || {
|
||||
default: model.id,
|
||||
opus: model.id,
|
||||
sonnet: model.id,
|
||||
haiku: model.id,
|
||||
};
|
||||
onApplyPreset({
|
||||
ANTHROPIC_MODEL: mapping.default,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: mapping.opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: mapping.sonnet,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: mapping.haiku,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Zap className="w-3 h-3" />
|
||||
{model.name}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{/* User saved presets */}
|
||||
{savedPresets.map((preset) => (
|
||||
<div key={preset.name} className="group relative">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-xs h-7 gap-1 pr-6"
|
||||
onClick={() => {
|
||||
onApplyPreset({
|
||||
ANTHROPIC_MODEL: preset.default,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: preset.opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: preset.sonnet,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: preset.haiku,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Star className="w-3 h-3 fill-current" />
|
||||
{preset.name}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-7 w-5 opacity-0 group-hover:opacity-100 hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeletePreset(preset.name);
|
||||
}}
|
||||
disabled={isDeletePending}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
<div className="space-y-4">
|
||||
{presetGroups.map((group) => (
|
||||
<div key={group.key}>
|
||||
{'label' in group && group.label && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge variant="outline" className={group.badgeClassName}>
|
||||
{group.label}
|
||||
</Badge>
|
||||
<span className="text-[10px] text-muted-foreground">{group.description}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{group.models.map((model) => (
|
||||
<Button
|
||||
key={model.id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-7 gap-1"
|
||||
onClick={() => onApplyPreset(getPresetUpdates(model))}
|
||||
>
|
||||
<Zap
|
||||
className={`w-3 h-3 ${'iconClassName' in group ? group.iconClassName : ''}`}
|
||||
/>
|
||||
{model.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-7 gap-1 border-primary/50 text-primary hover:bg-primary/10 hover:border-primary"
|
||||
onClick={onOpenCustomPreset}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
Custom
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* User saved presets */}
|
||||
{savedPresets.map((preset) => (
|
||||
<div key={preset.name} className="group relative">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-xs h-7 gap-1 pr-6"
|
||||
onClick={() => {
|
||||
onApplyPreset({
|
||||
ANTHROPIC_MODEL: preset.default,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: preset.opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: preset.sonnet,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: preset.haiku,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Star className="w-3 h-3 fill-current" />
|
||||
{preset.name}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-7 w-5 opacity-0 group-hover:opacity-100 hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeletePreset(preset.name);
|
||||
}}
|
||||
disabled={isDeletePending}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-7 gap-1 border-primary/50 text-primary hover:bg-primary/10 hover:border-primary"
|
||||
onClick={onOpenCustomPreset}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
Custom
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -111,45 +111,56 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
codex: {
|
||||
provider: 'codex',
|
||||
displayName: 'Codex',
|
||||
defaultModel: 'gpt-5.3-codex',
|
||||
defaultModel: 'gpt-5-codex',
|
||||
models: [
|
||||
{
|
||||
id: 'gpt-5.3-codex',
|
||||
name: 'GPT-5.3 Codex',
|
||||
description: 'Supports up to xhigh effort',
|
||||
id: 'gpt-5-codex',
|
||||
name: 'GPT-5 Codex',
|
||||
description: 'Cross-plan safe Codex default',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.3-codex',
|
||||
opus: 'gpt-5.3-codex',
|
||||
sonnet: 'gpt-5.3-codex',
|
||||
haiku: 'gpt-5.1-codex-mini',
|
||||
default: 'gpt-5-codex',
|
||||
opus: 'gpt-5-codex',
|
||||
sonnet: 'gpt-5-codex',
|
||||
haiku: 'gpt-5-codex-mini',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.2-codex',
|
||||
name: 'GPT-5.2 Codex',
|
||||
description: 'Previous stable Codex model',
|
||||
id: 'gpt-5-codex-mini',
|
||||
name: 'GPT-5 Codex Mini',
|
||||
description: 'Faster and cheaper Codex option',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.2-codex',
|
||||
opus: 'gpt-5.2-codex',
|
||||
sonnet: 'gpt-5.2-codex',
|
||||
haiku: 'gpt-5.1-codex-mini',
|
||||
default: 'gpt-5-codex-mini',
|
||||
opus: 'gpt-5-codex',
|
||||
sonnet: 'gpt-5-codex',
|
||||
haiku: 'gpt-5-codex-mini',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5-mini',
|
||||
name: 'GPT-5 Mini',
|
||||
description: 'Fast, capped at high effort (no xhigh)',
|
||||
description: 'Legacy mini model ID kept for backwards compatibility',
|
||||
presetMapping: {
|
||||
default: 'gpt-5-mini',
|
||||
opus: 'gpt-5.3-codex',
|
||||
opus: 'gpt-5-codex',
|
||||
sonnet: 'gpt-5-mini',
|
||||
haiku: 'gpt-5-mini',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.1-codex-mini',
|
||||
name: 'GPT-5.1 Codex Mini',
|
||||
description: 'Legacy fast Codex mini model',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.1-codex-mini',
|
||||
opus: 'gpt-5.1-codex-max',
|
||||
sonnet: 'gpt-5.1-codex-max',
|
||||
haiku: 'gpt-5.1-codex-mini',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.1-codex-max',
|
||||
name: 'Codex Max (5.1)',
|
||||
description: 'Legacy most capable Codex model',
|
||||
name: 'GPT-5.1 Codex Max',
|
||||
description: 'Higher-effort Codex model with xhigh support',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.1-codex-max',
|
||||
opus: 'gpt-5.1-codex-max',
|
||||
@@ -158,20 +169,51 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.2',
|
||||
name: 'GPT 5.2',
|
||||
description: 'Latest GPT model',
|
||||
id: 'gpt-5.2-codex',
|
||||
name: 'GPT-5.2 Codex',
|
||||
description: 'Cross-plan Codex model with xhigh support',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.2',
|
||||
opus: 'gpt-5.2',
|
||||
sonnet: 'gpt-5.2',
|
||||
haiku: 'gpt-5.2',
|
||||
default: 'gpt-5.2-codex',
|
||||
opus: 'gpt-5.2-codex',
|
||||
sonnet: 'gpt-5.2-codex',
|
||||
haiku: 'gpt-5-codex-mini',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.1-codex-mini',
|
||||
name: 'Codex Mini',
|
||||
description: 'Fast and efficient Codex model',
|
||||
id: 'gpt-5.3-codex',
|
||||
name: 'GPT-5.3 Codex',
|
||||
tier: 'paid',
|
||||
description: 'Paid Codex plans only',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.3-codex',
|
||||
opus: 'gpt-5.3-codex',
|
||||
sonnet: 'gpt-5.3-codex',
|
||||
haiku: 'gpt-5-codex-mini',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.3-codex-spark',
|
||||
name: 'GPT-5.3 Codex Spark',
|
||||
tier: 'paid',
|
||||
description: 'Paid Codex plans only, ultra-fast coding model',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.3-codex-spark',
|
||||
opus: 'gpt-5.3-codex',
|
||||
sonnet: 'gpt-5.3-codex',
|
||||
haiku: 'gpt-5-codex-mini',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.4',
|
||||
name: 'GPT-5.4',
|
||||
tier: 'paid',
|
||||
description: 'Paid Codex plans only, latest GPT-5 family model',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.4',
|
||||
opus: 'gpt-5.4',
|
||||
sonnet: 'gpt-5.4',
|
||||
haiku: 'gpt-5-codex-mini',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, userEvent } from '@tests/setup/test-utils';
|
||||
|
||||
vi.mock('@/components/cliproxy/provider-model-selector', () => ({
|
||||
FlexibleModelSelector: () => <div data-testid="flexible-model-selector" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/cliproxy/extended-context-toggle', () => ({
|
||||
ExtendedContextToggle: () => <div data-testid="extended-context-toggle" />,
|
||||
}));
|
||||
|
||||
import { ModelConfigSection } from '@/components/cliproxy/provider-editor/model-config-section';
|
||||
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
|
||||
|
||||
describe('ModelConfigSection presets', () => {
|
||||
it('groups codex presets by free and paid tiers', async () => {
|
||||
const onApplyPreset = vi.fn();
|
||||
|
||||
render(
|
||||
<ModelConfigSection
|
||||
catalog={MODEL_CATALOGS.codex}
|
||||
savedPresets={[]}
|
||||
currentModel="gpt-5-codex"
|
||||
opusModel="gpt-5-codex"
|
||||
sonnetModel="gpt-5-codex"
|
||||
haikuModel="gpt-5-codex-mini"
|
||||
providerModels={[]}
|
||||
provider="codex"
|
||||
onApplyPreset={onApplyPreset}
|
||||
onUpdateEnvValue={vi.fn()}
|
||||
onOpenCustomPreset={vi.fn()}
|
||||
onDeletePreset={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Free Tier')).toBeInTheDocument();
|
||||
expect(screen.getByText('Paid Tier')).toBeInTheDocument();
|
||||
expect(screen.getByText('Available on free or paid plans')).toBeInTheDocument();
|
||||
expect(screen.getByText('Requires paid access')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'GPT-5.4' }));
|
||||
|
||||
expect(onApplyPreset).toHaveBeenCalledWith({
|
||||
ANTHROPIC_MODEL: 'gpt-5.4',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.4',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-codex-mini',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps non-tiered provider presets ungrouped', () => {
|
||||
render(
|
||||
<ModelConfigSection
|
||||
catalog={MODEL_CATALOGS.agy}
|
||||
savedPresets={[]}
|
||||
currentModel="claude-opus-4-6-thinking"
|
||||
opusModel="claude-opus-4-6-thinking"
|
||||
sonnetModel="claude-sonnet-4-6"
|
||||
haikuModel="claude-sonnet-4-6"
|
||||
providerModels={[]}
|
||||
provider="agy"
|
||||
onApplyPreset={vi.fn()}
|
||||
onUpdateEnvValue={vi.fn()}
|
||||
onOpenCustomPreset={vi.fn()}
|
||||
onDeletePreset={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Free Tier')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Paid Tier')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Claude Opus 4.6 Thinking' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -2,12 +2,12 @@ import { describe, expect, it } from 'vitest';
|
||||
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
|
||||
|
||||
describe('codex model catalog defaults', () => {
|
||||
it('uses gpt-5.1-codex-mini as the haiku mapping for codex presets', () => {
|
||||
it('uses gpt-5-codex-mini as the haiku mapping for cross-plan codex presets', () => {
|
||||
const codexCatalog = MODEL_CATALOGS.codex;
|
||||
const codex53 = codexCatalog.models.find((model) => model.id === 'gpt-5.3-codex');
|
||||
const codex52 = codexCatalog.models.find((model) => model.id === 'gpt-5.2-codex');
|
||||
|
||||
expect(codex53?.presetMapping?.haiku).toBe('gpt-5.1-codex-mini');
|
||||
expect(codex52?.presetMapping?.haiku).toBe('gpt-5.1-codex-mini');
|
||||
expect(codex53?.presetMapping?.haiku).toBe('gpt-5-codex-mini');
|
||||
expect(codex52?.presetMapping?.haiku).toBe('gpt-5-codex-mini');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user