diff --git a/docs-site/public/pr-screenshots/openrouter-model-provider-routing.png b/docs-site/public/pr-screenshots/openrouter-model-provider-routing.png new file mode 100644 index 0000000000..1885d5e43a Binary files /dev/null and b/docs-site/public/pr-screenshots/openrouter-model-provider-routing.png differ diff --git a/docs-site/src/content/docs/fr/reference/configuration/agents.md b/docs-site/src/content/docs/fr/reference/configuration/agents.md index 37e1f2111d..ddf1f15b11 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/agents.md +++ b/docs-site/src/content/docs/fr/reference/configuration/agents.md @@ -5,6 +5,10 @@ description: Surfaces multi-agents, consignes de délégation, modèles privilé Les paramètres des agents déterminent la surface de collaboration Codex annoncée, ainsi que la manière dont opencodex guide, route et limite les tâches déléguées. +## Contrôle de récupération des tâches v2 chiffrées + +La récupération expérimentale est désactivée par défaut. Activez-la sous **Sous-agents → Paramètres → Récupérer les tâches v2 chiffrées** uniquement après avoir accepté l’avertissement de quota et de latence, ou utilisez `ocx agent recovery status`, `ocx agent recovery on` ou `ocx agent recovery off`. L’API équivalente est `GET/PUT /api/agent-task-recovery` ; `PUT` accepte seulement `{"enabled": boolean}`, écrit d’abord la configuration durable, puis met à jour l’état actif, tout en préservant les options avancées configurées manuellement. + ## Champs de configuration des agents | Champ | Type | Valeur par défaut | Signification | diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 4414e00cb3..a45f045bb7 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -6,6 +6,12 @@ description: Entrées du fournisseur, authentification, points de terminaison, c Un fournisseur indique à opencodex où se trouve un modèle, quel adaptateur de protocole il utilise et comment les requêtes sont authentifiées. +## Routage OpenRouter par modèle dans le tableau de bord + +Pour un fournisseur OpenRouter canonique, ouvrez **Fournisseurs → OpenRouter → Modèles → Routage du fournisseur par modèle**. Saisissez l’identifiant OpenRouter exact, chargez les endpoints, puis choisissez l’héritage de `openRouterRouting`, un ordre `order` ou une liste exclusive `only`. Le mode héritage supprime l’entrée correspondante de `modelOpenRouterRouting`. Les tags configurés mais absents d’une découverte restent visibles et peuvent être supprimés explicitement. + +La découverte utilise la clé API OpenRouter configurée. Un refus d’autorisation ne modifie pas le routage enregistré. Les résultats valides sont mis en cache pendant cinq minutes ; **Actualiser** contourne ce cache, même après une liste vide. + ## Champs de premier niveau liés aux fournisseurs | Champ | Type | Par défaut | Signification | diff --git a/docs-site/src/content/docs/ja/reference/configuration/agents.md b/docs-site/src/content/docs/ja/reference/configuration/agents.md index 03b86332ef..725c847349 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ja/reference/configuration/agents.md @@ -5,6 +5,10 @@ description: マルチエージェント サーフェス、委任ガイダンス エージェント設定は、どの Codex コラボレーション サーフェスをアドバタイズするか、および opencodex が委任された作業をどのようにガイド、ルーティング、制限するかを制御します。 +## 暗号化された v2 タスクの復旧コントロール + +実験的な復旧機能は既定で無効です。クォータと遅延の警告を確認したうえで、**サブエージェント → 設定 → 暗号化された v2 タスクを復旧**から有効にするか、`ocx agent recovery status`、`ocx agent recovery on`、または `ocx agent recovery off` を使います。同等の API は `GET/PUT /api/agent-task-recovery` です。`PUT` は `{"enabled": boolean}` のみを受け付け、永続設定を先に書き込んでから実行中の状態を更新し、手動設定された高度なオプションを保持します。 + ## エージェントフィールド |フィールド |タイプ |デフォルト |意味 | diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 4a890d6f14..72a3ddcd71 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -5,6 +5,12 @@ description: プロバイダー エントリ、認証、エンドポイント、 プロバイダーは、opencodex に、モデルが存在する場所、モデルが通信するワイヤー アダプター、およびリクエストの認証方法を伝えます。 +## ダッシュボードでのモデル別 OpenRouter ルーティング + +正規の OpenRouter プロバイダーでは、**プロバイダー → OpenRouter → モデル → モデル別プロバイダー ルーティング**を開きます。正確な OpenRouter モデル ID を入力してエンドポイントを読み込み、`openRouterRouting` の継承、`order`、または `only` を選択します。継承を保存すると、対応する `modelOpenRouterRouting` エントリが削除されます。検出結果にない設定済みタグも表示され、明示的に削除できます。 + +検出には設定済みの OpenRouter API キーを使用します。認証が拒否されても保存済みルーティングは変更されません。成功した結果は 5 分間キャッシュされ、空の結果の後でも**更新**でキャッシュを回避できます。 + ## プロバイダー関連のトップレベルフィールド |フィールド |タイプ |デフォルト |意味 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/agents.md b/docs-site/src/content/docs/ko/reference/configuration/agents.md index 47589fb3cc..4367c67a43 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ko/reference/configuration/agents.md @@ -5,6 +5,10 @@ description: 멀티 에이전트 표면, 위임 안내, 선호 모델, 대체 에이전트 설정은 어떤 Codex 협업 표면을 노출할지와, opencodex가 위임 작업을 어떻게 안내하고, 라우팅하고, 제한할지를 제어합니다. +## 암호화된 v2 작업 복구 제어 + +실험적 복구는 기본적으로 꺼져 있습니다. 할당량과 지연 경고를 확인한 뒤 **하위 에이전트 → 설정 → 암호화된 v2 작업 복구**에서 켜거나 `ocx agent recovery status`, `ocx agent recovery on`, 또는 `ocx agent recovery off`를 사용하세요. 동일한 API는 `GET/PUT /api/agent-task-recovery`입니다. `PUT`은 `{"enabled": boolean}`만 허용하며, 영구 설정을 먼저 기록한 뒤 활성 상태를 갱신하고 수동으로 설정한 고급 옵션은 보존합니다. + ## 에이전트 필드 | 필드 | 형식 | 기본값 | 의미 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 79bb85bcc2..bfdc77a738 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -5,6 +5,12 @@ description: 공급자 항목, 인증, 엔드포인트, 모델 카탈로그, 할 공급자는 opencodex에 모델의 위치, 사용하는 와이어 어댑터, 요청 인증 방식을 알려줍니다. +## 대시보드의 모델별 OpenRouter 라우팅 + +정식 OpenRouter 공급자에서 **공급자 → OpenRouter → 모델 → 모델별 공급자 라우팅**을 엽니다. 정확한 OpenRouter 모델 ID를 입력하고 엔드포인트를 불러온 뒤 `openRouterRouting` 상속, `order`, 또는 `only`를 선택합니다. 상속을 저장하면 해당 `modelOpenRouterRouting` 항목이 제거됩니다. 검색 결과에서 사라진 구성 태그도 계속 표시되며 명시적으로 제거할 수 있습니다. + +검색은 구성된 OpenRouter API 키를 사용합니다. 인증이 거부되어도 저장된 라우팅은 변경되지 않습니다. 성공한 결과는 5분 동안 캐시되며, 빈 결과 뒤에도 **새로 고침**으로 캐시를 우회할 수 있습니다. + ## 공급자 관련 최상위 필드 | 필드 | 타입 | 기본값 | 의미 | diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index ddf93a2d47..3efa7e6188 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -187,6 +187,20 @@ Enable this only when the additional authenticated request, quota use, plaintext and private-backend dependency are acceptable. Prefer a native ChatGPT child or v1 heterogeneous delegation when they are not. +The dashboard exposes the same explicit opt-in under **Subagents → Settings → Recover encrypted V2 +tasks** and shows the quota/latency warning before enabling it. Headless users can use: + +```bash +ocx agent recovery status +ocx agent recovery on +ocx agent recovery off +``` + +The management API equivalent is `GET/PUT /api/agent-task-recovery`; `PUT` accepts only +`{"enabled": boolean}` and preserves advanced `model`, `timeoutMs`, and `cacheEntries` values that +were configured manually. The route commits this field-scoped change to disk before mirroring it into +the running proxy, so a persistence failure cannot enable or disable live recovery by itself. + This recovery path applies to direct-routed children. At most 32 recovery requests can be active at once; additional misses fail closed. Combo routing keeps its existing native-only filter for encrypted tasks and does not invoke recovery. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 2743d81961..293fc2cbc6 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -361,6 +361,31 @@ Model keys are exact native OpenRouter ids, without the outer opencodex provider `openrouter/anthropic-claude-sonnet-5` restores native `anthropic/claude-sonnet-5` before applying the model rule. +### Discover and select providers in the dashboard + +For canonical OpenRouter providers (`openai-chat` plus `https://openrouter.ai/api/v1`), open +**Providers → OpenRouter → Models → Provider routing by model**. Enter an exact OpenRouter model id, +load its available endpoints, then choose one of three behaviors: + +- inherit the provider-wide `openRouterRouting` value; saving this mode removes that model's + `modelOpenRouterRouting` entry, so the provider-wide value applies again; +- prioritize selected endpoint tags with `order`; +- restrict routing to selected endpoint tags with `only`. + +Selections preserve the exact endpoint `tag` returned by OpenRouter, including region or speed +suffixes such as `google-vertex/us-east5` or `deepinfra/turbo`. Configured tags that disappear from a +later discovery response remain visible and can be removed explicitly; they are never silently +deleted. + +Endpoint discovery uses OpenRouter's authenticated +`GET /api/v1/models/{author}/{slug}/endpoints` API on demand and caches successful results in memory +for five minutes. It authenticates with the configured OpenRouter API key, the same credential class +used for inference. If OpenRouter rejects that key, existing routing remains unchanged and manual JSON +configuration is still available. opencodex never returns the key to the browser or includes upstream +error bodies in the dashboard response. At most eight distinct endpoint discoveries run concurrently; +additional unique requests receive a bounded busy response while callers for an existing discovery +still share that request. + ## Static model allowlists Set `liveModels: false` to expose only `models`. If `models` is empty or omitted, the provider exposes diff --git a/docs-site/src/content/docs/ru/reference/configuration/agents.md b/docs-site/src/content/docs/ru/reference/configuration/agents.md index 4febe3254f..eece8303f3 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ru/reference/configuration/agents.md @@ -6,6 +6,10 @@ description: Multi-agent surface, guidance при делегировании, pr Настройки агентов управляют тем, какая collaboration surface Codex рекламируется и как opencodex подсказывает, маршрутизирует и ограничивает делегированную работу. +## Управление восстановлением зашифрованных v2-задач + +Экспериментальное восстановление по умолчанию выключено. Включайте его через **Подагенты → Настройки → Восстанавливать зашифрованные v2-задачи** только после подтверждения предупреждения о quota и latency либо используйте `ocx agent recovery status`, `ocx agent recovery on` или `ocx agent recovery off`. Эквивалентный API — `GET/PUT /api/agent-task-recovery`; `PUT` принимает только `{"enabled": boolean}`, сначала сохраняет durable-конфигурацию, затем обновляет активное состояние и сохраняет расширенные параметры, заданные вручную. + ## Поля агентов | Поле | Тип | По умолчанию | Значение | diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 89c2cd52b8..a498e63cb0 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -6,6 +6,12 @@ description: Записи провайдеров, аутентификация, Провайдер сообщает opencodex, где живёт модель, на каком wire-adapter'е она работает и как аутентифицируются запросы. +## Маршрутизация OpenRouter по модели в дашборде + +Для канонического OpenRouter откройте **Провайдеры → OpenRouter → Модели → Маршрутизация провайдера по модели**. Укажите точный OpenRouter model id, загрузите endpoint'ы и выберите наследование `openRouterRouting`, порядок `order` или ограничение `only`. Сохранение наследования удаляет соответствующую запись `modelOpenRouterRouting`. Настроенные tag'и, которых больше нет в результате discovery, остаются видимыми и могут быть явно удалены. + +Discovery использует настроенный API-ключ OpenRouter. Отказ авторизации не меняет сохранённую маршрутизацию. Успешный результат кэшируется на пять минут; **Обновить** обходит кэш даже после пустого результата. + ## Верхнеуровневые поля, связанные с провайдерами | Поле | Тип | По умолчанию | Значение | diff --git a/docs-site/src/content/docs/tr/reference/configuration/agents.md b/docs-site/src/content/docs/tr/reference/configuration/agents.md index 8fa28a22af..15a4d11c2f 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/agents.md +++ b/docs-site/src/content/docs/tr/reference/configuration/agents.md @@ -7,6 +7,10 @@ Ajan ayarları hangi Codex işbirliği yüzeyinin tanıtılacağını ve opencod devredilen işleri nasıl yönlendireceğini, yönlendireceğini ve sınırlayacağını kontrol eder. +## Şifrelenmiş v2 görev kurtarma denetimi + +Deneysel kurtarma varsayılan olarak kapalıdır. Kota ve gecikme uyarısını kabul ettikten sonra **Alt ajanlar → Ayarlar → Şifrelenmiş v2 görevlerini kurtar** yolundan etkinleştirin veya `ocx agent recovery status`, `ocx agent recovery on` ya da `ocx agent recovery off` kullanın. Eşdeğer API `GET/PUT /api/agent-task-recovery`'dir. `PUT` yalnızca `{"enabled": boolean}` kabul eder, etkin durumu güncellemeden önce kalıcı yapılandırmayı yazar ve elle yapılandırılmış gelişmiş seçenekleri korur. + ## Ajan alanları | Alan | Tip | Varsayılan | Anlamı | @@ -246,5 +250,3 @@ ile `xhigh` arasını sunar. v1, varsayılan ve v2 davranışının yeni başlayanlara yönelik açıklaması için [Alt ajan yüzeyleri](/tr/guides/sub-agent-surface/) sayfasına bakın. - - diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 3de687ef3d..003027f771 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -6,6 +6,12 @@ description: Sağlayıcı girdileri, kimlik doğrulama, uç noktalar, model kata Bir sağlayıcı, opencodex'e bir modelin nerede yaşadığını, hangi hat adaptörünü konuştuğunu ve isteklerin nasıl doğrulandığını söyler. +## Kontrol panelinde model başına OpenRouter yönlendirmesi + +Kurallı bir OpenRouter sağlayıcısında **Sağlayıcılar → OpenRouter → Modeller → Model başına sağlayıcı yönlendirmesi** yolunu açın. Tam OpenRouter model kimliğini girip uçları yükleyin; ardından `openRouterRouting` kalıtımını, `order` sırasını veya `only` kısıtını seçin. Kalıtımı kaydetmek ilgili `modelOpenRouterRouting` girdisini kaldırır. Keşifte artık dönmeyen yapılandırılmış etiketler görünür kalır ve açıkça kaldırılabilir. + +Keşif yapılandırılmış OpenRouter API anahtarını kullanır. Yetkilendirme reddi kayıtlı yönlendirmeyi değiştirmez. Başarılı sonuçlar beş dakika önbelleğe alınır; boş bir sonuçtan sonra bile **Yenile** önbelleği atlar. + ## Sağlayıcı ile ilgili üst düzey alanlar | Alan | Tip | Varsayılan | Anlamı | @@ -478,5 +484,3 @@ bildirir; senkronize edilen katalog `xhigh`'ı ayrı tutarken `max` bildirir. "visionSidecar": { "enabled": true } } ``` - - diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md b/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md index 0afa4603fe..96d71fb70b 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md @@ -5,6 +5,10 @@ description: 多代理界面、委派引导、首选模型、回退链、原生 代理设置控制会公开哪种 Codex 协作界面,以及 opencodex 如何引导、路由并限制委派工作。 +## 加密 v2 任务恢复控制 + +实验性恢复功能默认关闭。确认配额和延迟警告后,可在**子代理 → 设置 → 恢复加密的 v2 任务**中启用,或分别使用 `ocx agent recovery status`、`ocx agent recovery on` 或 `ocx agent recovery off`。对应 API 为 `GET/PUT /api/agent-task-recovery`;`PUT` 只接受 `{"enabled": boolean}`,会先写入持久配置,再更新运行中的状态,并保留手动配置的高级选项。 + ## 代理字段 | 字段 | 类型 | 默认值 | 含义 | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 5ccabf5d10..6d358974d2 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -5,6 +5,12 @@ description: 提供者条目、身份验证、端点、模型目录、配额、 提供者用于告诉 opencodex 模型位于哪里、使用哪种线协议适配器,以及请求如何进行身份验证。 +## 在仪表板中按模型配置 OpenRouter 路由 + +对于规范 OpenRouter 提供者,请打开**提供者 → OpenRouter → 模型 → 按模型的提供者路由**。输入精确的 OpenRouter 模型 ID,加载 endpoint,然后选择继承 `openRouterRouting`、使用 `order` 排序或使用 `only` 限制。保存继承模式会删除对应的 `modelOpenRouterRouting` 条目。发现结果中已不存在的已配置 tag 仍会显示,并可明确移除。 + +发现功能使用已配置的 OpenRouter API 密钥。授权失败不会更改已保存的路由。成功结果会缓存五分钟;即使结果为空,也可通过**刷新**绕过缓存。 + ## 提供者相关顶级字段 | 字段 | 类型 | 默认值 | 含义 | diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/agents.md b/docs-site/src/content/docs/zh-tw/reference/configuration/agents.md index c909917679..1f9b200639 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/agents.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/agents.md @@ -5,6 +5,10 @@ description: 多代理介面、委派指引、偏好模型、fallback 鏈、原 Agent 設定控制要廣告哪個 Codex 協作介面,以及 opencodex 如何引導、路由並限制委派的工作。 +## 加密 v2 任務復原控制 + +實驗性復原功能預設關閉。確認配額與延遲警告後,可在**子代理 → 設定 → 復原加密的 v2 任務**中啟用,或分別使用 `ocx agent recovery status`、`ocx agent recovery on` 或 `ocx agent recovery off`。對應 API 為 `GET/PUT /api/agent-task-recovery`;`PUT` 僅接受 `{"enabled": boolean}`,會先寫入持久設定,再更新執行中的狀態,並保留手動設定的進階選項。 + ## Agent 欄位 | 欄位 | 型別 | 預設值 | 意義 | diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 2ea1148660..7c309c830e 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -5,6 +5,12 @@ description: 供應商項目、認證、端點、模型目錄、配額、context 供應商告訴 opencodex 模型在哪裡、它使用哪種 wire adapter,以及請求如何被認證。 +## 在儀表板中依模型設定 OpenRouter 路由 + +對於規範 OpenRouter 供應商,請開啟**供應商 → OpenRouter → 模型 → 依模型的供應商路由**。輸入精確的 OpenRouter model ID、載入 endpoint,然後選擇繼承 `openRouterRouting`、使用 `order` 排序或以 `only` 限制。儲存繼承模式會移除對應的 `modelOpenRouterRouting` 項目。探索結果中已不存在的已設定 tag 仍會顯示,並可明確移除。 + +探索使用已設定的 OpenRouter API 金鑰。授權失敗不會變更已儲存的路由。成功結果會快取五分鐘;即使結果為空,也能透過**重新整理**略過快取。 + ## 供應商相關的頂層欄位 | 欄位 | 型別 | 預設值 | 意義 | diff --git a/gui/src/components/provider-workspace/OpenRouterModelRouting.tsx b/gui/src/components/provider-workspace/OpenRouterModelRouting.tsx new file mode 100644 index 0000000000..d986f20d4f --- /dev/null +++ b/gui/src/components/provider-workspace/OpenRouterModelRouting.tsx @@ -0,0 +1,193 @@ +import { useMemo, useRef, useState } from "react"; +import { IconArrowDown, IconArrowUp, IconX } from "../../icons"; +import { useT } from "../../i18n/shared"; +import type { WorkspaceItem } from "../../provider-workspace/catalog"; +import type { ProviderUpdatePatch } from "./types"; + +type Endpoint = { tag: string; providerName: string; supportsImplicitCaching?: boolean }; +type Discovery = { endpoints?: Endpoint[]; code?: string; error?: string }; +type Mode = "inherit" | "order" | "only"; + +export default function OpenRouterModelRouting({ + item, apiBase, availableModels, onUpdateProvider, +}: { + item: WorkspaceItem; + apiBase: string; + availableModels: string[]; + onUpdateProvider?: (name: string, patch: ProviderUpdatePatch) => Promise<{ ok: boolean; error?: string }>; +}) { + const t = useT(); + const configuredModels = Object.keys(item.modelOpenRouterRouting ?? {}); + const [model, setModel] = useState(configuredModels[0] ?? item.defaultModel ?? ""); + const saved = model ? item.modelOpenRouterRouting?.[model] : undefined; + const savedMode: Mode = saved?.only ? "only" : saved?.order ? "order" : "inherit"; + const savedTags = saved?.only ?? saved?.order ?? []; + const [mode, setMode] = useState(savedMode); + const [selected, setSelected] = useState(savedTags); + const [allowFallbacks, setAllowFallbacks] = useState(saved?.allowFallbacks ?? true); + const [endpoints, setEndpoints] = useState([]); + const [discoveryComplete, setDiscoveryComplete] = useState(false); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); + const discoveryGeneration = useRef(0); + const activeModelRef = useRef(model); + const modelOptions = useMemo(() => [...new Set([ + ...configuredModels, + ...(item.defaultModel ? [item.defaultModel] : []), + ...availableModels.slice(0, 500), + ])], [availableModels, configuredModels, item.defaultModel]); + + const selectModel = (nextModel: string) => { + activeModelRef.current = nextModel; + discoveryGeneration.current += 1; + setModel(nextModel); + const route = item.modelOpenRouterRouting?.[nextModel]; + setMode(route?.only ? "only" : route?.order ? "order" : "inherit"); + setSelected(route?.only ?? route?.order ?? []); + setAllowFallbacks(route?.allowFallbacks ?? true); + setEndpoints([]); + setDiscoveryComplete(false); + setLoading(false); + setMessage(null); + }; + + const load = async (refresh = false) => { + const requestedModel = model.trim(); + if (!requestedModel || loading) return; + const generation = ++discoveryGeneration.current; + setLoading(true); + setDiscoveryComplete(false); + setMessage(null); + try { + const response = await fetch(`${apiBase}/api/openrouter/model-providers?provider=${encodeURIComponent(item.name)}&model=${encodeURIComponent(requestedModel)}${refresh ? "&refresh=1" : ""}`); + if (generation !== discoveryGeneration.current) return; + if (!response.ok) { + const data = await response.json().catch(() => ({})) as Discovery; + const text = data.code === "openrouter_authorization_failed" + ? t("pws.openrouter.authorizationFailed") + : data.code === "openrouter_key_required" + ? t("pws.openrouter.keyRequired") + : t("pws.openrouter.loadFailed"); + throw new Error(text); + } + const data = await response.json().catch(() => ({})) as Discovery; + if (generation !== discoveryGeneration.current) return; + setEndpoints(Array.isArray(data.endpoints) ? data.endpoints : []); + setDiscoveryComplete(true); + setMessage({ ok: true, text: t("pws.openrouter.loaded", { count: data.endpoints?.length ?? 0 }) }); + } catch (error) { + if (generation !== discoveryGeneration.current) return; + setEndpoints([]); + setDiscoveryComplete(false); + setMessage({ ok: false, text: error instanceof Error ? error.message : t("pws.openrouter.loadFailed") }); + } finally { + if (generation === discoveryGeneration.current) setLoading(false); + } + }; + + const toggle = (tag: string) => setSelected(current => current.includes(tag) + ? current.filter(value => value !== tag) + : [...current, tag]); + const remove = (tag: string) => setSelected(current => current.filter(value => value !== tag)); + const move = (index: number, direction: -1 | 1) => setSelected(current => { + const target = index + direction; + if (target < 0 || target >= current.length) return current; + const next = [...current]; + [next[index], next[target]] = [next[target]!, next[index]!]; + return next; + }); + + const save = async () => { + const requestedModel = model.trim(); + if (!onUpdateProvider || !requestedModel || saving || (mode !== "inherit" && selected.length === 0)) return; + setSaving(true); + setMessage(null); + const routing = mode === "inherit" ? null : { + [mode]: selected, + allowFallbacks, + }; + try { + const result = await onUpdateProvider(item.name, { + modelOpenRouterRouting: { [requestedModel]: routing }, + }); + if (activeModelRef.current.trim() === requestedModel) { + setMessage({ ok: result.ok, text: result.ok ? t("pws.openrouter.saved") : result.error ?? t("sub.saveFailed") }); + } + } catch { + if (activeModelRef.current.trim() === requestedModel) setMessage({ ok: false, text: t("sub.saveFailed") }); + } finally { + setSaving(false); + } + }; + + const knownTags = new Set(endpoints.map(endpoint => endpoint.tag)); + const selectedTags = useMemo(() => new Set(selected), [selected]); + return ( +
+
+
+

