From 5362a394e5323d485de806482ef33f6406c9c42a Mon Sep 17 00:00:00 2001 From: Henrique <81029476+riique@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:03:17 -0300 Subject: [PATCH 1/8] feat: expose routed agent recovery and OpenRouter endpoints --- .../openrouter-model-provider-routing.png | Bin 0 -> 178008 bytes .../docs/reference/configuration/agents.md | 13 ++ .../docs/reference/configuration/providers.md | 21 +++ .../OpenRouterModelRouting.tsx | 166 ++++++++++++++++++ .../provider-workspace/ProviderDetails.tsx | 1 + .../provider-workspace/ProviderModels.tsx | 7 + .../components/provider-workspace/types.ts | 1 + .../SubagentDelegationSection.tsx | 35 ++++ .../SubagentsWorkspace.tsx | 8 + gui/src/i18n/de.ts | 18 ++ gui/src/i18n/en.ts | 18 ++ gui/src/i18n/ja.ts | 18 ++ gui/src/i18n/ko.ts | 18 ++ gui/src/i18n/ru.ts | 18 ++ gui/src/i18n/tr.ts | 18 ++ gui/src/i18n/zh-TW.ts | 18 ++ gui/src/i18n/zh.ts | 18 ++ gui/src/pages/Subagents.tsx | 9 + gui/src/pages/providers-shared.ts | 2 + gui/src/pages/use-agent-task-recovery.ts | 52 ++++++ gui/src/provider-workspace/catalog.ts | 8 + .../styles/provider-workspace-settings.css | 24 +++ gui/tests/multi-agent-guidance.test.tsx | 20 +++ gui/tests/openrouter-model-routing.test.tsx | 80 +++++++++ src/cli/agent.ts | 28 ++- src/providers/openrouter-endpoints.ts | 145 +++++++++++++++ .../management/agent-settings-routes.ts | 24 +++ src/server/management/provider-routes.ts | 64 +++++++ structure/04_transports-and-sidecars.md | 9 + structure/05_gui-and-management-api.md | 4 +- tests/agent-task-recovery-api.test.ts | 69 ++++++++ tests/cli-headless-parity.test.ts | 13 ++ tests/openrouter-endpoints.test.ts | 73 ++++++++ tests/openrouter-routing-api.test.ts | 80 +++++++++ 34 files changed, 1096 insertions(+), 4 deletions(-) create mode 100644 docs-site/public/pr-screenshots/openrouter-model-provider-routing.png create mode 100644 gui/src/components/provider-workspace/OpenRouterModelRouting.tsx create mode 100644 gui/src/pages/use-agent-task-recovery.ts create mode 100644 gui/tests/openrouter-model-routing.test.tsx create mode 100644 src/providers/openrouter-endpoints.ts create mode 100644 tests/agent-task-recovery-api.test.ts create mode 100644 tests/openrouter-endpoints.test.ts create mode 100644 tests/openrouter-routing-api.test.ts 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 0000000000000000000000000000000000000000..1885d5e43a765a1575c586b2585285daf190ac13 GIT binary patch literal 178008 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[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 H4GH@{66@k; literal 0 HcmV?d00001 diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index 76e0a23927..237e678222 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -187,6 +187,19 @@ 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. + 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 782effab52..6e0ce57dbc 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -522,6 +522,27 @@ 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; +- 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 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. OpenRouter may require an authorized **Management Key** for this endpoint; a normal +inference key can continue serving model requests even when discovery returns 401/403. In that case, +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. + ## Static model allowlists Set `liveModels: false` to expose only `models`. If `models` is empty or omitted, the provider exposes diff --git a/gui/src/components/provider-workspace/OpenRouterModelRouting.tsx b/gui/src/components/provider-workspace/OpenRouterModelRouting.tsx new file mode 100644 index 0000000000..0442794f5f --- /dev/null +++ b/gui/src/components/provider-workspace/OpenRouterModelRouting.tsx @@ -0,0 +1,166 @@ +import { useMemo, useState } from "react"; +import { IconArrowDown, IconArrowUp } 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 [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); + const modelOptions = useMemo(() => [...new Set([ + ...configuredModels, + ...(item.defaultModel ? [item.defaultModel] : []), + ...availableModels.slice(0, 500), + ])], [availableModels, configuredModels, item.defaultModel]); + + const selectModel = (nextModel: string) => { + 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([]); + setMessage(null); + }; + + const load = async (refresh = false) => { + if (!model.trim() || loading) return; + setLoading(true); + setMessage(null); + try { + const response = await fetch(`${apiBase}/api/openrouter/model-providers?provider=${encodeURIComponent(item.name)}&model=${encodeURIComponent(model.trim())}${refresh ? "&refresh=1" : ""}`); + const data = await response.json().catch(() => ({})) as Discovery; + if (!response.ok) { + const text = data.code === "openrouter_management_key_required" + ? t("pws.openrouter.managementKeyRequired") + : data.code === "openrouter_key_required" + ? t("pws.openrouter.keyRequired") + : t("pws.openrouter.loadFailed"); + throw new Error(text); + } + setEndpoints(Array.isArray(data.endpoints) ? data.endpoints : []); + setMessage({ ok: true, text: t("pws.openrouter.loaded", { count: data.endpoints?.length ?? 0 }) }); + } catch (error) { + setEndpoints([]); + setMessage({ ok: false, text: error instanceof Error ? error.message : t("pws.openrouter.loadFailed") }); + } finally { + setLoading(false); + } + }; + + const toggle = (tag: string) => setSelected(current => current.includes(tag) + ? current.filter(value => value !== tag) + : [...current, 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 () => { + if (!onUpdateProvider || !model.trim() || saving || (mode !== "inherit" && selected.length === 0)) return; + setSaving(true); + setMessage(null); + const routing = mode === "inherit" ? null : { + [mode]: selected, + allowFallbacks, + }; + const result = await onUpdateProvider(item.name, { + modelOpenRouterRouting: { [model.trim()]: routing }, + }); + setMessage({ ok: result.ok, text: result.ok ? t("pws.openrouter.saved") : result.error ?? t("sub.saveFailed") }); + setSaving(false); + }; + + const knownTags = new Set(endpoints.map(endpoint => endpoint.tag)); + return ( +
+
+
+

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

+

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

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

{message.text}

} +
+ ); +} diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx index 1b02517835..ae54bd90f4 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 d23464500e..28f503226f 100644 --- a/gui/src/components/provider-workspace/types.ts +++ b/gui/src/components/provider-workspace/types.ts @@ -104,6 +104,7 @@ export type ProviderUpdatePatch = { codexAccountMode?: "direct" | "pool"; /** Management-only write that atomically owns the two supported xAI Grok adapter rows. */ xaiResponsesOptIn?: boolean; + modelOpenRouterRouting?: Record | null; }; export type ProviderUpdateResult = { 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 5bea652194..967cdae56a 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2207,6 +2207,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", @@ -2283,4 +2286,19 @@ export const de: Record = { "models.aliasAuto": "automatisch", "models.aliasUser": "benutzerdefiniert", "models.aliasStale": "veraltet", + "pws.openrouter.title": "Provider-Routing pro Modell", + "pws.openrouter.hint": "Wähle die genauen OpenRouter-Endpunkte für jedes Modell. Die Erkennung erfordert einen autorisierten OpenRouter Management Key; das Routing nutzt weiterhin den konfigurierten 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.managementKeyRequired": "Die Endpunkt-Erkennung erfordert einen autorisierten OpenRouter Management Key. 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 0e27ca29e2..f2c1507c3c 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -643,6 +643,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", @@ -2297,6 +2300,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. Endpoint discovery requires an authorized OpenRouter Management Key; routing itself keeps using the configured 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.managementKeyRequired": "Endpoint discovery requires an authorized OpenRouter Management Key. 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", "models.newPolicyGlobal": "New models start disabled", "models.newPolicyProvider": "New model policy", "models.newPolicy_inherit": "Inherit", "models.newPolicy_off": "Off", "models.newPolicy_on": "On", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 5be35ce9e4..6b363455dd 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2228,6 +2228,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", @@ -2304,4 +2307,19 @@ export const ja: Record = { "models.aliasAuto": "自動", "models.aliasUser": "ユーザー", "models.aliasStale": "古い", + "pws.openrouter.title": "モデル別プロバイダールーティング", + "pws.openrouter.hint": "モデルごとに使用する OpenRouter エンドポイントを選択します。検出には認可済み OpenRouter Management Key が必要ですが、実際のルーティングは設定済み API キーを使います。", + "pws.openrouter.load": "プロバイダーを読み込む", + "pws.openrouter.mode": "ルーティング動作", + "pws.openrouter.inherit": "プロバイダー既定値を継承", + "pws.openrouter.order": "選択したプロバイダーを優先", + "pws.openrouter.only": "選択したプロバイダーのみ使用", + "pws.openrouter.allowFallbacks": "選択後に OpenRouter フォールバックを許可", + "pws.openrouter.managementKeyRequired": "エンドポイント検出には認可済み OpenRouter Management Key が必要です。既存の手動ルーティングは変更されません。", + "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 d7def6f1c7..dac7535768 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -629,6 +629,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": "저장 실패", @@ -2305,4 +2308,19 @@ export const ko: Record = { "models.aliasAuto": "자동", "models.aliasUser": "사용자", "models.aliasStale": "오래됨", + "pws.openrouter.title": "모델별 제공자 라우팅", + "pws.openrouter.hint": "모델마다 사용할 정확한 OpenRouter 엔드포인트를 선택합니다. 검색에는 승인된 OpenRouter Management Key가 필요하며 실제 라우팅은 구성된 API 키를 계속 사용합니다.", + "pws.openrouter.load": "제공자 불러오기", + "pws.openrouter.mode": "라우팅 방식", + "pws.openrouter.inherit": "제공자 기본값 상속", + "pws.openrouter.order": "선택한 제공자 우선", + "pws.openrouter.only": "선택한 제공자만 사용", + "pws.openrouter.allowFallbacks": "선택한 제공자 이후 OpenRouter 폴백 허용", + "pws.openrouter.managementKeyRequired": "엔드포인트 검색에는 승인된 OpenRouter Management Key가 필요합니다. 기존 수동 라우팅은 유지됩니다.", + "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 97f77fcdca..ee132221fa 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2230,6 +2230,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", @@ -2306,4 +2309,19 @@ export const ru: Record = { "models.aliasAuto": "авто", "models.aliasUser": "пользователь", "models.aliasStale": "устарел", + "pws.openrouter.title": "Маршрутизация провайдеров по модели", + "pws.openrouter.hint": "Выберите точные endpoints OpenRouter для каждой модели. Для обнаружения нужен авторизованный OpenRouter Management Key; маршрутизация продолжает использовать настроенный API-ключ.", + "pws.openrouter.load": "Загрузить провайдеров", + "pws.openrouter.mode": "Поведение маршрутизации", + "pws.openrouter.inherit": "Наследовать настройку провайдера", + "pws.openrouter.order": "Отдать приоритет выбранным", + "pws.openrouter.only": "Использовать только выбранные", + "pws.openrouter.allowFallbacks": "Разрешить fallback OpenRouter после выбранных провайдеров", + "pws.openrouter.managementKeyRequired": "Для обнаружения endpoints нужен авторизованный OpenRouter Management Key. Ручная маршрутизация не изменена.", + "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 57e40312d0..a3c4ef281d 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -634,6 +634,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", @@ -2306,4 +2309,19 @@ export const tr: Record = { "models.aliasAuto": "otomatik", "models.aliasUser": "kullanıcı", "models.aliasStale": "eski", + "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 yetkili bir OpenRouter Management Key gerektirir; yönlendirme yapılandırılmış API anahtarını kullanmaya devam eder.", + "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.managementKeyRequired": "Uç keşfi yetkili bir OpenRouter Management Key gerektirir. 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 36d4e6b6d2..8bf0c99701 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1821,6 +1821,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", @@ -2269,4 +2272,19 @@ export const zhTW: Record = { "models.aliasAuto": "自動", "models.aliasUser": "使用者", "models.aliasStale": "過期", + "pws.openrouter.title": "依模型設定供應商路由", + "pws.openrouter.hint": "為每個模型選擇準確的 OpenRouter endpoint。探索清單需要已授權的 OpenRouter Management Key;實際路由仍使用已設定的 API 金鑰。", + "pws.openrouter.load": "載入供應商", + "pws.openrouter.mode": "路由行為", + "pws.openrouter.inherit": "繼承供應商預設值", + "pws.openrouter.order": "優先使用所選供應商", + "pws.openrouter.only": "僅使用所選供應商", + "pws.openrouter.allowFallbacks": "所選供應商之後允許 OpenRouter 備援", + "pws.openrouter.managementKeyRequired": "探索 endpoint 需要已授權的 OpenRouter Management Key。現有手動路由不會變更。", + "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 894c9a07ca..0622011d69 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2228,6 +2228,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", @@ -2304,4 +2307,19 @@ export const zh: Record = { "models.aliasAuto": "自动", "models.aliasUser": "用户", "models.aliasStale": "过期", + "pws.openrouter.title": "按模型设置提供商路由", + "pws.openrouter.hint": "为每个模型选择准确的 OpenRouter endpoint。发现列表需要授权的 OpenRouter Management Key;实际路由仍使用已配置的 API 密钥。", + "pws.openrouter.load": "加载提供商", + "pws.openrouter.mode": "路由行为", + "pws.openrouter.inherit": "继承提供商默认值", + "pws.openrouter.order": "优先使用所选提供商", + "pws.openrouter.only": "仅使用所选提供商", + "pws.openrouter.allowFallbacks": "所选提供商之后允许 OpenRouter 回退", + "pws.openrouter.managementKeyRequired": "发现 endpoint 需要授权的 OpenRouter Management Key。现有手动路由保持不变。", + "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 ea36769466..92a95a271b 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); @@ -228,6 +230,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 3b8607dc75..94b2e05e7e 100644 --- a/gui/src/pages/providers-shared.ts +++ b/gui/src/pages/providers-shared.ts @@ -17,6 +17,8 @@ export interface ProvidersConfig { note?: string; codexAccountMode?: "direct" | "pool"; xaiResponsesOptInState?: boolean | "mixed"; + 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..2cfdd2701a --- /dev/null +++ b/gui/src/pages/use-agent-task-recovery.ts @@ -0,0 +1,52 @@ +import { useCallback, useEffect, 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 load = useCallback(async () => { + const response = await fetch(`${apiBase}/api/agent-task-recovery`); + const data = await readJsonOrThrow(response); + setEnabled(data?.enabled === true); + setLoadFailed(false); + }, [apiBase]); + + useEffect(() => { + let active = true; + void fetch(`${apiBase}/api/agent-task-recovery`) + .then(response => readJsonOrThrow(response)) + .then(data => { + if (!active) return; + setEnabled(data?.enabled === true); + setLoadFailed(false); + }) + .catch(() => { if (active) setLoadFailed(true); }); + return () => { active = false; }; + }, [apiBase]); + + const save = useCallback(async (next: boolean) => { + if (saving) return false; + 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); + setEnabled(next); + setLoadFailed(false); + return true; + } catch { + return false; + } finally { + setSaving(false); + } + }, [apiBase, saving]); + + return { enabled, saving, loadFailed, save, retry: load }; +} diff --git a/gui/src/provider-workspace/catalog.ts b/gui/src/provider-workspace/catalog.ts index 522aa32857..ace20a9c86 100644 --- a/gui/src/provider-workspace/catalog.ts +++ b/gui/src/provider-workspace/catalog.ts @@ -57,6 +57,14 @@ export interface WorkspaceProvider { codexAccountMode?: "direct" | "pool"; /** Derived state of the two xAI Grok Responses model-adapter entries. */ xaiResponsesOptInState?: boolean | "mixed"; + 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 9f233c477d..34a4b66043 100644 --- a/gui/src/styles/provider-workspace-settings.css +++ b/gui/src/styles/provider-workspace-settings.css @@ -127,6 +127,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); @@ -147,6 +169,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/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..684a92c55b --- /dev/null +++ b/gui/tests/openrouter-model-routing.test.tsx @@ -0,0 +1,80 @@ +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", "IS_REACT_ACT_ENVIRONMENT"] as const; +const originalFetch = globalThis.fetch; +let previous: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +let root: Root | null; + +beforeEach(() => { + previous = Object.fromEntries(globals.map(key => [key, Reflect.get(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 () => { + globalThis.fetch = originalFetch; + if (root) await act(async () => root?.unmount()); + testWindow.close(); + for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previous[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 } } }, + }]); +}); diff --git a/src/cli/agent.ts b/src/cli/agent.ts index e16ca8e3e9..9ea92c8024 100644 --- a/src/cli/agent.ts +++ b/src/cli/agent.ts @@ -25,6 +25,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 [--list] [--model ] @@ -39,18 +40,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(); @@ -218,6 +241,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..490c66ce67 --- /dev/null +++ b/src/providers/openrouter-endpoints.ts @@ -0,0 +1,145 @@ +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_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) { + 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("management_key_required", 403, "OpenRouter endpoint discovery requires an authorized Management Key"); + } + 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 a1db433043..fd0763a0e2 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -223,6 +223,30 @@ 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); + } + config.agentTaskRecovery = { + ...(config.agentTaskRecovery ?? {}), + enabled: body.enabled, + }; + saveConfigPreservingClaudeCode(config); + 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 d542306885..904fc105d8 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -77,6 +77,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, @@ -228,6 +230,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")) { @@ -448,6 +468,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { + 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; + return { + port: 10100, + defaultProvider: "openai", + providers: {}, + ...(recovery ? { agentTaskRecovery: recovery } : {}), + } as OcxConfig; +} + +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 }); + } + }); +}); diff --git a/tests/cli-headless-parity.test.ts b/tests/cli-headless-parity.test.ts index b8faa77ea2..3d0aa3ea5f 100644 --- a/tests/cli-headless-parity.test.ts +++ b/tests/cli-headless-parity.test.ts @@ -228,6 +228,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"], @@ -252,6 +253,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"], @@ -279,12 +281,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..47fe902f82 --- /dev/null +++ b/tests/openrouter-endpoints.test.ts @@ -0,0 +1,73 @@ +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: "management_key_required", status: 403 }); + }); +}); diff --git a/tests/openrouter-routing-api.test.ts b/tests/openrouter-routing-api.test.ts new file mode 100644 index 0000000000..636a64a493 --- /dev/null +++ b/tests/openrouter-routing-api.test.ts @@ -0,0 +1,80 @@ +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-management-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); + }); +}); From 74ee5a552297ff8276d6b30ee6d39445e4e4b2ee Mon Sep 17 00:00:00 2001 From: Henrique <81029476+riique@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:23:13 -0300 Subject: [PATCH 2/8] fix: address routed recovery review findings --- .../docs/reference/configuration/providers.md | 3 +- .../OpenRouterModelRouting.tsx | 15 ++-- gui/src/i18n/fr.ts | 18 +++++ gui/src/pages/use-agent-task-recovery.ts | 44 ++++++----- gui/tests/agent-task-recovery-hook.test.tsx | 73 +++++++++++++++++++ 5 files changed, 128 insertions(+), 25 deletions(-) create mode 100644 gui/tests/agent-task-recovery-hook.test.tsx diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 6e0ce57dbc..19d858e27d 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -528,7 +528,8 @@ For canonical OpenRouter providers (`openai-chat` plus `https://openrouter.ai/ap **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; +- 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`. diff --git a/gui/src/components/provider-workspace/OpenRouterModelRouting.tsx b/gui/src/components/provider-workspace/OpenRouterModelRouting.tsx index 0442794f5f..631dfa64f9 100644 --- a/gui/src/components/provider-workspace/OpenRouterModelRouting.tsx +++ b/gui/src/components/provider-workspace/OpenRouterModelRouting.tsx @@ -89,11 +89,16 @@ export default function OpenRouterModelRouting({ [mode]: selected, allowFallbacks, }; - const result = await onUpdateProvider(item.name, { - modelOpenRouterRouting: { [model.trim()]: routing }, - }); - setMessage({ ok: result.ok, text: result.ok ? t("pws.openrouter.saved") : result.error ?? t("sub.saveFailed") }); - setSaving(false); + try { + const result = await onUpdateProvider(item.name, { + modelOpenRouterRouting: { [model.trim()]: routing }, + }); + setMessage({ ok: result.ok, text: result.ok ? t("pws.openrouter.saved") : result.error ?? t("sub.saveFailed") }); + } catch { + setMessage({ ok: false, text: t("sub.saveFailed") }); + } finally { + setSaving(false); + } }; const knownTags = new Set(endpoints.map(endpoint => endpoint.tag)); diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 4c5292014b..336d33154d 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -626,6 +626,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", @@ -1202,6 +1205,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 nécessite une Management Key OpenRouter autorisée ; le routage continue d’utiliser la clé API 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.managementKeyRequired": "La découverte des endpoints nécessite une Management Key OpenRouter autorisée. 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": "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/pages/use-agent-task-recovery.ts b/gui/src/pages/use-agent-task-recovery.ts index 2cfdd2701a..ac419d94bd 100644 --- a/gui/src/pages/use-agent-task-recovery.ts +++ b/gui/src/pages/use-agent-task-recovery.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { readJsonOrThrow } from "../fetch-json"; type RecoveryResponse = { enabled?: boolean }; @@ -7,29 +7,31 @@ 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 load = useCallback(async () => { - const response = await fetch(`${apiBase}/api/agent-task-recovery`); - const data = await readJsonOrThrow(response); - setEnabled(data?.enabled === true); - setLoadFailed(false); + 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(() => { - let active = true; - void fetch(`${apiBase}/api/agent-task-recovery`) - .then(response => readJsonOrThrow(response)) - .then(data => { - if (!active) return; - setEnabled(data?.enabled === true); - setLoadFailed(false); - }) - .catch(() => { if (active) setLoadFailed(true); }); - return () => { active = false; }; - }, [apiBase]); + void Promise.resolve().then(load); + return () => { operationRef.current += 1; }; + }, [load]); const save = useCallback(async (next: boolean) => { if (saving) return false; + const operation = ++operationRef.current; setSaving(true); try { const response = await fetch(`${apiBase}/api/agent-task-recovery`, { @@ -38,8 +40,10 @@ export function useAgentTaskRecovery(apiBase: string) { body: JSON.stringify({ enabled: next }), }); await readJsonOrThrow(response); - setEnabled(next); - setLoadFailed(false); + if (operation === operationRef.current) { + setEnabled(next); + setLoadFailed(false); + } return true; } catch { return false; @@ -48,5 +52,7 @@ export function useAgentTaskRecovery(apiBase: string) { } }, [apiBase, saving]); - return { enabled, saving, loadFailed, save, retry: load }; + const retry = useCallback(() => { void load(); }, [load]); + + return { enabled, saving, loadFailed, save, retry }; } 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..948bd285ba --- /dev/null +++ b/gui/tests/agent-task-recovery-hook.test.tsx @@ -0,0 +1,73 @@ +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 { 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], unknown>; +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, Reflect.get(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) Object.defineProperty(globalThis, key, { configurable: true, value: previous[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: ReturnType | undefined; + function Probe() { + recovery = useAgentTaskRecovery("http://proxy"); + return null; + } + + 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); +}); From 5755c5d3ddee9319e7cbc679236f7411b7b0a65d Mon Sep 17 00:00:00 2001 From: Henrique <81029476+riique@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:01:36 -0300 Subject: [PATCH 3/8] fix: harden routed settings concurrency --- .../OpenRouterModelRouting.tsx | 24 +++-- gui/src/i18n/fr.ts | 2 +- gui/src/pages/use-agent-task-recovery.ts | 11 ++- gui/tests/agent-task-recovery-hook.test.tsx | 54 ++++++++++- gui/tests/openrouter-model-routing.test.tsx | 95 +++++++++++++++++-- 5 files changed, 167 insertions(+), 19 deletions(-) diff --git a/gui/src/components/provider-workspace/OpenRouterModelRouting.tsx b/gui/src/components/provider-workspace/OpenRouterModelRouting.tsx index 631dfa64f9..4309ba560b 100644 --- a/gui/src/components/provider-workspace/OpenRouterModelRouting.tsx +++ b/gui/src/components/provider-workspace/OpenRouterModelRouting.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { IconArrowDown, IconArrowUp } from "../../icons"; import { useT } from "../../i18n/shared"; import type { WorkspaceItem } from "../../provider-workspace/catalog"; @@ -26,9 +26,11 @@ export default function OpenRouterModelRouting({ 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 modelOptions = useMemo(() => [...new Set([ ...configuredModels, ...(item.defaultModel ? [item.defaultModel] : []), @@ -36,22 +38,29 @@ export default function OpenRouterModelRouting({ ])], [availableModels, configuredModels, item.defaultModel]); const selectModel = (nextModel: string) => { + 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) => { - if (!model.trim() || loading) return; + 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(model.trim())}${refresh ? "&refresh=1" : ""}`); + const response = await fetch(`${apiBase}/api/openrouter/model-providers?provider=${encodeURIComponent(item.name)}&model=${encodeURIComponent(requestedModel)}${refresh ? "&refresh=1" : ""}`); const data = await response.json().catch(() => ({})) as Discovery; + if (generation !== discoveryGeneration.current) return; if (!response.ok) { const text = data.code === "openrouter_management_key_required" ? t("pws.openrouter.managementKeyRequired") @@ -61,12 +70,15 @@ export default function OpenRouterModelRouting({ throw new Error(text); } 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 { - setLoading(false); + if (generation === discoveryGeneration.current) setLoading(false); } }; @@ -139,9 +151,9 @@ export default function OpenRouterModelRouting({ {mode !== "inherit" && selected.length > 0 && (
    {selected.map((tag, index) => ( -
  1. +
  2. {tag} - {!knownTags.has(tag) && {t("pws.openrouter.notReturned")}} + {discoveryComplete && !knownTags.has(tag) && {t("pws.openrouter.notReturned")}}
  3. diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 336d33154d..8d795f7be4 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1219,7 +1219,7 @@ export const fr: Record = { "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": "Cache", + "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/pages/use-agent-task-recovery.ts b/gui/src/pages/use-agent-task-recovery.ts index ac419d94bd..95ffdff5b7 100644 --- a/gui/src/pages/use-agent-task-recovery.ts +++ b/gui/src/pages/use-agent-task-recovery.ts @@ -8,6 +8,7 @@ export function useAgentTaskRecovery(apiBase: string) { 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; @@ -30,8 +31,9 @@ export function useAgentTaskRecovery(apiBase: string) { }, [load]); const save = useCallback(async (next: boolean) => { - if (saving) return false; + 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`, { @@ -48,9 +50,12 @@ export function useAgentTaskRecovery(apiBase: string) { } catch { return false; } finally { - setSaving(false); + if (activeSaveRef.current === operation) { + activeSaveRef.current = null; + setSaving(false); + } } - }, [apiBase, saving]); + }, [apiBase]); const retry = useCallback(() => { void load(); }, [load]); diff --git a/gui/tests/agent-task-recovery-hook.test.tsx b/gui/tests/agent-task-recovery-hook.test.tsx index 948bd285ba..fe0951d4d2 100644 --- a/gui/tests/agent-task-recovery-hook.test.tsx +++ b/gui/tests/agent-task-recovery-hook.test.tsx @@ -5,7 +5,7 @@ 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], unknown>; +let previous: Record<(typeof globals)[number], PropertyDescriptor | undefined>; let testWindow: Window; let root: Root | null; @@ -21,7 +21,9 @@ async function flush(): Promise { } beforeEach(() => { - previous = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previous; + 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 }, @@ -35,7 +37,11 @@ beforeEach(() => { afterEach(async () => { if (root) await act(async () => root?.unmount()); root = null; - for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + 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?.(); }); @@ -71,3 +77,45 @@ test("a delayed initial read cannot overwrite a successful save", async () => { }); 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: ReturnType | undefined; + function Probe() { + recovery = useAgentTaskRecovery("http://proxy"); + return null; + } + + 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/openrouter-model-routing.test.tsx b/gui/tests/openrouter-model-routing.test.tsx index 684a92c55b..ff9fd0c04b 100644 --- a/gui/tests/openrouter-model-routing.test.tsx +++ b/gui/tests/openrouter-model-routing.test.tsx @@ -7,14 +7,26 @@ import OpenRouterModelRouting from "../src/components/provider-workspace/OpenRou import type { ProviderUpdatePatch } from "../src/components/provider-workspace/types"; import type { WorkspaceItem } from "../src/provider-workspace/catalog"; -const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; -const originalFetch = globalThis.fetch; -let previous: Record<(typeof globals)[number], unknown>; +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, Reflect.get(globalThis, key)])) as typeof previous; + 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, { @@ -28,10 +40,13 @@ beforeEach(() => { }); afterEach(async () => { - globalThis.fetch = originalFetch; if (root) await act(async () => root?.unmount()); testWindow.close(); - for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + 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 () => { @@ -78,3 +93,71 @@ test("loads exact OpenRouter endpoint tags and saves a model-only allowlist", as 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("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"); +}); From 177592bfb9c19a89595324a04d2abe07db15567f Mon Sep 17 00:00:00 2001 From: Henrique <81029476+riique@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:07:52 -0300 Subject: [PATCH 4/8] fix: satisfy routed settings React checks --- .../OpenRouterModelRouting.tsx | 6 ++-- gui/tests/agent-task-recovery-hook.test.tsx | 28 ++++++++++--------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/gui/src/components/provider-workspace/OpenRouterModelRouting.tsx b/gui/src/components/provider-workspace/OpenRouterModelRouting.tsx index 4309ba560b..cdaa24ff36 100644 --- a/gui/src/components/provider-workspace/OpenRouterModelRouting.tsx +++ b/gui/src/components/provider-workspace/OpenRouterModelRouting.tsx @@ -59,9 +59,9 @@ export default function OpenRouterModelRouting({ setMessage(null); try { const response = await fetch(`${apiBase}/api/openrouter/model-providers?provider=${encodeURIComponent(item.name)}&model=${encodeURIComponent(requestedModel)}${refresh ? "&refresh=1" : ""}`); - const data = await response.json().catch(() => ({})) as Discovery; if (generation !== discoveryGeneration.current) return; if (!response.ok) { + const data = await response.json().catch(() => ({})) as Discovery; const text = data.code === "openrouter_management_key_required" ? t("pws.openrouter.managementKeyRequired") : data.code === "openrouter_key_required" @@ -69,6 +69,7 @@ export default function OpenRouterModelRouting({ : t("pws.openrouter.loadFailed"); throw new Error(text); } + const data = await response.json().catch(() => ({})) as Discovery; setEndpoints(Array.isArray(data.endpoints) ? data.endpoints : []); setDiscoveryComplete(true); setMessage({ ok: true, text: t("pws.openrouter.loaded", { count: data.endpoints?.length ?? 0 }) }); @@ -114,6 +115,7 @@ export default function OpenRouterModelRouting({ }; const knownTags = new Set(endpoints.map(endpoint => endpoint.tag)); + const selectedTags = useMemo(() => new Set(selected), [selected]); return (
    @@ -164,7 +166,7 @@ export default function OpenRouterModelRouting({
    {endpoints.map(endpoint => ( diff --git a/gui/tests/agent-task-recovery-hook.test.tsx b/gui/tests/agent-task-recovery-hook.test.tsx index fe0951d4d2..c48bb564b3 100644 --- a/gui/tests/agent-task-recovery-hook.test.tsx +++ b/gui/tests/agent-task-recovery-hook.test.tsx @@ -1,6 +1,6 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { Window } from "happy-dom"; -import { act } from "react"; +import { act, useEffect } from "react"; import type { Root } from "react-dom/client"; import { useAgentTaskRecovery } from "../src/pages/use-agent-task-recovery"; @@ -20,6 +20,14 @@ async function flush(): Promise { 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)]), @@ -51,18 +59,15 @@ test("a delayed initial read cannot overwrite a successful save", async () => { ? Response.json({ ok: true, enabled: true }) : initialGet.promise) as typeof fetch; - let recovery: ReturnType | undefined; - function Probe() { - recovery = useAgentTaskRecovery("http://proxy"); - return null; - } + 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(); + root.render(); }); await act(async () => { @@ -87,18 +92,15 @@ test("overlapping saves issue only one recovery write", async () => { return pendingPut.promise; }) as typeof fetch; - let recovery: ReturnType | undefined; - function Probe() { - recovery = useAgentTaskRecovery("http://proxy"); - return null; - } + 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(); + root.render(); await flush(); }); From 6ed317a4bb9e3829ea9e51ba32f4a718e6239f4c Mon Sep 17 00:00:00 2001 From: Henrique <81029476+riique@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:25:18 -0300 Subject: [PATCH 5/8] fix: close OpenRouter routing UI races --- .../OpenRouterModelRouting.tsx | 16 ++-- gui/tests/openrouter-model-routing.test.tsx | 89 +++++++++++++++++++ 2 files changed, 100 insertions(+), 5 deletions(-) diff --git a/gui/src/components/provider-workspace/OpenRouterModelRouting.tsx b/gui/src/components/provider-workspace/OpenRouterModelRouting.tsx index cdaa24ff36..8d6e469e8f 100644 --- a/gui/src/components/provider-workspace/OpenRouterModelRouting.tsx +++ b/gui/src/components/provider-workspace/OpenRouterModelRouting.tsx @@ -31,6 +31,7 @@ export default function OpenRouterModelRouting({ 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] : []), @@ -38,6 +39,7 @@ export default function OpenRouterModelRouting({ ])], [availableModels, configuredModels, item.defaultModel]); const selectModel = (nextModel: string) => { + activeModelRef.current = nextModel; discoveryGeneration.current += 1; setModel(nextModel); const route = item.modelOpenRouterRouting?.[nextModel]; @@ -70,6 +72,7 @@ export default function OpenRouterModelRouting({ 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 }) }); @@ -95,7 +98,8 @@ export default function OpenRouterModelRouting({ }); const save = async () => { - if (!onUpdateProvider || !model.trim() || saving || (mode !== "inherit" && selected.length === 0)) return; + const requestedModel = model.trim(); + if (!onUpdateProvider || !requestedModel || saving || (mode !== "inherit" && selected.length === 0)) return; setSaving(true); setMessage(null); const routing = mode === "inherit" ? null : { @@ -104,11 +108,13 @@ export default function OpenRouterModelRouting({ }; try { const result = await onUpdateProvider(item.name, { - modelOpenRouterRouting: { [model.trim()]: routing }, + modelOpenRouterRouting: { [requestedModel]: routing }, }); - setMessage({ ok: result.ok, text: result.ok ? t("pws.openrouter.saved") : result.error ?? t("sub.saveFailed") }); + if (activeModelRef.current.trim() === requestedModel) { + setMessage({ ok: result.ok, text: result.ok ? t("pws.openrouter.saved") : result.error ?? t("sub.saveFailed") }); + } } catch { - setMessage({ ok: false, text: t("sub.saveFailed") }); + if (activeModelRef.current.trim() === requestedModel) setMessage({ ok: false, text: t("sub.saveFailed") }); } finally { setSaving(false); } @@ -177,7 +183,7 @@ export default function OpenRouterModelRouting({ - {endpoints.length > 0 && } + {discoveryComplete && }
    {message &&

    {message.text}

    }
    diff --git a/gui/tests/openrouter-model-routing.test.tsx b/gui/tests/openrouter-model-routing.test.tsx index ff9fd0c04b..c3f71de28a 100644 --- a/gui/tests/openrouter-model-routing.test.tsx +++ b/gui/tests/openrouter-model-routing.test.tsx @@ -136,6 +136,95 @@ test("ignores endpoint discovery that finishes after the selected model changes" 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(); }); + 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); +}); + +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"); From 04575e7cbc04b86e39d556ecdf72c2a083003efe Mon Sep 17 00:00:00 2001 From: Henrique <81029476+riique@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:43:35 -0300 Subject: [PATCH 6/8] fix: address provider routing security review --- .../docs/fr/reference/configuration/agents.md | 4 + .../fr/reference/configuration/providers.md | 6 ++ .../docs/ja/reference/configuration/agents.md | 4 + .../ja/reference/configuration/providers.md | 6 ++ .../docs/ko/reference/configuration/agents.md | 4 + .../ko/reference/configuration/providers.md | 6 ++ .../docs/reference/configuration/agents.md | 3 +- .../docs/reference/configuration/providers.md | 13 ++- .../docs/ru/reference/configuration/agents.md | 4 + .../ru/reference/configuration/providers.md | 6 ++ .../docs/tr/reference/configuration/agents.md | 5 +- .../tr/reference/configuration/providers.md | 7 +- .../zh-cn/reference/configuration/agents.md | 4 + .../reference/configuration/providers.md | 6 ++ .../zh-tw/reference/configuration/agents.md | 4 + .../reference/configuration/providers.md | 6 ++ .../OpenRouterModelRouting.tsx | 20 ++-- gui/src/i18n/de.ts | 4 +- gui/src/i18n/en.ts | 4 +- gui/src/i18n/fr.ts | 4 +- gui/src/i18n/ja.ts | 4 +- gui/src/i18n/ko.ts | 4 +- gui/src/i18n/ru.ts | 4 +- gui/src/i18n/tr.ts | 4 +- gui/src/i18n/zh-TW.ts | 4 +- gui/src/i18n/zh.ts | 4 +- gui/tests/openrouter-model-routing.test.tsx | 96 +++++++++++++++++++ src/providers/openrouter-endpoints.ts | 6 +- .../management/agent-settings-routes.ts | 27 +++++- structure/04_transports-and-sidecars.md | 7 +- structure/05_gui-and-management-api.md | 4 +- tests/agent-task-recovery-api.test.ts | 23 ++++- tests/openrouter-endpoints.test.ts | 37 ++++++- tests/openrouter-routing-api.test.ts | 62 +++++++++++- 34 files changed, 357 insertions(+), 49 deletions(-) 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..f6275b7cf5 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|on|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 822c7ba47c..1dc9aef52e 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..268d54b55b 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|on|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 f1210892a5..b34d33bb55 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..20ff9f3a19 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|on|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 ccacb0a94f..d01aed670d 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 237e678222..11dddcc2c2 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -198,7 +198,8 @@ 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. +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 diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 19d858e27d..eeb5fd4d01 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -535,14 +535,17 @@ load its available endpoints, then choose one of three behaviors: 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 are never silently deleted. +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. OpenRouter may require an authorized **Management Key** for this endpoint; a normal -inference key can continue serving model requests even when discovery returns 401/403. In that case, -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. +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 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..2012f2ecf8 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|on|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 c415517074..b2ba9bd45c 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..9d211e1129 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|on|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ı | @@ -247,4 +251,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 4db3211bc5..f6fb8d39d2 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ı | @@ -480,4 +486,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..193921b3f7 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|on|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 3630a9ba6c..eb19ede615 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..ee560bdf4a 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|on|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 b0a46f49ec..59d203b2d0 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 index 8d6e469e8f..d986f20d4f 100644 --- a/gui/src/components/provider-workspace/OpenRouterModelRouting.tsx +++ b/gui/src/components/provider-workspace/OpenRouterModelRouting.tsx @@ -1,5 +1,5 @@ import { useMemo, useRef, useState } from "react"; -import { IconArrowDown, IconArrowUp } from "../../icons"; +import { IconArrowDown, IconArrowUp, IconX } from "../../icons"; import { useT } from "../../i18n/shared"; import type { WorkspaceItem } from "../../provider-workspace/catalog"; import type { ProviderUpdatePatch } from "./types"; @@ -64,8 +64,8 @@ export default function OpenRouterModelRouting({ if (generation !== discoveryGeneration.current) return; if (!response.ok) { const data = await response.json().catch(() => ({})) as Discovery; - const text = data.code === "openrouter_management_key_required" - ? t("pws.openrouter.managementKeyRequired") + const text = data.code === "openrouter_authorization_failed" + ? t("pws.openrouter.authorizationFailed") : data.code === "openrouter_key_required" ? t("pws.openrouter.keyRequired") : t("pws.openrouter.loadFailed"); @@ -89,6 +89,7 @@ export default function OpenRouterModelRouting({ 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; @@ -143,7 +144,7 @@ export default function OpenRouterModelRouting({
    {mode !== "inherit" && ( )} @@ -162,8 +163,9 @@ export default function OpenRouterModelRouting({
  4. {tag} {discoveryComplete && !knownTags.has(tag) && {t("pws.openrouter.notReturned")}} - - + + +
  5. ))}
@@ -172,7 +174,7 @@ export default function OpenRouterModelRouting({
{endpoints.map(endpoint => ( @@ -180,7 +182,7 @@ export default function OpenRouterModelRouting({
)}
- {discoveryComplete && } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 967cdae56a..9ed98aaf5c 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2287,14 +2287,14 @@ export const de: Record = { "models.aliasUser": "benutzerdefiniert", "models.aliasStale": "veraltet", "pws.openrouter.title": "Provider-Routing pro Modell", - "pws.openrouter.hint": "Wähle die genauen OpenRouter-Endpunkte für jedes Modell. Die Erkennung erfordert einen autorisierten OpenRouter Management Key; das Routing nutzt weiterhin den konfigurierten API-Key.", + "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.managementKeyRequired": "Die Endpunkt-Erkennung erfordert einen autorisierten OpenRouter Management Key. Bestehendes manuelles Routing bleibt unverändert.", + "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.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index f2c1507c3c..b905d2fa90 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2301,14 +2301,14 @@ export const en = { "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. Endpoint discovery requires an authorized OpenRouter Management Key; routing itself keeps using the configured API key.", + "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.managementKeyRequired": "Endpoint discovery requires an authorized OpenRouter Management Key. Existing manual routing remains unchanged.", + "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.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 8d795f7be4..39aa425b68 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1206,14 +1206,14 @@ export const fr: Record = { "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 nécessite une Management Key OpenRouter autorisée ; le routage continue d’utiliser la clé API configurée.", + "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.managementKeyRequired": "La découverte des endpoints nécessite une Management Key OpenRouter autorisée. Le routage manuel existant reste inchangé.", + "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.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 6b363455dd..22ff66b6fd 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2308,14 +2308,14 @@ export const ja: Record = { "models.aliasUser": "ユーザー", "models.aliasStale": "古い", "pws.openrouter.title": "モデル別プロバイダールーティング", - "pws.openrouter.hint": "モデルごとに使用する OpenRouter エンドポイントを選択します。検出には認可済み OpenRouter Management Key が必要ですが、実際のルーティングは設定済み API キーを使います。", + "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.managementKeyRequired": "エンドポイント検出には認可済み OpenRouter Management Key が必要です。既存の手動ルーティングは変更されません。", + "pws.openrouter.authorizationFailed": "OpenRouter がエンドポイント検出用の設定済み API キーを拒否しました。既存の手動ルーティングは変更されません。", "pws.openrouter.keyRequired": "プロバイダーを検出する前に OpenRouter API キーを追加してください。", "pws.openrouter.loadFailed": "このモデルのプロバイダーを読み込めませんでした。", "pws.openrouter.loaded": "{count} 件のプロバイダーエンドポイントを読み込みました。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index dac7535768..0a17fd27c6 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2309,14 +2309,14 @@ export const ko: Record = { "models.aliasUser": "사용자", "models.aliasStale": "오래됨", "pws.openrouter.title": "모델별 제공자 라우팅", - "pws.openrouter.hint": "모델마다 사용할 정확한 OpenRouter 엔드포인트를 선택합니다. 검색에는 승인된 OpenRouter Management Key가 필요하며 실제 라우팅은 구성된 API 키를 계속 사용합니다.", + "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.managementKeyRequired": "엔드포인트 검색에는 승인된 OpenRouter Management Key가 필요합니다. 기존 수동 라우팅은 유지됩니다.", + "pws.openrouter.authorizationFailed": "OpenRouter가 엔드포인트 검색에 사용된 구성된 API 키를 거부했습니다. 기존 수동 라우팅은 유지됩니다.", "pws.openrouter.keyRequired": "제공자를 검색하기 전에 OpenRouter API 키를 추가하세요.", "pws.openrouter.loadFailed": "이 모델의 제공자를 불러오지 못했습니다.", "pws.openrouter.loaded": "제공자 엔드포인트 {count}개를 불러왔습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index ee132221fa..8368ffb1c7 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2310,14 +2310,14 @@ export const ru: Record = { "models.aliasUser": "пользователь", "models.aliasStale": "устарел", "pws.openrouter.title": "Маршрутизация провайдеров по модели", - "pws.openrouter.hint": "Выберите точные endpoints OpenRouter для каждой модели. Для обнаружения нужен авторизованный OpenRouter Management Key; маршрутизация продолжает использовать настроенный API-ключ.", + "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.managementKeyRequired": "Для обнаружения endpoints нужен авторизованный OpenRouter Management Key. Ручная маршрутизация не изменена.", + "pws.openrouter.authorizationFailed": "OpenRouter отклонил настроенный API-ключ для обнаружения endpoints. Ручная маршрутизация не изменена.", "pws.openrouter.keyRequired": "Добавьте API-ключ OpenRouter перед обнаружением провайдеров.", "pws.openrouter.loadFailed": "Не удалось загрузить провайдеров этой модели.", "pws.openrouter.loaded": "Загружено endpoints провайдеров: {count}.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index a3c4ef281d..bd27d8f7a8 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2310,14 +2310,14 @@ export const tr: Record = { "models.aliasUser": "kullanıcı", "models.aliasStale": "eski", "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 yetkili bir OpenRouter Management Key gerektirir; yönlendirme yapılandırılmış API anahtarını kullanmaya devam eder.", + "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.managementKeyRequired": "Uç keşfi yetkili bir OpenRouter Management Key gerektirir. Mevcut elle yönlendirme değişmez.", + "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.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 8bf0c99701..46cd0d77fa 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2273,14 +2273,14 @@ export const zhTW: Record = { "models.aliasUser": "使用者", "models.aliasStale": "過期", "pws.openrouter.title": "依模型設定供應商路由", - "pws.openrouter.hint": "為每個模型選擇準確的 OpenRouter endpoint。探索清單需要已授權的 OpenRouter Management Key;實際路由仍使用已設定的 API 金鑰。", + "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.managementKeyRequired": "探索 endpoint 需要已授權的 OpenRouter Management Key。現有手動路由不會變更。", + "pws.openrouter.authorizationFailed": "OpenRouter 拒絕了用於探索 endpoint 的已設定 API 金鑰。現有手動路由不會變更。", "pws.openrouter.keyRequired": "探索供應商前請新增 OpenRouter API 金鑰。", "pws.openrouter.loadFailed": "無法載入此模型的供應商。", "pws.openrouter.loaded": "已載入 {count} 個供應商 endpoint。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 0622011d69..dc90258950 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2308,14 +2308,14 @@ export const zh: Record = { "models.aliasUser": "用户", "models.aliasStale": "过期", "pws.openrouter.title": "按模型设置提供商路由", - "pws.openrouter.hint": "为每个模型选择准确的 OpenRouter endpoint。发现列表需要授权的 OpenRouter Management Key;实际路由仍使用已配置的 API 密钥。", + "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.managementKeyRequired": "发现 endpoint 需要授权的 OpenRouter Management Key。现有手动路由保持不变。", + "pws.openrouter.authorizationFailed": "OpenRouter 拒绝了用于发现 endpoint 的已配置 API 密钥。现有手动路由保持不变。", "pws.openrouter.keyRequired": "发现提供商前请添加 OpenRouter API 密钥。", "pws.openrouter.loadFailed": "无法加载此模型的提供商。", "pws.openrouter.loaded": "已加载 {count} 个提供商 endpoint。", diff --git a/gui/tests/openrouter-model-routing.test.tsx b/gui/tests/openrouter-model-routing.test.tsx index c3f71de28a..ccdbf3a138 100644 --- a/gui/tests/openrouter-model-routing.test.tsx +++ b/gui/tests/openrouter-model-routing.test.tsx @@ -187,6 +187,7 @@ test("does not show an old model save result after the selected model changes", 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"); @@ -196,6 +197,7 @@ test("does not show an old model save result after the selected model changes", 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 () => { @@ -250,3 +252,97 @@ test("labels a configured tag as absent only after successful discovery", async 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/providers/openrouter-endpoints.ts b/src/providers/openrouter-endpoints.ts index 490c66ce67..9191ffad9b 100644 --- a/src/providers/openrouter-endpoints.ts +++ b/src/providers/openrouter-endpoints.ts @@ -5,6 +5,7 @@ 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); @@ -110,6 +111,9 @@ export async function listOpenRouterModelEndpoints( 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, { @@ -120,7 +124,7 @@ export async function listOpenRouterModelEndpoints( 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("management_key_required", 403, "OpenRouter endpoint discovery requires an authorized Management Key"); + 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 */ } diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index fd0763a0e2..6b76c4ab29 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -126,6 +126,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, @@ -239,11 +257,10 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise if (typeof body.enabled !== "boolean") { return jsonResponse({ error: "body.enabled must be a boolean" }, 400); } - config.agentTaskRecovery = { - ...(config.agentTaskRecovery ?? {}), - enabled: body.enabled, - }; - saveConfigPreservingClaudeCode(config); + 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 }); } diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 0a8ed16d61..c507336129 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -921,9 +921,10 @@ Provider discovery for this editor is a separate, management-only path. It calls an exact matching model envelope and bounded endpoint rows with nonblank `tag` values, and caches the sanitized result in memory for five minutes under a process-keyed credential scope. Credentials and upstream error bodies never enter the management response. A 401/403 is surfaced as the static -`openrouter_management_key_required` class because OpenRouter may require a Management Key even when -the same configured inference key remains valid for completions. The dashboard writes one exact-model -override through the normal provider PATCH path; null removes that override and restores inheritance. +`openrouter_authorization_failed` class; discovery uses the configured OpenRouter API key. No more +than eight distinct discovery flights may be active at once, while +same-key callers continue joining the existing flight. The dashboard writes one exact-model override +through the normal provider PATCH path; null removes that override and restores inheritance. ## Kimi Coding Plan prompt-cache affinity diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index c25e0c7a76..5c0b40cc5e 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -106,12 +106,12 @@ this document owns is which module holds which area and what invariant that area | Startup safety | `GET /api/startup-health` reports whether injected Codex routing is restart-safe, with secret-free service/shim diagnostics. `POST /api/startup-action` provides allowlisted one-click installation for the background service or launcher shim. On Windows a healthy script shim is CLI-only; Codex Desktop requires the background service for full protection. | | Windows tray | `GET/POST /api/windows-tray` controls an owned, per-user HKCU login tray. The tray delegates fixed actions to the CLI and is never a proxy supervisor or restart-protection signal. | | Updates | `GET /api/update/check`, `POST /api/update/run`, and `GET /api/update/status` own dashboard self-update state. A launched worker PID is persisted in `update-job.json`; dead PIDs recover immediately, while legacy active records without a PID recover only after ten minutes. Live PIDs remain exclusive regardless of record age. `GET /api/update/badge` backs the sidebar badge: it reports that an update exists and links to the update surface rather than gating other actions. | -| Providers | Create/update/delete ordinary provider configs and enrich registry metadata. The reserved `openai` card exposes Pool(default)/Direct account mode; `openai-apikey` remains the separate API route. Canonical OpenRouter cards can read sanitized per-model endpoint tags through `GET /api/openrouter/model-providers` and persist exact-model routing with the existing provider PATCH. | +| Providers | Create/update/delete ordinary provider configs and enrich registry metadata. The reserved `openai` card exposes Pool(default)/Direct account mode; `openai-apikey` remains the separate API route. Canonical OpenRouter cards can read sanitized per-model endpoint tags through `GET /api/openrouter/model-providers` using the configured API key and persist exact-model routing with the existing provider PATCH. Discovery permits eight unique active flights and returns a bounded busy response above that cap while identical callers share one flight. | | Models | Fetch routed model lists, disabled model visibility, and catalog-facing ids. | | OAuth | Login/status/logout for OAuth-backed providers, plus multiauth account management: `GET /api/oauth/accounts`, `PUT /api/oauth/accounts/active`, `PUT /api/oauth/accounts/alias`, `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, and remove one. The login flow itself is `GET /api/oauth/providers`, `POST /api/oauth/login`, `POST /api/oauth/login/code`, `POST /api/oauth/login/cancel`, `POST /api/oauth/logout`, and `GET /api/oauth/status`; pool controls are `GET/PUT/PATCH /api/oauth/accounts/pool` and `POST /api/oauth/accounts/clear-cooldown`. Login accepts `addAccount: true` to force a fresh browser identity. Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. | | Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | | OpenAI account mode | Report one OpenAI Codex card with Pool/Direct controls and one API-key card. Mode PATCH persists live without restart or catalog identity changes; Pool owns account/quota controls and Direct uses caller/main login only. Main-account DTOs report real credential presence and terminal `needsReauth` state instead of treating missing/invalid native auth as an unknown quota. Selection order has its own route: `PUT /api/codex-auth/accounts/priority` takes `{ id, priority }`, where `priority` is an integer -100..100 or `null` to restore the default, accepts `__main__`, 404s an unknown id, and echoes the stored value. Re-ordering never clears thread affinity, so the response carries no `appliesImmediately`, but it does release any pin — see [`08_openai-provider-tiers.md`](08_openai-provider-tiers.md) for why. `PUT /api/codex-auth/active` with a null id releases one too, but that drops the operator's account selection along with it, so this route is the only operator-facing way to clear a pin while leaving the selected account in place. `GET /api/codex-auth/active` reports `pinned`, true only while the manually selected account is still the effective active one, plus `pinnedAccountId`, which names the pinned account whether or not it is the active one. Surfaces should render `pinnedAccountId`: under round-robin and fill-first the pin caps the tier ceiling at its own tier while the strategy cursor moves freely inside that tier, so `pinned` goes false on a sibling's turn even though the pin is still suppressing every higher tier — which is why the dashboard badges `pinnedAccountId` and the GUI controller tracks only the id. `pinned` answers the narrower question of whether routing is *currently* on the operator's choice; no surface in this repo asks it, and a new one almost certainly wants the id instead. | -| Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent OpenCodex guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. `GET/PUT /api/agent-task-recovery` separately exposes only the explicit encrypted-V2 recovery switch, preserving advanced hand-configured values and never implying per-request eligibility. When OpenCodex owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. | +| Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent OpenCodex guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. `GET/PUT /api/agent-task-recovery` separately exposes only the explicit encrypted-V2 recovery switch, preserving advanced hand-configured values and never implying per-request eligibility; PUT commits the field-scoped disk mutation before mirroring it into live state. When OpenCodex owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. | | V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), the `keepNativeChatGptOnV1` hybrid pin, and the logical maximum thread count. Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. | | Logs & Debug | One sidebar entry (`/#logs`) with two tabs. Logs tab: request/runtime logs for local diagnosis. Debug tab (`/#logs/debug`; legacy `/#debug` deep links redirect there): provider + usage toggles, refresh/follow log viewer. `GET/PUT /api/debug`; `GET /api/debug/logs` and `GET /api/debug/usage-logs` (monotonic `after` cursor, legacy `since` accepted). CLI: `ocx debug provider|usage …` (both streams via running proxy API). | | Usage | `GET /api/usage` aggregate read-only summary derived from `~/.opencodex/usage.jsonl`; measured / reported / unreported / unsupported / estimated counts, daily zero-filled grid, model and provider breakdowns. Never exposes prompts. | diff --git a/tests/agent-task-recovery-api.test.ts b/tests/agent-task-recovery-api.test.ts index bce178ef39..5ea20726cb 100644 --- a/tests/agent-task-recovery-api.test.ts +++ b/tests/agent-task-recovery-api.test.ts @@ -2,6 +2,7 @@ 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 { saveConfig, setPersistedConfigMutationBeforeCommitForTests } from "../src/config"; import { handleManagementAPI } from "../src/server/management-api"; import type { OcxConfig } from "../src/types"; @@ -9,6 +10,7 @@ const savedHome = process.env.OPENCODEX_HOME; let tempHome: string | null = null; afterEach(() => { + setPersistedConfigMutationBeforeCommitForTests(null); if (savedHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = savedHome; if (tempHome) rmSync(tempHome, { recursive: true, force: true }); @@ -18,12 +20,14 @@ afterEach(() => { function config(recovery?: OcxConfig["agentTaskRecovery"]): OcxConfig { tempHome = mkdtempSync(join(tmpdir(), "ocx-agent-recovery-api-")); process.env.OPENCODEX_HOME = tempHome; - return { + 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 { @@ -66,4 +70,21 @@ describe("/api/agent-task-recovery", () => { 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/openrouter-endpoints.test.ts b/tests/openrouter-endpoints.test.ts index 47fe902f82..2b3e04c4f1 100644 --- a/tests/openrouter-endpoints.test.ts +++ b/tests/openrouter-endpoints.test.ts @@ -68,6 +68,41 @@ describe("OpenRouter model endpoint discovery", () => { 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: "management_key_required", status: 403 }); + .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 index 636a64a493..f6bde985ee 100644 --- a/tests/openrouter-routing-api.test.ts +++ b/tests/openrouter-routing-api.test.ts @@ -37,7 +37,7 @@ describe("OpenRouter model routing management API", () => { const current = config({ adapter: "openai-chat", baseUrl: "https://openrouter.ai/api/v1", - apiKey: "test-management-key", + apiKey: "test-api-key", fetch: async () => Response.json({ data: { id: "deepseek/deepseek-r1", endpoints: [{ tag: "deepinfra/turbo", provider_name: "DeepInfra" }] }, }), @@ -77,4 +77,64 @@ describe("OpenRouter model routing management API", () => { 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 fetches = 0; + const gate = new Promise(resolve => { release = resolve; }); + const current = config({ + adapter: "openai-chat", + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "test-key", + 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) => management( + current, + `/api/openrouter/model-providers?provider=openrouter&model=author%2Fmodel-${index}`, + )); + for (let attempt = 0; attempt < 100 && fetches < 8; attempt += 1) await new Promise(resolve => setTimeout(resolve, 0)); + 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); + }); }); From 0aac2a70023aa21c0ce8a8a64a2ddd072f03b527 Mon Sep 17 00:00:00 2001 From: Henrique <81029476+riique@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:06:04 -0300 Subject: [PATCH 7/8] test: stabilize routed discovery review coverage --- .../src/content/docs/fr/reference/configuration/agents.md | 2 +- .../src/content/docs/ja/reference/configuration/agents.md | 2 +- .../src/content/docs/ko/reference/configuration/agents.md | 2 +- .../src/content/docs/ru/reference/configuration/agents.md | 2 +- .../src/content/docs/tr/reference/configuration/agents.md | 3 +-- .../src/content/docs/zh-cn/reference/configuration/agents.md | 2 +- .../src/content/docs/zh-tw/reference/configuration/agents.md | 2 +- tests/openrouter-routing-api.test.ts | 5 ++++- 8 files changed, 11 insertions(+), 9 deletions(-) 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 f6275b7cf5..ddf1f15b11 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/agents.md +++ b/docs-site/src/content/docs/fr/reference/configuration/agents.md @@ -7,7 +7,7 @@ Les paramètres des agents déterminent la surface de collaboration Codex annonc ## 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|on|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. +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 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 268d54b55b..725c847349 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ja/reference/configuration/agents.md @@ -7,7 +7,7 @@ description: マルチエージェント サーフェス、委任ガイダンス ## 暗号化された v2 タスクの復旧コントロール -実験的な復旧機能は既定で無効です。クォータと遅延の警告を確認したうえで、**サブエージェント → 設定 → 暗号化された v2 タスクを復旧**から有効にするか、`ocx agent recovery status|on|off` を使います。同等の API は `GET/PUT /api/agent-task-recovery` です。`PUT` は `{"enabled": boolean}` のみを受け付け、永続設定を先に書き込んでから実行中の状態を更新し、手動設定された高度なオプションを保持します。 +実験的な復旧機能は既定で無効です。クォータと遅延の警告を確認したうえで、**サブエージェント → 設定 → 暗号化された 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/agents.md b/docs-site/src/content/docs/ko/reference/configuration/agents.md index 20ff9f3a19..4367c67a43 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ko/reference/configuration/agents.md @@ -7,7 +7,7 @@ description: 멀티 에이전트 표면, 위임 안내, 선호 모델, 대체 ## 암호화된 v2 작업 복구 제어 -실험적 복구는 기본적으로 꺼져 있습니다. 할당량과 지연 경고를 확인한 뒤 **하위 에이전트 → 설정 → 암호화된 v2 작업 복구**에서 켜거나 `ocx agent recovery status|on|off`를 사용하세요. 동일한 API는 `GET/PUT /api/agent-task-recovery`입니다. `PUT`은 `{"enabled": boolean}`만 허용하며, 영구 설정을 먼저 기록한 뒤 활성 상태를 갱신하고 수동으로 설정한 고급 옵션은 보존합니다. +실험적 복구는 기본적으로 꺼져 있습니다. 할당량과 지연 경고를 확인한 뒤 **하위 에이전트 → 설정 → 암호화된 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/ru/reference/configuration/agents.md b/docs-site/src/content/docs/ru/reference/configuration/agents.md index 2012f2ecf8..eece8303f3 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ru/reference/configuration/agents.md @@ -8,7 +8,7 @@ description: Multi-agent surface, guidance при делегировании, pr ## Управление восстановлением зашифрованных v2-задач -Экспериментальное восстановление по умолчанию выключено. Включайте его через **Подагенты → Настройки → Восстанавливать зашифрованные v2-задачи** только после подтверждения предупреждения о quota и latency либо используйте `ocx agent recovery status|on|off`. Эквивалентный API — `GET/PUT /api/agent-task-recovery`; `PUT` принимает только `{"enabled": boolean}`, сначала сохраняет durable-конфигурацию, затем обновляет активное состояние и сохраняет расширенные параметры, заданные вручную. +Экспериментальное восстановление по умолчанию выключено. Включайте его через **Подагенты → Настройки → Восстанавливать зашифрованные 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/tr/reference/configuration/agents.md b/docs-site/src/content/docs/tr/reference/configuration/agents.md index 9d211e1129..15a4d11c2f 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/agents.md +++ b/docs-site/src/content/docs/tr/reference/configuration/agents.md @@ -9,7 +9,7 @@ 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|on|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. +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ı @@ -250,4 +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/zh-cn/reference/configuration/agents.md b/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md index 193921b3f7..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 @@ -7,7 +7,7 @@ description: 多代理界面、委派引导、首选模型、回退链、原生 ## 加密 v2 任务恢复控制 -实验性恢复功能默认关闭。确认配额和延迟警告后,可在**子代理 → 设置 → 恢复加密的 v2 任务**中启用,或使用 `ocx agent recovery status|on|off`。对应 API 为 `GET/PUT /api/agent-task-recovery`;`PUT` 只接受 `{"enabled": boolean}`,会先写入持久配置,再更新运行中的状态,并保留手动配置的高级选项。 +实验性恢复功能默认关闭。确认配额和延迟警告后,可在**子代理 → 设置 → 恢复加密的 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-tw/reference/configuration/agents.md b/docs-site/src/content/docs/zh-tw/reference/configuration/agents.md index ee560bdf4a..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 @@ -7,7 +7,7 @@ Agent 設定控制要廣告哪個 Codex 協作介面,以及 opencodex 如何 ## 加密 v2 任務復原控制 -實驗性復原功能預設關閉。確認配額與延遲警告後,可在**子代理 → 設定 → 復原加密的 v2 任務**中啟用,或使用 `ocx agent recovery status|on|off`。對應 API 為 `GET/PUT /api/agent-task-recovery`;`PUT` 僅接受 `{"enabled": boolean}`,會先寫入持久設定,再更新執行中的狀態,並保留手動設定的進階選項。 +實驗性復原功能預設關閉。確認配額與延遲警告後,可在**子代理 → 設定 → 復原加密的 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/tests/openrouter-routing-api.test.ts b/tests/openrouter-routing-api.test.ts index f6bde985ee..599597bae7 100644 --- a/tests/openrouter-routing-api.test.ts +++ b/tests/openrouter-routing-api.test.ts @@ -99,14 +99,17 @@ describe("OpenRouter model routing management API", () => { 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({ @@ -121,7 +124,7 @@ describe("OpenRouter model routing management API", () => { current, `/api/openrouter/model-providers?provider=openrouter&model=author%2Fmodel-${index}`, )); - for (let attempt = 0; attempt < 100 && fetches < 8; attempt += 1) await new Promise(resolve => setTimeout(resolve, 0)); + await fetchesStarted; expect(fetches).toBe(8); const overflow = await management( From 48f0e38c1c1a374073db3af709d15545a4ee18a4 Mon Sep 17 00:00:00 2001 From: Henrique <81029476+riique@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:13:53 -0300 Subject: [PATCH 8/8] fix: close post-rebase security review findings --- .../docs/fr/reference/configuration/agents.md | 2 +- .../fr/reference/configuration/providers.md | 2 +- .../docs/ja/reference/configuration/agents.md | 2 +- .../ja/reference/configuration/providers.md | 2 +- .../docs/ko/reference/configuration/agents.md | 2 +- .../ko/reference/configuration/providers.md | 2 +- .../docs/reference/configuration/agents.md | 2 +- .../docs/reference/configuration/providers.md | 3 +- .../docs/ru/reference/configuration/agents.md | 2 +- .../ru/reference/configuration/providers.md | 2 +- .../docs/tr/reference/configuration/agents.md | 2 +- .../tr/reference/configuration/providers.md | 2 +- .../zh-cn/reference/configuration/agents.md | 2 +- .../reference/configuration/providers.md | 2 +- .../zh-tw/reference/configuration/agents.md | 2 +- .../reference/configuration/providers.md | 2 +- src/cli/agent.ts | 10 ++- src/providers/openrouter-endpoints.ts | 60 ++++++++++++--- tests/cli-headless-parity.test.ts | 10 ++- tests/openrouter-endpoints.test.ts | 77 +++++++++++++++++++ tests/openrouter-routing-api.test.ts | 27 +++++++ 21 files changed, 187 insertions(+), 30 deletions(-) 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 ddf1f15b11..c7bca8c319 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/agents.md +++ b/docs-site/src/content/docs/fr/reference/configuration/agents.md @@ -7,7 +7,7 @@ Les paramètres des agents déterminent la surface de collaboration Codex annonc ## 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. +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 --yes` 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 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 1dc9aef52e..0681820e0f 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -10,7 +10,7 @@ authentifiées. 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. +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. Dans l’API de gestion, `cached: true` signifie que la réponse provient d’une entrée de cache terminée ; les appels qui rejoignent une requête partagée active reçoivent `cached: false`. ## Champs de premier niveau liés aux fournisseurs 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 725c847349..59cba9e4ce 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ja/reference/configuration/agents.md @@ -7,7 +7,7 @@ description: マルチエージェント サーフェス、委任ガイダンス ## 暗号化された v2 タスクの復旧コントロール -実験的な復旧機能は既定で無効です。クォータと遅延の警告を確認したうえで、**サブエージェント → 設定 → 暗号化された v2 タスクを復旧**から有効にするか、`ocx agent recovery status`、`ocx agent recovery on`、または `ocx agent recovery off` を使います。同等の API は `GET/PUT /api/agent-task-recovery` です。`PUT` は `{"enabled": boolean}` のみを受け付け、永続設定を先に書き込んでから実行中の状態を更新し、手動設定された高度なオプションを保持します。 +実験的な復旧機能は既定で無効です。クォータと遅延の警告を確認したうえで、**サブエージェント → 設定 → 暗号化された v2 タスクを復旧**から有効にするか、`ocx agent recovery status`、`ocx agent recovery on --yes`、または `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 b34d33bb55..9dfbdde878 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -9,7 +9,7 @@ description: プロバイダー エントリ、認証、エンドポイント、 正規の OpenRouter プロバイダーでは、**プロバイダー → OpenRouter → モデル → モデル別プロバイダー ルーティング**を開きます。正確な OpenRouter モデル ID を入力してエンドポイントを読み込み、`openRouterRouting` の継承、`order`、または `only` を選択します。継承を保存すると、対応する `modelOpenRouterRouting` エントリが削除されます。検出結果にない設定済みタグも表示され、明示的に削除できます。 -検出には設定済みの OpenRouter API キーを使用します。認証が拒否されても保存済みルーティングは変更されません。成功した結果は 5 分間キャッシュされ、空の結果の後でも**更新**でキャッシュを回避できます。 +検出には設定済みの OpenRouter API キーを使用します。認証が拒否されても保存済みルーティングは変更されません。成功した結果は 5 分間キャッシュされ、空の結果の後でも**更新**でキャッシュを回避できます。管理 API の `cached: true` は完了済みのキャッシュ エントリから返されたことを示し、進行中の共有リクエストに合流した呼び出しは `cached: false` を受け取ります。 ## プロバイダー関連のトップレベルフィールド 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 4367c67a43..cea985e3c1 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ko/reference/configuration/agents.md @@ -7,7 +7,7 @@ description: 멀티 에이전트 표면, 위임 안내, 선호 모델, 대체 ## 암호화된 v2 작업 복구 제어 -실험적 복구는 기본적으로 꺼져 있습니다. 할당량과 지연 경고를 확인한 뒤 **하위 에이전트 → 설정 → 암호화된 v2 작업 복구**에서 켜거나 `ocx agent recovery status`, `ocx agent recovery on`, 또는 `ocx agent recovery off`를 사용하세요. 동일한 API는 `GET/PUT /api/agent-task-recovery`입니다. `PUT`은 `{"enabled": boolean}`만 허용하며, 영구 설정을 먼저 기록한 뒤 활성 상태를 갱신하고 수동으로 설정한 고급 옵션은 보존합니다. +실험적 복구는 기본적으로 꺼져 있습니다. 할당량과 지연 경고를 확인한 뒤 **하위 에이전트 → 설정 → 암호화된 v2 작업 복구**에서 켜거나 `ocx agent recovery status`, `ocx agent recovery on --yes`, 또는 `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 d01aed670d..4e76fac4e0 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -9,7 +9,7 @@ description: 공급자 항목, 인증, 엔드포인트, 모델 카탈로그, 할 정식 OpenRouter 공급자에서 **공급자 → OpenRouter → 모델 → 모델별 공급자 라우팅**을 엽니다. 정확한 OpenRouter 모델 ID를 입력하고 엔드포인트를 불러온 뒤 `openRouterRouting` 상속, `order`, 또는 `only`를 선택합니다. 상속을 저장하면 해당 `modelOpenRouterRouting` 항목이 제거됩니다. 검색 결과에서 사라진 구성 태그도 계속 표시되며 명시적으로 제거할 수 있습니다. -검색은 구성된 OpenRouter API 키를 사용합니다. 인증이 거부되어도 저장된 라우팅은 변경되지 않습니다. 성공한 결과는 5분 동안 캐시되며, 빈 결과 뒤에도 **새로 고침**으로 캐시를 우회할 수 있습니다. +검색은 구성된 OpenRouter API 키를 사용합니다. 인증이 거부되어도 저장된 라우팅은 변경되지 않습니다. 성공한 결과는 5분 동안 캐시되며, 빈 결과 뒤에도 **새로 고침**으로 캐시를 우회할 수 있습니다. 관리 API에서 `cached: true`는 완료된 캐시 항목에서 응답했다는 뜻이며, 진행 중인 공유 요청에 합류한 호출은 `cached: false`를 받습니다. ## 공급자 관련 최상위 필드 diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index 11dddcc2c2..da95042f26 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -192,7 +192,7 @@ tasks** and shows the quota/latency warning before enabling it. Headless users c ```bash ocx agent recovery status -ocx agent recovery on +ocx agent recovery on --yes ocx agent recovery off ``` diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index eeb5fd4d01..eb5bffbf02 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -545,7 +545,8 @@ used for inference. If OpenRouter rejects that key, existing routing remains unc 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. +still share that request. In the management API, `cached: true` means the response came from a +completed five-minute cache entry; callers that join an active shared request receive `cached: false`. ## Static model allowlists 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 eece8303f3..94797f4100 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ru/reference/configuration/agents.md @@ -8,7 +8,7 @@ description: Multi-agent surface, guidance при делегировании, pr ## Управление восстановлением зашифрованных 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-конфигурацию, затем обновляет активное состояние и сохраняет расширенные параметры, заданные вручную. +Экспериментальное восстановление по умолчанию выключено. Включайте его через **Подагенты → Настройки → Восстанавливать зашифрованные v2-задачи** только после подтверждения предупреждения о quota и latency либо используйте `ocx agent recovery status`, `ocx agent recovery on --yes` или `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 b2ba9bd45c..2217321ee6 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -10,7 +10,7 @@ description: Записи провайдеров, аутентификация, Для канонического OpenRouter откройте **Провайдеры → OpenRouter → Модели → Маршрутизация провайдера по модели**. Укажите точный OpenRouter model id, загрузите endpoint'ы и выберите наследование `openRouterRouting`, порядок `order` или ограничение `only`. Сохранение наследования удаляет соответствующую запись `modelOpenRouterRouting`. Настроенные tag'и, которых больше нет в результате discovery, остаются видимыми и могут быть явно удалены. -Discovery использует настроенный API-ключ OpenRouter. Отказ авторизации не меняет сохранённую маршрутизацию. Успешный результат кэшируется на пять минут; **Обновить** обходит кэш даже после пустого результата. +Discovery использует настроенный API-ключ OpenRouter. Отказ авторизации не меняет сохранённую маршрутизацию. Успешный результат кэшируется на пять минут; **Обновить** обходит кэш даже после пустого результата. В management API `cached: true` означает ответ из уже завершённой записи кэша; вызовы, присоединившиеся к активному общему запросу, получают `cached: false`. ## Верхнеуровневые поля, связанные с провайдерами 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 15a4d11c2f..260dfdffaa 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/agents.md +++ b/docs-site/src/content/docs/tr/reference/configuration/agents.md @@ -9,7 +9,7 @@ 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. +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 --yes` 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ı 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 f6fb8d39d2..08652a6957 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -10,7 +10,7 @@ konuştuğunu ve isteklerin nasıl doğrulandığını söyler. 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. +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. Yönetim API'sinde `cached: true`, yanıtın tamamlanmış bir önbellek girdisinden geldiğini belirtir; etkin paylaşılan isteğe katılan çağrılar `cached: false` alır. ## Sağlayıcı ile ilgili üst düzey alanlar 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 96d71fb70b..2f06cc6ff1 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 @@ -7,7 +7,7 @@ description: 多代理界面、委派引导、首选模型、回退链、原生 ## 加密 v2 任务恢复控制 -实验性恢复功能默认关闭。确认配额和延迟警告后,可在**子代理 → 设置 → 恢复加密的 v2 任务**中启用,或分别使用 `ocx agent recovery status`、`ocx agent recovery on` 或 `ocx agent recovery off`。对应 API 为 `GET/PUT /api/agent-task-recovery`;`PUT` 只接受 `{"enabled": boolean}`,会先写入持久配置,再更新运行中的状态,并保留手动配置的高级选项。 +实验性恢复功能默认关闭。确认配额和延迟警告后,可在**子代理 → 设置 → 恢复加密的 v2 任务**中启用,或分别使用 `ocx agent recovery status`、`ocx agent recovery on --yes` 或 `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 eb19ede615..5256ea3341 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 @@ -9,7 +9,7 @@ description: 提供者条目、身份验证、端点、模型目录、配额、 对于规范 OpenRouter 提供者,请打开**提供者 → OpenRouter → 模型 → 按模型的提供者路由**。输入精确的 OpenRouter 模型 ID,加载 endpoint,然后选择继承 `openRouterRouting`、使用 `order` 排序或使用 `only` 限制。保存继承模式会删除对应的 `modelOpenRouterRouting` 条目。发现结果中已不存在的已配置 tag 仍会显示,并可明确移除。 -发现功能使用已配置的 OpenRouter API 密钥。授权失败不会更改已保存的路由。成功结果会缓存五分钟;即使结果为空,也可通过**刷新**绕过缓存。 +发现功能使用已配置的 OpenRouter API 密钥。授权失败不会更改已保存的路由。成功结果会缓存五分钟;即使结果为空,也可通过**刷新**绕过缓存。在管理 API 中,`cached: true` 表示响应来自已完成的缓存条目;加入正在进行的共享请求的调用会收到 `cached: false`。 ## 提供者相关顶级字段 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 1f9b200639..42069792a7 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 @@ -7,7 +7,7 @@ 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}`,會先寫入持久設定,再更新執行中的狀態,並保留手動設定的進階選項。 +實驗性復原功能預設關閉。確認配額與延遲警告後,可在**子代理 → 設定 → 復原加密的 v2 任務**中啟用,或分別使用 `ocx agent recovery status`、`ocx agent recovery on --yes` 或 `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 59d203b2d0..a84e86a92d 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 @@ -9,7 +9,7 @@ description: 供應商項目、認證、端點、模型目錄、配額、context 對於規範 OpenRouter 供應商,請開啟**供應商 → OpenRouter → 模型 → 依模型的供應商路由**。輸入精確的 OpenRouter model ID、載入 endpoint,然後選擇繼承 `openRouterRouting`、使用 `order` 排序或以 `only` 限制。儲存繼承模式會移除對應的 `modelOpenRouterRouting` 項目。探索結果中已不存在的已設定 tag 仍會顯示,並可明確移除。 -探索使用已設定的 OpenRouter API 金鑰。授權失敗不會變更已儲存的路由。成功結果會快取五分鐘;即使結果為空,也能透過**重新整理**略過快取。 +探索使用已設定的 OpenRouter API 金鑰。授權失敗不會變更已儲存的路由。成功結果會快取五分鐘;即使結果為空,也能透過**重新整理**略過快取。在管理 API 中,`cached: true` 表示回應來自已完成的快取項目;加入進行中共享請求的呼叫會收到 `cached: false`。 ## 供應商相關的頂層欄位 diff --git a/src/cli/agent.ts b/src/cli/agent.ts index 9ea92c8024..991eb9e83e 100644 --- a/src/cli/agent.ts +++ b/src/cli/agent.ts @@ -25,7 +25,9 @@ const USAGE = `Usage: ocx agent injection [--model ] [--effort ] [--prompt ] [--guidance ] [--json] ocx agent effort [--main ] [--subagent ] [--json] - ocx agent recovery [--json] + ocx agent recovery status [--json] + ocx agent recovery on --yes [--json] + ocx agent recovery off [--json] ocx agent subagents [model,model...] [--json] ocx agent fallback [model,model...] [--poll-ms <5000-600000>] [--json] ocx agent sidecar [--list] [--model ] @@ -57,14 +59,20 @@ async function recovery(argv: string[], deps: RuntimeApiDeps): Promise { const args = [...argv]; const action = (args.shift() ?? "status").toLowerCase(); const wantsJson = takeFlag(args, "--json"); + const confirmed = takeFlag(args, "--yes"); rejectArgs(args, USAGE); if (action === "status") { + if (confirmed) throw new CliUsageError("--yes is valid only when enabling recovery", USAGE); 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"; + if (enabled && !confirmed) { + throw new CliUsageError("enabling encrypted V2 task recovery requires --yes", USAGE); + } + if (!enabled && confirmed) throw new CliUsageError("--yes is valid only when enabling recovery", USAGE); const result = await runtimeRequest("/api/agent-task-recovery", { method: "PUT", body: JSON.stringify({ enabled }), diff --git a/src/providers/openrouter-endpoints.ts b/src/providers/openrouter-endpoints.ts index 9191ffad9b..5316cf9f9a 100644 --- a/src/providers/openrouter-endpoints.ts +++ b/src/providers/openrouter-endpoints.ts @@ -8,6 +8,13 @@ const MAX_CACHE_ENTRIES = 128; const MAX_ACTIVE_FLIGHTS = 8; const MAX_RESPONSE_BYTES = 4 * 1024 * 1024; const MAX_ENDPOINTS = 512; +const MAX_MODEL_COMPONENT_LENGTH = 256; +const MAX_ENDPOINT_TAG_LENGTH = 128; +const MAX_PROVIDER_NAME_LENGTH = 128; +const MAX_ENDPOINT_NAME_LENGTH = 256; +const MAX_SUPPORTED_PARAMETERS = 128; +const MAX_SUPPORTED_PARAMETER_LENGTH = 128; +const MAX_PRICING_STRING_LENGTH = 128; const CACHE_SCOPE_KEY = randomBytes(32); export interface OpenRouterModelEndpoint { @@ -35,17 +42,38 @@ export class OpenRouterEndpointsError extends Error { 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))}`; + const author = modelId.slice(0, slash); + const slug = modelId.slice(slash + 1); + if ( + author.length > MAX_MODEL_COMPONENT_LENGTH + || slug.length > MAX_MODEL_COMPONENT_LENGTH + || author === "." + || author === ".." + || slug === "." + || slug === ".." + ) return null; + return `${encodeURIComponent(author)}/${encodeURIComponent(slug)}`; } function optionalPositiveInteger(value: unknown): number | undefined { return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; } +function optionalBoundedString(value: unknown, maxLength: number): string | undefined { + if (typeof value !== "string") return undefined; + const normalized = value.trim(); + return normalized && normalized.length <= maxLength ? normalized : 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; + const strings = value.filter((item): item is string => ( + typeof item === "string" + && item.trim() === item + && item.length > 0 + && item.length <= MAX_SUPPORTED_PARAMETER_LENGTH + )); + return strings.length > 0 ? [...new Set(strings)].slice(0, MAX_SUPPORTED_PARAMETERS) : undefined; } export function parseOpenRouterModelEndpoints(value: unknown, expectedModel: string): OpenRouterModelEndpoint[] { @@ -61,24 +89,30 @@ export function parseOpenRouterModelEndpoints(value: unknown, expectedModel: str 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; + const tag = optionalBoundedString(endpoint.tag, MAX_ENDPOINT_TAG_LENGTH); + const providerName = optionalBoundedString(endpoint.provider_name, MAX_PROVIDER_NAME_LENGTH); + if (!tag || !providerName || seen.has(tag)) continue; seen.add(tag); const pricing = endpoint.pricing && typeof endpoint.pricing === "object" && !Array.isArray(endpoint.pricing) ? endpoint.pricing as Record : undefined; + const name = optionalBoundedString(endpoint.name, MAX_ENDPOINT_NAME_LENGTH); + const contextLength = optionalPositiveInteger(endpoint.context_length); + const maxCompletionTokens = optionalPositiveInteger(endpoint.max_completion_tokens); + const supportedParameters = optionalStringArray(endpoint.supported_parameters); + const promptPricing = optionalBoundedString(pricing?.prompt, MAX_PRICING_STRING_LENGTH); + const completionPricing = optionalBoundedString(pricing?.completion, MAX_PRICING_STRING_LENGTH); 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) } : {}), + ...(name !== undefined ? { name } : {}), + ...(contextLength !== undefined ? { contextLength } : {}), + ...(maxCompletionTokens !== undefined ? { maxCompletionTokens } : {}), ...(typeof endpoint.supports_implicit_caching === "boolean" ? { supportsImplicitCaching: endpoint.supports_implicit_caching } : {}), - ...(optionalStringArray(endpoint.supported_parameters) ? { supportedParameters: optionalStringArray(endpoint.supported_parameters) } : {}), + ...(supportedParameters ? { supportedParameters } : {}), ...(pricing ? { pricing: { - ...(typeof pricing.prompt === "string" ? { prompt: pricing.prompt } : {}), - ...(typeof pricing.completion === "string" ? { completion: pricing.completion } : {}), + ...(promptPricing !== undefined ? { prompt: promptPricing } : {}), + ...(completionPricing !== undefined ? { completion: completionPricing } : {}), } } : {}), }); } @@ -140,6 +174,8 @@ export async function listOpenRouterModelEndpoints( flights.set(key, flight); } const entry = await flight; + // `cached` means a completed TTL-cache hit. Callers that join the same active + // network flight report false because no completed cache entry served them. return { fetchedAt: entry.fetchedAt, cached: false, endpoints: entry.endpoints }; } diff --git a/tests/cli-headless-parity.test.ts b/tests/cli-headless-parity.test.ts index 3d0aa3ea5f..bcb9a59d73 100644 --- a/tests/cli-headless-parity.test.ts +++ b/tests/cli-headless-parity.test.ts @@ -289,13 +289,21 @@ describe("headless GUI parity CLI", () => { }); test("agent recovery mirrors the explicit management toggle", async () => { + const unconfirmed = fakeRuntime(); + expect(await handleAgentCommand(["recovery", "on", "--json"], unconfirmed.deps)).toBe(2); + expect(unconfirmed.requests).toEqual([]); + const runtime = fakeRuntime(); - expect(await handleAgentCommand(["recovery", "on", "--json"], runtime.deps)).toBe(0); + expect(await handleAgentCommand(["recovery", "on", "--yes", "--json"], runtime.deps)).toBe(0); expect(runtime.requests).toEqual([{ path: "/api/agent-task-recovery", method: "PUT", body: { enabled: true }, }]); + + const disabled = fakeRuntime(); + expect(await handleAgentCommand(["recovery", "off", "--json"], disabled.deps)).toBe(0); + expect(disabled.requests[0]?.body).toEqual({ enabled: false }); }); test("provider edit reuses the management provider patch", async () => { diff --git a/tests/openrouter-endpoints.test.ts b/tests/openrouter-endpoints.test.ts index 2b3e04c4f1..a06fcbdd8c 100644 --- a/tests/openrouter-endpoints.test.ts +++ b/tests/openrouter-endpoints.test.ts @@ -61,6 +61,83 @@ describe("OpenRouter model endpoint discovery", () => { expect(urls).toEqual(["https://openrouter.ai/api/v1/models/deepseek/deepseek-r1/endpoints"]); }); + test("bounds every external string before returning or caching the DTO", () => { + const oversized = "x".repeat(257); + const longParameter = "p".repeat(129); + expect(parseOpenRouterModelEndpoints({ + data: { + id: "author/model", + endpoints: [{ + tag: "provider/live", + provider_name: "Provider", + name: oversized, + context_length: Number.MAX_VALUE, + max_completion_tokens: -1, + supported_parameters: ["tools", longParameter], + pricing: { prompt: "1".repeat(129), completion: " 0.000002 " }, + }], + }, + }, "author/model")).toEqual([{ + tag: "provider/live", + providerName: "Provider", + supportedParameters: ["tools"], + pricing: { completion: "0.000002" }, + }]); + + expect(parseOpenRouterModelEndpoints({ + data: { id: "author/model", endpoints: [{ tag: oversized, provider_name: "Provider" }] }, + }, "author/model")).toEqual([]); + }); + + test("rejects dot segments and oversized model path components before outbound fetch", async () => { + let fetches = 0; + const provider = { + adapter: "openai-chat", + baseUrl: "https://openrouter.ai/api/v1", + fetch: async () => { + fetches += 1; + return Response.json(responseBody); + }, + } as OcxProviderConfig; + + for (const modelId of ["../model", "author/..", `${"a".repeat(257)}/model`, `author/${"m".repeat(257)}`]) { + await expect(listOpenRouterModelEndpoints("openrouter", provider, modelId, "test-token")) + .rejects.toMatchObject({ code: "invalid_model", status: 400 }); + } + expect(fetches).toBe(0); + }); + + test("same-key callers share one flight and do not call it a completed cache hit", async () => { + let release!: () => void; + let started!: () => void; + let fetches = 0; + const gate = new Promise(resolve => { release = resolve; }); + const fetchStarted = new Promise(resolve => { started = resolve; }); + const provider = { + adapter: "openai-chat", + baseUrl: "https://openrouter.ai/api/v1", + fetch: async () => { + fetches += 1; + started(); + await gate; + return Response.json({ data: { id: "author/model", endpoints: [] } }); + }, + } as OcxProviderConfig; + + const first = listOpenRouterModelEndpoints("openrouter", provider, "author/model", "test-token"); + await fetchStarted; + const joined = listOpenRouterModelEndpoints("openrouter", provider, "author/model", "test-token"); + release(); + + expect(await Promise.all([first, joined])).toEqual([ + expect.objectContaining({ cached: false }), + expect.objectContaining({ cached: false }), + ]); + expect(fetches).toBe(1); + await expect(listOpenRouterModelEndpoints("openrouter", provider, "author/model", "test-token")) + .resolves.toMatchObject({ cached: true }); + }); + test("maps authorization failures without returning the upstream body", async () => { const provider = { adapter: "openai-chat", diff --git a/tests/openrouter-routing-api.test.ts b/tests/openrouter-routing-api.test.ts index 599597bae7..2247e0e6b7 100644 --- a/tests/openrouter-routing-api.test.ts +++ b/tests/openrouter-routing-api.test.ts @@ -72,6 +72,33 @@ describe("OpenRouter model routing management API", () => { expect(current.providers.openrouter?.modelOpenRouterRouting).toBeUndefined(); }); + test("rejects invalid model routing through the canonical schema before persistence", async () => { + const current = config({ + adapter: "openai-chat", + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "test-key", + modelOpenRouterRouting: { "author/existing": { order: ["provider/live"] } }, + }); + const original = structuredClone(current.providers.openrouter?.modelOpenRouterRouting); + const invalid = [ + { "author/model": { order: "provider/live" } }, + { "author/model": { only: [] } }, + { "author/model": { allowFallbacks: "yes" } }, + { "author/model": { allow_fallbacks: false } }, + { "author/model": { order: ["provider/live", "provider/live"] } }, + ]; + + for (const modelOpenRouterRouting of invalid) { + const response = await management(current, "/api/providers?name=openrouter", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ modelOpenRouterRouting }), + }); + expect(response.status).toBe(400); + expect(current.providers.openrouter?.modelOpenRouterRouting).toEqual(original); + } + }); + 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");