mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 16:17:02 +00:00
Merge remote-tracking branch 'origin/dev' into kai/review/pr-1655-retry-followup
This commit is contained in:
commit
2d909a162f
43 files changed
+953
-218
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user