mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
196 lines
5.8 KiB
TypeScript
196 lines
5.8 KiB
TypeScript
import { getDefaultAccount } from './account-manager';
|
|
import { getProviderCatalog } from './model-catalog';
|
|
import { normalizeModelIdForProvider } from './model-id-normalizer';
|
|
import { fetchCodexQuota } from './quota-fetcher-codex';
|
|
import { getCachedQuota, setCachedQuota } from './quota-response-cache';
|
|
import type { CodexQuotaResult } from './quota-types';
|
|
import { info, warn } from '../utils/ui';
|
|
|
|
export type CodexPlanType = CodexQuotaResult['planType'];
|
|
|
|
const FREE_SAFE_DEFAULT_MODEL = 'gpt-5.4';
|
|
const FREE_SAFE_FAST_MODEL = 'gpt-5.4-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],
|
|
['gpt-5.3-codex-spark', FREE_SAFE_FAST_MODEL],
|
|
]);
|
|
|
|
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;
|
|
}
|
|
|
|
interface CodexPlanCompatibilityDeps {
|
|
getDefaultAccount?: typeof getDefaultAccount;
|
|
fetchCodexQuota?: typeof fetchCodexQuota;
|
|
formatInfo?: typeof info;
|
|
formatWarn?: typeof warn;
|
|
}
|
|
|
|
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 {
|
|
const stripped = model
|
|
.trim()
|
|
.replace(EXTENDED_CONTEXT_SUFFIX_REGEX, '')
|
|
.replace(CODEX_PAREN_SUFFIX_REGEX, '')
|
|
.replace(CODEX_EFFORT_SUFFIX_REGEX, '')
|
|
.trim();
|
|
return normalizeModelIdForProvider(stripped, 'codex').trim().toLowerCase();
|
|
}
|
|
|
|
export function getDefaultCodexModel(): string {
|
|
return FREE_SAFE_DEFAULT_MODEL;
|
|
}
|
|
|
|
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: {
|
|
currentModel: string | undefined;
|
|
verbose: boolean;
|
|
},
|
|
deps: CodexPlanCompatibilityDeps = {}
|
|
): Promise<void> {
|
|
const { currentModel, verbose } = options;
|
|
if (!currentModel) return;
|
|
|
|
const fallbackModel = getFreePlanFallbackCodexModel(currentModel);
|
|
if (!fallbackModel) return;
|
|
|
|
const resolveDefaultAccount = deps.getDefaultAccount ?? getDefaultAccount;
|
|
const fetchQuota = deps.fetchCodexQuota ?? fetchCodexQuota;
|
|
const formatInfo = deps.formatInfo ?? info;
|
|
const formatWarn = deps.formatWarn ?? warn;
|
|
|
|
const defaultAccount = resolveDefaultAccount('codex');
|
|
if (!defaultAccount) {
|
|
console.error(
|
|
formatWarn(
|
|
`Configured Codex model "${normalizeCodexModelId(currentModel)}" may require a paid Codex plan. ` +
|
|
`If startup fails, switch to "${fallbackModel}" with "ccs codex --config".`
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
|
|
const cachedQuota = getCachedQuota<CodexQuotaResult>('codex', defaultAccount.id);
|
|
const quota = cachedQuota ?? (await fetchQuota(defaultAccount.id, verbose));
|
|
if (!cachedQuota) {
|
|
setCachedQuota('codex', defaultAccount.id, quota);
|
|
}
|
|
|
|
if (quota.planType === 'free') {
|
|
console.error(
|
|
formatInfo(
|
|
`Codex free plan detected. Keeping saved model "${normalizeCodexModelId(currentModel)}" in settings; ` +
|
|
`runtime requests will fall back to "${fallbackModel}" when needed.`
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (quota.planType) {
|
|
return;
|
|
}
|
|
|
|
console.error(
|
|
formatWarn(
|
|
`Could not verify Codex plan for model "${normalizeCodexModelId(currentModel)}". ` +
|
|
`If startup fails with model_not_supported, switch to "${fallbackModel}" via "ccs codex --config".`
|
|
)
|
|
);
|
|
}
|