{t("pws.openrouter.title")}

+

{t("pws.openrouter.hint")}

+
+
+
+ + +
+
+ + {mode !== "inherit" && ( + + )} +
+ {mode !== "inherit" && selected.length > 0 && ( +
    + {selected.map((tag, index) => ( +
  1. + {tag} + {discoveryComplete && !knownTags.has(tag) && {t("pws.openrouter.notReturned")}} + + + +
  2. + ))} +
+ )} + {endpoints.length > 0 && mode !== "inherit" && ( +
+ {endpoints.map(endpoint => ( + + ))} +
+ )} +
+ + {discoveryComplete && } +
+ {message &&

{message.text}

} +
+ ); +} diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx index 62cf94cad8..bd5ff84ff9 100644 --- a/gui/src/components/provider-workspace/ProviderDetails.tsx +++ b/gui/src/components/provider-workspace/ProviderDetails.tsx @@ -293,6 +293,7 @@ export default function ProviderDetails({ } onRetryModels={onRetryModels} onOpenAccounts={authSurface ? () => switchTab("accounts") : undefined} + onUpdateProvider={onUpdateProvider} /> )} {tab === "usage" && ( diff --git a/gui/src/components/provider-workspace/ProviderModels.tsx b/gui/src/components/provider-workspace/ProviderModels.tsx index 56cc588292..9b5380822e 100644 --- a/gui/src/components/provider-workspace/ProviderModels.tsx +++ b/gui/src/components/provider-workspace/ProviderModels.tsx @@ -8,6 +8,8 @@ import { useT } from "../../i18n/shared"; import type { WorkspaceItem } from "../../provider-workspace/catalog"; import { filterModels } from "../../provider-workspace/report"; import { encodedModelIdCollides } from "../../../../src/providers/slug-codec"; +import OpenRouterModelRouting from "./OpenRouterModelRouting"; +import type { ProviderUpdatePatch } from "./types"; export default function ProviderModels({ item, @@ -20,6 +22,7 @@ export default function ProviderModels({ needsReauth = false, onRetryModels, onOpenAccounts, + onUpdateProvider, }: { item: WorkspaceItem; apiBase: string; @@ -33,6 +36,7 @@ export default function ProviderModels({ needsReauth?: boolean; onRetryModels?: () => void; onOpenAccounts?: () => void; + onUpdateProvider?: (name: string, patch: ProviderUpdatePatch) => Promise<{ ok: boolean; error?: string }>; }) { const t = useT(); const [query, setQuery] = useState(""); @@ -170,6 +174,9 @@ export default function ProviderModels({ {t("pws.modelsAvailable", { count: models.length })} )} + {item.adapter === "openai-chat" && item.baseUrl.replace(/\/+$/, "") === "https://openrouter.ai/api/v1" && ( + + )} {needsReauth && (
{t("pws.modelsNeedsReauth")} diff --git a/gui/src/components/provider-workspace/types.ts b/gui/src/components/provider-workspace/types.ts index b5757d06eb..43e4cc3ea0 100644 --- a/gui/src/components/provider-workspace/types.ts +++ b/gui/src/components/provider-workspace/types.ts @@ -101,4 +101,5 @@ export type ProviderUpdatePatch = { requestPacing?: WorkspaceItem["requestPacing"] | null; /** Dedicated field: the API PATCHes it alone for the canonical `openai` provider. */ codexAccountMode?: "direct" | "pool"; + modelOpenRouterRouting?: Record | null; }; diff --git a/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx b/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx index e631570734..a47836c13f 100644 --- a/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx +++ b/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx @@ -27,6 +27,13 @@ export interface SubagentDelegationSectionProps { onUltraModeSave: (patch: UltraModePatch) => void; ultraLoadFailed: boolean; onUltraModeRetry: () => void; + taskRecovery: { + enabled: boolean; + saving: boolean; + loadFailed: boolean; + onSave: (enabled: boolean) => void; + onRetry: () => void; + }; } export default function SubagentDelegationSection({ @@ -43,6 +50,7 @@ export default function SubagentDelegationSection({ onUltraModeSave, ultraLoadFailed, onUltraModeRetry, + taskRecovery, }: SubagentDelegationSectionProps) { const t = useT(); // A present empty/whitespace hint is an upstream override that suppresses the @@ -63,6 +71,33 @@ export default function SubagentDelegationSection({
)} +
+
+
{t("sub.taskRecovery")}
+
{t("sub.taskRecoveryHint")}
+
+ {taskRecovery.loadFailed ? ( + + ) : ( + + )} +
+
{t("sub.delegation.model")}
diff --git a/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx b/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx index 862467da09..a8744af6d9 100644 --- a/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx +++ b/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx @@ -50,6 +50,13 @@ export interface SubagentsWorkspaceProps { onUltraModeSave: (patch: UltraModePatch) => void; ultraLoadFailed: boolean; onUltraModeRetry: () => void; + taskRecovery: { + enabled: boolean; + saving: boolean; + loadFailed: boolean; + onSave: (enabled: boolean) => void; + onRetry: () => void; + }; }; } @@ -234,6 +241,7 @@ export default function SubagentsWorkspace({ onUltraModeSave={delegation.onUltraModeSave} ultraLoadFailed={delegation.ultraLoadFailed} onUltraModeRetry={delegation.onUltraModeRetry} + taskRecovery={delegation.taskRecovery} />
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index a4a681d9b7..6ab3d8852e 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2008,6 +2008,9 @@ export const de: Record = { "sub.sections": "Subagent-Abschnitte", "sub.delegation.model": "Zuerst aufgerufenes Modell", "sub.delegation.modelHint": "Das Modell, zu dem Codex zuerst greift, wenn es Arbeit übergibt. Oben steht, wen es überhaupt aufrufen darf; hier wählst du den Ersten davon.", + "sub.taskRecovery": "Verschlüsselte V2-Aufgaben wiederherstellen", + "sub.taskRecoveryHint": "Für native ChatGPT-Eltern, die Arbeit an geroutete Modelle übergeben. Eine zusätzliche authentifizierte ChatGPT-Anfrage kann Kontingent verbrauchen und Latenz erhöhen. Wiederhergestellter Text bleibt nur in einem begrenzten Speicher-Cache; Fehler bleiben sicher geschlossen.", + "sub.taskRecoveryConfirm": "Experimentelle Wiederherstellung verschlüsselter Aufgaben aktivieren? Sie kann zusätzliches ChatGPT-Kontingent verbrauchen, Latenz erhöhen und hängt von nicht dokumentiertem Backend-Verhalten ab.", "dash.syncModelsHint": "Schreibt Codex' Modellkatalog anhand deiner verbundenen Provider neu.", "dash.syncRun": "Jetzt synchronisieren", "lab.title": "Kompatibilitäts-Labor", @@ -2069,4 +2072,19 @@ export const de: Record = { "dash.visionTimeout": "Timeout", "dash.visionTimeoutInvalid": "Geben Sie eine ganze Zahl von {min} bis {max} Millisekunden ein.", "dash.visionAdvancedPopover": "Erweiterte Vision-Einstellungen", + "pws.openrouter.title": "Provider-Routing pro Modell", + "pws.openrouter.hint": "Wähle die genauen OpenRouter-Endpunkte für jedes Modell. Erkennung und Routing verwenden den konfigurierten OpenRouter API-Key.", + "pws.openrouter.load": "Provider laden", + "pws.openrouter.mode": "Routing-Verhalten", + "pws.openrouter.inherit": "Provider-Standard übernehmen", + "pws.openrouter.order": "Ausgewählte Provider priorisieren", + "pws.openrouter.only": "Nur ausgewählte Provider verwenden", + "pws.openrouter.allowFallbacks": "OpenRouter-Fallbacks nach den ausgewählten Providern zulassen", + "pws.openrouter.authorizationFailed": "OpenRouter hat den konfigurierten API-Key für die Endpunkt-Erkennung abgelehnt. Bestehendes manuelles Routing bleibt unverändert.", + "pws.openrouter.keyRequired": "Füge vor der Provider-Erkennung einen OpenRouter API-Key hinzu.", + "pws.openrouter.loadFailed": "Provider für dieses Modell konnten nicht geladen werden.", + "pws.openrouter.loaded": "{count} Provider-Endpunkte geladen.", + "pws.openrouter.notReturned": "Bei der letzten Erkennung nicht zurückgegeben", + "pws.openrouter.saved": "Provider-Routing des Modells gespeichert.", + "pws.openrouter.cache": "Cache", }; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 6ed131e19c..97c26722a5 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -630,6 +630,9 @@ export const en = { "sub.sections": "Subagent sections", "sub.delegation.model": "Model to call first", "sub.delegation.modelHint": "The model Codex reaches for first when it hands off work. Featured above is the list it may call; this is the one it calls first.", + "sub.taskRecovery": "Recover encrypted V2 tasks", + "sub.taskRecoveryHint": "For native ChatGPT parents handing work to routed models. Uses one additional authenticated ChatGPT request, which can consume quota and add latency. Recovered text stays only in a bounded in-memory cache; failures remain fail-closed.", + "sub.taskRecoveryConfirm": "Enable experimental encrypted-task recovery? It can consume additional ChatGPT quota, add latency, and depends on undocumented backend behavior.", "sub.noModels": "No models — log into a provider or add one first.", "sub.saved": "Saved {n} models. Start a new Codex session (or run {cmd}) to see them as spawn_agent overrides.", "sub.saveFailed": "Save failed", @@ -2098,6 +2101,21 @@ export const en = { "lab.layer.protocol_conformance": "Protocol conformance", "lab.layer.live_route_compatibility": "Live route compatibility", "lab.layer.task_effectiveness": "Task effectiveness", + "pws.openrouter.title": "Provider routing by model", + "pws.openrouter.hint": "Choose the exact OpenRouter endpoints used by each model. Discovery and routing use the configured OpenRouter API key.", + "pws.openrouter.load": "Load providers", + "pws.openrouter.mode": "Routing behavior", + "pws.openrouter.inherit": "Inherit provider default", + "pws.openrouter.order": "Prioritize selected providers", + "pws.openrouter.only": "Use only selected providers", + "pws.openrouter.allowFallbacks": "Allow OpenRouter fallbacks after the selected providers", + "pws.openrouter.authorizationFailed": "OpenRouter rejected the configured API key for endpoint discovery. Existing manual routing remains unchanged.", + "pws.openrouter.keyRequired": "Add an OpenRouter API key before discovering providers.", + "pws.openrouter.loadFailed": "Could not load providers for this model.", + "pws.openrouter.loaded": "Loaded {count} provider endpoints.", + "pws.openrouter.notReturned": "Not returned by the latest discovery", + "pws.openrouter.saved": "Model provider routing saved.", + "pws.openrouter.cache": "Cache", } as const; diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 1382ed2ee9..a09e1f4759 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -613,6 +613,9 @@ export const fr: Record = { "sub.sections": "Sections des sous-agents", "sub.delegation.model": "Modèle à appeler en premier", "sub.delegation.modelHint": "Le modèle que Codex sollicite en premier lorsqu’il délègue une tâche. La liste À la une ci-dessus contient les modèles qu’il peut appeler ; celui-ci est appelé en premier.", + "sub.taskRecovery": "Récupérer les tâches V2 chiffrées", + "sub.taskRecoveryHint": "Pour les parents ChatGPT natifs qui délèguent à des modèles routés. Utilise une requête ChatGPT authentifiée supplémentaire, ce qui peut consommer du quota et ajouter de la latence. Le texte récupéré reste uniquement dans un cache mémoire limité ; les échecs restent bloquants.", + "sub.taskRecoveryConfirm": "Activer la récupération expérimentale des tâches chiffrées ? Elle peut consommer du quota ChatGPT supplémentaire, ajouter de la latence et dépend d’un comportement backend non documenté.", "sub.noModels": "Aucun modèle — connectez-vous d’abord à un fournisseur ou ajoutez-en un.", "sub.saved": "{n} modèles enregistrés. Démarrez une nouvelle session Codex (ou exécutez {cmd}) pour les voir comme remplacements de spawn_agent.", "sub.saveFailed": "Échec de l’enregistrement", @@ -1167,6 +1170,21 @@ export const fr: Record = { "pws.pacingNone": "Aucun", "pws.pacingModelOverrides": "Remplacements par modèle", "pws.pacingModel": "Modèle", + "pws.openrouter.title": "Routage des fournisseurs par modèle", + "pws.openrouter.hint": "Choisissez les endpoints OpenRouter exacts utilisés par chaque modèle. La découverte et le routage utilisent la clé API OpenRouter configurée.", + "pws.openrouter.load": "Charger les fournisseurs", + "pws.openrouter.mode": "Comportement du routage", + "pws.openrouter.inherit": "Hériter du réglage du fournisseur", + "pws.openrouter.order": "Prioriser les fournisseurs sélectionnés", + "pws.openrouter.only": "Utiliser uniquement les fournisseurs sélectionnés", + "pws.openrouter.allowFallbacks": "Autoriser les fallbacks OpenRouter après les fournisseurs sélectionnés", + "pws.openrouter.authorizationFailed": "OpenRouter a refusé la clé API configurée pour la découverte des endpoints. Le routage manuel existant reste inchangé.", + "pws.openrouter.keyRequired": "Ajoutez une clé API OpenRouter avant de découvrir les fournisseurs.", + "pws.openrouter.loadFailed": "Impossible de charger les fournisseurs pour ce modèle.", + "pws.openrouter.loaded": "{count} endpoints de fournisseurs chargés.", + "pws.openrouter.notReturned": "Absent de la dernière découverte", + "pws.openrouter.saved": "Routage des fournisseurs du modèle enregistré.", + "pws.openrouter.cache": "Mise en cache", "pws.pacingAdd": "Ajouter un remplacement", "pws.pacingRemove": "Supprimer", "pws.pacingRemoveModel": "Supprimer le remplacement du cadencement des requêtes pour {model}", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 10f91145f2..494d4cba3e 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2029,6 +2029,9 @@ export const ja: Record = { "sub.sections": "サブエージェントのセクション", "sub.delegation.model": "最初に呼ぶモデル", "sub.delegation.modelHint": "Codex が作業を任せるとき、最初に呼ぶモデルです。上のおすすめが呼べる候補で、ここで選んだものがその中の第一候補になります。", + "sub.taskRecovery": "暗号化された V2 タスクを復元", + "sub.taskRecoveryHint": "ネイティブ ChatGPT 親からルーティングモデルへ委任する場合に使います。追加の認証済み ChatGPT リクエストにより、クォータ消費と遅延が増える可能性があります。復元テキストは制限付きメモリキャッシュにのみ保持され、失敗時は安全に停止します。", + "sub.taskRecoveryConfirm": "実験的な暗号化タスク復元を有効にしますか?追加の ChatGPT クォータと遅延が発生し、非公開のバックエンド動作に依存します。", "dash.syncModelsHint": "接続済みのプロバイダーをもとに Codex のモデルカタログを書き直します。", "dash.syncRun": "今すぐ同期", "lab.title": "Compatibility Lab", @@ -2090,4 +2093,19 @@ export const ja: Record = { "dash.visionTimeout": "タイムアウト", "dash.visionTimeoutInvalid": "{min} から {max} ミリ秒の整数を入力してください。", "dash.visionAdvancedPopover": "詳細なビジョン設定", + "pws.openrouter.title": "モデル別プロバイダールーティング", + "pws.openrouter.hint": "モデルごとに使用する OpenRouter エンドポイントを選択します。検出とルーティングには設定済みの OpenRouter API キーを使います。", + "pws.openrouter.load": "プロバイダーを読み込む", + "pws.openrouter.mode": "ルーティング動作", + "pws.openrouter.inherit": "プロバイダー既定値を継承", + "pws.openrouter.order": "選択したプロバイダーを優先", + "pws.openrouter.only": "選択したプロバイダーのみ使用", + "pws.openrouter.allowFallbacks": "選択後に OpenRouter フォールバックを許可", + "pws.openrouter.authorizationFailed": "OpenRouter がエンドポイント検出用の設定済み API キーを拒否しました。既存の手動ルーティングは変更されません。", + "pws.openrouter.keyRequired": "プロバイダーを検出する前に OpenRouter API キーを追加してください。", + "pws.openrouter.loadFailed": "このモデルのプロバイダーを読み込めませんでした。", + "pws.openrouter.loaded": "{count} 件のプロバイダーエンドポイントを読み込みました。", + "pws.openrouter.notReturned": "最新の検出結果にありません", + "pws.openrouter.saved": "モデルのプロバイダールーティングを保存しました。", + "pws.openrouter.cache": "キャッシュ", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index b19fcad3e4..358cb6088c 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -616,6 +616,9 @@ export const ko: Record = { "sub.sections": "서브에이전트 구역", "sub.delegation.model": "먼저 부를 모델", "sub.delegation.modelHint": "Codex가 일을 나눠 맡길 때 가장 먼저 부를 모델입니다. 위 추천 목록이 부를 수 있는 후보라면, 여기서 고른 모델이 그중 1순위가 됩니다.", + "sub.taskRecovery": "암호화된 V2 작업 복구", + "sub.taskRecoveryHint": "네이티브 ChatGPT 부모가 라우팅 모델에 작업을 넘길 때 사용합니다. 추가 인증 ChatGPT 요청으로 할당량과 지연이 늘 수 있습니다. 복구된 텍스트는 제한된 메모리 캐시에만 보관되며 실패 시 안전하게 차단됩니다.", + "sub.taskRecoveryConfirm": "실험적 암호화 작업 복구를 켤까요? 추가 ChatGPT 할당량과 지연이 발생할 수 있고 문서화되지 않은 백엔드 동작에 의존합니다.", "sub.noModels": "모델 없음 — 먼저 프로바이더에 로그인하거나 추가하세요.", "sub.saved": "{n}개 모델을 저장했습니다. spawn_agent 오버라이드로 보려면 새 Codex 세션을 시작하거나 {cmd} 를 실행하세요.", "sub.saveFailed": "저장 실패", @@ -2091,4 +2094,19 @@ export const ko: Record = { "dash.visionTimeout": "제한 시간", "dash.visionTimeoutInvalid": "{min}에서 {max} 밀리초 사이의 정수를 입력하세요.", "dash.visionAdvancedPopover": "고급 비전 설정", + "pws.openrouter.title": "모델별 제공자 라우팅", + "pws.openrouter.hint": "모델마다 사용할 정확한 OpenRouter 엔드포인트를 선택합니다. 검색과 라우팅은 구성된 OpenRouter API 키를 사용합니다.", + "pws.openrouter.load": "제공자 불러오기", + "pws.openrouter.mode": "라우팅 방식", + "pws.openrouter.inherit": "제공자 기본값 상속", + "pws.openrouter.order": "선택한 제공자 우선", + "pws.openrouter.only": "선택한 제공자만 사용", + "pws.openrouter.allowFallbacks": "선택한 제공자 이후 OpenRouter 폴백 허용", + "pws.openrouter.authorizationFailed": "OpenRouter가 엔드포인트 검색에 사용된 구성된 API 키를 거부했습니다. 기존 수동 라우팅은 유지됩니다.", + "pws.openrouter.keyRequired": "제공자를 검색하기 전에 OpenRouter API 키를 추가하세요.", + "pws.openrouter.loadFailed": "이 모델의 제공자를 불러오지 못했습니다.", + "pws.openrouter.loaded": "제공자 엔드포인트 {count}개를 불러왔습니다.", + "pws.openrouter.notReturned": "최신 검색에서 반환되지 않음", + "pws.openrouter.saved": "모델 제공자 라우팅을 저장했습니다.", + "pws.openrouter.cache": "캐시", }; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 252de8396c..38b1e96521 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2031,6 +2031,9 @@ export const ru: Record = { "sub.sections": "Разделы подагентов", "sub.delegation.model": "Модель, которую вызывать первой", "sub.delegation.modelHint": "Модель, к которой Codex обращается первой, когда передаёт работу. Список выше — кого он вообще может вызвать, а здесь выбирается первый в очереди.", + "sub.taskRecovery": "Восстанавливать зашифрованные задачи V2", + "sub.taskRecoveryHint": "Для нативного родителя ChatGPT, передающего работу маршрутизируемой модели. Дополнительный аутентифицированный запрос ChatGPT может расходовать квоту и увеличивать задержку. Восстановленный текст хранится только в ограниченном кэше памяти; ошибки остаются fail-closed.", + "sub.taskRecoveryConfirm": "Включить экспериментальное восстановление зашифрованных задач? Оно может расходовать дополнительную квоту ChatGPT, увеличивать задержку и зависит от недокументированного поведения backend.", "dash.syncModelsHint": "Перезаписывает каталог моделей Codex по подключённым провайдерам.", "dash.syncRun": "Синхронизировать", "lab.title": "Compatibility Lab", @@ -2092,4 +2095,19 @@ export const ru: Record = { "dash.visionTimeout": "Таймаут", "dash.visionTimeoutInvalid": "Введите целое число от {min} до {max} миллисекунд.", "dash.visionAdvancedPopover": "Дополнительные настройки изображений", + "pws.openrouter.title": "Маршрутизация провайдеров по модели", + "pws.openrouter.hint": "Выберите точные endpoints OpenRouter для каждой модели. Обнаружение и маршрутизация используют настроенный API-ключ OpenRouter.", + "pws.openrouter.load": "Загрузить провайдеров", + "pws.openrouter.mode": "Поведение маршрутизации", + "pws.openrouter.inherit": "Наследовать настройку провайдера", + "pws.openrouter.order": "Отдать приоритет выбранным", + "pws.openrouter.only": "Использовать только выбранные", + "pws.openrouter.allowFallbacks": "Разрешить fallback OpenRouter после выбранных провайдеров", + "pws.openrouter.authorizationFailed": "OpenRouter отклонил настроенный API-ключ для обнаружения endpoints. Ручная маршрутизация не изменена.", + "pws.openrouter.keyRequired": "Добавьте API-ключ OpenRouter перед обнаружением провайдеров.", + "pws.openrouter.loadFailed": "Не удалось загрузить провайдеров этой модели.", + "pws.openrouter.loaded": "Загружено endpoints провайдеров: {count}.", + "pws.openrouter.notReturned": "Нет в последнем результате обнаружения", + "pws.openrouter.saved": "Маршрутизация провайдеров модели сохранена.", + "pws.openrouter.cache": "Кэш", }; diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 7d50716b79..c49266c93c 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -621,6 +621,9 @@ export const tr: Record = { "sub.sections": "Alt ajan bölümleri", "sub.delegation.model": "İlk çağrılacak model", "sub.delegation.modelHint": "Codex'in iş devrederken ilk ulaştığı model.", + "sub.taskRecovery": "Şifreli V2 görevlerini kurtar", + "sub.taskRecoveryHint": "Yerel ChatGPT üst modelinin işi yönlendirilmiş modellere devretmesi içindir. Ek bir kimlik doğrulamalı ChatGPT isteği kota tüketebilir ve gecikme ekleyebilir. Kurtarılan metin yalnızca sınırlı bellek önbelleğinde kalır; hatalar güvenli biçimde kapalı kalır.", + "sub.taskRecoveryConfirm": "Deneysel şifreli görev kurtarma etkinleştirilsin mi? Ek ChatGPT kotası tüketebilir, gecikme ekleyebilir ve belgelenmemiş backend davranışına bağlıdır.", "sub.noModels": "Model yok — önce bir sağlayıcı ekleyin.", "sub.saved": "{n} model kaydedildi. Bunları spawn_agent geçersiz kılmaları olarak görmek için yeni bir Codex oturumu başlatın (veya {cmd} çalıştırın).", "sub.saveFailed": "Kaydetme başarısız", @@ -2092,4 +2095,19 @@ export const tr: Record = { "dash.visionTimeout": "Zaman aşımı", "dash.visionTimeoutInvalid": "{min} ile {max} milisaniye arasında bir tam sayı girin.", "dash.visionAdvancedPopover": "Gelişmiş görsel ayarları", + "pws.openrouter.title": "Modele göre sağlayıcı yönlendirmesi", + "pws.openrouter.hint": "Her model için kullanılacak kesin OpenRouter uçlarını seçin. Keşif ve yönlendirme yapılandırılmış OpenRouter API anahtarını kullanır.", + "pws.openrouter.load": "Sağlayıcıları yükle", + "pws.openrouter.mode": "Yönlendirme davranışı", + "pws.openrouter.inherit": "Sağlayıcı varsayılanını devral", + "pws.openrouter.order": "Seçili sağlayıcılara öncelik ver", + "pws.openrouter.only": "Yalnızca seçili sağlayıcıları kullan", + "pws.openrouter.allowFallbacks": "Seçili sağlayıcılardan sonra OpenRouter geri dönüşlerine izin ver", + "pws.openrouter.authorizationFailed": "OpenRouter, uç keşfi için yapılandırılmış API anahtarını reddetti. Mevcut elle yönlendirme değişmez.", + "pws.openrouter.keyRequired": "Sağlayıcıları keşfetmeden önce bir OpenRouter API anahtarı ekleyin.", + "pws.openrouter.loadFailed": "Bu modelin sağlayıcıları yüklenemedi.", + "pws.openrouter.loaded": "{count} sağlayıcı ucu yüklendi.", + "pws.openrouter.notReturned": "Son keşifte döndürülmedi", + "pws.openrouter.saved": "Model sağlayıcı yönlendirmesi kaydedildi.", + "pws.openrouter.cache": "Önbellek", }; diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 9dd4ef7b69..99c44541bf 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1630,6 +1630,9 @@ export const zhTW: Record = { "sub.sections": "子代理分區", "sub.delegation.model": "優先調用的模型", "sub.delegation.modelHint": "Codex 分派工作時最先調用的模型。上面的推薦是可調用的名單,這裡選的是其中第一順位。", + "sub.taskRecovery": "復原加密的 V2 工作", + "sub.taskRecoveryHint": "用於原生 ChatGPT 父代理將工作委派給路由模型。會額外送出一次已驗證的 ChatGPT 請求,可能消耗額度並增加延遲。復原文字只保存在有界記憶體快取中;失敗時仍會安全關閉。", + "sub.taskRecoveryConfirm": "啟用實驗性的加密工作復原嗎?它可能額外消耗 ChatGPT 額度、增加延遲,並依賴未公開的後端行為。", "debug.loadFailed": "無法載入偵錯設定。", "provider.name.volcengine": "Volcengine Ark", "provider.name.volcengineCodingPlan": "Volcengine Ark Coding Plan", @@ -2055,4 +2058,19 @@ export const zhTW: Record = { "dash.visionTimeout": "逾時", "dash.visionTimeoutInvalid": "請輸入 {min} 到 {max} 毫秒之間的整數。", "dash.visionAdvancedPopover": "進階視覺設定", + "pws.openrouter.title": "依模型設定供應商路由", + "pws.openrouter.hint": "為每個模型選擇準確的 OpenRouter endpoint。探索與路由都使用已設定的 OpenRouter API 金鑰。", + "pws.openrouter.load": "載入供應商", + "pws.openrouter.mode": "路由行為", + "pws.openrouter.inherit": "繼承供應商預設值", + "pws.openrouter.order": "優先使用所選供應商", + "pws.openrouter.only": "僅使用所選供應商", + "pws.openrouter.allowFallbacks": "所選供應商之後允許 OpenRouter 備援", + "pws.openrouter.authorizationFailed": "OpenRouter 拒絕了用於探索 endpoint 的已設定 API 金鑰。現有手動路由不會變更。", + "pws.openrouter.keyRequired": "探索供應商前請新增 OpenRouter API 金鑰。", + "pws.openrouter.loadFailed": "無法載入此模型的供應商。", + "pws.openrouter.loaded": "已載入 {count} 個供應商 endpoint。", + "pws.openrouter.notReturned": "最新探索中未回傳", + "pws.openrouter.saved": "模型供應商路由已儲存。", + "pws.openrouter.cache": "快取", }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 260b6a233f..dc6878e6e2 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2029,6 +2029,9 @@ export const zh: Record = { "sub.sections": "子代理分区", "sub.delegation.model": "优先调用的模型", "sub.delegation.modelHint": "Codex 分派工作时最先调用的模型。上面的推荐是可调用的名单,这里选的是其中第一顺位。", + "sub.taskRecovery": "恢复加密的 V2 任务", + "sub.taskRecoveryHint": "用于原生 ChatGPT 父代理向路由模型分派工作。会额外发送一次经过身份验证的 ChatGPT 请求,可能消耗额度并增加延迟。恢复文本仅保存在有界内存缓存中;失败时仍会安全关闭。", + "sub.taskRecoveryConfirm": "启用实验性的加密任务恢复吗?它可能额外消耗 ChatGPT 额度、增加延迟,并依赖未公开的后端行为。", "dash.syncModelsHint": "按已连接的提供商重写 Codex 的模型目录。", "dash.syncRun": "立即同步", "lab.title": "Compatibility Lab", @@ -2090,4 +2093,19 @@ export const zh: Record = { "dash.visionTimeout": "超时", "dash.visionTimeoutInvalid": "请输入 {min} 到 {max} 毫秒之间的整数。", "dash.visionAdvancedPopover": "高级视觉设置", + "pws.openrouter.title": "按模型设置提供商路由", + "pws.openrouter.hint": "为每个模型选择准确的 OpenRouter endpoint。发现和路由都使用已配置的 OpenRouter API 密钥。", + "pws.openrouter.load": "加载提供商", + "pws.openrouter.mode": "路由行为", + "pws.openrouter.inherit": "继承提供商默认值", + "pws.openrouter.order": "优先使用所选提供商", + "pws.openrouter.only": "仅使用所选提供商", + "pws.openrouter.allowFallbacks": "所选提供商之后允许 OpenRouter 回退", + "pws.openrouter.authorizationFailed": "OpenRouter 拒绝了用于发现 endpoint 的已配置 API 密钥。现有手动路由保持不变。", + "pws.openrouter.keyRequired": "发现提供商前请添加 OpenRouter API 密钥。", + "pws.openrouter.loadFailed": "无法加载此模型的提供商。", + "pws.openrouter.loaded": "已加载 {count} 个提供商 endpoint。", + "pws.openrouter.notReturned": "最新发现中未返回", + "pws.openrouter.saved": "模型提供商路由已保存。", + "pws.openrouter.cache": "缓存", }; diff --git a/gui/src/pages/Subagents.tsx b/gui/src/pages/Subagents.tsx index cc1028fc76..fc1a2d3b82 100644 --- a/gui/src/pages/Subagents.tsx +++ b/gui/src/pages/Subagents.tsx @@ -7,6 +7,7 @@ import { readSessionListCache, writeSessionListCache } from "../session-list-cac import { useDataSurface } from "../data-surface"; import { DataSurfaceSkeleton } from "../components/data-surface"; import { useSubagentDelegation, type UltraModePatch, type UltraModeState } from "./use-subagent-delegation"; +import { useAgentTaskRecovery } from "./use-agent-task-recovery"; type CachedSubagents = { available: string[]; chosen: string[] }; @@ -25,6 +26,7 @@ export default function Subagents({ apiBase }: { apiBase: string }) { /** Sync guard: state-only `busy` can miss clicks before the disabled re-render commits. */ const saveInFlight = useRef(false); const delegation = useSubagentDelegation(apiBase); + const taskRecovery = useAgentTaskRecovery(apiBase); const [ultraMode, setUltraMode] = useState({ enabled: false, hintText: null, multiAgentV2Enabled: false }); const [ultraSaving, setUltraSaving] = useState(false); const [ultraLoadFailed, setUltraLoadFailed] = useState(false); @@ -226,6 +228,13 @@ export default function Subagents({ apiBase }: { apiBase: string }) { onUltraModeSave: patch => { void saveUltraMode(patch); }, ultraLoadFailed, onUltraModeRetry: () => { void retryUltraMode(); }, + taskRecovery: { + enabled: taskRecovery.enabled, + saving: taskRecovery.saving, + loadFailed: taskRecovery.loadFailed, + onSave: enabled => { void taskRecovery.save(enabled); }, + onRetry: () => { void taskRecovery.retry(); }, + }, }} /> diff --git a/gui/src/pages/providers-shared.ts b/gui/src/pages/providers-shared.ts index fb64a56a86..aa2ea10f25 100644 --- a/gui/src/pages/providers-shared.ts +++ b/gui/src/pages/providers-shared.ts @@ -15,6 +15,8 @@ export interface ProvidersConfig { disabled?: boolean; note?: string; codexAccountMode?: "direct" | "pool"; + openRouterRouting?: import("../provider-workspace/catalog").OpenRouterRoutingPreference; + modelOpenRouterRouting?: Record; }>; } diff --git a/gui/src/pages/use-agent-task-recovery.ts b/gui/src/pages/use-agent-task-recovery.ts new file mode 100644 index 0000000000..95ffdff5b7 --- /dev/null +++ b/gui/src/pages/use-agent-task-recovery.ts @@ -0,0 +1,63 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { readJsonOrThrow } from "../fetch-json"; + +type RecoveryResponse = { enabled?: boolean }; + +export function useAgentTaskRecovery(apiBase: string) { + const [enabled, setEnabled] = useState(false); + const [saving, setSaving] = useState(false); + const [loadFailed, setLoadFailed] = useState(false); + const operationRef = useRef(0); + const activeSaveRef = useRef(null); + + const load = useCallback(async () => { + const operation = ++operationRef.current; + try { + const response = await fetch(`${apiBase}/api/agent-task-recovery`); + const data = await readJsonOrThrow(response); + if (operation !== operationRef.current) return false; + setEnabled(data?.enabled === true); + setLoadFailed(false); + return true; + } catch { + if (operation === operationRef.current) setLoadFailed(true); + return false; + } + }, [apiBase]); + + useEffect(() => { + void Promise.resolve().then(load); + return () => { operationRef.current += 1; }; + }, [load]); + + const save = useCallback(async (next: boolean) => { + if (activeSaveRef.current !== null) return false; + const operation = ++operationRef.current; + activeSaveRef.current = operation; + setSaving(true); + try { + const response = await fetch(`${apiBase}/api/agent-task-recovery`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: next }), + }); + await readJsonOrThrow(response); + if (operation === operationRef.current) { + setEnabled(next); + setLoadFailed(false); + } + return true; + } catch { + return false; + } finally { + if (activeSaveRef.current === operation) { + activeSaveRef.current = null; + setSaving(false); + } + } + }, [apiBase]); + + const retry = useCallback(() => { void load(); }, [load]); + + return { enabled, saving, loadFailed, save, retry }; +} diff --git a/gui/src/provider-workspace/catalog.ts b/gui/src/provider-workspace/catalog.ts index 53b680cc09..7c09c27a28 100644 --- a/gui/src/provider-workspace/catalog.ts +++ b/gui/src/provider-workspace/catalog.ts @@ -53,6 +53,14 @@ export interface WorkspaceProvider { }; /** Codex account routing mode for the canonical `openai` forward provider. */ codexAccountMode?: "direct" | "pool"; + openRouterRouting?: OpenRouterRoutingPreference; + modelOpenRouterRouting?: Record; +} + +export interface OpenRouterRoutingPreference { + order?: string[]; + only?: string[]; + allowFallbacks?: boolean; } /** Three-way pricing/ownership tier for a ready provider row. */ diff --git a/gui/src/styles/provider-workspace-settings.css b/gui/src/styles/provider-workspace-settings.css index 3429106e7f..0632e7a047 100644 --- a/gui/src/styles/provider-workspace-settings.css +++ b/gui/src/styles/provider-workspace-settings.css @@ -117,6 +117,28 @@ .pwi-settings-dirty { font-size: var(--text-caption); color: var(--amber); } +.pwi-openrouter-routing { + display: flex; flex-direction: column; gap: 12px; margin-block: 4px 18px; padding: 14px; + border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); +} +.pwi-openrouter-head h3 { margin: 0; color: var(--text); font-size: var(--text-control); } +.pwi-openrouter-head p { max-width: 70ch; margin: 4px 0 0; color: var(--muted); font-size: var(--text-caption); line-height: 1.45; } +.pwi-openrouter-model-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: end; } +.pwi-openrouter-controls { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(220px, 2fr); gap: 12px; align-items: end; } +.pwi-openrouter-fallbacks { display: flex; align-items: center; gap: 8px; min-height: 36px; color: var(--text); font-size: var(--text-label); } +.pwi-openrouter-selected { display: flex; flex-direction: column; gap: 5px; margin: 0; padding: 0; list-style: none; } +.pwi-openrouter-selected li { display: grid; grid-template-columns: minmax(0, 1fr) auto auto auto; align-items: center; gap: 6px; padding: 6px 8px; border-radius: var(--radius-xs); background: var(--raised); } +.pwi-openrouter-selected code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pwi-openrouter-selected span { color: var(--amber); font-size: var(--text-caption); } +.pwi-openrouter-endpoints { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; max-height: 320px; overflow: auto; } +.pwi-openrouter-endpoint { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 8px; border-radius: var(--radius-xs); background: var(--raised); cursor: pointer; } +.pwi-openrouter-endpoint:hover { background: var(--hover); } +.pwi-openrouter-endpoint > span { min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.pwi-openrouter-endpoint strong, .pwi-openrouter-endpoint code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pwi-openrouter-endpoint strong { color: var(--text); font-size: var(--text-label); } +.pwi-openrouter-endpoint code { color: var(--muted); font-size: var(--text-caption); } +.pwi-openrouter-actions { display: flex; gap: 8px; flex-wrap: wrap; } + .pwi-pacing-card { display: flex; flex-direction: column; gap: 12px; padding: 14px; border: 1px solid var(--border); border-radius: var(--radius-sm); @@ -137,6 +159,8 @@ .pwi-pacing-row span { color: var(--muted); font-size: var(--text-caption); } @media (max-width: 760px) { + .pwi-openrouter-model-row, .pwi-openrouter-controls, .pwi-openrouter-endpoints { grid-template-columns: 1fr; } + .pwi-openrouter-model-row .btn { width: 100%; } .pwi-pacing-head { flex-direction: column; } .pwi-pacing-grid, .pwi-pacing-grid--model, .pwi-pacing-status { grid-template-columns: 1fr; } .pwi-pacing-grid--model .btn { width: 100%; } diff --git a/gui/tests/agent-task-recovery-hook.test.tsx b/gui/tests/agent-task-recovery-hook.test.tsx new file mode 100644 index 0000000000..c48bb564b3 --- /dev/null +++ b/gui/tests/agent-task-recovery-hook.test.tsx @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, useEffect } from "react"; +import type { Root } from "react-dom/client"; +import { useAgentTaskRecovery } from "../src/pages/use-agent-task-recovery"; + +const globals = ["document", "window", "navigator", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record<(typeof globals)[number], PropertyDescriptor | undefined>; +let testWindow: Window; +let root: Root | null; + +function deferred(): { promise: Promise; resolve(value: T): void } { + let resolve!: (value: T) => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +async function flush(): Promise { + await Promise.resolve(); + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); +} + +type RecoveryState = ReturnType; + +function RecoveryProbe({ onChange }: { onChange(value: RecoveryState): void }) { + const recovery = useAgentTaskRecovery("http://proxy"); + useEffect(() => { onChange(recovery); }, [onChange, recovery]); + return null; +} + +beforeEach(() => { + previous = Object.fromEntries( + globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ) as typeof previous; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + root = null; +}); + +afterEach(async () => { + if (root) await act(async () => root?.unmount()); + root = null; + for (const key of globals) { + const descriptor = previous[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + await testWindow.happyDOM?.close?.(); +}); + +test("a delayed initial read cannot overwrite a successful save", async () => { + const initialGet = deferred(); + globalThis.fetch = (async (_input, init) => init?.method === "PUT" + ? Response.json({ ok: true, enabled: true }) + : initialGet.promise) as typeof fetch; + + let recovery: RecoveryState | undefined; + const publishRecovery = (value: RecoveryState) => { recovery = value; }; + + const host = document.createElement("div"); + document.body.append(host); + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render(); + }); + + await act(async () => { + expect(await recovery!.save(true)).toBe(true); + await flush(); + }); + expect(recovery!.enabled).toBe(true); + + await act(async () => { + initialGet.resolve(Response.json({ enabled: false })); + await flush(); + }); + expect(recovery!.enabled).toBe(true); +}); + +test("overlapping saves issue only one recovery write", async () => { + const pendingPut = deferred(); + let putCount = 0; + globalThis.fetch = (async (_input, init) => { + if (init?.method !== "PUT") return Response.json({ enabled: false }); + putCount += 1; + return pendingPut.promise; + }) as typeof fetch; + + let recovery: RecoveryState | undefined; + const publishRecovery = (value: RecoveryState) => { recovery = value; }; + + const host = document.createElement("div"); + document.body.append(host); + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render(); + await flush(); + }); + + let firstSave!: Promise; + await act(async () => { + firstSave = recovery!.save(true); + expect(await recovery!.save(false)).toBe(false); + await flush(); + }); + expect(putCount).toBe(1); + expect(recovery!.saving).toBe(true); + + await act(async () => { + pendingPut.resolve(Response.json({ enabled: true })); + expect(await firstSave).toBe(true); + await flush(); + }); + expect(recovery!.enabled).toBe(true); + expect(recovery!.saving).toBe(false); +}); diff --git a/gui/tests/multi-agent-guidance.test.tsx b/gui/tests/multi-agent-guidance.test.tsx index 16470385b8..54b10191de 100644 --- a/gui/tests/multi-agent-guidance.test.tsx +++ b/gui/tests/multi-agent-guidance.test.tsx @@ -70,6 +70,13 @@ function props(overrides: Partial = {}): Props { onUltraModeSave: () => {}, ultraLoadFailed: false, onUltraModeRetry: () => {}, + taskRecovery: { + enabled: false, + saving: false, + loadFailed: false, + onSave: enabled => { requests.push({ recovery: enabled }); }, + onRetry: () => {}, + }, ...overrides, }; } @@ -104,6 +111,19 @@ test("renders independent guidance and native-default controls with editable sel expect(host.textContent).not.toContain("Guidance active"); }); +test("requires explicit confirmation before enabling encrypted task recovery", async () => { + let confirmed = false; + testWindow.confirm = () => confirmed; + await mount(props()); + const recovery = host.querySelector(`button[aria-label="${en["sub.taskRecovery"]}"]`)!; + + await act(async () => { recovery.click(); }); + expect(requests).toEqual([]); + confirmed = true; + await act(async () => { recovery.click(); }); + expect(requests).toEqual([{ recovery: true }]); +}); + test("requires a selected model before enabling native Codex subagent defaults", async () => { await mount(props({ model: "", diff --git a/gui/tests/openrouter-model-routing.test.tsx b/gui/tests/openrouter-model-routing.test.tsx new file mode 100644 index 0000000000..ccdbf3a138 --- /dev/null +++ b/gui/tests/openrouter-model-routing.test.tsx @@ -0,0 +1,348 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import OpenRouterModelRouting from "../src/components/provider-workspace/OpenRouterModelRouting"; +import type { ProviderUpdatePatch } from "../src/components/provider-workspace/types"; +import type { WorkspaceItem } from "../src/provider-workspace/catalog"; + +const globals = ["document", "window", "navigator", "localStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record<(typeof globals)[number], PropertyDescriptor | undefined>; +let testWindow: Window; +let root: Root | null; + +function deferred(): { promise: Promise; resolve(value: T): void } { + let resolve!: (value: T) => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +async function flush(): Promise { + await Promise.resolve(); + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); +} + +beforeEach(() => { + previous = Object.fromEntries( + globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ) as typeof previous; + testWindow = new Window({ url: "http://localhost/#providers" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + root = null; +}); + +afterEach(async () => { + if (root) await act(async () => root?.unmount()); + testWindow.close(); + for (const key of globals) { + const descriptor = previous[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } +}); + +test("loads exact OpenRouter endpoint tags and saves a model-only allowlist", async () => { + const requests: string[] = []; + globalThis.fetch = (async input => { + requests.push(String(input)); + return Response.json({ endpoints: [{ tag: "deepinfra/turbo", providerName: "DeepInfra", supportsImplicitCaching: true }] }); + }) as typeof fetch; + const updates: Array<{ name: string; patch: ProviderUpdatePatch }> = []; + const host = document.createElement("div"); + document.body.append(host); + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( { updates.push({ name, patch }); return { ok: true }; }} + />); + }); + + const model = host.querySelector('input[list^="openrouter-models-"]')!; + await act(async () => { + Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")!.set!.call(model, "deepseek/deepseek-r1"); + model.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + const load = [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Load providers"))!; + await act(async () => { load.click(); await new Promise(resolve => setTimeout(resolve, 0)); }); + expect(requests[0]).toContain("model=deepseek%2Fdeepseek-r1"); + + const mode = host.querySelector("select")!; + await act(async () => { + mode.value = "only"; + mode.dispatchEvent(new testWindow.Event("change", { bubbles: true })); + }); + const endpoint = [...host.querySelectorAll('input[type="checkbox"]')].at(-1)!; + await act(async () => { endpoint.click(); }); + const save = [...host.querySelectorAll("button")].find(button => button.textContent?.trim() === "Save")!; + await act(async () => { save.click(); await Promise.resolve(); }); + + expect(updates).toEqual([{ + name: "openrouter", + patch: { modelOpenRouterRouting: { "deepseek/deepseek-r1": { only: ["deepinfra/turbo"], allowFallbacks: true } } }, + }]); +}); + +test("ignores endpoint discovery that finishes after the selected model changes", async () => { + const alpha = deferred(); + globalThis.fetch = (async input => String(input).includes("model=author%2Falpha") + ? alpha.promise + : Response.json({ endpoints: [{ tag: "beta/provider", providerName: "Beta" }] })) as typeof fetch; + + const host = document.createElement("div"); + document.body.append(host); + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render(); + }); + + const model = host.querySelector('input[list^="openrouter-models-"]')!; + const loadButton = () => [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Load providers"))!; + await act(async () => { loadButton().click(); await flush(); }); + await act(async () => { + Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")!.set!.call(model, "author/beta"); + model.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + await act(async () => { loadButton().click(); await flush(); }); + + const mode = host.querySelector("select")!; + await act(async () => { + mode.value = "only"; + mode.dispatchEvent(new testWindow.Event("change", { bubbles: true })); + }); + expect(host.textContent).toContain("beta/provider"); + + await act(async () => { + alpha.resolve(Response.json({ endpoints: [{ tag: "alpha/provider", providerName: "Alpha" }] })); + await flush(); + }); + expect(host.textContent).toContain("beta/provider"); + expect(host.textContent).not.toContain("alpha/provider"); +}); + +test("ignores a stale discovery body that finishes parsing after the selected model changes", async () => { + const body = deferred<{ endpoints: Array<{ tag: string; providerName: string }> }>(); + globalThis.fetch = (async () => ({ + ok: true, + json: () => body.promise, + }) as Response) as typeof fetch; + + const host = document.createElement("div"); + document.body.append(host); + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render(); + }); + + const load = [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Load providers"))!; + await act(async () => { load.click(); await flush(); }); + const model = host.querySelector('input[list^="openrouter-models-"]')!; + await act(async () => { + Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")!.set!.call(model, "author/beta"); + model.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + await act(async () => { + body.resolve({ endpoints: [{ tag: "alpha/provider", providerName: "Alpha" }] }); + await flush(); + }); + + expect(host.textContent).not.toContain("alpha/provider"); +}); + +test("does not show an old model save result after the selected model changes", async () => { + const update = deferred<{ ok: boolean }>(); + const host = document.createElement("div"); + document.body.append(host); + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( update.promise} + />); + }); + + const save = [...host.querySelectorAll("button")].find(button => button.textContent?.trim() === "Save")!; + await act(async () => { save.click(); await flush(); }); + expect(host.querySelector("select")?.disabled).toBe(true); + const model = host.querySelector('input[list^="openrouter-models-"]')!; + await act(async () => { + Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")!.set!.call(model, "author/beta"); + model.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + await act(async () => { update.resolve({ ok: true }); await flush(); }); + + expect(host.textContent).not.toContain("Saved"); + expect(save.disabled).toBe(false); + expect(host.querySelector("select")?.disabled).toBe(false); +}); + +test("offers a cache-bypassing refresh after an empty successful discovery", async () => { + const requests: string[] = []; + globalThis.fetch = (async input => { + requests.push(String(input)); + return Response.json({ endpoints: [] }); + }) as typeof fetch; + const host = document.createElement("div"); + document.body.append(host); + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render(); + }); + + const load = [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Load providers"))!; + await act(async () => { load.click(); await flush(); }); + const refresh = [...host.querySelectorAll("button")].find(button => button.textContent?.trim() === "Refresh")!; + expect(refresh).toBeDefined(); + await act(async () => { refresh.click(); await flush(); }); + expect(requests).toHaveLength(2); + expect(requests[1]).toContain("refresh=1"); +}); + +test("labels a configured tag as absent only after successful discovery", async () => { + globalThis.fetch = (async () => Response.json({ endpoints: [] })) as typeof fetch; + const host = document.createElement("div"); + document.body.append(host); + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render(); + }); + + expect(host.textContent).toContain("saved/provider"); + expect(host.querySelector(".pwi-openrouter-missing")).toBeNull(); + const load = [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Load providers"))!; + await act(async () => { load.click(); await flush(); }); + expect(host.querySelector(".pwi-openrouter-missing")?.textContent).toContain("saved/provider"); +}); + +test("removes a configured tag missing from discovery and saves only the live tag", async () => { + globalThis.fetch = (async () => Response.json({ + endpoints: [{ tag: "live/provider", providerName: "Live" }], + })) as typeof fetch; + const updates: ProviderUpdatePatch[] = []; + const host = document.createElement("div"); + document.body.append(host); + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( { updates.push(patch); return { ok: true }; }} + />); + }); + + const load = [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Load providers"))!; + await act(async () => { load.click(); await flush(); }); + const remove = host.querySelector('button[aria-label="Remove missing/provider"]')!; + expect(remove).not.toBeNull(); + await act(async () => { remove.click(); }); + const save = [...host.querySelectorAll("button")].find(button => button.textContent?.trim() === "Save")!; + await act(async () => { save.click(); await flush(); }); + + expect(updates).toEqual([{ + modelOpenRouterRouting: { + "author/model": { only: ["live/provider"], allowFallbacks: false }, + }, + }]); +}); + +test("disables Save when the provider update callback is unavailable", async () => { + const host = document.createElement("div"); + document.body.append(host); + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render(); + }); + + const save = [...host.querySelectorAll("button")].find(button => button.textContent?.trim() === "Save")!; + expect(save.disabled).toBe(true); +}); + +test("locks routing mutation controls to the payload while Save is pending", async () => { + const update = deferred<{ ok: boolean }>(); + const host = document.createElement("div"); + document.body.append(host); + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( update.promise} + />); + }); + + const save = [...host.querySelectorAll("button")].find(button => button.textContent?.trim() === "Save")!; + await act(async () => { save.click(); await flush(); }); + const mode = host.querySelector("select")!; + const fallbacks = host.querySelector('.pwi-openrouter-fallbacks input[type="checkbox"]')!; + const remove = host.querySelector('button[aria-label="Remove saved/provider"]')!; + expect(mode.disabled).toBe(true); + expect(fallbacks.disabled).toBe(true); + expect(remove.disabled).toBe(true); + + await act(async () => { update.resolve({ ok: true }); await flush(); }); + expect(mode.disabled).toBe(false); + expect(fallbacks.disabled).toBe(false); + expect(remove.disabled).toBe(false); +}); diff --git a/src/cli/agent.ts b/src/cli/agent.ts index 62e4f6c929..29f47ca1a2 100644 --- a/src/cli/agent.ts +++ b/src/cli/agent.ts @@ -18,6 +18,7 @@ const USAGE = `Usage: ocx agent injection [--model ] [--effort ] [--prompt ] [--guidance ] [--json] ocx agent effort [--main ] [--subagent ] [--json] + ocx agent recovery [--json] ocx agent subagents [model,model...] [--json] ocx agent fallback [model,model...] [--poll-ms <5000-600000>] [--json] ocx agent sidecar [--model ] [--backend ] @@ -31,18 +32,40 @@ async function status(argv: string[], deps: RuntimeApiDeps): Promise { const args = [...argv]; const wantsJson = takeFlag(args, "--json"); rejectArgs(args, USAGE); - const [v2, injection, caps, subagents, fallback, sidecars] = await Promise.all([ + const [v2, injection, recovery, caps, subagents, fallback, sidecars] = await Promise.all([ runtimeRequest("/api/v2", {}, deps), runtimeRequest("/api/injection-model", {}, deps), + runtimeRequest("/api/agent-task-recovery", {}, deps), runtimeRequest("/api/effort-caps", {}, deps), runtimeRequest("/api/subagent-models", {}, deps), runtimeRequest("/api/subagent-model-fallback", {}, deps), runtimeRequest("/api/sidecar-settings", {}, deps), ]); - const result = { v2, injection, caps, subagents, fallback, sidecars }; + const result = { v2, injection, recovery, caps, subagents, fallback, sidecars }; printData(result, wantsJson, summaryLines(result)); } +async function recovery(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const action = (args.shift() ?? "status").toLowerCase(); + const wantsJson = takeFlag(args, "--json"); + rejectArgs(args, USAGE); + if (action === "status") { + const result = await runtimeRequest("/api/agent-task-recovery", {}, deps); + printData(result, wantsJson, summaryLines(result)); + return; + } + if (action !== "on" && action !== "off") throw new CliUsageError(`unknown recovery action ${action}`, USAGE); + const enabled = action === "on"; + const result = await runtimeRequest("/api/agent-task-recovery", { + method: "PUT", + body: JSON.stringify({ enabled }), + }, deps); + printData(result, wantsJson, [enabled + ? "Encrypted V2 task recovery enabled. This may consume additional ChatGPT quota and add latency." + : "Encrypted V2 task recovery disabled."]); +} + async function injection(argv: string[], deps: RuntimeApiDeps): Promise { const args = [...argv]; const action = (args.shift() ?? "status").toLowerCase(); @@ -174,6 +197,7 @@ export async function handleAgentCommand(argv: string[], deps: RuntimeApiDeps = if (sub === "status") await status(rest, deps); else if (sub === "injection" || sub === "guidance") await injection(rest, deps); else if (sub === "effort") await effort(rest, deps); + else if (sub === "recovery") await recovery(rest, deps); else if (sub === "subagents" || sub === "roster") await subagents(rest, deps); else if (sub === "fallback") await fallback(rest, deps); else if (sub === "sidecar") await sidecar(rest, deps); diff --git a/src/providers/openrouter-endpoints.ts b/src/providers/openrouter-endpoints.ts new file mode 100644 index 0000000000..9191ffad9b --- /dev/null +++ b/src/providers/openrouter-endpoints.ts @@ -0,0 +1,149 @@ +import { createHmac, randomBytes } from "node:crypto"; +import { providerOutboundGet, providerRedirectError } from "../lib/provider-outbound"; +import { readBoundedDiscoveryJson } from "./model-discovery"; +import type { OcxProviderConfig } from "../types"; + +const ENDPOINTS_TTL_MS = 5 * 60_000; +const MAX_CACHE_ENTRIES = 128; +const MAX_ACTIVE_FLIGHTS = 8; +const MAX_RESPONSE_BYTES = 4 * 1024 * 1024; +const MAX_ENDPOINTS = 512; +const CACHE_SCOPE_KEY = randomBytes(32); + +export interface OpenRouterModelEndpoint { + tag: string; + providerName: string; + name?: string; + contextLength?: number; + maxCompletionTokens?: number; + supportsImplicitCaching?: boolean; + supportedParameters?: string[]; + pricing?: { prompt?: string; completion?: string }; +} + +type CacheEntry = { expiresAt: number; fetchedAt: number; endpoints: OpenRouterModelEndpoint[] }; +const cache = new Map(); +const flights = new Map>(); + +export class OpenRouterEndpointsError extends Error { + constructor(readonly code: string, readonly status: number, message: string) { + super(message); + this.name = "OpenRouterEndpointsError"; + } +} + +function modelPath(modelId: string): string | null { + const slash = modelId.indexOf("/"); + if (slash <= 0 || slash === modelId.length - 1 || modelId.indexOf("/", slash + 1) !== -1) return null; + return `${encodeURIComponent(modelId.slice(0, slash))}/${encodeURIComponent(modelId.slice(slash + 1))}`; +} + +function optionalPositiveInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; +} + +function optionalStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const strings = value.filter((item): item is string => typeof item === "string" && item.trim() === item && item.length > 0); + return strings.length > 0 ? [...new Set(strings)].slice(0, 128) : undefined; +} + +export function parseOpenRouterModelEndpoints(value: unknown, expectedModel: string): OpenRouterModelEndpoint[] { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new OpenRouterEndpointsError("invalid_response", 502, "OpenRouter returned an invalid endpoint catalog"); + const data = (value as { data?: unknown }).data; + if (!data || typeof data !== "object" || Array.isArray(data)) throw new OpenRouterEndpointsError("invalid_response", 502, "OpenRouter returned an invalid endpoint catalog"); + const record = data as { id?: unknown; endpoints?: unknown }; + if (record.id !== expectedModel || !Array.isArray(record.endpoints) || record.endpoints.length > MAX_ENDPOINTS) { + throw new OpenRouterEndpointsError("invalid_response", 502, "OpenRouter returned an unexpected endpoint catalog"); + } + const seen = new Set(); + const parsed: OpenRouterModelEndpoint[] = []; + for (const raw of record.endpoints) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue; + const endpoint = raw as Record; + const tag = typeof endpoint.tag === "string" ? endpoint.tag.trim() : ""; + const providerName = typeof endpoint.provider_name === "string" ? endpoint.provider_name.trim() : ""; + if (!tag || tag.length > 128 || !providerName || providerName.length > 128 || seen.has(tag)) continue; + seen.add(tag); + const pricing = endpoint.pricing && typeof endpoint.pricing === "object" && !Array.isArray(endpoint.pricing) + ? endpoint.pricing as Record + : undefined; + parsed.push({ + tag, + providerName, + ...(typeof endpoint.name === "string" && endpoint.name.trim() ? { name: endpoint.name.trim().slice(0, 256) } : {}), + ...(optionalPositiveInteger(endpoint.context_length) !== undefined ? { contextLength: optionalPositiveInteger(endpoint.context_length) } : {}), + ...(optionalPositiveInteger(endpoint.max_completion_tokens) !== undefined ? { maxCompletionTokens: optionalPositiveInteger(endpoint.max_completion_tokens) } : {}), + ...(typeof endpoint.supports_implicit_caching === "boolean" ? { supportsImplicitCaching: endpoint.supports_implicit_caching } : {}), + ...(optionalStringArray(endpoint.supported_parameters) ? { supportedParameters: optionalStringArray(endpoint.supported_parameters) } : {}), + ...(pricing ? { pricing: { + ...(typeof pricing.prompt === "string" ? { prompt: pricing.prompt } : {}), + ...(typeof pricing.completion === "string" ? { completion: pricing.completion } : {}), + } } : {}), + }); + } + return parsed; +} + +function cacheKey(providerName: string, modelId: string, token: string): string { + const credentialScope = createHmac("sha256", CACHE_SCOPE_KEY).update(token).digest("hex"); + return `${providerName}\n${modelId}\n${credentialScope}`; +} + +function insert(key: string, entry: CacheEntry): void { + cache.delete(key); + cache.set(key, entry); + while (cache.size > MAX_CACHE_ENTRIES) cache.delete(cache.keys().next().value!); +} + +export async function listOpenRouterModelEndpoints( + providerName: string, + provider: OcxProviderConfig, + modelId: string, + token: string, + refresh = false, +): Promise<{ fetchedAt: number; cached: boolean; endpoints: OpenRouterModelEndpoint[] }> { + const path = modelPath(modelId); + if (!path) throw new OpenRouterEndpointsError("invalid_model", 400, "model must be an OpenRouter author/slug id"); + const key = cacheKey(providerName, modelId, token); + const now = Date.now(); + const cached = cache.get(key); + if (!refresh && cached && cached.expiresAt > now) return { fetchedAt: cached.fetchedAt, cached: true, endpoints: cached.endpoints }; + let flight = flights.get(key); + if (!flight) { + if (flights.size >= MAX_ACTIVE_FLIGHTS) { + throw new OpenRouterEndpointsError("busy", 429, "OpenRouter endpoint discovery is busy"); + } + flight = (async () => { + const requestUrl = `https://openrouter.ai/api/v1/models/${path}/endpoints`; + const response = await providerOutboundGet(providerName, provider, requestUrl, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(10_000), + }); + const redirect = await providerRedirectError(response, requestUrl); + if (redirect) throw new OpenRouterEndpointsError("upstream_redirect", 502, redirect); + if (response.status === 401 || response.status === 403) { + try { await response.body?.cancel(); } catch { /* best effort */ } + throw new OpenRouterEndpointsError("authorization_failed", response.status, "OpenRouter rejected the configured API key for endpoint discovery"); + } + if (!response.ok) { + try { await response.body?.cancel(); } catch { /* best effort */ } + throw new OpenRouterEndpointsError("upstream_error", 502, `OpenRouter endpoint discovery returned ${response.status}`); + } + const bounded = await readBoundedDiscoveryJson(response, MAX_RESPONSE_BYTES); + if (!bounded.ok) throw new OpenRouterEndpointsError("invalid_response", 502, "OpenRouter returned an invalid or oversized endpoint catalog"); + const fetchedAt = Date.now(); + const entry = { fetchedAt, expiresAt: fetchedAt + ENDPOINTS_TTL_MS, endpoints: parseOpenRouterModelEndpoints(bounded.value, modelId) }; + insert(key, entry); + return entry; + })().finally(() => flights.delete(key)); + flights.set(key, flight); + } + const entry = await flight; + return { fetchedAt: entry.fetchedAt, cached: false, endpoints: entry.endpoints }; +} + +export function resetOpenRouterEndpointCacheForTests(): void { + cache.clear(); + flights.clear(); +} diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index dbd8088c28..5d70f28ed2 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -119,6 +119,24 @@ function persistDesktopProfileField( return { ok: true }; } +function persistAgentTaskRecoveryEnabled( + config: OcxConfig, + enabled: boolean, +): { ok: true } | { ok: false; reason: "missing" | "invalid" | "conflict" } { + const outcome = mutatePersistedConfig(persisted => { + const changed = persisted.agentTaskRecovery?.enabled !== enabled; + const next = { ...(persisted.agentTaskRecovery ?? {}), enabled }; + persisted.agentTaskRecovery = next; + return { + changed, + value: next, + }; + }); + if (outcome.status === "unavailable") return { ok: false, reason: outcome.reason }; + config.agentTaskRecovery = outcome.value; + return { ok: true }; +} + export function grokApplyFlightSnapshot(): { currentBytes: number; highWaterBytes: number; active: number } { return { currentBytes: grokApplyFlight?.bytes ?? 0, @@ -215,6 +233,29 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise } catch { /* best-effort */ } } + // Explicit control for the experimental native-ChatGPT compatibility recovery used + // when a V2 NEW_TASK reaches a routed child as backend-only ciphertext. Keep this + // separate from injection/delegation preferences: enabling it may consume additional + // ChatGPT quota and briefly retain recovered plaintext in the bounded process cache. + if (url.pathname === "/api/agent-task-recovery" && req.method === "GET") { + return jsonResponse({ enabled: config.agentTaskRecovery?.enabled === true }); + } + if (url.pathname === "/api/agent-task-recovery" && req.method === "PUT") { + let body: unknown; + try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } + if (!isPlainRecord(body) || Object.keys(body).some(key => key !== "enabled")) { + return jsonResponse({ error: "body must contain only enabled" }, 400); + } + if (typeof body.enabled !== "boolean") { + return jsonResponse({ error: "body.enabled must be a boolean" }, 400); + } + const persisted = persistAgentTaskRecoveryEnabled(config, body.enabled); + if (!persisted.ok) { + return jsonResponse({ error: "configuration update unavailable", code: "config_update_unavailable" }, 409); + } + return jsonResponse({ ok: true, enabled: body.enabled }); + } + // multi_agent_v2 surface toggle. GET reports the flag + the agents.max_threads // boot conflict; PUT flips it via the official `codex features` CLI and RESYNCS // the catalog so multi-agent surface metadata stays fresh. The catalog build diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 8ecae2c46b..9dadc925a6 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -75,6 +75,8 @@ import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerS import type { PersistedUsageAttempt } from "../../usage/log"; import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; import { providerServiceTierConfigError } from "./provider-capability-config"; +import { isCanonicalOpenRouterTarget } from "../../providers/openrouter-routing"; +import { listOpenRouterModelEndpoints, OpenRouterEndpointsError } from "../../providers/openrouter-endpoints"; import { applySystemEnvToggle } from "../system-env"; import { LOCAL_PROVIDER_RELOAD_NAME_HEADER, @@ -208,6 +210,24 @@ function applyProviderPatchFields( } touched = true; } + if (Object.hasOwn(rawBody, "modelOpenRouterRouting")) { + const value = rawBody.modelOpenRouterRouting; + if (value === null) { + delete next.modelOpenRouterRouting; + } else { + if (!isPlainRecord(value)) return { error: "modelOpenRouterRouting must be a plain object or null" }; + const routes = { ...(next.modelOpenRouterRouting ?? {}) }; + for (const [modelId, routing] of Object.entries(value)) { + if (!modelId.trim() || modelId !== modelId.trim()) return { error: "modelOpenRouterRouting keys must be nonblank trimmed model ids" }; + if (routing === null) delete routes[modelId]; + else if (!isPlainRecord(routing)) return { error: `modelOpenRouterRouting.${modelId} must be a plain object or null` }; + else routes[modelId] = structuredClone(routing) as NonNullable[string]; + } + if (Object.keys(routes).length > 0) next.modelOpenRouterRouting = routes; + else delete next.modelOpenRouterRouting; + } + touched = true; + } // The Models page edits the catalog hints in place; keep them on the existing // provider mutation path so validation, cache invalidation, and convergence stay unified (#1073). if (Object.hasOwn(rawBody, "contextWindow")) { @@ -383,6 +403,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { + setPersistedConfigMutationBeforeCommitForTests(null); + if (savedHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = savedHome; + if (tempHome) rmSync(tempHome, { recursive: true, force: true }); + tempHome = null; +}); + +function config(recovery?: OcxConfig["agentTaskRecovery"]): OcxConfig { + tempHome = mkdtempSync(join(tmpdir(), "ocx-agent-recovery-api-")); + process.env.OPENCODEX_HOME = tempHome; + const current = { + port: 10100, + defaultProvider: "openai", + providers: {}, + ...(recovery ? { agentTaskRecovery: recovery } : {}), + } as OcxConfig; + saveConfig(current); + return current; +} + +async function request(current: OcxConfig, method: "GET" | "PUT", body?: unknown): Promise { + const req = new Request("http://localhost/api/agent-task-recovery", { + method, + headers: { + Host: "localhost", + ...(body === undefined ? {} : { "Content-Type": "application/json" }), + }, + ...(body === undefined ? {} : { + body: JSON.stringify(body), + }), + }); + const response = await handleManagementAPI(req, new URL(req.url), current); + expect(response).not.toBeNull(); + return response!; +} + +describe("/api/agent-task-recovery", () => { + test("reports disabled by default without exposing configuration details", async () => { + const response = await request(config(), "GET"); + expect(await response.json()).toEqual({ enabled: false }); + }); + + test("enables and disables recovery while preserving advanced options", async () => { + const current = config({ enabled: false, model: "gpt-5.6-terra", timeoutMs: 12_000, cacheEntries: 24 }); + expect(await (await request(current, "PUT", { enabled: true })).json()).toEqual({ ok: true, enabled: true }); + expect(current.agentTaskRecovery).toEqual({ enabled: true, model: "gpt-5.6-terra", timeoutMs: 12_000, cacheEntries: 24 }); + expect(await (await request(current, "GET")).json()).toEqual({ enabled: true }); + + await request(current, "PUT", { enabled: false }); + expect(current.agentTaskRecovery).toEqual({ enabled: false, model: "gpt-5.6-terra", timeoutMs: 12_000, cacheEntries: 24 }); + }); + + test("rejects malformed or expanded writes without mutating state", async () => { + const current = config({ enabled: false, timeoutMs: 9_000 }); + for (const body of [{}, { enabled: "true" }, { enabled: true, model: "gpt-5.6-sol" }, []]) { + const response = await request(current, "PUT", body); + expect(response.status).toBe(400); + expect(current.agentTaskRecovery).toEqual({ enabled: false, timeoutMs: 9_000 }); + } + }); + + test("keeps live recovery state unchanged when the durable mutation becomes unavailable", async () => { + const current = config({ enabled: false, timeoutMs: 9_000 }); + setPersistedConfigMutationBeforeCommitForTests(() => { + rmSync(join(tempHome!, "config.json"), { force: true }); + }); + + const response = await request(current, "PUT", { enabled: true }); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + error: "configuration update unavailable", + code: "config_update_unavailable", + }); + expect(current.agentTaskRecovery).toEqual({ enabled: false, timeoutMs: 9_000 }); + expect(await (await request(current, "GET")).json()).toEqual({ enabled: false }); + }); +}); diff --git a/tests/cli-headless-parity.test.ts b/tests/cli-headless-parity.test.ts index e051ee9763..bab5f9bbbc 100644 --- a/tests/cli-headless-parity.test.ts +++ b/tests/cli-headless-parity.test.ts @@ -64,6 +64,7 @@ describe("headless GUI parity CLI", () => { // skipping the endpoint. ["/api/github/star", "(none — GUI-only)"], ["/api/oauth", "ocx account"], + ["/api/openrouter/model-providers", "ocx provider"], ["/api/providers/keys", "ocx account"], ["/api/providers", "ocx provider"], ["/api/provider-", "ocx provider/models"], @@ -83,6 +84,7 @@ describe("headless GUI parity CLI", () => { ["/api/debug", "ocx debug/observe"], ["/api/diagnostics", "ocx system"], ["/api/effort", "ocx agent"], + ["/api/agent-task-recovery", "ocx agent recovery"], ["/api/grok", "ocx grok"], ["/api/injection", "ocx agent"], ["/api/keys", "ocx access"], @@ -106,12 +108,23 @@ describe("headless GUI parity CLI", () => { ["/api/update", "ocx system update"], ["/api/usage", "ocx observe usage"], ["/api/v2", "ocx v2/agent"], + ["/api/v1", "(none — canonical upstream URL literal in GUI eligibility check)"], ["/api/windows-tray", "ocx tray"], ]; const uncovered = [...endpoints].filter(endpoint => !coverage.some(([prefix]) => endpoint === prefix || endpoint.startsWith(prefix))); expect(uncovered).toEqual([]); }); + test("agent recovery mirrors the explicit management toggle", async () => { + const runtime = fakeRuntime(); + expect(await handleAgentCommand(["recovery", "on", "--json"], runtime.deps)).toBe(0); + expect(runtime.requests).toEqual([{ + path: "/api/agent-task-recovery", + method: "PUT", + body: { enabled: true }, + }]); + }); + test("provider edit reuses the management provider patch", async () => { const runtime = fakeRuntime(); const code = await handleProviderRuntimeCommand("edit", [ diff --git a/tests/openrouter-endpoints.test.ts b/tests/openrouter-endpoints.test.ts new file mode 100644 index 0000000000..2b3e04c4f1 --- /dev/null +++ b/tests/openrouter-endpoints.test.ts @@ -0,0 +1,108 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + listOpenRouterModelEndpoints, + OpenRouterEndpointsError, + parseOpenRouterModelEndpoints, + resetOpenRouterEndpointCacheForTests, +} from "../src/providers/openrouter-endpoints"; +import type { OcxProviderConfig } from "../src/types"; + +afterEach(() => resetOpenRouterEndpointCacheForTests()); + +const responseBody = { + data: { + id: "deepseek/deepseek-r1", + endpoints: [ + { + tag: "deepinfra/turbo", + provider_name: "DeepInfra", + name: "DeepInfra Turbo", + context_length: 131072, + max_completion_tokens: 32768, + supports_implicit_caching: true, + supported_parameters: ["tools", "reasoning"], + pricing: { prompt: "0.000001", completion: "0.000002" }, + }, + { tag: "deepinfra/turbo", provider_name: "duplicate" }, + { tag: "", provider_name: "invalid" }, + ], + }, +}; + +describe("OpenRouter model endpoint discovery", () => { + test("parses exact routing tags and rejects a mismatched model envelope", () => { + expect(parseOpenRouterModelEndpoints(responseBody, "deepseek/deepseek-r1")).toEqual([{ + tag: "deepinfra/turbo", + providerName: "DeepInfra", + name: "DeepInfra Turbo", + contextLength: 131072, + maxCompletionTokens: 32768, + supportsImplicitCaching: true, + supportedParameters: ["tools", "reasoning"], + pricing: { prompt: "0.000001", completion: "0.000002" }, + }]); + expect(() => parseOpenRouterModelEndpoints(responseBody, "other/model")).toThrow(OpenRouterEndpointsError); + }); + + test("uses the canonical encoded model URL and caches successful results", async () => { + const urls: string[] = []; + const provider = { + adapter: "openai-chat", + baseUrl: "https://openrouter.ai/api/v1", + fetch: async (url: string | URL | Request) => { + urls.push(String(url)); + return Response.json(responseBody); + }, + } as OcxProviderConfig; + const first = await listOpenRouterModelEndpoints("openrouter", provider, "deepseek/deepseek-r1", "test-token"); + const second = await listOpenRouterModelEndpoints("openrouter", provider, "deepseek/deepseek-r1", "test-token"); + expect(first.cached).toBe(false); + expect(second.cached).toBe(true); + expect(urls).toEqual(["https://openrouter.ai/api/v1/models/deepseek/deepseek-r1/endpoints"]); + }); + + test("maps authorization failures without returning the upstream body", async () => { + const provider = { + adapter: "openai-chat", + baseUrl: "https://openrouter.ai/api/v1", + fetch: async () => Response.json({ error: { message: "secret upstream detail" } }, { status: 403 }), + } as OcxProviderConfig; + await expect(listOpenRouterModelEndpoints("openrouter", provider, "deepseek/deepseek-r1", "test-token")) + .rejects.toMatchObject({ code: "authorization_failed", status: 403 }); + }); + + test("bounds unique discovery flights while still allowing existing flights to settle", async () => { + let release!: () => void; + let fetches = 0; + const gate = new Promise(resolve => { release = resolve; }); + const provider = { + adapter: "openai-chat", + baseUrl: "https://openrouter.ai/api/v1", + fetch: async (input: string | URL | Request) => { + fetches += 1; + await gate; + const parts = new URL(String(input)).pathname.split("/"); + return Response.json({ + data: { + id: `${decodeURIComponent(parts.at(-3)!)}/${decodeURIComponent(parts.at(-2)!)}`, + endpoints: [], + }, + }); + }, + } as OcxProviderConfig; + const active = Array.from({ length: 8 }, (_, index) => ( + listOpenRouterModelEndpoints("openrouter", provider, `author/model-${index}`, "test-token") + )); + await Promise.resolve(); + const joined = listOpenRouterModelEndpoints("openrouter", provider, "author/model-0", "test-token"); + + await expect(listOpenRouterModelEndpoints("openrouter", provider, "author/overflow", "test-token")) + .rejects.toMatchObject({ code: "busy", status: 429 }); + + release(); + await expect(Promise.all([...active, joined])).resolves.toHaveLength(9); + expect(fetches).toBe(8); + await expect(listOpenRouterModelEndpoints("openrouter", provider, "author/after", "test-token")) + .resolves.toMatchObject({ cached: false, endpoints: [] }); + }); +}); diff --git a/tests/openrouter-routing-api.test.ts b/tests/openrouter-routing-api.test.ts new file mode 100644 index 0000000000..599597bae7 --- /dev/null +++ b/tests/openrouter-routing-api.test.ts @@ -0,0 +1,143 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleManagementAPI } from "../src/server/management-api"; +import { resetOpenRouterEndpointCacheForTests } from "../src/providers/openrouter-endpoints"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; + +const savedHome = process.env.OPENCODEX_HOME; +let tempHome: string | null = null; + +afterEach(() => { + resetOpenRouterEndpointCacheForTests(); + if (savedHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = savedHome; + if (tempHome) rmSync(tempHome, { recursive: true, force: true }); + tempHome = null; +}); + +function config(provider: OcxProviderConfig): OcxConfig { + tempHome = mkdtempSync(join(tmpdir(), "ocx-openrouter-api-")); + process.env.OPENCODEX_HOME = tempHome; + return { port: 10100, defaultProvider: "openrouter", providers: { openrouter: provider } } as OcxConfig; +} + +async function management(current: OcxConfig, path: string, init?: RequestInit): Promise { + const headers = new Headers(init?.headers); + headers.set("Host", "localhost"); + const request = new Request(`http://localhost${path}`, { ...init, headers }); + const response = await handleManagementAPI(request, new URL(request.url), current); + expect(response).not.toBeNull(); + return response!; +} + +describe("OpenRouter model routing management API", () => { + test("discovers exact provider tags for one model", async () => { + const current = config({ + adapter: "openai-chat", + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "test-api-key", + fetch: async () => Response.json({ + data: { id: "deepseek/deepseek-r1", endpoints: [{ tag: "deepinfra/turbo", provider_name: "DeepInfra" }] }, + }), + } as OcxProviderConfig); + const response = await management(current, "/api/openrouter/model-providers?provider=openrouter&model=deepseek%2Fdeepseek-r1"); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + provider: "openrouter", + model: "deepseek/deepseek-r1", + routing: { source: "none" }, + endpoints: [{ tag: "deepinfra/turbo", providerName: "DeepInfra" }], + }); + }); + + test("patches one model override and null restores inheritance", async () => { + const current = config({ + adapter: "openai-chat", + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "test-key", + openRouterRouting: { order: ["openai"] }, + }); + const patch = async (value: unknown) => management(current, "/api/providers?name=openrouter", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ modelOpenRouterRouting: value }), + }); + expect((await patch({ "deepseek/deepseek-r1": { only: ["deepinfra/turbo"], allowFallbacks: false } })).status).toBe(200); + expect(current.providers.openrouter?.modelOpenRouterRouting).toEqual({ + "deepseek/deepseek-r1": { only: ["deepinfra/turbo"], allowFallbacks: false }, + }); + expect((await patch({ "deepseek/deepseek-r1": null })).status).toBe(200); + expect(current.providers.openrouter?.modelOpenRouterRouting).toBeUndefined(); + }); + + test("rejects discovery for a noncanonical OpenRouter-shaped provider", async () => { + const current = config({ adapter: "openai-chat", baseUrl: "https://example.test/v1", apiKey: "test-key" }); + const response = await management(current, "/api/openrouter/model-providers?provider=openrouter&model=deepseek%2Fdeepseek-r1"); + expect(response.status).toBe(400); + }); + + test("reports ordinary OpenRouter API-key rejection without reflecting upstream details", async () => { + const current = config({ + adapter: "openai-chat", + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "test-api-key", + fetch: async () => Response.json({ error: "private upstream detail" }, { status: 401 }), + } as OcxProviderConfig); + const response = await management( + current, + "/api/openrouter/model-providers?provider=openrouter&model=deepseek%2Fdeepseek-r1", + ); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ + error: "OpenRouter rejected the configured API key for endpoint discovery", + code: "openrouter_authorization_failed", + }); + }); + + test("returns a bounded busy response when unique discovery capacity is exhausted", async () => { + let release!: () => void; + let allFetchesStarted!: () => void; + let fetches = 0; + const gate = new Promise(resolve => { release = resolve; }); + const fetchesStarted = new Promise(resolve => { allFetchesStarted = resolve; }); + const current = config({ + adapter: "openai-chat", + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "test-key", + fetch: async (input: string | URL | Request) => { + fetches += 1; + if (fetches === 8) allFetchesStarted(); + await gate; + const parts = new URL(String(input)).pathname.split("/"); + return Response.json({ + data: { + id: `${decodeURIComponent(parts.at(-3)!)}/${decodeURIComponent(parts.at(-2)!)}`, + endpoints: [], + }, + }); + }, + } as OcxProviderConfig); + const active = Array.from({ length: 8 }, (_, index) => management( + current, + `/api/openrouter/model-providers?provider=openrouter&model=author%2Fmodel-${index}`, + )); + await fetchesStarted; + expect(fetches).toBe(8); + + const overflow = await management( + current, + "/api/openrouter/model-providers?provider=openrouter&model=author%2Foverflow", + ); + expect(overflow.status).toBe(429); + expect(await overflow.json()).toEqual({ + error: "OpenRouter endpoint discovery is busy", + code: "openrouter_busy", + }); + + release(); + await expect(Promise.all(active)).resolves.toHaveLength(8); + }); +});