mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
Merge pull request #1650 from minhbi245/feat/codex-effort-dropdown
feat(ui): per-tier reasoning effort dropdown in model mapping (codex + grok)
This commit is contained in:
9 files changed
+555
-83
No files matched your search
@@ -10,6 +10,13 @@ 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 type { CliproxyProviderRoutingHints } from '@/lib/api-client';
|
||||
import type { CodexEffort, CodexServiceTier } from '@/lib/codex-effort';
|
||||
@@ -20,6 +27,7 @@ import {
|
||||
parseCodexServiceTier,
|
||||
} from '@/lib/codex-effort';
|
||||
import { getResolvedCatalogModels, getSupplementalCatalogModels } from '@/lib/model-catalogs';
|
||||
import { getReasoningAdapter } from '@/lib/reasoning-control';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Model entry from catalog */
|
||||
@@ -47,6 +55,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 +571,21 @@ 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;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
@@ -568,87 +593,116 @@ 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>
|
||||
)}
|
||||
</div>
|
||||
{selectedRoutingHint ? (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -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',
|
||||
@@ -102,6 +102,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 }
|
||||
|
||||
@@ -228,6 +228,8 @@ const resources = {
|
||||
'Pinned model is not currently advertised by the proxy: {{model}}',
|
||||
codexReasoningVariants: 'Reasoning variants',
|
||||
codexFastVariants: 'Fast variants',
|
||||
effortLabel: 'Effort',
|
||||
effortAuto: 'Auto',
|
||||
},
|
||||
createAuthProfileDialog: {
|
||||
title: 'Create New Account',
|
||||
@@ -2954,6 +2956,8 @@ const resources = {
|
||||
pinnedModelNotAdvertised: '固定模型当前未被代理广播:{{model}}',
|
||||
codexReasoningVariants: '推理变体',
|
||||
codexFastVariants: '快速变体',
|
||||
effortLabel: '推理强度',
|
||||
effortAuto: '自动',
|
||||
},
|
||||
createAuthProfileDialog: {
|
||||
title: '创建新账号',
|
||||
@@ -5563,6 +5567,8 @@ 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',
|
||||
},
|
||||
createAuthProfileDialog: {
|
||||
title: 'Tạo tài khoản mới',
|
||||
@@ -8288,6 +8294,8 @@ const resources = {
|
||||
pinnedModelNotAdvertised: 'ピンモデルは現在プロキシから通知されていません: {{model}}',
|
||||
codexReasoningVariants: '推論バリアント',
|
||||
codexFastVariants: '高速バリアント',
|
||||
effortLabel: '推論レベル',
|
||||
effortAuto: '自動',
|
||||
},
|
||||
createAuthProfileDialog: {
|
||||
title: '新しいアカウントを作成',
|
||||
@@ -11036,6 +11044,8 @@ const resources = {
|
||||
pinnedModelNotAdvertised: '고정된 모델이 현재 프록시에서 알리지 않고 있습니다: {{model}}',
|
||||
codexReasoningVariants: '추론 변형',
|
||||
codexFastVariants: '빠른 변형',
|
||||
effortLabel: '추론 강도',
|
||||
effortAuto: '자동',
|
||||
},
|
||||
createAuthProfileDialog: {
|
||||
title: '새 계정 생성',
|
||||
|
||||
@@ -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,7 @@ export function buildUiCatalog(
|
||||
? undefined
|
||||
: (model.extendedContext ?? staticModel?.extendedContext),
|
||||
presetMapping: staticModel?.presetMapping,
|
||||
reasoningLevels: model.reasoningLevels ?? staticModel?.reasoningLevels,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
applyCodexEffortSuffix,
|
||||
getCodexEffortDisplay,
|
||||
getCodexEffortVariants,
|
||||
getSelectableCodexEfforts,
|
||||
parseCodexEffort,
|
||||
parseCodexServiceTier,
|
||||
stripCodexEffortSuffix,
|
||||
@@ -85,6 +86,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 +110,35 @@ describe('codex effort helpers', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
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,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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user