Merge remote-tracking branch 'origin/dev' into kai/review/pr-1655-retry-followup

This commit is contained in:
Tam Nhu Tran committed 2026-07-22 15:08:11 -04:00
commit 2d909a162f
43 files changed
+953 -218

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CCS Dashboard</title>
<meta name="description" content="Claude Code Switch - Profile management dashboard" />
<meta name="description" content="Claude Codex Switch - Profile management dashboard" />
<!-- Favicons -->
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
@@ -122,6 +122,7 @@ export function ProviderEditor({
opusModel,
sonnetModel,
haikuModel,
fableModel,
extendedContextEnabled,
toggleExtendedContext,
handleRawJsonChange,
@@ -271,6 +272,7 @@ export function ProviderEditor({
opusModel={opusModel}
sonnetModel={sonnetModel}
haikuModel={haikuModel}
fableModel={fableModel}
providerModels={providerModels}
routing={routing}
extendedContextEnabled={extendedContextEnabled}
@@ -43,6 +43,7 @@ export function ModelConfigSection({
opusModel,
sonnetModel,
haikuModel,
fableModel,
providerModels,
routing,
provider,
@@ -67,7 +68,7 @@ export function ModelConfigSection({
const extendedContextModels = useMemo(() => {
if (!catalog) return [];
const selectedModels = [currentModel, opusModel, sonnetModel, haikuModel]
const selectedModels = [currentModel, opusModel, sonnetModel, haikuModel, fableModel]
.filter((modelId): modelId is string => Boolean(modelId))
.map((modelId) => stripExtendedContextSuffix(modelId));
@@ -75,7 +76,7 @@ export function ModelConfigSection({
return uniqueIds
.map((modelId) => findCatalogModel(catalog.provider, modelId, catalog))
.filter((model): model is NonNullable<typeof model> => Boolean(model?.extendedContext));
}, [catalog, currentModel, opusModel, sonnetModel, haikuModel]);
}, [catalog, currentModel, opusModel, sonnetModel, haikuModel, fableModel]);
const resolvedCatalogModels = useMemo(
() => getResolvedCatalogModels(catalog, providerModels),
@@ -253,6 +254,15 @@ export function ModelConfigSection({
onToggle={onExtendedContextToggle}
/>
)}
<FlexibleModelSelector
label="Fable (Frontier)"
description="Maps the fable subagent tier; leave empty to keep the native Claude Fable model"
value={fableModel}
onChange={(model) => onUpdateEnvValue('ANTHROPIC_DEFAULT_FABLE_MODEL', model)}
catalog={catalog}
allModels={providerModels}
routing={routing}
/>
<FlexibleModelSelector
label="Opus (Most capable)"
description="For complex reasoning tasks"
@@ -27,6 +27,7 @@ interface ModelConfigTabProps {
opusModel?: string;
sonnetModel?: string;
haikuModel?: string;
fableModel?: string;
providerModels: Array<{ id: string; owned_by: string }>;
routing?: CliproxyProviderRoutingHints;
/** Whether extended context (1M tokens) is enabled */
@@ -72,6 +73,7 @@ export function ModelConfigTab({
opusModel,
sonnetModel,
haikuModel,
fableModel,
providerModels,
routing,
extendedContextEnabled,
@@ -163,6 +165,7 @@ export function ModelConfigTab({
opusModel={opusModel}
sonnetModel={sonnetModel}
haikuModel={haikuModel}
fableModel={fableModel}
providerModels={providerModels}
routing={routing}
provider={provider}
@@ -126,6 +126,7 @@ export interface ModelConfigSectionProps {
opusModel?: string;
sonnetModel?: string;
haikuModel?: string;
fableModel?: string;
providerModels: Array<{ id: string; owned_by: string }>;
routing?: CliproxyProviderRoutingHints;
/** Provider name for display */
@@ -154,6 +155,7 @@ export interface UseProviderEditorReturn {
opusModel?: string;
sonnetModel?: string;
haikuModel?: string;
fableModel?: string;
/** Whether extended context (1M tokens) is enabled */
extendedContextEnabled: boolean;
/** Toggle extended context on/off */
@@ -85,6 +85,7 @@ export function useProviderEditor(
const opusModel = currentSettings?.env?.ANTHROPIC_DEFAULT_OPUS_MODEL;
const sonnetModel = currentSettings?.env?.ANTHROPIC_DEFAULT_SONNET_MODEL;
const haikuModel = currentSettings?.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL;
const fableModel = currentSettings?.env?.ANTHROPIC_DEFAULT_FABLE_MODEL;
// Extended context is enabled if any model has [1m] suffix
const extendedContextEnabled = useMemo(() => {
@@ -224,6 +225,7 @@ export function useProviderEditor(
opusModel,
sonnetModel,
haikuModel,
fableModel,
extendedContextEnabled,
toggleExtendedContext,
handleRawJsonChange,
@@ -10,16 +10,31 @@ import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { SearchableSelect } from '@/components/ui/searchable-select';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { Switch } from '@/components/ui/switch';
import type { CliproxyProviderRoutingHints } from '@/lib/api-client';
import type { CodexEffort, CodexServiceTier } from '@/lib/codex-effort';
import {
applyCodexServiceTierSuffix,
getCodexEffortDisplay,
getCodexEffortVariants,
parseCodexEffort,
parseCodexServiceTier,
stripCodexEffortSuffix,
} from '@/lib/codex-effort';
import { getResolvedCatalogModels, getSupplementalCatalogModels } from '@/lib/model-catalogs';
import {
findCatalogModel,
getResolvedCatalogModels,
getSupplementalCatalogModels,
} from '@/lib/model-catalogs';
import { getReasoningAdapter } from '@/lib/reasoning-control';
import { cn } from '@/lib/utils';
/** Model entry from catalog */
@@ -47,6 +62,8 @@ export interface ModelEntry {
codexEfforts?: CodexEffort[];
/** Additional Codex service-tier suffixes supported by this model in the dashboard UI. */
codexServiceTiers?: CodexServiceTier[];
/** Reasoning-level suffixes this model supports via paren syntax in the dashboard UI. */
reasoningLevels?: string[];
}
/** Provider catalog */
@@ -561,6 +578,34 @@ export function FlexibleModelSelector({
}
: null;
const hasAvailableModels = recommendedOptions.length + allModelOptions.length > 0;
const reasoningAdapter = getReasoningAdapter(catalog?.provider);
const currentReasoningLevel = reasoningAdapter?.parse(value);
const selectableReasoningLevels =
reasoningAdapter && catalog
? reasoningAdapter.getOptions(
reasoningAdapter.strip(normalizeModelValue(value, routing)),
catalog
)
: null;
const reasoningOptions =
selectableReasoningLevels &&
currentReasoningLevel &&
!selectableReasoningLevels.includes(currentReasoningLevel)
? [...selectableReasoningLevels, currentReasoningLevel]
: selectableReasoningLevels;
const currentServiceTier = isCodexProvider ? parseCodexServiceTier(value) : undefined;
const selectedCodexBaseModel =
isCodexProvider && catalog && value
? findCatalogModel(
catalog.provider,
stripCodexEffortSuffix(normalizeModelValue(value, routing)),
catalog
)
: undefined;
const showFastToggle =
isCodexProvider &&
(selectedCodexBaseModel?.codexServiceTiers?.includes('fast') === true ||
currentServiceTier === 'fast');
return (
<div className="space-y-1.5">
@@ -568,87 +613,129 @@ export function FlexibleModelSelector({
<label className="text-xs font-medium">{label}</label>
{description && <p className="text-[10px] text-muted-foreground">{description}</p>}
</div>
<SearchableSelect
value={value || undefined}
onChange={onChange}
disabled={disabled}
placeholder={t('providerModelSelector.selectModel')}
searchPlaceholder={t('searchableSelect.searchModels')}
emptyText={
hasAvailableModels
? t('searchableSelect.noResults')
: t('providerModelSelector.noModelsAvailable')
}
triggerClassName="h-9"
createOption={createCustomModelOption}
groups={[
...(selectedValueMissing && legacySelectedOption
? [
{
key: 'current',
label: (
<span className="text-xs text-muted-foreground">
{t('providerModelSelector.currentValue')}
</span>
),
},
]
: []),
{
key: 'recommended',
label: (
<span className="text-xs text-primary">{t('providerModelSelector.recommended')}</span>
),
},
...(isCodexProvider
? [
{
key: 'codex-reasoning',
label: (
<span className="text-xs text-muted-foreground">
{t('providerModelSelector.codexReasoningVariants')}
</span>
),
},
{
key: 'codex-fast',
label: (
<span className="text-xs text-amber-600">
{t('providerModelSelector.codexFastVariants')}
</span>
),
},
]
: []),
...(allModelOptions.length > 0
? [
{
key: 'all',
label: (
<span className="text-xs text-muted-foreground">
{t('providerModelSelector.allModelsCount', {
count: allModelOptions.length,
})}
</span>
),
},
]
: []),
{
key: 'custom',
label: (
<span className="text-xs text-muted-foreground">
{t('providerModelSelector.customModel')}
</span>
),
},
]}
options={[
...(selectedValueMissing && legacySelectedOption ? [legacySelectedOption] : []),
...recommendedOptions,
...allModelOptions,
]}
/>
<div className="flex gap-2">
<SearchableSelect
value={value || undefined}
onChange={onChange}
disabled={disabled}
placeholder={t('providerModelSelector.selectModel')}
searchPlaceholder={t('searchableSelect.searchModels')}
emptyText={
hasAvailableModels
? t('searchableSelect.noResults')
: t('providerModelSelector.noModelsAvailable')
}
className="min-w-0 flex-1"
triggerClassName="h-9"
createOption={createCustomModelOption}
groups={[
...(selectedValueMissing && legacySelectedOption
? [
{
key: 'current',
label: (
<span className="text-xs text-muted-foreground">
{t('providerModelSelector.currentValue')}
</span>
),
},
]
: []),
{
key: 'recommended',
label: (
<span className="text-xs text-primary">
{t('providerModelSelector.recommended')}
</span>
),
},
...(isCodexProvider
? [
{
key: 'codex-reasoning',
label: (
<span className="text-xs text-muted-foreground">
{t('providerModelSelector.codexReasoningVariants')}
</span>
),
},
{
key: 'codex-fast',
label: (
<span className="text-xs text-amber-600">
{t('providerModelSelector.codexFastVariants')}
</span>
),
},
]
: []),
...(allModelOptions.length > 0
? [
{
key: 'all',
label: (
<span className="text-xs text-muted-foreground">
{t('providerModelSelector.allModelsCount', {
count: allModelOptions.length,
})}
</span>
),
},
]
: []),
{
key: 'custom',
label: (
<span className="text-xs text-muted-foreground">
{t('providerModelSelector.customModel')}
</span>
),
},
]}
options={[
...(selectedValueMissing && legacySelectedOption ? [legacySelectedOption] : []),
...recommendedOptions,
...allModelOptions,
]}
/>
{reasoningAdapter && reasoningOptions && (
<Select
value={currentReasoningLevel ?? 'auto'}
onValueChange={(level) =>
onChange(reasoningAdapter.apply(value, level === 'auto' ? undefined : level))
}
disabled={disabled || !value}
>
<SelectTrigger
className="h-9 w-28 shrink-0"
aria-label={t('providerModelSelector.effortLabel')}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t('providerModelSelector.effortAuto')}</SelectItem>
{reasoningOptions.map((level) => (
<SelectItem key={level} value={level}>
{level}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{showFastToggle && (
<label className="flex h-9 shrink-0 items-center gap-1.5">
<Switch
checked={currentServiceTier === 'fast'}
onCheckedChange={(checked) => onChange(applyCodexServiceTierSuffix(value, checked))}
disabled={disabled || !value}
aria-label={t('providerModelSelector.fastTier')}
/>
<span className="text-xs text-muted-foreground">
{t('providerModelSelector.fastTier')}
</span>
</label>
)}
</div>
{selectedRoutingHint ? (
<div
className={cn(
+17 -1
View File
@@ -2,7 +2,7 @@ export type CodexEffort = 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
export type CodexServiceTier = 'fast';
const CODEX_TUNING_SUFFIX_TOKEN_REGEX = /-(minimal|low|medium|high|xhigh|fast)$/i;
const CODEX_EFFORTS_IN_ORDER: readonly CodexEffort[] = [
export const CODEX_EFFORTS_IN_ORDER: readonly CodexEffort[] = [
'minimal',
'low',
'medium',
@@ -66,6 +66,12 @@ export function applyCodexEffortSuffix(
return [normalizedModelId, effort, parsed.serviceTier].filter(Boolean).join('-');
}
export function applyCodexServiceTierSuffix(modelId: string | undefined, enabled: boolean): string {
const parsed = parseCodexTuningSuffix(modelId);
if (!parsed.baseModel) return parsed.baseModel;
return [parsed.baseModel, parsed.effort, enabled ? 'fast' : undefined].filter(Boolean).join('-');
}
export function getCodexEffortVariants(
modelId: string,
maxEffort: CodexEffort | undefined,
@@ -102,6 +108,16 @@ export function getCodexEffortVariants(
return variantIds;
}
export function getSelectableCodexEfforts(
maxEffort: CodexEffort | undefined,
efforts?: readonly CodexEffort[]
): CodexEffort[] {
if (efforts?.length) return [...efforts];
if (!maxEffort) return [...CODEX_EFFORTS_IN_ORDER];
const maxEffortIndex = CODEX_EFFORTS_IN_ORDER.indexOf(maxEffort);
return CODEX_EFFORTS_IN_ORDER.slice(0, maxEffortIndex + 1);
}
export function getCodexEffortDisplay(
modelId: string | undefined,
effortLabels?: { pinned: (effort: string) => string; auto: string }
+20 -5
View File
@@ -228,6 +228,9 @@ const resources = {
'Pinned model is not currently advertised by the proxy: {{model}}',
codexReasoningVariants: 'Reasoning variants',
codexFastVariants: 'Fast variants',
effortLabel: 'Effort',
effortAuto: 'Auto',
fastTier: 'Fast',
},
createAuthProfileDialog: {
title: 'Create New Account',
@@ -1582,7 +1585,7 @@ const resources = {
// ========================================
heroSection: {
title: 'CCS Config',
subtitle: 'Claude Code Switch Dashboard',
subtitle: 'Claude Codex Switch Dashboard',
},
hubFooter: {
logs: 'Logs',
@@ -2960,6 +2963,9 @@ const resources = {
pinnedModelNotAdvertised: '固定模型当前未被代理广播:{{model}}',
codexReasoningVariants: '推理变体',
codexFastVariants: '快速变体',
effortLabel: '推理强度',
effortAuto: '自动',
fastTier: '快速',
},
createAuthProfileDialog: {
title: '创建新账号',
@@ -4233,7 +4239,7 @@ const resources = {
},
heroSection: {
title: 'CCS Config',
subtitle: 'Claude Code Switch Dashboard',
subtitle: 'Claude Codex Switch Dashboard',
},
hubFooter: {
logs: '日志',
@@ -5575,6 +5581,9 @@ const resources = {
pinnedModelNotAdvertised: 'Mô hình ghim hiện không được proxy quảng bá: {{model}}',
codexReasoningVariants: 'Biến thể lý luận',
codexFastVariants: 'Biến thể nhanh',
effortLabel: 'Mức suy luận',
effortAuto: 'Tự động',
fastTier: 'Nhanh',
},
createAuthProfileDialog: {
title: 'Tạo tài khoản mới',
@@ -6943,7 +6952,7 @@ const resources = {
},
heroSection: {
title: 'CCS Config',
subtitle: 'Bảng điều khiển Claude Code Switch',
subtitle: 'Bảng điều khiển Claude Codex Switch',
},
hubFooter: {
logs: 'Nhật ký',
@@ -8306,6 +8315,9 @@ const resources = {
pinnedModelNotAdvertised: 'ピンモデルは現在プロキシから通知されていません: {{model}}',
codexReasoningVariants: '推論バリアント',
codexFastVariants: '高速バリアント',
effortLabel: '推論レベル',
effortAuto: '自動',
fastTier: '高速',
},
createAuthProfileDialog: {
title: '新しいアカウントを作成',
@@ -10136,7 +10148,7 @@ const resources = {
},
heroSection: {
title: 'CCS Config',
subtitle: 'Claude Code Switch Dashboard',
subtitle: 'Claude Codex Switch Dashboard',
},
homePageV2: {
title: 'ホーム',
@@ -11060,6 +11072,9 @@ const resources = {
pinnedModelNotAdvertised: '고정된 모델이 현재 프록시에서 알리지 않고 있습니다: {{model}}',
codexReasoningVariants: '추론 변형',
codexFastVariants: '빠른 변형',
effortLabel: '추론 강도',
effortAuto: '자동',
fastTier: '고속',
},
createAuthProfileDialog: {
title: '새 계정 생성',
@@ -12414,7 +12429,7 @@ const resources = {
// ========================================
heroSection: {
title: 'CCS Config',
subtitle: 'Claude Code Switch 대시보드',
subtitle: 'Claude Codex Switch 대시보드',
},
hubFooter: {
logs: '로그',
+6
View File
@@ -402,11 +402,13 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
id: 'grok-4.5',
name: 'Grok 4.5',
description: 'Frontier model for coding, engineering, and agentic workflows',
reasoningLevels: ['low', 'medium', 'high'],
},
{
id: 'grok-4.3',
name: 'Grok 4.3',
description: 'General-purpose Grok model with a one-million-token context window',
reasoningLevels: ['none', 'low', 'medium', 'high'],
},
{
id: 'grok-4.20-0309-reasoning',
@@ -1147,6 +1149,10 @@ export function buildUiCatalog(
? undefined
: (model.extendedContext ?? staticModel?.extendedContext),
presetMapping: staticModel?.presetMapping,
reasoningLevels: model.reasoningLevels ?? staticModel?.reasoningLevels,
codexMaxEffort: model.codexMaxEffort ?? staticModel?.codexMaxEffort,
codexEfforts: model.codexEfforts ?? staticModel?.codexEfforts,
codexServiceTiers: model.codexServiceTiers ?? staticModel?.codexServiceTiers,
};
});
+63
View File
@@ -0,0 +1,63 @@
import type { ProviderCatalog } from '@/components/cliproxy/provider-model-selector';
import {
applyCodexEffortSuffix,
getSelectableCodexEfforts,
parseCodexEffort,
stripCodexEffortSuffix,
type CodexEffort,
} from '@/lib/codex-effort';
import { findCatalogModel } from '@/lib/model-catalogs';
export interface ReasoningAdapter {
parse(value: string | undefined): string | undefined;
strip(value: string | undefined): string;
apply(value: string | undefined, level?: string): string;
getOptions(baseId: string, catalog: ProviderCatalog): string[] | null;
}
const XAI_LEVEL_SUFFIX_REGEX = /\(([a-z]+)\)$/i;
export function parseXaiReasoningLevel(modelId: string | undefined): string | undefined {
const match = modelId?.trim().match(XAI_LEVEL_SUFFIX_REGEX);
return match?.[1]?.toLowerCase();
}
export function stripXaiReasoningSuffix(modelId: string | undefined): string {
return modelId?.trim().replace(XAI_LEVEL_SUFFIX_REGEX, '') ?? '';
}
export function applyXaiReasoningSuffix(modelId: string | undefined, level?: string): string {
const baseModelId = stripXaiReasoningSuffix(modelId);
if (!baseModelId || !level) return baseModelId;
return `${baseModelId}(${level})`;
}
const codexAdapter: ReasoningAdapter = {
parse: (value) => parseCodexEffort(value),
strip: (value) => stripCodexEffortSuffix(value),
apply: (value, level) => applyCodexEffortSuffix(value, level as CodexEffort | undefined),
getOptions: (baseId, catalog) => {
const model = baseId ? findCatalogModel(catalog.provider, baseId, catalog) : undefined;
return getSelectableCodexEfforts(model?.codexMaxEffort, model?.codexEfforts);
},
};
const xaiAdapter: ReasoningAdapter = {
parse: parseXaiReasoningLevel,
strip: stripXaiReasoningSuffix,
apply: applyXaiReasoningSuffix,
getOptions: (baseId, catalog) => {
if (!baseId) return null;
const levels = findCatalogModel(catalog.provider, baseId, catalog)?.reasoningLevels;
return levels?.length ? [...levels] : null;
},
};
const REASONING_ADAPTERS: Record<string, ReasoningAdapter> = {
codex: codexAdapter,
xai: xaiAdapter,
};
export function getReasoningAdapter(provider: string | undefined): ReasoningAdapter | undefined {
return provider ? REASONING_ADAPTERS[provider.toLowerCase()] : undefined;
}
@@ -13,6 +13,29 @@ import { ModelConfigSection } from '@/components/cliproxy/provider-editor/model-
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
describe('ModelConfigSection presets', () => {
it('renders five tier selectors including the fable row', () => {
render(
<ModelConfigSection
catalog={MODEL_CATALOGS.codex}
savedPresets={[]}
currentModel="gpt-5-codex"
opusModel="gpt-5-codex"
sonnetModel="gpt-5-codex"
haikuModel="gpt-5-codex-mini"
fableModel="gpt-5.6-sol-xhigh"
providerModels={[]}
provider="codex"
onExtendedContextToggle={vi.fn()}
onApplyPreset={vi.fn()}
onUpdateEnvValue={vi.fn()}
onOpenCustomPreset={vi.fn()}
onDeletePreset={vi.fn()}
/>
);
expect(screen.getAllByTestId('flexible-model-selector')).toHaveLength(5);
});
it('groups codex presets by free and paid tiers', async () => {
const onApplyPreset = vi.fn();
@@ -152,6 +152,44 @@ describe('useProviderEditor', () => {
});
});
it('derives fableModel and round-trips ANTHROPIC_DEFAULT_FABLE_MODEL updates', async () => {
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/api/settings/codex/raw')) {
return Promise.resolve(
createJsonResponse({
profile: 'codex',
settings: {
env: {
ANTHROPIC_MODEL: 'gpt-5.3-codex-high',
ANTHROPIC_DEFAULT_FABLE_MODEL: 'gpt-5.6-sol-xhigh',
},
},
mtime: 1,
path: '~/.ccs/codex.settings.json',
})
);
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
})
);
const { result } = renderHook(() => useProviderEditor('codex'), { wrapper });
await waitFor(() => expect(result.current.fableModel).toBe('gpt-5.6-sol-xhigh'));
act(() => {
result.current.updateEnvValue('ANTHROPIC_DEFAULT_FABLE_MODEL', 'gpt-5.6-sol-max');
});
const nextSettings = JSON.parse(result.current.rawJsonContent);
expect(nextSettings.env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('gpt-5.6-sol-max');
});
it('preserves explicit codex effort suffixes in editor state updates', async () => {
vi.stubGlobal(
'fetch',
+77
View File
@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest';
import {
applyCodexEffortSuffix,
applyCodexServiceTierSuffix,
getCodexEffortDisplay,
getCodexEffortVariants,
getSelectableCodexEfforts,
parseCodexEffort,
parseCodexServiceTier,
stripCodexEffortSuffix,
@@ -85,6 +87,15 @@ describe('codex effort helpers', () => {
]);
});
it('replaces effort while preserving the fast service tier', () => {
expect(applyCodexEffortSuffix('gpt-5.4-high-fast', 'low')).toBe('gpt-5.4-low-fast');
expect(applyCodexEffortSuffix('gpt-5.4-high-fast', undefined)).toBe('gpt-5.4-fast');
});
it('applies effort suffixes to routing-prefixed model ids', () => {
expect(applyCodexEffortSuffix('codex/gpt-5.6-sol', 'high')).toBe('codex/gpt-5.6-sol-high');
});
it('builds fast variants for models with a fast service tier', () => {
expect(getCodexEffortVariants('gpt-5.4', 'high', ['fast'])).toEqual([
'gpt-5.4',
@@ -100,3 +111,69 @@ describe('codex effort helpers', () => {
]);
});
});
describe('applyCodexServiceTierSuffix', () => {
it('adds the fast tier while preserving the effort suffix', () => {
expect(applyCodexServiceTierSuffix('gpt-5.4-high', true)).toBe('gpt-5.4-high-fast');
});
it('removes only the fast tier', () => {
expect(applyCodexServiceTierSuffix('gpt-5.4-high-fast', false)).toBe('gpt-5.4-high');
expect(applyCodexServiceTierSuffix('gpt-5.4-fast', false)).toBe('gpt-5.4');
});
it('preserves routing prefixes', () => {
expect(applyCodexServiceTierSuffix('codex/gpt-5.6-sol', true)).toBe('codex/gpt-5.6-sol-fast');
});
it('is idempotent and safe on empty input', () => {
expect(applyCodexServiceTierSuffix('gpt-5.4-fast', true)).toBe('gpt-5.4-fast');
expect(applyCodexServiceTierSuffix('gpt-5.4', false)).toBe('gpt-5.4');
expect(applyCodexServiceTierSuffix(undefined, true)).toBe('');
expect(applyCodexServiceTierSuffix('', true)).toBe('');
});
it('composes with applyCodexEffortSuffix in both orders', () => {
expect(applyCodexEffortSuffix(applyCodexServiceTierSuffix('gpt-5.4', true), 'high')).toBe(
'gpt-5.4-high-fast'
);
expect(applyCodexServiceTierSuffix(applyCodexEffortSuffix('gpt-5.4', 'high'), true)).toBe(
'gpt-5.4-high-fast'
);
expect(
applyCodexEffortSuffix(applyCodexServiceTierSuffix('gpt-5.4-high-fast', false), 'low')
).toBe('gpt-5.4-low');
});
});
describe('getSelectableCodexEfforts', () => {
it('returns the explicit efforts list when provided', () => {
expect(getSelectableCodexEfforts('xhigh', ['low', 'medium', 'high', 'xhigh'])).toEqual([
'low',
'medium',
'high',
'xhigh',
]);
});
it('returns efforts up to and including the max effort', () => {
expect(getSelectableCodexEfforts('xhigh')).toEqual([
'minimal',
'low',
'medium',
'high',
'xhigh',
]);
expect(getSelectableCodexEfforts('medium')).toEqual(['minimal', 'low', 'medium']);
});
it('returns all efforts for custom models without catalog metadata', () => {
expect(getSelectableCodexEfforts(undefined)).toEqual([
'minimal',
'low',
'medium',
'high',
'xhigh',
]);
});
});
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { buildUiCatalog } from '@/lib/model-catalogs';
describe('buildUiCatalog codex tuning metadata merge', () => {
it('preserves static codex effort and service-tier metadata when the live catalog omits them', () => {
const merged = buildUiCatalog('codex', {
provider: 'codex',
displayName: 'Codex',
defaultModel: 'gpt-5.6-sol',
models: [{ id: 'gpt-5.6-sol', name: 'GPT-5.6 Sol' }],
});
const model = merged?.models.find((entry) => entry.id === 'gpt-5.6-sol');
expect(model?.codexMaxEffort).toBe('xhigh');
expect(model?.codexEfforts).toEqual(['low', 'medium', 'high', 'xhigh']);
expect(model?.codexServiceTiers).toEqual(['fast']);
});
it('prefers live metadata over static when the live catalog provides it', () => {
const merged = buildUiCatalog('codex', {
provider: 'codex',
displayName: 'Codex',
defaultModel: 'gpt-5.6-sol',
models: [{ id: 'gpt-5.6-sol', name: 'GPT-5.6 Sol', codexServiceTiers: [] }],
});
const model = merged?.models.find((entry) => entry.id === 'gpt-5.6-sol');
expect(model?.codexServiceTiers).toEqual([]);
});
});
@@ -0,0 +1,168 @@
import { describe, expect, it } from 'vitest';
import type { ProviderCatalog } from '@/components/cliproxy/provider-model-selector';
import {
applyXaiReasoningSuffix,
getReasoningAdapter,
parseXaiReasoningLevel,
stripXaiReasoningSuffix,
} from '@/lib/reasoning-control';
const xaiCatalog: ProviderCatalog = {
provider: 'xai',
displayName: 'xAI (Grok)',
defaultModel: 'grok-build-0.1',
models: [
{ id: 'grok-build-0.1', name: 'Grok Build 0.1' },
{ id: 'grok-4.5', name: 'Grok 4.5', reasoningLevels: ['low', 'medium', 'high'] },
{ id: 'grok-4.3', name: 'Grok 4.3', reasoningLevels: ['none', 'low', 'medium', 'high'] },
],
};
const codexCatalog: ProviderCatalog = {
provider: 'codex',
displayName: 'Codex',
defaultModel: 'gpt-5.3-codex',
models: [
{ id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', codexMaxEffort: 'xhigh' },
{ id: 'gpt-5.4-mini', name: 'GPT-5.4 Mini', codexMaxEffort: 'high' },
{
id: 'gpt-5.6-sol',
name: 'GPT-5.6 Sol',
codexEfforts: ['low', 'medium', 'high', 'xhigh'],
},
],
};
describe('xai paren codec', () => {
it('parses paren level suffixes', () => {
expect(parseXaiReasoningLevel('grok-4.5(high)')).toBe('high');
expect(parseXaiReasoningLevel('grok-4.3(none)')).toBe('none');
expect(parseXaiReasoningLevel('grok-4.5(HIGH)')).toBe('high');
});
it('returns undefined for unsuffixed or non-level values', () => {
expect(parseXaiReasoningLevel('grok-4.5')).toBeUndefined();
expect(parseXaiReasoningLevel('grok-4(1m)')).toBeUndefined();
expect(parseXaiReasoningLevel(undefined)).toBeUndefined();
});
it('only matches the suffix at end-of-string', () => {
expect(parseXaiReasoningLevel('grok(high)-fast')).toBeUndefined();
expect(stripXaiReasoningSuffix('grok(high)-fast')).toBe('grok(high)-fast');
});
it('strips paren suffixes for catalog lookup', () => {
expect(stripXaiReasoningSuffix('grok-4.5(high)')).toBe('grok-4.5');
expect(stripXaiReasoningSuffix('grok-4.5')).toBe('grok-4.5');
expect(stripXaiReasoningSuffix(undefined)).toBe('');
});
it('applies and replaces paren suffixes', () => {
expect(applyXaiReasoningSuffix('grok-4.5', 'high')).toBe('grok-4.5(high)');
expect(applyXaiReasoningSuffix('grok-4.5(high)', 'low')).toBe('grok-4.5(low)');
expect(applyXaiReasoningSuffix('grok-4.3', 'none')).toBe('grok-4.3(none)');
});
it('strips the suffix when no level is given', () => {
expect(applyXaiReasoningSuffix('grok-4.5(high)', undefined)).toBe('grok-4.5');
expect(applyXaiReasoningSuffix('grok-4.3(none)', undefined)).toBe('grok-4.3');
expect(applyXaiReasoningSuffix(undefined, 'high')).toBe('');
});
it('round-trips through parse and apply', () => {
const stored = applyXaiReasoningSuffix('grok-4.5', 'high');
expect(parseXaiReasoningLevel(stored)).toBe('high');
expect(applyXaiReasoningSuffix(stored, parseXaiReasoningLevel(stored))).toBe(stored);
});
});
describe('getReasoningAdapter', () => {
it('returns adapters for codex and xai only', () => {
expect(getReasoningAdapter('codex')).toBeDefined();
expect(getReasoningAdapter('xai')).toBeDefined();
expect(getReasoningAdapter('XAI')).toBeDefined();
expect(getReasoningAdapter('gemini')).toBeUndefined();
expect(getReasoningAdapter(undefined)).toBeUndefined();
});
});
function requireAdapter(provider: string) {
const adapter = getReasoningAdapter(provider);
if (!adapter) throw new Error(`missing reasoning adapter for ${provider}`);
return adapter;
}
describe('xai adapter', () => {
const adapter = requireAdapter('xai');
it('returns per-model levels from catalog metadata', () => {
expect(adapter.getOptions('grok-4.5', xaiCatalog)).toEqual(['low', 'medium', 'high']);
expect(adapter.getOptions('grok-4.3', xaiCatalog)).toEqual(['none', 'low', 'medium', 'high']);
});
it('hides the control for models without levels metadata', () => {
expect(adapter.getOptions('grok-build-0.1', xaiCatalog)).toBeNull();
expect(adapter.getOptions('grok-unknown-model', xaiCatalog)).toBeNull();
expect(adapter.getOptions('', xaiCatalog)).toBeNull();
});
it('writes and clears suffixed values through apply', () => {
expect(adapter.apply('grok-4.5', 'high')).toBe('grok-4.5(high)');
expect(adapter.apply('grok-4.5(high)', undefined)).toBe('grok-4.5');
expect(adapter.apply('grok-4.3', 'none')).toBe('grok-4.3(none)');
});
it('pre-selects stored suffixed values', () => {
expect(adapter.parse('grok-4.5(high)')).toBe('high');
expect(adapter.strip('grok-4.5(high)')).toBe('grok-4.5');
});
});
describe('codex adapter', () => {
const adapter = requireAdapter('codex');
it('delegates parse and strip to codex-effort helpers', () => {
expect(adapter.parse('gpt-5.3-codex-high')).toBe('high');
expect(adapter.parse('gpt-5.4-high-fast')).toBe('high');
expect(adapter.parse('gpt-5.3-codex')).toBeUndefined();
expect(adapter.strip('gpt-5.4-fast-high')).toBe('gpt-5.4');
});
it('applies dash suffixes preserving fast tier and routing prefix', () => {
expect(adapter.apply('gpt-5.4-high-fast', 'low')).toBe('gpt-5.4-low-fast');
expect(adapter.apply('gpt-5.4-high-fast', undefined)).toBe('gpt-5.4-fast');
expect(adapter.apply('codex/gpt-5.6-sol', 'high')).toBe('codex/gpt-5.6-sol-high');
});
it('returns catalog-driven effort options', () => {
expect(adapter.getOptions('gpt-5.4-mini', codexCatalog)).toEqual([
'minimal',
'low',
'medium',
'high',
]);
expect(adapter.getOptions('gpt-5.6-sol', codexCatalog)).toEqual([
'low',
'medium',
'high',
'xhigh',
]);
});
it('falls back to all five efforts for custom or unselected models', () => {
expect(adapter.getOptions('my-org/custom-model', codexCatalog)).toEqual([
'minimal',
'low',
'medium',
'high',
'xhigh',
]);
expect(adapter.getOptions('', codexCatalog)).toEqual([
'minimal',
'low',
'medium',
'high',
'xhigh',
]);
});
});
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it, vi } from 'vitest';
import type { UserConfig } from 'vite';
vi.mock('vite', () => ({ defineConfig: (config: UserConfig) => config }));
vi.mock('@vitejs/plugin-react', () => ({ default: () => ({ name: 'react' }) }));
vi.mock('@tailwindcss/vite', () => ({ default: () => ({ name: 'tailwindcss' }) }));
const { default: viteConfig } = await import('../../vite.config');
type ProxyRequest = {
getHeader(name: string): string | undefined;
setHeader(name: string, value: string): void;
};
type IncomingRequest = {
headers: { origin?: string };
};
type ProxyListener = (proxyRequest: ProxyRequest, request: IncomingRequest) => void;
function getProxyListener(route: '/api' | '/ws', event: 'proxyReq' | 'proxyReqWs'): ProxyListener {
const proxyOptions = (viteConfig as UserConfig).server?.proxy?.[route];
if (!proxyOptions || typeof proxyOptions === 'string' || !proxyOptions.configure) {
throw new Error(`Missing configure hook for ${route}`);
}
const listeners = new Map<string, ProxyListener>();
const proxy = {
on(registeredEvent: string, listener: ProxyListener) {
listeners.set(registeredEvent, listener);
},
};
proxyOptions.configure(proxy as never, proxyOptions);
const listener = listeners.get(event);
if (!listener) {
throw new Error(`Missing ${event} listener for ${route}`);
}
return listener;
}
function applyOriginRewrite(listener: ProxyListener, origin?: string): string | undefined {
const headers = new Map<string, string>();
if (origin !== undefined) {
headers.set('origin', origin);
}
listener(
{
getHeader: (name) => headers.get(name),
setHeader: (name, value) => headers.set(name, value),
},
{ headers: { origin } }
);
return headers.get('origin');
}
it('requires the trusted Vite development port', () => {
expect((viteConfig as UserConfig).server?.strictPort).toBe(true);
});
describe.each([
['/api', 'proxyReq'],
['/ws', 'proxyReqWs'],
] as const)('%s Vite dev proxy origin handling', (route, event) => {
const listener = getProxyListener(route, event);
it.each(['http://localhost:5173', 'http://127.0.0.1:5173', 'http://[::1]:5173'])(
'rewrites trusted Vite origin %s to the backend origin',
(origin) => {
expect(applyOriginRewrite(listener, origin)).toBe('http://localhost:3000');
}
);
it.each([
'http://attacker.example.test',
'http://localhost.evil:5173',
'http://localhost:5174',
'https://localhost:5173',
])('preserves untrusted origin %s', (origin) => {
expect(applyOriginRewrite(listener, origin)).toBe(origin);
});
it('preserves a missing origin', () => {
expect(applyOriginRewrite(listener)).toBeUndefined();
});
});
+33 -1
View File
@@ -1,10 +1,33 @@
import type { ClientRequest, IncomingMessage } from 'node:http';
import { defineConfig } from 'vite';
import type { ProxyOptions } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import path from 'path';
const UI_ROOT = __dirname;
const REPO_ROOT = path.resolve(__dirname, '..');
const BACKEND_ORIGIN = 'http://localhost:3000';
const TRUSTED_VITE_DEV_ORIGINS = new Set([
'http://localhost:5173',
'http://127.0.0.1:5173',
'http://[::1]:5173',
]);
function rewriteTrustedViteDevOrigin(proxyRequest: ClientRequest, request: IncomingMessage): void {
const origin = request.headers.origin;
if (origin && TRUSTED_VITE_DEV_ORIGINS.has(origin)) {
proxyRequest.setHeader('origin', BACKEND_ORIGIN);
}
}
const configureHttpProxy: NonNullable<ProxyOptions['configure']> = (proxy) => {
proxy.on('proxyReq', rewriteTrustedViteDevOrigin);
};
const configureWebSocketProxy: NonNullable<ProxyOptions['configure']> = (proxy) => {
proxy.on('proxyReqWs', rewriteTrustedViteDevOrigin);
};
// https://vite.dev/config/
export default defineConfig({
@@ -58,14 +81,23 @@ export default defineConfig({
},
server: {
port: 5173,
strictPort: true,
fs: {
allow: [UI_ROOT, REPO_ROOT],
},
proxy: {
'/api': 'http://localhost:3000',
// Translate only trusted local Vite origins for the dashboard's CSRF guard.
// Preserve every other Origin so the backend can reject untrusted callers.
'/api': {
target: BACKEND_ORIGIN,
changeOrigin: true,
configure: configureHttpProxy,
},
'/ws': {
target: 'ws://localhost:3000',
ws: true,
changeOrigin: true,
configure: configureWebSocketProxy,
},
},
},