From f1a3c283c7b8a138fa6c9a726f3c4012f8c562f3 Mon Sep 17 00:00:00 2001 From: x3M3x Date: Wed, 2 Sep 2026 03:26:12 +0400 Subject: [PATCH 1/2] fix encrypted V2 spawn fallback without configured chain --- src/codex/subagent-model-fallback.ts | 9 +++++++-- tests/subagent-model-fallback.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index 27eea1e53d..ddd5b67e60 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -7,7 +7,7 @@ */ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; -import { hasOwnProvider } from "../config"; +import { DEFAULT_SUBAGENT_MODELS, hasOwnProvider } from "../config"; import { isRateLimitOrQuotaFailureMessage } from "../lib/errors"; import type { OcxParsedRequest, OcxConfig } from "../types"; import { slugsEquivalent } from "../providers/slug-codec"; @@ -609,9 +609,14 @@ export function applySubagentModelFallback( resolvedFallbackChain?: readonly string[] | null, ): { from?: string; to?: string; skipped?: string[] } | null { if (!isThreadSpawnRequest(headers)) return null; - const fallbackChain = resolvedFallbackChain === undefined + const configuredFallbackChain = resolvedFallbackChain === undefined ? resolveSubagentFallbackChain(parsed, config) : resolvedFallbackChain; + // Native-only encrypted V2 tasks need a readable ChatGPT backend even when the + // operator configured no fallback chain. Keep ordinary routed spawns unchanged. + const fallbackChain = configuredFallbackChain === null && nativeFallbackOnly + ? normalizedChain(parsed.modelId, config, [], DEFAULT_SUBAGENT_MODELS) + : configuredFallbackChain; if (!fallbackChain) return null; const selection = selectAvailableSubagentModel( parsed.modelId, diff --git a/tests/subagent-model-fallback.test.ts b/tests/subagent-model-fallback.test.ts index 75b2359ad5..d600072798 100644 --- a/tests/subagent-model-fallback.test.ts +++ b/tests/subagent-model-fallback.test.ts @@ -1056,6 +1056,29 @@ describe("subagent model fallback chain", () => { expect((parsed._rawBody as { model?: string }).model).toBe("alibaba-token-plan/qwen3.8-max"); }); + test("encrypted routed spawn gets an automatic native fallback when none is configured", () => { + const config = cfg({ + subagentModelFallback: undefined, + defaultProvider: "xai", + }); + const parsed = { + modelId: "xai/grok-4.5", + options: {}, + context: { messages: [] }, + _rawBody: { model: "xai/grok-4.5" }, + }; + const result = applySubagentModelFallback( + parsed as never, + new Headers({ "x-openai-subagent": "collab_spawn" }), + config, + "pool-a", + Date.now(), + true, + ); + expect(result?.to).toBe("gpt-5.5"); + expect(parsed.modelId).toBe("gpt-5.5"); + }); + test("applySubagentModelFallback is a no-op for main turns", () => { updateAccountQuota("pool-a", 95); const parsed = { From 06fe048bdd8af133c369dc71062ea508c9b6c97c Mon Sep 17 00:00:00 2001 From: x3M3x Date: Wed, 2 Sep 2026 03:42:09 +0400 Subject: [PATCH 2/2] feat gui: expose subagent fallback settings --- .../SubagentDelegationSection.tsx | 33 ++++++++++++ .../SubagentsWorkspace.tsx | 14 +++++ gui/src/i18n/de.ts | 6 +++ gui/src/i18n/en.ts | 6 +++ gui/src/i18n/fr.ts | 6 +++ gui/src/i18n/ja.ts | 6 +++ gui/src/i18n/ko.ts | 6 +++ gui/src/i18n/ru.ts | 6 +++ gui/src/i18n/tr.ts | 6 +++ gui/src/i18n/zh-TW.ts | 6 +++ gui/src/i18n/zh.ts | 6 +++ gui/src/pages/Subagents.tsx | 53 ++++++++++++++++--- 12 files changed, 147 insertions(+), 7 deletions(-) diff --git a/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx b/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx index e631570734..7924c9fedf 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; + fallback: string[]; + fallbackPollMs: number; + fallbackBusy: boolean; + availableModels: string[]; + onFallbackChange: (models: string[]) => void; + onFallbackPollMsChange: (pollMs: number) => void; + onFallbackSave: () => void; } export default function SubagentDelegationSection({ @@ -43,6 +50,7 @@ export default function SubagentDelegationSection({ onUltraModeSave, ultraLoadFailed, onUltraModeRetry, + fallback, fallbackPollMs, fallbackBusy, availableModels, onFallbackChange, onFallbackPollMsChange, onFallbackSave, }: SubagentDelegationSectionProps) { const t = useT(); // A present empty/whitespace hint is an upstream override that suppresses the @@ -96,6 +104,31 @@ export default function SubagentDelegationSection({ +
+
+
{t("sub.fallbackLabel")}
+
{t("sub.fallbackHint")}
+
+
+ {fallback.map((modelName, index) => ( +
+ {index + 1}. {modelName} + + + +
+ ))} + + + +
+
+
{t("dash.syncCodexSubagentDefaults")}
diff --git a/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx b/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx index 862467da09..230421b6cb 100644 --- a/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx +++ b/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx @@ -36,6 +36,12 @@ export interface SubagentsWorkspaceProps { onToggle: (m: string) => void; onMove: (i: number, dir: -1 | 1) => void; onSave: () => void; + fallback: string[]; + fallbackPollMs: number; + fallbackBusy: boolean; + onFallbackChange: (models: string[]) => void; + onFallbackPollMsChange: (pollMs: number) => void; + onFallbackSave: () => void; delegation: { model: string; effort: string; @@ -62,6 +68,7 @@ export default function SubagentsWorkspace({ onToggle, onMove, onSave, + fallback, fallbackPollMs, fallbackBusy, onFallbackChange, onFallbackPollMsChange, onFallbackSave, delegation, }: SubagentsWorkspaceProps) { const t = useT(); @@ -234,6 +241,13 @@ export default function SubagentsWorkspace({ onUltraModeSave={delegation.onUltraModeSave} ultraLoadFailed={delegation.ultraLoadFailed} onUltraModeRetry={delegation.onUltraModeRetry} + fallback={fallback} + fallbackPollMs={fallbackPollMs} + fallbackBusy={fallbackBusy} + availableModels={available} + onFallbackChange={onFallbackChange} + onFallbackPollMsChange={onFallbackPollMsChange} + onFallbackSave={onFallbackSave} />
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index eff4574b7a..c627b1e9cf 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2361,4 +2361,10 @@ export const de: Record = { "usage.scope.machine": "This machine", "usage.scope.hub": "Hub-wide", "usage.hubOffline": "Hub usage is unavailable. Local usage was not substituted.", + "sub.fallbackLabel": "Fallback-Kette für Sub-Agenten", + "sub.fallbackHint": "Modelle in dieser Reihenfolge versuchen, wenn ein Sub-Agent-Modell nicht verfügbar ist oder fehlschlägt.", + "sub.fallbackAdd": "Fallback-Modell hinzufügen…", + "sub.fallbackPoll": "Intervall der Verfügbarkeitsprüfung", + "sub.fallbackSaved": "Fallback-Einstellungen für Sub-Agenten gespeichert.", + "sub.fallbackSaveFailed": "Fallback-Einstellungen konnten nicht gespeichert werden", }; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 8abb9816fa..44f3094ea9 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2395,6 +2395,12 @@ export const en = { "usage.scope.machine": "This machine", "usage.scope.hub": "Hub-wide", "usage.hubOffline": "Hub usage is unavailable. Local usage was not substituted.", + "sub.fallbackLabel": "Sub-agent fallback chain", + "sub.fallbackHint": "Ordered models tried when a sub-agent model is unavailable or fails.", + "sub.fallbackAdd": "Add fallback model…", + "sub.fallbackPoll": "Availability check interval", + "sub.fallbackSaved": "Sub-agent fallback settings saved.", + "sub.fallbackSaveFailed": "Failed to save fallback settings", } as const; export type TKey = keyof typeof en; diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 4797b8719b..8e0cf44380 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2348,4 +2348,10 @@ export const fr: Record = { "usage.scope.machine": "Cette machine", "usage.scope.hub": "Tout le hub", "usage.hubOffline": "L'utilisation du hub est indisponible. Les données locales n'ont pas été substituées.", + "sub.fallbackLabel": "Chaîne de secours des sous-agents", + "sub.fallbackHint": "Modèles essayés dans cet ordre lorsqu’un modèle de sous-agent est indisponible ou échoue.", + "sub.fallbackAdd": "Ajouter un modèle de secours…", + "sub.fallbackPoll": "Intervalle de vérification de disponibilité", + "sub.fallbackSaved": "Paramètres de secours des sous-agents enregistrés.", + "sub.fallbackSaveFailed": "Échec de l’enregistrement des paramètres de secours", }; diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 0c2a1b69d8..fadd61cea8 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2382,4 +2382,10 @@ export const ja: Record = { "usage.scope.machine": "This machine", "usage.scope.hub": "Hub-wide", "usage.hubOffline": "Hub usage is unavailable. Local usage was not substituted.", + "sub.fallbackLabel": "サブエージェントのフォールバックチェーン", + "sub.fallbackHint": "サブエージェントモデルが利用できない、または失敗した場合に試すモデルの順序です。", + "sub.fallbackAdd": "フォールバックモデルを追加…", + "sub.fallbackPoll": "可用性確認間隔", + "sub.fallbackSaved": "サブエージェントのフォールバック設定を保存しました。", + "sub.fallbackSaveFailed": "フォールバック設定を保存できませんでした", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 898cd67d3e..8dc035ec36 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2383,4 +2383,10 @@ export const ko: Record = { "usage.scope.machine": "이 머신", "usage.scope.hub": "허브 전체", "usage.hubOffline": "허브 사용량을 불러올 수 없습니다. 로컬 사용량으로 대체하지 않았습니다.", + "sub.fallbackLabel": "하위 에이전트 폴백 체인", + "sub.fallbackHint": "하위 에이전트 모델을 사용할 수 없거나 실패할 때 이 순서로 시도합니다.", + "sub.fallbackAdd": "폴백 모델 추가…", + "sub.fallbackPoll": "가용성 확인 간격", + "sub.fallbackSaved": "하위 에이전트 폴백 설정을 저장했습니다.", + "sub.fallbackSaveFailed": "폴백 설정을 저장하지 못했습니다", }; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 12c47f477c..c3396570eb 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2384,4 +2384,10 @@ export const ru: Record = { "usage.scope.machine": "This machine", "usage.scope.hub": "Hub-wide", "usage.hubOffline": "Hub usage is unavailable. Local usage was not substituted.", + "sub.fallbackLabel": "Цепочка резервных моделей подагентов", + "sub.fallbackHint": "Модели, которые пробуются по порядку, если модель подагента недоступна или завершилась ошибкой.", + "sub.fallbackAdd": "Добавить резервную модель…", + "sub.fallbackPoll": "Интервал проверки доступности", + "sub.fallbackSaved": "Настройки резервных моделей подагентов сохранены.", + "sub.fallbackSaveFailed": "Не удалось сохранить настройки резервных моделей", }; diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 4aaeffec66..fbb65f2b47 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2384,4 +2384,10 @@ export const tr: Record = { "usage.scope.machine": "This machine", "usage.scope.hub": "Hub-wide", "usage.hubOffline": "Hub usage is unavailable. Local usage was not substituted.", + "sub.fallbackLabel": "Alt ajan geri dönüş zinciri", + "sub.fallbackHint": "Alt ajan modeli kullanılamadığında veya başarısız olduğunda sırayla denenecek modeller.", + "sub.fallbackAdd": "Geri dönüş modeli ekle…", + "sub.fallbackPoll": "Kullanılabilirlik denetimi aralığı", + "sub.fallbackSaved": "Alt ajan geri dönüş ayarları kaydedildi.", + "sub.fallbackSaveFailed": "Geri dönüş ayarları kaydedilemedi", }; diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 182fa48751..293605863d 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2346,4 +2346,10 @@ export const zhTW: Record = { "usage.scope.machine": "此機器", "usage.scope.hub": "整個 Hub", "usage.hubOffline": "Hub 使用量無法使用,未以本機使用量替代。", + "sub.fallbackLabel": "子代理備援鏈", + "sub.fallbackHint": "子代理模型無法使用或失敗時,會依此順序嘗試模型。", + "sub.fallbackAdd": "新增備援模型…", + "sub.fallbackPoll": "可用性檢查間隔", + "sub.fallbackSaved": "子代理備援設定已儲存。", + "sub.fallbackSaveFailed": "無法儲存備援設定", }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 96f3d939c2..738d389bc9 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2382,4 +2382,10 @@ export const zh: Record = { "usage.scope.machine": "This machine", "usage.scope.hub": "Hub-wide", "usage.hubOffline": "Hub usage is unavailable. Local usage was not substituted.", + "sub.fallbackLabel": "子代理回退链", + "sub.fallbackHint": "子代理模型不可用或失败时按此顺序尝试的模型。", + "sub.fallbackAdd": "添加回退模型…", + "sub.fallbackPoll": "可用性检查间隔", + "sub.fallbackSaved": "子代理回退设置已保存。", + "sub.fallbackSaveFailed": "保存回退设置失败", }; diff --git a/gui/src/pages/Subagents.tsx b/gui/src/pages/Subagents.tsx index ea36769466..ab1bc11bf2 100644 --- a/gui/src/pages/Subagents.tsx +++ b/gui/src/pages/Subagents.tsx @@ -8,7 +8,7 @@ import { useDataSurface } from "../data-surface"; import { DataSurfaceSkeleton } from "../components/data-surface"; import { useSubagentDelegation, type UltraModePatch, type UltraModeState } from "./use-subagent-delegation"; -type CachedSubagents = { available: string[]; chosen: string[] }; +type CachedSubagents = { available: string[]; chosen: string[]; fallback: string[]; pollMs: number }; function seedSubagents(cacheKey: string): CachedSubagents | null { return readSessionListCache(cacheKey); @@ -19,6 +19,9 @@ export default function Subagents({ apiBase }: { apiBase: string }) { const cacheKey = `ocx.subagents.v1:${apiBase}`; const cached = seedSubagents(cacheKey); const [chosen, setChosen] = useState(() => cached?.chosen ?? []); + const [fallback, setFallback] = useState(() => cached?.fallback ?? []); + const [fallbackPollMs, setFallbackPollMs] = useState(() => cached?.pollMs ?? 60000); + const [fallbackBusy, setFallbackBusy] = useState(false); const [status, setStatus] = useState(""); const [ok, setOk] = useState(false); const [busy, setBusy] = useState(false); @@ -116,16 +119,24 @@ export default function Subagents({ apiBase }: { apiBase: string }) { const loadSubagents = useCallback(async (signal?: AbortSignal): Promise => { // The resource layer's deadline abort must reach the wire — a signal dropped // here is a store that can only settle by race timeout. - const res = await fetch(`${apiBase}/api/subagent-models`, { signal }); - const response = await readJsonOrThrow<{ available?: string[]; chosen?: string[] }>(res, t("sub.loadFail")); - if (!response) throw new Error(t("sub.loadFail")); - const available = response.available ?? []; + const [rosterRes, fallbackRes] = await Promise.all([ + fetch(`${apiBase}/api/subagent-models`, { signal }), + fetch(`${apiBase}/api/subagent-model-fallback`, { signal }), + ]); + const response = await readJsonOrThrow<{ available?: string[]; chosen?: string[] }>(rosterRes, t("sub.loadFail")); + const fallbackResponse = await readJsonOrThrow<{ available?: string[]; models?: string[]; pollMs?: number }>(fallbackRes, t("sub.loadFail")); + if (!response || !fallbackResponse) throw new Error(t("sub.loadFail")); + const available = response.available ?? fallbackResponse.available ?? []; const availableSet = new Set(available); const next = { available, chosen: (response.chosen ?? []).filter(model => availableSet.has(model)), + fallback: (fallbackResponse.models ?? []).filter(model => availableSet.has(model)), + pollMs: fallbackResponse.pollMs ?? 60000, }; setChosen(next.chosen); + setFallback(next.fallback); + setFallbackPollMs(next.pollMs); writeSessionListCache(cacheKey, next); return next; }, [apiBase, cacheKey, t]); @@ -173,7 +184,7 @@ export default function Subagents({ apiBase }: { apiBase: string }) { const d = await readJsonOrThrow<{ applied?: string[] }>(r, t("sub.saveFailed")); const applied = d?.applied ?? chosen; if (d?.applied) setChosen(d.applied); - writeSessionListCache(cacheKey, { available, chosen: applied }); + writeSessionListCache(cacheKey, { available, chosen: applied, fallback, pollMs: fallbackPollMs }); setOk(true); setStatus(t("sub.saved", { n: applied.length, cmd: "ocx sync" })); } catch (error) { @@ -185,6 +196,28 @@ export default function Subagents({ apiBase }: { apiBase: string }) { } }; + const saveFallback = async () => { + if (fallbackBusy) return; + setFallbackBusy(true); + try { + const r = await fetch(`${apiBase}/api/subagent-model-fallback`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ models: fallback, pollMs: fallbackPollMs }), + }); + const d = await readJsonOrThrow<{ models?: string[]; pollMs?: number }>(r, t("sub.fallbackSaveFailed")); + if (d?.models) setFallback(d.models); + if (d?.pollMs) setFallbackPollMs(d.pollMs); + setOk(true); + setStatus(t("sub.fallbackSaved")); + } catch (error) { + setOk(false); + setStatus(error instanceof Error && error.message ? error.message : t("sub.networkError")); + } finally { + setFallbackBusy(false); + } + }; + // The skeleton owns the live region while this resource has no content yet. if (state.showSkeleton && !snapshot) { return ; @@ -213,7 +246,13 @@ export default function Subagents({ apiBase }: { apiBase: string }) { busy={busy} onToggle={toggle} onMove={move} - onSave={() => { void save(); }} + onSave={() => { void save(); }} + fallback={fallback} + fallbackPollMs={fallbackPollMs} + fallbackBusy={fallbackBusy} + onFallbackChange={setFallback} + onFallbackPollMsChange={setFallbackPollMs} + onFallbackSave={() => { void saveFallback(); }} delegation={{ model: delegation.model, effort: delegation.effort,