diff --git a/ui/src/components/cliproxy/provider-model-selector.tsx b/ui/src/components/cliproxy/provider-model-selector.tsx
index a9c47779..405c9f19 100644
--- a/ui/src/components/cliproxy/provider-model-selector.tsx
+++ b/ui/src/components/cliproxy/provider-model-selector.tsx
@@ -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 (
- {t('providerModelSelector.currentValue')}
-
- ),
- },
- ]
- : []),
- {
- key: 'recommended',
- label: (
- {t('providerModelSelector.recommended')}
- ),
- },
- ...(isCodexProvider
- ? [
- {
- key: 'codex-reasoning',
- label: (
-
- {t('providerModelSelector.codexReasoningVariants')}
-
- ),
- },
- {
- key: 'codex-fast',
- label: (
-
- {t('providerModelSelector.codexFastVariants')}
-
- ),
- },
- ]
- : []),
- ...(allModelOptions.length > 0
- ? [
- {
- key: 'all',
- label: (
-
- {t('providerModelSelector.allModelsCount', {
- count: allModelOptions.length,
- })}
-
- ),
- },
- ]
- : []),
- {
- key: 'custom',
- label: (
-
- {t('providerModelSelector.customModel')}
-
- ),
- },
- ]}
- options={[
- ...(selectedValueMissing && legacySelectedOption ? [legacySelectedOption] : []),
- ...recommendedOptions,
- ...allModelOptions,
- ]}
- />
+
+
+ {t('providerModelSelector.currentValue')}
+
+ ),
+ },
+ ]
+ : []),
+ {
+ key: 'recommended',
+ label: (
+
+ {t('providerModelSelector.recommended')}
+
+ ),
+ },
+ ...(isCodexProvider
+ ? [
+ {
+ key: 'codex-reasoning',
+ label: (
+
+ {t('providerModelSelector.codexReasoningVariants')}
+
+ ),
+ },
+ {
+ key: 'codex-fast',
+ label: (
+
+ {t('providerModelSelector.codexFastVariants')}
+
+ ),
+ },
+ ]
+ : []),
+ ...(allModelOptions.length > 0
+ ? [
+ {
+ key: 'all',
+ label: (
+
+ {t('providerModelSelector.allModelsCount', {
+ count: allModelOptions.length,
+ })}
+
+ ),
+ },
+ ]
+ : []),
+ {
+ key: 'custom',
+ label: (
+
+ {t('providerModelSelector.customModel')}
+
+ ),
+ },
+ ]}
+ options={[
+ ...(selectedValueMissing && legacySelectedOption ? [legacySelectedOption] : []),
+ ...recommendedOptions,
+ ...allModelOptions,
+ ]}
+ />
+ {reasoningAdapter && reasoningOptions && (
+
+ )}
+
{selectedRoutingHint ? (
string; auto: string }
diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts
index 39988ff4..e9d047c9 100644
--- a/ui/src/lib/i18n.ts
+++ b/ui/src/lib/i18n.ts
@@ -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: '새 계정 생성',
diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts
index 34c82ca4..ea566f9f 100644
--- a/ui/src/lib/model-catalogs.ts
+++ b/ui/src/lib/model-catalogs.ts
@@ -402,11 +402,13 @@ export const MODEL_CATALOGS: Record
= {
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,
};
});
diff --git a/ui/src/lib/reasoning-control.ts b/ui/src/lib/reasoning-control.ts
new file mode 100644
index 00000000..9bf6489d
--- /dev/null
+++ b/ui/src/lib/reasoning-control.ts
@@ -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 = {
+ codex: codexAdapter,
+ xai: xaiAdapter,
+};
+
+export function getReasoningAdapter(provider: string | undefined): ReasoningAdapter | undefined {
+ return provider ? REASONING_ADAPTERS[provider.toLowerCase()] : undefined;
+}
diff --git a/ui/tests/unit/ui/lib/codex-effort.test.ts b/ui/tests/unit/ui/lib/codex-effort.test.ts
index 557da198..7031d89f 100644
--- a/ui/tests/unit/ui/lib/codex-effort.test.ts
+++ b/ui/tests/unit/ui/lib/codex-effort.test.ts
@@ -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',
+ ]);
+ });
+});
diff --git a/ui/tests/unit/ui/lib/reasoning-control.test.ts b/ui/tests/unit/ui/lib/reasoning-control.test.ts
new file mode 100644
index 00000000..a31f815f
--- /dev/null
+++ b/ui/tests/unit/ui/lib/reasoning-control.test.ts
@@ -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',
+ ]);
+ });
+});
diff --git a/ui/tests/unit/vite-proxy-origin.test.ts b/ui/tests/unit/vite-proxy-origin.test.ts
new file mode 100644
index 00000000..2db01199
--- /dev/null
+++ b/ui/tests/unit/vite-proxy-origin.test.ts
@@ -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();
+ 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();
+ 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();
+ });
+});
diff --git a/ui/vite.config.ts b/ui/vite.config.ts
index 4a31e9f5..96e5459a 100644
--- a/ui/vite.config.ts
+++ b/ui/vite.config.ts
@@ -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 = (proxy) => {
+ proxy.on('proxyReq', rewriteTrustedViteDevOrigin);
+};
+
+const configureWebSocketProxy: NonNullable = (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,
},
},
},