From 1f710b5f0f8b616ee33198f1e8e8a476c0574cd3 Mon Sep 17 00:00:00 2001 From: mounir0672230294-alt <256871522+mounir0672230294-alt@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:23:13 +0800 Subject: [PATCH 1/2] feat(routing): add per-prompt smart model selection --- .../docs/guides/routing-profile-editor.md | 46 ++++++- .../docs/reference/configuration/routing.md | 43 +++++-- .../zh-cn/reference/configuration/routing.md | 8 +- gui/src/i18n/de.ts | 8 ++ gui/src/i18n/en.ts | 8 ++ gui/src/i18n/ja.ts | 8 ++ gui/src/i18n/ko.ts | 8 ++ gui/src/i18n/ru.ts | 8 ++ gui/src/i18n/tr.ts | 8 ++ gui/src/i18n/zh.ts | 8 ++ gui/src/pages/RoutingProfiles.tsx | 114 ++++++++++++++++-- gui/src/routing-profile-editor-data.ts | 17 ++- gui/tests/routing-profiles.test.tsx | 60 +++++++++ src/cli/route-policy.ts | 10 +- src/routing/evaluator.ts | 31 ++++- src/routing/profile.ts | 75 +++++++++++- src/routing/prompt-classifier.ts | 97 +++++++++++++++ src/routing/request-evidence.ts | 56 ++++++++- src/server/chat-completions.ts | 8 +- src/server/claude-messages.ts | 8 +- .../management/routing-profile-routes.ts | 7 ++ src/server/responses/compact.ts | 4 +- src/server/responses/core.ts | 6 +- src/types.ts | 17 +++ tests/policy-execution.test.ts | 54 ++++++++- tests/prompt-classifier.test.ts | 41 +++++++ tests/request-evidence.test.ts | 68 ++++++++++- tests/route-explainability.test.ts | 29 ++++- tests/routing-profile-editor-data.test.ts | 32 ++++- tests/routing-profile.test.ts | 114 ++++++++++++++++++ 30 files changed, 954 insertions(+), 47 deletions(-) create mode 100644 src/routing/prompt-classifier.ts create mode 100644 tests/prompt-classifier.test.ts diff --git a/docs-site/src/content/docs/guides/routing-profile-editor.md b/docs-site/src/content/docs/guides/routing-profile-editor.md index 5cf5fc6d7..88ecefc12 100644 --- a/docs-site/src/content/docs/guides/routing-profile-editor.md +++ b/docs-site/src/content/docs/guides/routing-profile-editor.md @@ -16,6 +16,34 @@ The **Models → Routing** tab in the OpenCodex dashboard can manage `config.rou Profile ids are immutable after creation. To use a different id, create a new profile and remove the old one after updating callers. +## Route every prompt automatically + +Enable **Automatic prompt routing** when one picker entry should choose a different candidate for +each request. Assign every candidate one or more task tiers: + +- **Fast** for greetings, short rewrites, translations, and other lightweight requests. +- **Balanced** for ordinary questions and general work. +- **Powerful** for implementation, debugging, architecture, security, multi-step work, and other + complex requests. + +All three tiers must be covered before the profile can be saved. A candidate may serve more than one +tier, so the same model can be used for both balanced and powerful requests. + +After saving, select the profile's alias (for example `ocx/auto`) in Codex once. OpenCodex +reclassifies the latest user prompt on **every request**, so a simple follow-up can use the fast +candidate and the next complex coding request can use the powerful candidate without changing the +picker again. + +Classification is local and deterministic: it does not make a separate model request and does not +persist the raw prompt as routing metadata. Capability requirements, health, quota, cost limits, and +the profile's normal scoring rules still apply after the task-tier filter. The selected provider, +model, tier reason, and exclusions are available in the route-decision trace and +`ocx logs explain`; OpenCodex does not modify the assistant response with a routing footer. + +Prompt routing is provider-agnostic. Any model already configured in OpenCodex can be assigned to a +tier, including DeepSeek, Qwen, Kimi, GLM, and other OpenAI-compatible or native providers. The +provider and model ids in the example below are illustrative. + ## Validation and persistence The dashboard sends the same profile object used by `config.routingProfiles` to the management API. The server validates the complete candidate before writing it: @@ -55,15 +83,23 @@ Example save payload: ```json { - "id": "fast", + "id": "smart", "mode": "create", "profile": { - "alias": "ocx/fast", + "alias": "ocx/auto", + "promptRouting": { "enabled": true }, "candidates": [ - { "provider": "anthropic", "model": "claude-sonnet-5" }, - { "provider": "openai", "model": "gpt-5.6" } + { + "provider": "deepseek", + "model": "deepseek-chat", + "taskTiers": ["fast", "balanced"] + }, + { + "provider": "openai", + "model": "gpt-5.6", + "taskTiers": ["powerful"] + } ], - "require": { "tools": true, "minContextWindow": 128000 }, "optimize": { "latency": 0.55, "health": 0.25, "cost": 0.1, "quota": 0.1 }, "limits": { "maxEstimatedCostUsd": 0.5, "onUnknownCost": "allow" }, "unknownEvidence": { diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index 8a2b039c2..3c505da5e 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -110,8 +110,9 @@ namespace, or reserved bare native families (`gpt-*`, `o1-*`, `o3-*`, `o4-*`, `c | Key | Type | Default | Meaning | | --- | --- | --- | --- | -| `candidates` | `{ provider: string; model: string }[]` | required | Explicit allowlist of `provider/model` refs. No implicit expansion. | +| `candidates` | `{ provider: string; model: string; taskTiers?: ("fast" \| "balanced" \| "powerful")[] }[]` | required | Explicit allowlist of `provider/model` refs. `taskTiers` declares which prompt-complexity tiers a candidate may serve when automatic prompt routing is enabled. No implicit expansion. | | `alias?` | `string` | — | Optional public model id in place of `policy/`. | +| `promptRouting?` | `{ enabled?: boolean }` | disabled | Locally classify the latest user prompt on every request and restrict eligibility to candidates assigned to the resulting task tier. | | `require?` | object | `{}` | Hard capability requirements evaluated before scoring (see below). | | `optimize?` | object | latency 0.55, health 0.25, cost 0.10, quota 0.10 | Scoring weights, normalized deterministically. `health`, `quota`, and `cost` have score dimensions; the configured-priority share is `1 - health - quota - cost` (default 0.55), and `latency` folds into that priority share rather than scoring independently. | | `limits?` | object | — | Hard limits. `maxEstimatedCostUsd` excludes a candidate when its estimated cost is known and above the cap. When that cap is set, `onUnknownCost` (`"allow"` default, or `"exclude"`) controls unknown estimates: allow prevents a cap-specific exclusion and records `cost.capOutcome: "unknown-allowed"`; exclude emits `cost-limit-unknown` and `capOutcome: "unknown-excluded"`. `onUnknownCost` alone (no cap) is inert. Separate from `unknownEvidence.cost`, which can still exclude or penalize unknown prices via `unknown-price` / scoring. | @@ -134,16 +135,44 @@ evidence surface for context-sensitive profiles. The CLI dry-run accepts request-evidence flags but cannot supply candidate capability evidence yet; candidate evidence is provided through the API (`POST /api/routing-profiles/dry-run`). +### Automatic prompt routing + +With `promptRouting.enabled: true`, OpenCodex classifies the latest user prompt as `fast`, +`balanced`, or `powerful` for every request. The classifier is deterministic and local: it makes no +extra model call and adds only the resulting tier to routing evidence, not the raw prompt. Requests +without usable text, such as image-only input, default to `balanced`. The requested reasoning effort +can also raise or lower the tier. + +Every candidate must declare a non-empty `taskTiers` array, and the profile must cover all three +tiers. Tier matching is an eligibility filter; the existing capability, health, quota, cost-limit, +unknown-evidence, and scoring rules still decide among candidates eligible for that tier. + +Select the profile alias once in Codex. Each later request through that alias is classified again, +so consecutive turns may use different providers or models. The selected trace reason is +`prompt-tier-fast`, `prompt-tier-balanced`, or `prompt-tier-powerful`, visible through the +route-decision API and `ocx logs explain`. + +The tier filter is provider-agnostic: any configured provider/model can participate. DeepSeek, Qwen, +Kimi, GLM, and the ids in the example are illustrative rather than a hardcoded model catalog. + ```json { "routingProfiles": { - "fast": { - "alias": "ocx/fast", + "smart": { + "alias": "ocx/auto", + "promptRouting": { "enabled": true }, "candidates": [ - { "provider": "anthropic", "model": "claude-sonnet-5" }, - { "provider": "openai", "model": "gpt-5.6-sol" } + { + "provider": "deepseek", + "model": "deepseek-chat", + "taskTiers": ["fast", "balanced"] + }, + { + "provider": "openai", + "model": "gpt-5.6-sol", + "taskTiers": ["powerful"] + } ], - "require": { "tools": true, "minContextWindow": 128000 }, "optimize": { "latency": 0.55, "health": 0.25, "cost": 0.10, "quota": 0.10 }, "limits": { "maxEstimatedCostUsd": 0.50, "onUnknownCost": "allow" }, "unknownEvidence": { @@ -158,7 +187,7 @@ candidate evidence is provided through the API (`POST /api/routing-profiles/dry- ``` CLI: `ocx route policy list [--json]`, `ocx route policy show [--json]`, and -`ocx route policy dry-run [--model-context ] [--tools] [--image] [--structured-output] [--json]`. +`ocx route policy dry-run [--task-tier ] [--model-context ] [--tools] [--image] [--structured-output] [--json]`. Dry-run evaluates candidates without sending any upstream request. Quota evidence (`optimize.quota`, `require.minQuotaHeadroom`, `unknownEvidence.quota`) comes from diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md b/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md index 166a2ce17..f9fa4a345 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md @@ -102,9 +102,13 @@ Codex Auth 页面将此 picker 行为作为选择加入项。关闭它会隐藏 ## 路由策略配置文件(`config.routingProfiles`) -显式请求的 `policy/`(或配置的别名)会在固定的候选白名单中,根据硬性能力要求与确定性、可解释的评分进行选择。现有模型 ID 永远不会隐式经过配置文件。支持 `candidates`(显式白名单)、可选 `alias`、`require`(`minContextWindow`、`minQuotaHeadroom`、`tools`、`imageInput`、`structuredOutput`、`localOnly`、`remoteAllowed`、`encryptedCodexTasks`、`reasoningEffort`、`serviceTier`)、`optimize`(latency/health/cost/quota 权重)、`limits.maxEstimatedCostUsd`、`unknownEvidence`(allow/penalize/exclude)。未知不会被当作零或免费。 +显式请求的 `policy/`(或配置的别名)会在固定的候选白名单中,根据硬性能力要求与确定性、可解释的评分进行选择。现有模型 ID 永远不会隐式经过配置文件。支持 `candidates`(显式白名单)、可选 `alias`、`promptRouting`、`require`(`minContextWindow`、`minQuotaHeadroom`、`tools`、`imageInput`、`structuredOutput`、`localOnly`、`remoteAllowed`、`encryptedCodexTasks`、`reasoningEffort`、`serviceTier`)、`optimize`(latency/health/cost/quota 权重)、`limits.maxEstimatedCostUsd`、`unknownEvidence`(allow/penalize/exclude)。未知不会被当作零或免费。 -CLI:`ocx route policy list`、`ocx route policy show `、`ocx route policy dry-run --model-context --tools`、`ocx route policy evaluate `。 +设置 `"promptRouting": { "enabled": true }` 后,只需在 Codex 中选择一次该配置文件的别名(例如 `ocx/auto`)。OpenCodex 会在每次请求时重新分析最新一条用户提示词,把任务确定性地分为 `fast`、`balanced` 或 `powerful`,再从声明了对应 `taskTiers` 的候选中选择模型。所有三个等级都必须有候选覆盖。该分类在本地完成,不会额外调用模型,也不会把原始提示词写入路由元数据;能力、健康度、额度、成本限制和正常评分仍会继续生效。最终模型与 `prompt-tier-*` 原因可通过路由决策记录和 `ocx logs explain` 查看。 + +任务等级筛选与提供商无关:任何已经在 OpenCodex 中配置好的模型都能参与,包括 DeepSeek、通义千问、Kimi、智谱 GLM 等国内模型;它们不是写死在路由器里的模型清单。 + +CLI:`ocx route policy list`、`ocx route policy show `、`ocx route policy dry-run --task-tier powerful --model-context --tools`、`ocx route policy evaluate `。 组合是显式的有序/加权目标路由与故障转移;策略配置文件是基于证据在候选之间进行选择。 diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 31e855746..caf5230f1 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -31,6 +31,12 @@ export const de: Record = { "routing.unknownEvidence.exclude": "ausschließen", "routing.removeCandidate": "Kandidat {provider}/{model} entfernen", "routing.candidates": "Kandidaten", + "routing.promptRouting": "Automatisches Prompt-Routing", + "routing.promptRoutingHelp": "Klassifiziert den neuesten Benutzer-Prompt lokal und wählt für jede Anfrage eine geeignete Aufgabenstufe.", + "routing.taskTiers": "Aufgabenstufen", + "routing.taskTier.fast": "Schnell", + "routing.taskTier.balanced": "Ausgewogen", + "routing.taskTier.powerful": "Leistungsstark", "routing.require": "Harte Anforderungen", "routing.optimize": "Optimierungsgewichte", "routing.limits": "Grenzen", @@ -39,6 +45,7 @@ export const de: Record = { "routing.unavailable": "–", "routing.dryRun": "Trockenlauf-Bewertung", "routing.dryRunContext": "Kontextfenster der Anfrage (Tokens)", + "routing.dryRunTaskTier": "Aufgabenstufe der Anfrage", "routing.dryRunTools": "Anfrage benötigt Tools", "routing.dryRunImage": "Anfrage benötigt Bild-Eingabe", "routing.dryRunStructured": "Anfrage benötigt strukturierte Ausgabe", @@ -52,6 +59,7 @@ export const de: Record = { "routing.capOutcome.unknown-allowed": "unbekannt (erlaubt)", "routing.capOutcome.unknown-excluded": "unbekannt (ausgeschlossen)", "routing.exclusion.capability-unsatisfied": "Anforderung nicht erfüllt", + "routing.exclusion.task-tier-mismatch": "Nicht dieser Aufgabenstufe zugewiesen", "routing.exclusion.unknown-capability": "unbekannte Fähigkeit", "routing.exclusion.cost-limit": "über Kostenobergrenze", "routing.exclusion.cost-limit-unknown": "unbekannte Kosten unter Obergrenze", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 2dc6a0793..7a4305700 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -74,6 +74,12 @@ export const en = { "routing.unknownEvidence.exclude": "exclude", "routing.removeCandidate": "Remove candidate {provider}/{model}", "routing.candidates": "Candidates", + "routing.promptRouting": "Automatic prompt routing", + "routing.promptRoutingHelp": "Classify each latest user prompt locally and select an eligible task tier for every request.", + "routing.taskTiers": "Task tiers", + "routing.taskTier.fast": "Fast", + "routing.taskTier.balanced": "Balanced", + "routing.taskTier.powerful": "Powerful", "routing.require": "Hard requirements", "routing.optimize": "Optimization weights", "routing.limits": "Limits", @@ -82,6 +88,7 @@ export const en = { "routing.unavailable": "–", "routing.dryRun": "Dry-run evaluation", "routing.dryRunContext": "Request context window (tokens)", + "routing.dryRunTaskTier": "Request task tier", "routing.dryRunTools": "Request requires tools", "routing.dryRunImage": "Request requires image input", "routing.dryRunStructured": "Request requires structured output", @@ -95,6 +102,7 @@ export const en = { "routing.capOutcome.unknown-allowed": "unknown (allowed)", "routing.capOutcome.unknown-excluded": "unknown (excluded)", "routing.exclusion.capability-unsatisfied": "capability not met", + "routing.exclusion.task-tier-mismatch": "not assigned to this task tier", "routing.exclusion.unknown-capability": "unknown capability", "routing.exclusion.cost-limit": "over cost cap", "routing.exclusion.cost-limit-unknown": "unknown cost under cap", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index c097ec8b4..0817773d0 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -31,6 +31,12 @@ export const ja: Record = { "routing.unknownEvidence.exclude": "除外", "routing.removeCandidate": "候補 {provider}/{model} を削除", "routing.candidates": "候補", + "routing.promptRouting": "自動プロンプトルーティング", + "routing.promptRoutingHelp": "最新のユーザープロンプトをローカルで分類し、リクエストごとに適切なタスク階層を選択します。", + "routing.taskTiers": "タスク階層", + "routing.taskTier.fast": "高速", + "routing.taskTier.balanced": "バランス", + "routing.taskTier.powerful": "高性能", "routing.require": "必須要件", "routing.optimize": "最適化ウェイト", "routing.limits": "制限", @@ -39,6 +45,7 @@ export const ja: Record = { "routing.unavailable": "–", "routing.dryRun": "ドライラン評価", "routing.dryRunContext": "リクエストのコンテキストウィンドウ(トークン)", + "routing.dryRunTaskTier": "リクエストのタスク階層", "routing.dryRunTools": "リクエストにツールが必要", "routing.dryRunImage": "リクエストに画像入力が必要", "routing.dryRunStructured": "リクエストに構造化出力が必要", @@ -52,6 +59,7 @@ export const ja: Record = { "routing.capOutcome.unknown-allowed": "不明(許可)", "routing.capOutcome.unknown-excluded": "不明(除外)", "routing.exclusion.capability-unsatisfied": "能力要件未達", + "routing.exclusion.task-tier-mismatch": "このタスク階層に未割り当て", "routing.exclusion.unknown-capability": "能力不明", "routing.exclusion.cost-limit": "コスト上限超過", "routing.exclusion.cost-limit-unknown": "上限下でコスト不明", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index d81b15431..a3c2c29d7 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -31,6 +31,12 @@ export const ko: Record = { "routing.unknownEvidence.exclude": "제외", "routing.removeCandidate": "후보 {provider}/{model} 제거", "routing.candidates": "후보", + "routing.promptRouting": "자동 프롬프트 라우팅", + "routing.promptRoutingHelp": "최신 사용자 프롬프트를 로컬에서 분류하고 요청마다 적합한 작업 등급을 선택합니다.", + "routing.taskTiers": "작업 등급", + "routing.taskTier.fast": "빠름", + "routing.taskTier.balanced": "균형", + "routing.taskTier.powerful": "강력", "routing.require": "필수 요구사항", "routing.optimize": "최적화 가중치", "routing.limits": "제한", @@ -39,6 +45,7 @@ export const ko: Record = { "routing.unavailable": "–", "routing.dryRun": "드라이런 평가", "routing.dryRunContext": "요청 컨텍스트 창(토큰)", + "routing.dryRunTaskTier": "요청 작업 등급", "routing.dryRunTools": "요청에 도구 필요", "routing.dryRunImage": "요청에 이미지 입력 필요", "routing.dryRunStructured": "요청에 구조화된 출력 필요", @@ -52,6 +59,7 @@ export const ko: Record = { "routing.capOutcome.unknown-allowed": "알 수 없음(허용)", "routing.capOutcome.unknown-excluded": "알 수 없음(제외)", "routing.exclusion.capability-unsatisfied": "기능 미충족", + "routing.exclusion.task-tier-mismatch": "이 작업 등급에 할당되지 않음", "routing.exclusion.unknown-capability": "알 수 없는 기능", "routing.exclusion.cost-limit": "비용 상한 초과", "routing.exclusion.cost-limit-unknown": "상한 이하 비용 불명", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 07089f6e4..dd754c067 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -31,6 +31,12 @@ export const ru: Record = { "routing.unknownEvidence.exclude": "исключить", "routing.removeCandidate": "Удалить кандидата {provider}/{model}", "routing.candidates": "Кандидаты", + "routing.promptRouting": "Автоматическая маршрутизация промптов", + "routing.promptRoutingHelp": "Локально классифицирует последний промпт пользователя и выбирает подходящий уровень задачи для каждого запроса.", + "routing.taskTiers": "Уровни задач", + "routing.taskTier.fast": "Быстрый", + "routing.taskTier.balanced": "Сбалансированный", + "routing.taskTier.powerful": "Мощный", "routing.require": "Жёсткие требования", "routing.optimize": "Веса оптимизации", "routing.limits": "Лимиты", @@ -39,6 +45,7 @@ export const ru: Record = { "routing.unavailable": "–", "routing.dryRun": "Пробная оценка", "routing.dryRunContext": "Контекстное окно запроса (токены)", + "routing.dryRunTaskTier": "Уровень задачи запроса", "routing.dryRunTools": "Запрос требует инструменты", "routing.dryRunImage": "Запрос требует изображения", "routing.dryRunStructured": "Запрос требует структурированный вывод", @@ -52,6 +59,7 @@ export const ru: Record = { "routing.capOutcome.unknown-allowed": "неизвестно (разрешено)", "routing.capOutcome.unknown-excluded": "неизвестно (исключено)", "routing.exclusion.capability-unsatisfied": "требование не выполнено", + "routing.exclusion.task-tier-mismatch": "Не назначено этому уровню задачи", "routing.exclusion.unknown-capability": "неизвестная возможность", "routing.exclusion.cost-limit": "сверх лимита стоимости", "routing.exclusion.cost-limit-unknown": "неизвестная стоимость при лимите", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 546aebead..b33518226 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -73,6 +73,12 @@ export const tr: Record = { "routing.unknownEvidence.exclude": "hariç tut", "routing.removeCandidate": "{provider}/{model} adayı kaldırılsın mı", "routing.candidates": "Adaylar", + "routing.promptRouting": "Otomatik istem yönlendirme", + "routing.promptRoutingHelp": "En son kullanıcı istemini yerel olarak sınıflandırır ve her istek için uygun görev seviyesini seçer.", + "routing.taskTiers": "Görev seviyeleri", + "routing.taskTier.fast": "Hızlı", + "routing.taskTier.balanced": "Dengeli", + "routing.taskTier.powerful": "Güçlü", "routing.require": "Katı gereksinimler", "routing.optimize": "Optimizasyon ağırlıkları", "routing.limits": "Limitler", @@ -81,6 +87,7 @@ export const tr: Record = { "routing.unavailable": "–", "routing.dryRun": "Simülasyon değerlendirmesi", "routing.dryRunContext": "İstek bağlam penceresi (jetonlar)", + "routing.dryRunTaskTier": "İstek görev seviyesi", "routing.dryRunTools": "İstek araç gerektiriyor", "routing.dryRunImage": "İstek görsel girdisi gerektiriyor", "routing.dryRunStructured": "İstek yapılandırılmış çıktı gerektiriyor", @@ -94,6 +101,7 @@ export const tr: Record = { "routing.capOutcome.unknown-allowed": "bilinmiyor (izinli)", "routing.capOutcome.unknown-excluded": "bilinmiyor (hariç tutuldu)", "routing.exclusion.capability-unsatisfied": "yetenek karşılanmadı", + "routing.exclusion.task-tier-mismatch": "Bu görev seviyesine atanmadı", "routing.exclusion.unknown-capability": "bilinmeyen yetenek", "routing.exclusion.cost-limit": "maliyet tavanı aşıldı", "routing.exclusion.cost-limit-unknown": "tavan altında bilinmeyen maliyet", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 71c41897a..97e33b74a 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -31,6 +31,12 @@ export const zh: Record = { "routing.unknownEvidence.exclude": "排除", "routing.removeCandidate": "移除候选 {provider}/{model}", "routing.candidates": "候选", + "routing.promptRouting": "自动提示词路由", + "routing.promptRoutingHelp": "在本地分析每次最新的用户提示词,并为每个请求选择合适的任务等级。", + "routing.taskTiers": "任务等级", + "routing.taskTier.fast": "快速", + "routing.taskTier.balanced": "均衡", + "routing.taskTier.powerful": "强力", "routing.require": "硬性要求", "routing.optimize": "优化权重", "routing.limits": "限制", @@ -39,6 +45,7 @@ export const zh: Record = { "routing.unavailable": "–", "routing.dryRun": "试运行评估", "routing.dryRunContext": "请求上下文窗口(令牌)", + "routing.dryRunTaskTier": "请求任务等级", "routing.dryRunTools": "请求需要工具", "routing.dryRunImage": "请求需要图像输入", "routing.dryRunStructured": "请求需要结构化输出", @@ -52,6 +59,7 @@ export const zh: Record = { "routing.capOutcome.unknown-allowed": "未知(允许)", "routing.capOutcome.unknown-excluded": "未知(排除)", "routing.exclusion.capability-unsatisfied": "能力未满足", + "routing.exclusion.task-tier-mismatch": "未分配到此任务等级", "routing.exclusion.unknown-capability": "能力未知", "routing.exclusion.cost-limit": "超出成本上限", "routing.exclusion.cost-limit-unknown": "上限下成本未知", diff --git a/gui/src/pages/RoutingProfiles.tsx b/gui/src/pages/RoutingProfiles.tsx index b6a19ac3c..d08a8300c 100644 --- a/gui/src/pages/RoutingProfiles.tsx +++ b/gui/src/pages/RoutingProfiles.tsx @@ -11,6 +11,7 @@ import { type OptionalBoolean, type RoutingProfileDraft, type RoutingProfileDto, + type RoutingTaskTier, type UnknownCostCapMode, type UnknownEvidenceMode, } from "../routing-profile-editor-data"; @@ -72,6 +73,7 @@ const OPTIMIZE_KEYS = ["latency", "health", "cost", "quota"] as const; const UNKNOWN_EVIDENCE_KEYS = ["capability", "health", "quota", "cost"] as const; const UNKNOWN_EVIDENCE_OPTIONS: UnknownEvidenceMode[] = ["allow", "penalize", "exclude"]; const UNKNOWN_COST_CAP_OPTIONS: UnknownCostCapMode[] = ["allow", "exclude"]; +const TASK_TIERS: RoutingTaskTier[] = ["fast", "balanced", "powerful"]; function fmtMs(value: number | undefined, unavailable: string): string { return value === undefined ? unavailable : `${Math.round(value)}ms`; @@ -104,6 +106,8 @@ function fmtExclusion(code: string, t: ReturnType): string { switch (code) { case "capability-unsatisfied": return t("routing.exclusion.capability-unsatisfied"); + case "task-tier-mismatch": + return t("routing.exclusion.task-tier-mismatch"); case "unknown-capability": return t("routing.exclusion.unknown-capability"); case "cost-limit": @@ -213,6 +217,7 @@ export default function RoutingProfiles({ const [status, setStatus] = useState<{ message: string; ok: boolean } | null>(null); const [saving, setSaving] = useState(false); const [context, setContext] = useState(""); + const [taskTier, setTaskTier] = useState<"" | RoutingTaskTier>(""); const [tools, setTools] = useState(false); const [image, setImage] = useState(false); const [structured, setStructured] = useState(false); @@ -472,13 +477,20 @@ export default function RoutingProfiles({ }; const addCandidate = () => { - setDraft(current => current ? { - ...current, - candidates: [ - ...current.candidates, - newDraftCandidate(firstProvider, firstModel), - ], - } : current); + setDraft(current => { + if (!current) return current; + return { + ...current, + candidates: [ + ...current.candidates, + newDraftCandidate( + firstProvider, + firstModel, + current.promptRoutingEnabled ? TASK_TIERS : [], + ), + ], + }; + }); }; const removeCandidate = (index: number) => { @@ -488,6 +500,45 @@ export default function RoutingProfiles({ } : current); }; + const setPromptRoutingEnabled = (enabled: boolean) => { + setDraft(current => { + if (!current) return current; + return { + ...current, + promptRoutingEnabled: enabled, + candidates: enabled + ? current.candidates.map(candidate => ({ + ...candidate, + taskTiers: candidate.taskTiers?.length ? candidate.taskTiers : [...TASK_TIERS], + })) + : current.candidates, + }; + }); + }; + + const toggleCandidateTaskTier = ( + index: number, + tier: RoutingTaskTier, + enabled: boolean, + ) => { + setDraft(current => { + if (!current) return current; + return { + ...current, + candidates: current.candidates.map((candidate, candidateIndex) => { + if (candidateIndex !== index) return candidate; + const tiers = new Set(candidate.taskTiers ?? []); + if (enabled) tiers.add(tier); + else tiers.delete(tier); + return { + ...candidate, + taskTiers: TASK_TIERS.filter(candidateTier => tiers.has(candidateTier)), + }; + }), + }; + }); + }; + const runDryRun = async () => { if (!selected) return; const generation = ++dryRunGenerationRef.current; @@ -495,7 +546,7 @@ export default function RoutingProfiles({ setDryRunResult(null); setDryRunError(""); try { - const evidence: Record = {}; + const evidence: Record = {}; const contextTokens = context.trim() ? Number(context.trim()) : NaN; if (Number.isFinite(contextTokens) && contextTokens > 0) { evidence.contextWindow = contextTokens; @@ -503,6 +554,7 @@ export default function RoutingProfiles({ if (tools) evidence.toolsRequired = true; if (image) evidence.imageInputRequired = true; if (structured) evidence.structuredOutputRequired = true; + if (taskTier) evidence.taskTier = taskTier; const response = await fetch(`${apiBase}/api/routing-profiles/dry-run`, { method: "POST", headers: { "content-type": "application/json" }, @@ -610,6 +662,18 @@ export default function RoutingProfiles({ + +
{t("routing.candidates")}
@@ -645,6 +709,23 @@ export default function RoutingProfiles({
+ {draft.promptRoutingEnabled ? ( +
+ {t("routing.taskTiers")} +
+ {TASK_TIERS.map(tier => ( + + ))} +
+
+ ) : null}