Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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
Expand Down Expand Up @@ -96,6 +104,31 @@ export default function SubagentDelegationSection({
</div>
</div>

<div className="swi-delegation-row swi-fallback-editor">
<div className="setting-copy">
<div className="font-semibold">{t("sub.fallbackLabel")}</div>
<div className="muted setting-hint">{t("sub.fallbackHint")}</div>
</div>
<div className="swi-fallback-controls">
{fallback.map((modelName, index) => (
<div key={modelName} className="swi-fallback-row">
<span>{index + 1}. {modelName}</span>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => { const next = [...fallback]; if (index > 0) [next[index - 1], next[index]] = [next[index], next[index - 1]]; onFallbackChange(next); }} disabled={fallbackBusy || index === 0} aria-label={t("sub.moveUp", { m: modelName })}>↑</button>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => { const next = [...fallback]; if (index < next.length - 1) [next[index], next[index + 1]] = [next[index + 1], next[index]]; onFallbackChange(next); }} disabled={fallbackBusy || index === fallback.length - 1} aria-label={t("sub.moveDown", { m: modelName })}>↓</button>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => onFallbackChange(fallback.filter(item => item !== modelName))} disabled={fallbackBusy}>×</button>
</div>
))}
<select className="input" value="" onChange={e => { if (e.target.value && !fallback.includes(e.target.value)) onFallbackChange([...fallback, e.target.value]); }} disabled={fallbackBusy}>
<option value="">{t("sub.fallbackAdd")}</option>
{availableModels.filter(modelName => !fallback.includes(modelName)).map(modelName => <option key={modelName} value={modelName}>{modelName}</option>)}
</select>
<label className="setting-hint">{t("sub.fallbackPoll")}
<input className="input" type="number" min={5000} max={600000} step={1000} value={fallbackPollMs} onChange={e => onFallbackPollMsChange(Number(e.target.value) || 60000)} disabled={fallbackBusy} /> ms
</label>
<button type="button" className="btn btn-primary btn-sm" onClick={onFallbackSave} disabled={fallbackBusy}>{t("common.save")}</button>
</div>
</div>

<div className="swi-delegation-row">
<div className="setting-copy">
<div className="font-semibold">{t("dash.syncCodexSubagentDefaults")}</div>
Expand Down
14 changes: 14 additions & 0 deletions gui/src/components/subagents-workspace/SubagentsWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -62,6 +68,7 @@ export default function SubagentsWorkspace({
onToggle,
onMove,
onSave,
fallback, fallbackPollMs, fallbackBusy, onFallbackChange, onFallbackPollMsChange, onFallbackSave,
delegation,
}: SubagentsWorkspaceProps) {
const t = useT();
Expand Down Expand Up @@ -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}
/>
</section>
</div>
Expand Down
6 changes: 6 additions & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2361,4 +2361,10 @@ export const de: Record<TKey, string> = {
"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",
};
6 changes: 6 additions & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
6 changes: 6 additions & 0 deletions gui/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2348,4 +2348,10 @@ export const fr: Record<TKey, string> = {
"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",
};
6 changes: 6 additions & 0 deletions gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2382,4 +2382,10 @@ export const ja: Record<TKey, string> = {
"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": "フォールバック設定を保存できませんでした",
};
6 changes: 6 additions & 0 deletions gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2383,4 +2383,10 @@ export const ko: Record<TKey, string> = {
"usage.scope.machine": "이 머신",
"usage.scope.hub": "허브 전체",
"usage.hubOffline": "허브 사용량을 불러올 수 없습니다. 로컬 사용량으로 대체하지 않았습니다.",
"sub.fallbackLabel": "하위 에이전트 폴백 체인",
"sub.fallbackHint": "하위 에이전트 모델을 사용할 수 없거나 실패할 때 이 순서로 시도합니다.",
"sub.fallbackAdd": "폴백 모델 추가…",
"sub.fallbackPoll": "가용성 확인 간격",
"sub.fallbackSaved": "하위 에이전트 폴백 설정을 저장했습니다.",
"sub.fallbackSaveFailed": "폴백 설정을 저장하지 못했습니다",
};
6 changes: 6 additions & 0 deletions gui/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2384,4 +2384,10 @@ export const ru: Record<TKey, string> = {
"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": "Не удалось сохранить настройки резервных моделей",
};
6 changes: 6 additions & 0 deletions gui/src/i18n/tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2384,4 +2384,10 @@ export const tr: Record<TKey, string> = {
"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",
};
6 changes: 6 additions & 0 deletions gui/src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2346,4 +2346,10 @@ export const zhTW: Record<TKey, string> = {
"usage.scope.machine": "此機器",
"usage.scope.hub": "整個 Hub",
"usage.hubOffline": "Hub 使用量無法使用,未以本機使用量替代。",
"sub.fallbackLabel": "子代理備援鏈",
"sub.fallbackHint": "子代理模型無法使用或失敗時,會依此順序嘗試模型。",
"sub.fallbackAdd": "新增備援模型…",
"sub.fallbackPoll": "可用性檢查間隔",
"sub.fallbackSaved": "子代理備援設定已儲存。",
"sub.fallbackSaveFailed": "無法儲存備援設定",
};
6 changes: 6 additions & 0 deletions gui/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2382,4 +2382,10 @@ export const zh: Record<TKey, string> = {
"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": "保存回退设置失败",
};
53 changes: 46 additions & 7 deletions gui/src/pages/Subagents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<CachedSubagents>(cacheKey);
Expand All @@ -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<string[]>(() => cached?.chosen ?? []);
const [fallback, setFallback] = useState<string[]>(() => 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);
Expand Down Expand Up @@ -116,16 +119,24 @@ export default function Subagents({ apiBase }: { apiBase: string }) {
const loadSubagents = useCallback(async (signal?: AbortSignal): Promise<CachedSubagents> => {
// 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]);
Expand Down Expand Up @@ -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) {
Expand All @@ -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 <DataSurfaceSkeleton label={t("sub.loading")} rows={4} />;
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 7 additions & 2 deletions src/codex/subagent-model-fallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions tests/subagent-model-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading