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 (
@@ -568,87 +593,116 @@ export function FlexibleModelSelector({ {description &&

{description}

}
- - {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, }, }, },