feat(coding-agent): implemented interactive fallback chain configuration

- Implemented model-specific keys and provider wildcards for `retry.fallbackChains` with updated resolution logic.
- Added interactive fallback chain management in the model roles UI, including support for reordering and editing.
- Improved fallback chain specificity rules and added comprehensive validation with startup warnings.
- Fixed mouse interaction alignment and hover state coordinate mapping in the roles view.
This commit is contained in:
can1357
2026-07-11 21:52:28 +02:00
parent 2ab2da2b1d
commit 54bafa1cce
10 changed files with 1060 additions and 148 deletions
+1 -1
View File
@@ -303,7 +303,7 @@ Ollama `local` · Ollama Cloud · LM Studio `local` · llama.cpp `local` · vLLM
### Four knobs that make routing useful
- **Custom providers** — Declare anything that speaks `openai-completions`, `openai-responses`, `openai-codex-responses`, `azure-openai-responses`, `anthropic-messages`, `google-generative-ai`, or `google-vertex` in `~/.omp/agent/models.yml`.
- **Fallback chains** — Per-role chains under `retry.fallbackChains`. When the primary throws 429s or hits a quota wall, the next entry takes the rest of the turn — restored on cooldown.
- **Fallback chains** — Per-role or per-model chains under `retry.fallbackChains`. When the primary throws 429s or hits a quota wall, the next entry takes the rest of the turn — restored on cooldown.
- **Path-scoped models** — Scope `enabledModels` and `disabledProviders` entries to a `path:` prefix to pin a different model set on one repo without touching the global config. Scoped entries cover the path and everything under it.
- **Round-robin credentials** — Stack API keys per provider and the runtime rotates with session affinity and per-credential backoff. Useful when one key would burn its quota by lunch.
+29 -2
View File
@@ -385,6 +385,31 @@ retry:
maxDelayMs: 300000
modelFallback: true
fallbackRevertPolicy: cooldown-expiry
fallbackChains:
# Any role without an explicit chain inherits the "default" chain.
default:
- anthropic/claude-opus-4-5
- openai/gpt-5.5
- google/gemini-3-pro
# Per-role chains override the default (roles from `modelRoles`,
# including custom roles). Selectors accept an optional thinking
# suffix, e.g. openai/gpt-5.5:low.
smol:
- openai/gpt-5.5-mini
- anthropic/claude-haiku-4-5
# Model-selector keys (any key containing "/") attach the chain to the
# model itself: it applies whenever that model is active, no matter
# which role it is assigned to, and survives role reassignment.
google/gemini-3-pro:
- google-vertex/gemini-3-pro
# A `provider/*` KEY covers every model of a provider — current or
# future. A `provider/*` ENTRY keeps the failing model's id and swaps
# the provider: google-antigravity/x -> google/x -> google-vertex/x.
# Ids missing on the target provider are skipped (near-miss ids resolve
# fuzzily); exact model keys override the wildcard for a specific model.
google-antigravity/*:
- google/*
- google-vertex/*
```
| Key | Type | Default | Notes |
@@ -394,8 +419,10 @@ retry:
| `retry.baseDelayMs` | number | `500` | Initial backoff. |
| `retry.maxDelayMs` | number | `300000` | Backoff ceiling (5 min). |
| `retry.modelFallback` | boolean | `true` | Fall back to another model when one is unavailable. |
| `retry.fallbackChains` | record | `{}` | Per-model fallback chains. |
| `retry.fallbackRevertPolicy` | enum | `cooldown-expiry` | `cooldown-expiry`, `never`. |
| `retry.fallbackChains` | record | `{}` | Maps roles, model selectors, or `provider/*` wildcards to ordered fallback selectors. Keys containing `/` are model-oriented and win over roles: `provider/model-id` matches that exact model, `provider/*` matches every model of the provider. A `provider/*` *entry* keeps the failing model's id and swaps the provider. The `default` chain covers every assigned role without its own chain. Unknown models/providers or malformed chains are reported as config warnings at startup. |
| `retry.fallbackRevertPolicy` | enum | `cooldown-expiry` | `cooldown-expiry` returns to the primary model once its suppression window ends; `never` stays on the fallback until switched manually. |
When the active model keeps failing (429s, quota walls, provider outages) and `retry.modelFallback` is on, the session picks the chain that owns the failing model, by specificity: an exact `provider/model-id` key, then a `provider/*` wildcard, then the current role's chain, then `default`. It skips models whose selectors are still cooling down and switches for the rest of the turn. Subagents get their own per-spawn chains when their agent definition lists multiple model patterns — the first resolvable pattern is primary and the rest become its fallbacks; there is no `agent:<name>` key in `fallbackChains`.
### Tools and approvals
@@ -1606,6 +1606,44 @@ describe("AuthStorage codex oauth ranking", () => {
expect(apiKey).toBe("api-acct-paid");
});
test("attempts every exhausted account for a paid-gated model until one passes the plan gate", async () => {
if (!authStorage) throw new Error("test setup failed");
// Production shape of the same regression: EVERY seat is usage-blocked
// (the free seat resets soonest, so it leads the blocked ordering) and
// only a later-resetting paid seat can serve gpt-5.6-sol. The blocked
// pass must keep iterating past the plan-ineligible free seat instead
// of giving up after the first blocked candidate.
await authStorage.set("openai-codex", [
{ type: "oauth", ...createCredential("acct-free", "free@example.com") },
{ type: "oauth", ...createCredential("acct-paid", "paid@example.com") },
]);
usageByAccount.set(
"acct-free",
createCodexUsageReport({
accountId: "acct-free",
primary: { usedFraction: 1, resetInMs: 5 * 60 * 1000 },
secondary: { usedFraction: 1, resetInMs: 5 * 60 * 1000 },
metadata: { planType: "free", email: "free@example.com", limitReached: true },
}),
);
usageByAccount.set(
"acct-paid",
createCodexUsageReport({
accountId: "acct-paid",
primary: { usedFraction: 1, resetInMs: 2 * HOUR_MS },
secondary: { usedFraction: 1, resetInMs: 6 * 24 * 60 * 60 * 1000 },
metadata: { planType: "plus", email: "paid@example.com", limitReached: true },
}),
);
const apiKey = await authStorage.getApiKey("openai-codex", "session-sol-all-exhausted", {
modelId: "gpt-5.6-sol",
});
expect(apiKey).toBe("api-acct-paid");
});
test("prefers Pro accounts for codex spark models over Plus accounts", async () => {
if (!authStorage) throw new Error("test setup failed");
+5
View File
@@ -5,16 +5,21 @@
### Added
- Added `invalidate` action to the usage command to clear cached usage reports
- Added model-oriented keys and wildcard entries to `retry.fallbackChains`: a `provider/model-id` key attaches a fallback chain to that exact model, a `provider/*` key covers every current or future model of a provider, and a `provider/*` chain entry keeps the failing model's id while swapping the provider (`google-antigravity/x` → `google/x`) — so fallbacks survive role and model reassignments without config edits. Keys resolve by specificity: exact model, then provider wildcard, then role, then `default`.
- Added fallback-chain editing to the /models Roles view: each role's `retry.fallbackChains` entries render as indented rows beneath it, `f` picks a fallback model to append, Enter on an entry replaces it, `x`/backspace removes it, and `[`/`]` (or shift+↑/↓) reorder the chain.
- Added model-keyed fallback management to the /models Roles view: model and `provider/*` chains render as a separate section below the roles (divider + "+ New fallback…" row for creating one by picking the protected model, then keying it by model or provider), with the same replace/remove/reorder editing as role chains; the model strip gains `fallbacks:<model>` and `fallbacks:<provider>/*` chips as shortcuts.
### Changed
- Retain completed and abandoned tasks in session history for improved context on resume
- Changed the Model Hub `retry-fallback` strip chip to append the model to the default fallback chain instead of prepending it, matching the chain-building order of the Roles view (already-registered models are a no-op).
### Fixed
- Fixed compiled release binaries bundling `fastembed` and baking the build-machine `@anush008/tokenizers` path; native runtime dependencies now stay external for every compiled build path so Mnemopi resolves its on-demand install instead. ([#5195](https://github.com/can1357/oh-my-pi/issues/5195))
- Fixed `/btw` side-channel turns on Codex models such as `gpt-5.6-luna` by preserving the session websocket preference instead of forcing SSE, and made Esc dismiss the active `/btw` panel before interrupting loop/maintenance work. ([#5213](https://github.com/can1357/oh-my-pi/issues/5213))
- Fixed the Model Hub role-assignment strip hiding the selected chip once the row overflowed; the strip now scrolls horizontally, truncating passed chips behind a leading ellipsis so the selection (plus one chip of lookahead) stays visible.
- Fixed mouse hover and clicks in the /models Roles view landing one row above the pointer (the row mapping subtracted the status row twice).
## [16.4.5] - 2026-07-11
@@ -1375,7 +1375,7 @@ export const SETTINGS_SCHEMA = {
group: "Retry & Fallback",
label: "Retry Fallback Chains",
description:
'JSON object mapping model roles to ordered fallback model selectors, e.g. {"default":["openai/gpt-4o-mini"]}.',
'JSON object mapping model roles, model selectors ("provider/model-id"), or provider wildcards ("provider/*") to ordered fallback selectors, e.g. {"default":["openai/gpt-4o-mini"],"google-antigravity/*":["google/*","google-vertex/*"]}. Model-oriented keys apply whenever that model/provider is active, regardless of role; a "provider/*" entry keeps the failing model\'s id and swaps the provider.',
},
},
"retry.fallbackRevertPolicy": {
@@ -52,7 +52,29 @@ import { renderSegmentTrack } from "./segment-track";
/** `roles` is the full /models hub; `pick` is a one-shot session/embedded picker. */
export type ModelHubMode = "roles" | "pick";
export type ModelHubAction = "modelRole" | "retryFallback";
/**
* A row of the Roles view: a role, a model/wildcard chain-key header, one of a
* chain's fallback entries, or the trailing "+ New role…". Fallback rows under
* a chain-key header carry the key in `role` — `retry.fallbackChains` treats
* roles, `provider/model-id`, and `provider/*` keys uniformly.
*/
type RolesRow =
| { kind: "role"; role: string }
| { kind: "chainKey"; role: string }
| { kind: "fallback"; role: string; chainIndex: number; selector: string }
| { kind: "separator" }
| { kind: "newFallback" }
| { kind: "newRole" };
/**
* What the model browser is currently picking for: a role's model, a slot in
* a fallback chain (`role` may be a role name, model selector, or `provider/*`
* key), or the primary model a brand-new fallback chain protects.
*/
type AssignTarget =
| { kind: "role"; role: string }
| { kind: "fallback"; role: string; index: number | null }
| { kind: "fallbackKey" };
/** A `--models` scope entry (mirrors the session's scoped model list). */
export interface ScopedModelItem {
@@ -61,16 +83,12 @@ export interface ScopedModelItem {
}
export interface ModelHubCallbacks {
/** Persist a role assignment (or a retry-fallback registration). */
onAssign: (
model: Model,
role: string,
thinkingLevel: ConfiguredThinkingLevel | undefined,
selector: string,
action: ModelHubAction,
) => void;
/** Persist a role assignment. */
onAssign: (model: Model, role: string, thinkingLevel: ConfiguredThinkingLevel | undefined, selector: string) => void;
/** Clear a configured role back to auto-selection. */
onUnassign: (role: string) => void;
/** Persist a `retry.fallbackChains` entry — keyed by a role, `provider/model-id`, or `provider/*`; an empty chain clears the key. */
onFallbackChainChange?: (role: string, chain: string[]) => void;
/** Pick-mode activation: session-only switch or embedded pick. */
onPick?: (model: Model, selector: string) => void;
/** Locked provider activation: forward to the /login flow. */
@@ -108,7 +126,7 @@ interface StripChip {
/** Pre-styled label body (without selection decoration). */
styled: string;
role?: string;
action: "assign" | "unassign" | "fallback" | "thinking";
action: "assign" | "unassign" | "fallback" | "fallbackModel" | "fallbackProvider" | "thinking";
thinkingLevel?: ConfiguredThinkingLevel;
}
@@ -192,11 +210,11 @@ export class ModelHubComponent implements Component {
*/
#focus: "scope" | "list" = "scope";
#roleIds: string[] = [];
#rolesRows: RolesRow[] = [];
#roleIndex = 0;
#roleHover: number | null = null;
#assigningRole: string | null = null;
#assigning: AssignTarget | null = null;
#strip: StripState | null = null;
/** Per-provider fuzzy match counts while a query is active; null when not searching. */
#searchCounts: Map<string, number> | null = null;
@@ -215,7 +233,7 @@ export class ModelHubComponent implements Component {
#footerRow = 0;
#chipRanges: ChipRange[] = [];
#lockedLoginLine: number | null = null;
#rolesRowStart = 2;
#rolesRowStart = 1;
constructor(
tui: TUI,
@@ -373,6 +391,7 @@ export class ModelHubComponent implements Component {
}
this.#reloadRoles(availableModels);
this.#buildRolesRows();
const mruOrder = this.#settings.getStorage()?.getModelUsageOrder() ?? [];
this.#availableItems = buildBrowserItems(availableModels);
@@ -545,7 +564,6 @@ export class ModelHubComponent implements Component {
break;
}
case "roles":
this.#roleIds = this.#visibleRoleIds();
this.#roleIndex = Math.min(this.#roleIndex, Math.max(0, this.#rolesRowCount - 1));
break;
default:
@@ -555,6 +573,58 @@ export class ModelHubComponent implements Component {
}
}
/**
* The configured `retry.fallbackChains` record with malformed keys/entries
* dropped: non-array chains and non-string selectors never reach the rows
* or chain editors, so an edit through the hub replaces them wholesale.
*/
#fallbackChains(): Record<string, string[]> {
try {
const chains = this.#settings.get("retry.fallbackChains");
if (!chains || typeof chains !== "object" || Array.isArray(chains)) return {};
const sanitized: Record<string, string[]> = {};
for (const key in chains) {
const chain = (chains as Record<string, unknown>)[key];
if (!Array.isArray(chain)) continue;
sanitized[key] = chain.filter((entry): entry is string => typeof entry === "string");
}
return sanitized;
} catch {
return {};
}
}
/**
* Rebuild the Roles view rows: each visible role followed by its
* fallback-chain entries, then model-oriented chains (`provider/model-id`
* and `provider/*` keys) as headed groups.
*/
#buildRolesRows(): void {
const rows: RolesRow[] = [];
const chains = this.#fallbackChains();
for (const role of this.#visibleRoleIds()) {
rows.push({ kind: "role", role });
const chain = chains[role] ?? [];
for (let i = 0; i < chain.length; i++) {
rows.push({ kind: "fallback", role, chainIndex: i, selector: chain[i] });
}
}
rows.push({ kind: "newRole" });
rows.push({ kind: "separator" });
const modelKeys = Object.keys(chains)
.filter(key => key.includes("/"))
.sort();
for (const key of modelKeys) {
const chain = chains[key] ?? [];
rows.push({ kind: "chainKey", role: key });
for (let i = 0; i < chain.length; i++) {
rows.push({ kind: "fallback", role: key, chainIndex: i, selector: chain[i] });
}
}
rows.push({ kind: "newFallback" });
this.#rolesRows = rows;
}
/** Refresh roles + dependent state after a settings mutation (assign/unassign). */
#refreshAfterMutation(): void {
this.#syncFromRegistryState();
@@ -588,7 +658,7 @@ export class ModelHubComponent implements Component {
this.#composeEntries();
const entry = this.#activeEntry();
if (
this.#assigningRole === null &&
this.#assigning === null &&
entry.kind === "provider" &&
(entry.locked || (counts.get(entry.providerId ?? "") ?? 0) === 0)
) {
@@ -739,10 +809,16 @@ export class ModelHubComponent implements Component {
this.#callbacks.onPick?.(item.model, item.selector);
return;
}
if (this.#assigningRole) {
const role = this.#assigningRole;
this.#assigningRole = null;
this.#assignRole(item, role, true);
if (this.#assigning) {
const target = this.#assigning;
this.#assigning = null;
if (target.kind === "role") {
this.#assignRole(item, target.role, true);
} else if (target.kind === "fallbackKey") {
this.#openFallbackKeyStrip(item);
} else {
this.#commitFallback(item, target);
}
return;
}
this.#openRoleStrip(item);
@@ -756,7 +832,7 @@ export class ModelHubComponent implements Component {
const supported = this.#thinkingOptionsFor(item.model);
level = supported.includes(current.thinkingLevel) ? current.thinkingLevel : ThinkingLevel.Inherit;
}
this.#callbacks.onAssign(item.model, role, level, item.selector, "modelRole");
this.#callbacks.onAssign(item.model, role, level, item.selector);
this.#refreshAfterMutation();
this.#openThinkingStrip(item, role, returnToRoles);
}
@@ -793,6 +869,16 @@ export class ModelHubComponent implements Component {
action: assignedHere ? "unassign" : "assign",
});
}
chips.push({
label: `fallbacks:${item.model.id}`,
styled: theme.fg("muted", `fallbacks:${item.model.id}`),
action: "fallbackModel",
});
chips.push({
label: `fallbacks:${item.model.provider}/*`,
styled: theme.fg("muted", `fallbacks:${item.model.provider}/*`),
action: "fallbackProvider",
});
chips.push({ label: "fallback", styled: theme.fg("muted", "retry-fallback"), action: "fallback" });
this.#strip = { kind: "role", item, chips, index: 0, returnToRoles: false };
}
@@ -851,18 +937,20 @@ export class ModelHubComponent implements Component {
this.#closeStrip();
return;
case "fallback":
this.#callbacks.onAssign(strip.item.model, "default", undefined, strip.item.selector, "retryFallback");
this.#appendFallback(strip.item, "default");
this.#closeStrip();
return;
case "fallbackModel":
this.#closeStrip();
this.#startAssignFallback(strip.item.selector, null);
return;
case "fallbackProvider":
this.#closeStrip();
this.#startAssignFallback(`${strip.item.model.provider}/*`, null);
return;
case "thinking":
if (strip.role && chip.thinkingLevel !== undefined) {
this.#callbacks.onAssign(
strip.item.model,
strip.role,
chip.thinkingLevel,
strip.item.selector,
"modelRole",
);
this.#callbacks.onAssign(strip.item.model, strip.role, chip.thinkingLevel, strip.item.selector);
this.#refreshAfterMutation();
}
this.#closeStrip();
@@ -872,7 +960,7 @@ export class ModelHubComponent implements Component {
/** Switch the body into assign mode for `role`: full catalog, cleared query, current model preselected. */
#startAssign(role: string): void {
this.#assigningRole = role;
this.#assigning = { kind: "role", role };
this.#focus = "scope";
this.#browser.setShowProvider(true);
this.#browser.setItems([...this.#availableItems]);
@@ -883,8 +971,104 @@ export class ModelHubComponent implements Component {
}
}
/** Browse the catalog to fill a fallback-chain slot: `index` replaces an entry, `null` appends. */
#startAssignFallback(role: string, index: number | null): void {
this.#assigning = { kind: "fallback", role, index };
this.#focus = "scope";
this.#browser.setShowProvider(true);
this.#browser.setItems([...this.#availableItems]);
this.#browser.setQuery("");
if (index !== null) {
const selector = this.#fallbackChains()[role]?.[index];
if (selector) this.#browser.selectSelector(selector);
}
}
/** Browse the catalog for the primary model a brand-new fallback chain protects. */
#startAssignFallbackKey(): void {
this.#assigning = { kind: "fallbackKey" };
this.#focus = "scope";
this.#browser.setShowProvider(true);
this.#browser.setItems([...this.#availableItems]);
this.#browser.setQuery("");
}
/** Second step of "+ New fallback…": key the chain by the picked model or its whole provider. */
#openFallbackKeyStrip(item: ModelBrowserItem): void {
const chips: StripChip[] = [
{
label: `for ${item.selector}`,
styled: theme.fg("muted", `for ${item.selector}`),
action: "fallbackModel",
},
{
label: `for ${item.model.provider}/*`,
styled: theme.fg("muted", `for ${item.model.provider}/*`),
action: "fallbackProvider",
},
];
this.#strip = { kind: "role", item, chips, index: 0, returnToRoles: false };
}
/** Write the picked model into the target chain slot, dedupe, and land back on its Roles row. */
#commitFallback(item: ModelBrowserItem, target: { role: string; index: number | null }): void {
const chain = [...(this.#fallbackChains()[target.role] ?? [])];
const selector = item.selector;
if (target.index !== null && target.index < chain.length) {
chain[target.index] = selector;
for (let i = chain.length - 1; i >= 0; i--) {
if (i !== target.index && chain[i] === selector) chain.splice(i, 1);
}
} else if (!chain.includes(selector)) {
chain.push(selector);
}
this.#setFallbackChain(target.role, chain);
this.#browser.setQuery("");
if (this.#mode === "roles") {
this.#setActiveEntry("roles");
this.#focus = "list";
const rowIndex = this.#rolesRows.findIndex(
row => row.kind === "fallback" && row.role === target.role && row.selector === selector,
);
if (rowIndex >= 0) this.#roleIndex = rowIndex;
}
}
/** Persist `role`'s chain through the host callback and rebuild dependent state. */
#setFallbackChain(role: string, chain: string[]): void {
this.#callbacks.onFallbackChainChange?.(role, chain);
this.#refreshAfterMutation();
}
/** Append `item` to `role`'s fallback chain (no-op when already present). */
#appendFallback(item: ModelBrowserItem, role: string): void {
const chain = [...(this.#fallbackChains()[role] ?? [])];
if (chain.includes(item.selector)) return;
chain.push(item.selector);
this.#setFallbackChain(role, chain);
}
/** Remove one chain entry; the cursor stays on the nearest surviving row. */
#removeFallback(row: { role: string; chainIndex: number }): void {
const chain = [...(this.#fallbackChains()[row.role] ?? [])];
if (row.chainIndex >= chain.length) return;
chain.splice(row.chainIndex, 1);
this.#setFallbackChain(row.role, chain);
this.#roleIndex = Math.min(this.#roleIndex, Math.max(0, this.#rolesRows.length - 1));
}
/** Move a chain entry one slot earlier/later; the cursor follows the moved entry. */
#moveFallback(row: { role: string; chainIndex: number }, delta: -1 | 1): void {
const chain = [...(this.#fallbackChains()[row.role] ?? [])];
const target = row.chainIndex + delta;
if (row.chainIndex >= chain.length || target < 0 || target >= chain.length) return;
[chain[row.chainIndex], chain[target]] = [chain[target], chain[row.chainIndex]];
this.#setFallbackChain(row.role, chain);
this.#roleIndex += delta;
}
#cancelAssign(): void {
this.#assigningRole = null;
this.#assigning = null;
this.#browser.setQuery("");
if (this.#mode === "roles") {
this.#setActiveEntry("roles");
@@ -961,7 +1145,7 @@ export class ModelHubComponent implements Component {
}
if (matchesSelectCancel(data)) {
if (this.#assigningRole !== null) {
if (this.#assigning !== null) {
this.#cancelAssign();
return;
}
@@ -975,8 +1159,8 @@ export class ModelHubComponent implements Component {
}
const entry = this.#activeEntry();
const rolesView = entry.kind === "roles" && this.#assigningRole === null;
const lockedView = entry.kind === "provider" && entry.locked && this.#assigningRole === null;
const rolesView = entry.kind === "roles" && this.#assigning === null;
const lockedView = entry.kind === "provider" && entry.locked && this.#assigning === null;
if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) {
this.#focus = this.#focus === "scope" ? "list" : "scope";
@@ -1030,7 +1214,7 @@ export class ModelHubComponent implements Component {
}
#isBrowserView(entry: SidebarEntry): boolean {
if (this.#assigningRole !== null) return true;
if (this.#assigning !== null) return true;
return entry.kind === "recent" || entry.kind === "all" || (entry.kind === "provider" && !entry.locked);
}
@@ -1074,16 +1258,50 @@ export class ModelHubComponent implements Component {
if (entry && !this.#isHopSkipped(entry)) {
// Scope changes keep an active assignment (scoping helps find the
// model); landing on the Roles view cancels it.
if (entry.kind === "roles") this.#assigningRole = null;
if (entry.kind === "roles") this.#assigning = null;
this.#setActiveEntry(entry.id);
return;
}
}
}
/** Row count of the roles view: every visible role plus the trailing "+ New role…" row. */
/** Row count of the roles view (roles, their fallback entries, and the trailing "+ New role…" row). */
get #rolesRowCount(): number {
return this.#roleIds.length + 1;
return this.#rolesRows.length;
}
/** Enter/click activation for a Roles-view row. */
#activateRolesRow(row: RolesRow): void {
switch (row.kind) {
case "role":
this.#startAssign(row.role);
return;
case "chainKey":
this.#startAssignFallback(row.role, null);
return;
case "fallback":
this.#startAssignFallback(row.role, row.chainIndex);
return;
case "newFallback":
this.#startAssignFallbackKey();
return;
case "newRole":
this.#openRoleNameStrip();
return;
case "separator":
return;
}
}
/** Step the roles cursor by one row, wrapping and skipping separator rows. */
#stepRoleIndex(from: number, delta: -1 | 1): number {
const count = Math.max(1, this.#rolesRows.length);
let index = from;
for (let i = 0; i < count; i++) {
index = (index + delta + count) % count;
if (this.#rolesRows[index]?.kind !== "separator") break;
}
return index;
}
#handleRolesViewInput(data: string): void {
@@ -1095,41 +1313,50 @@ export class ModelHubComponent implements Component {
}
return;
}
const rowCount = Math.max(1, this.#rolesRowCount);
if (matchesSelectUp(data)) {
this.#roleIndex = (this.#roleIndex - 1 + rowCount) % rowCount;
this.#roleIndex = this.#stepRoleIndex(this.#roleIndex, -1);
return;
}
if (matchesSelectDown(data)) {
this.#roleIndex = (this.#roleIndex + 1) % rowCount;
this.#roleIndex = this.#stepRoleIndex(this.#roleIndex, 1);
return;
}
const role = this.#roleIds[this.#roleIndex];
const row = this.#rolesRows[this.#roleIndex];
const role = row?.kind === "role" ? row.role : undefined;
if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
if (role) {
this.#startAssign(role);
} else {
// The virtual "+ New role…" row.
this.#openRoleNameStrip();
}
if (row) this.#activateRolesRow(row);
return;
}
if (matchesKey(data, "backspace") || matchesKey(data, "delete")) {
if (role) this.#unassignRole(role);
else if (row?.kind === "fallback") this.#removeFallback(row);
else if (row?.kind === "chainKey") this.#setFallbackChain(row.role, []);
return;
}
// Cycle reordering: [ / shift+↑ moves the role earlier, ] / shift+↓ later.
// Reordering: [ / shift+↑ moves the row earlier, ] / shift+↓ later —
// cycle order on a role row, chain order on a fallback row.
if (matchesKey(data, "shift+up")) {
if (role) this.#moveCycleMembership(role, -1);
else if (row?.kind === "fallback") this.#moveFallback(row, -1);
return;
}
if (matchesKey(data, "shift+down")) {
if (role) this.#moveCycleMembership(role, 1);
else if (row?.kind === "fallback") this.#moveFallback(row, 1);
return;
}
const printable = extractPrintableText(data);
if (printable === "x") {
if (role) this.#unassignRole(role);
else if (row?.kind === "fallback") this.#removeFallback(row);
else if (row?.kind === "chainKey") this.#setFallbackChain(row.role, []);
return;
}
if (printable === "f") {
if (row?.kind === "newFallback") this.#startAssignFallbackKey();
else if (row && row.kind !== "newRole" && row.kind !== "separator") {
this.#startAssignFallback(row.role, null);
}
return;
}
if (printable === "c") {
@@ -1138,10 +1365,12 @@ export class ModelHubComponent implements Component {
}
if (printable === "[") {
if (role) this.#moveCycleMembership(role, -1);
else if (row?.kind === "fallback") this.#moveFallback(row, -1);
return;
}
if (printable === "]") {
if (role) this.#moveCycleMembership(role, 1);
else if (row?.kind === "fallback") this.#moveFallback(row, 1);
return;
}
if (printable === "n") {
@@ -1204,9 +1433,8 @@ export class ModelHubComponent implements Component {
if (overSidebar) {
this.#moveSidebar(event.wheel);
} else if (overBody) {
if (entry.kind === "roles" && this.#assigningRole === null) {
const count = Math.max(1, this.#rolesRowCount);
this.#roleIndex = (this.#roleIndex + event.wheel + count) % count;
if (entry.kind === "roles" && this.#assigning === null) {
this.#roleIndex = this.#stepRoleIndex(this.#roleIndex, event.wheel > 0 ? 1 : -1);
} else if (this.#isBrowserView(entry)) {
this.#browser.routeMouse(event, bodyLine);
}
@@ -1216,7 +1444,7 @@ export class ModelHubComponent implements Component {
if (event.motion) {
this.#sidebarHover = overSidebar ? this.#sidebarEntryIndexAt(contentLine) : null;
if (overBody && entry.kind === "roles" && this.#assigningRole === null) {
if (overBody && entry.kind === "roles" && this.#assigning === null) {
const roleLine = bodyLine - this.#rolesRowStart;
this.#roleHover = roleLine >= 0 && roleLine < this.#rolesRowCount ? roleLine : null;
} else {
@@ -1235,7 +1463,7 @@ export class ModelHubComponent implements Component {
const clicked = index !== null ? this.#entries[index] : undefined;
if (clicked && clicked.kind !== "separator") {
const already = clicked.id === this.#activeEntryId;
if (clicked.kind === "roles") this.#assigningRole = null;
if (clicked.kind === "roles") this.#assigning = null;
this.#setActiveEntry(clicked.id);
// A click on Roles is a deliberate dive into the rows.
if (clicked.kind === "roles") this.#focus = "list";
@@ -1247,22 +1475,20 @@ export class ModelHubComponent implements Component {
}
if (overBody) {
if (entry.kind === "roles" && this.#assigningRole === null) {
if (entry.kind === "roles" && this.#assigning === null) {
this.#focus = "list";
const roleLine = bodyLine - this.#rolesRowStart;
if (roleLine >= 0 && roleLine < this.#rolesRowCount) {
if (roleLine === this.#roleIndex) {
const role = this.#roleIds[roleLine];
if (role) {
this.#startAssign(role);
const rowDef = this.#rolesRows[roleLine];
if (rowDef && rowDef.kind !== "separator") {
if (roleLine === this.#roleIndex) {
this.#activateRolesRow(rowDef);
} else {
this.#openRoleNameStrip();
this.#roleIndex = roleLine;
}
} else {
this.#roleIndex = roleLine;
}
}
} else if (entry.kind === "provider" && entry.locked && this.#assigningRole === null) {
} else if (entry.kind === "provider" && entry.locked && this.#assigning === null) {
if (this.#lockedLoginLine !== null && bodyLine === this.#lockedLoginLine) {
this.#requestLogin(entry);
}
@@ -1374,9 +1600,22 @@ export class ModelHubComponent implements Component {
}
#statusRow(width: number): string {
if (this.#assigningRole !== null) {
const info = getRoleInfo(this.#assigningRole, this.#settings);
const label = info.tag ?? info.name ?? this.#assigningRole;
if (this.#assigning !== null) {
if (this.#assigning.kind === "fallbackKey") {
return truncateToWidth(
theme.fg("accent", " New fallback chain — Enter picks the model it protects, Esc cancels"),
width,
);
}
const info = getRoleInfo(this.#assigning.role, this.#settings);
const label = info.tag ?? info.name ?? this.#assigning.role;
if (this.#assigning.kind === "fallback") {
const verb = this.#assigning.index === null ? "Adding fallback for" : "Replacing fallback of";
return truncateToWidth(
theme.fg("accent", ` ${verb} ${theme.bold(label)} — Enter picks the fallback model, Esc cancels`),
width,
);
}
return truncateToWidth(
theme.fg("accent", ` Assigning ${theme.bold(label)} — Enter assigns, Esc cancels`),
width,
@@ -1390,7 +1629,7 @@ export class ModelHubComponent implements Component {
text = this.#mode === "pick" ? this.#pickerHint : `Recently used models${scopedSuffix}`;
break;
case "roles":
text = "Model roles — assignments fall back to auto-selection when cleared";
text = "Model roles — f adds a retry fallback, cleared roles fall back to auto-selection";
break;
case "provider":
if (entry.locked) {
@@ -1415,22 +1654,71 @@ export class ModelHubComponent implements Component {
#renderRolesView(width: number, rows: number): string[] {
const lines: string[] = [];
lines.push("");
this.#rolesRowStart = lines.length + 1; // +1 for the status row offset handled by caller
// First row's offset in bodyLine coordinates: the mouse router's
// `bodyLine` has already dropped the status row, so this is just the
// leading blank line — no extra status-row offset here.
this.#rolesRowStart = lines.length;
let tagWidth = 0;
for (const role of this.#roleIds) {
const info = getRoleInfo(role, this.#settings);
tagWidth = Math.max(tagWidth, visibleWidth(info.tag ?? info.name ?? role));
for (const rowDef of this.#rolesRows) {
if (rowDef.kind !== "role") continue;
const info = getRoleInfo(rowDef.role, this.#settings);
tagWidth = Math.max(tagWidth, visibleWidth(info.tag ?? info.name ?? rowDef.role));
}
const cycleOrder = this.#cycleOrder();
for (let i = 0; i < this.#roleIds.length && lines.length < rows - 3; i++) {
const role = this.#roleIds[i];
const info = getRoleInfo(role, this.#settings);
const assignment = this.#roles[role];
for (let i = 0; i < this.#rolesRows.length && lines.length < rows - 2; i++) {
const rowDef = this.#rolesRows[i];
if (!rowDef) continue;
const selected = i === this.#roleIndex;
const hovered = i === this.#roleHover;
const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ";
if (rowDef.kind === "separator") {
lines.push(` ${theme.fg("border", "─".repeat(Math.max(1, width - 6)))}`);
continue;
}
if (rowDef.kind === "newRole" || rowDef.kind === "newFallback") {
const label = rowDef.kind === "newRole" ? "+ New role…" : "+ New fallback…";
let line = ` ${cursor} ${theme.fg(selected ? "accent" : "dim", label)}`;
line = truncateToWidth(line, width);
if (hovered && !selected) {
line = theme.bg("selectedBg", line);
}
lines.push(line);
continue;
}
if (rowDef.kind === "chainKey") {
const key = rowDef.role;
const slash = key.lastIndexOf("/");
const tail = key.slice(slash + 1);
const keyStyled = theme.fg("dim", key.slice(0, slash + 1)) + (selected ? theme.fg("accent", tail) : tail);
let line = ` ${cursor} ${theme.fg("dim", theme.status.shadowed)} ${keyStyled}`;
line = truncateToWidth(line, width);
if (hovered && !selected) {
line = theme.bg("selectedBg", line);
}
lines.push(line);
continue;
}
if (rowDef.kind === "fallback") {
const branch = theme.fg("dim", `${"".padEnd(tagWidth + 3)}↳`);
const selector = selected ? theme.fg("accent", rowDef.selector) : theme.fg("muted", rowDef.selector);
let line = ` ${cursor} ${branch} ${selector}`;
line = truncateToWidth(line, width);
if (hovered && !selected) {
line = theme.bg("selectedBg", line);
}
lines.push(line);
continue;
}
const role = rowDef.role;
const info = getRoleInfo(role, this.#settings);
const assignment = this.#roles[role];
const tag = (info.tag ?? info.name ?? role).padEnd(tagWidth);
let dot: string;
@@ -1475,27 +1763,16 @@ export class ModelHubComponent implements Component {
lines.push(line);
}
// Trailing virtual row: create a custom role.
if (lines.length < rows - 2) {
const newRoleIndex = this.#roleIds.length;
const selected = this.#roleIndex === newRoleIndex;
const hovered = this.#roleHover === newRoleIndex;
const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ";
let line = ` ${cursor} ${theme.fg(selected ? "accent" : "dim", "+ New role…")}`;
line = truncateToWidth(line, width);
if (hovered && !selected) {
line = theme.bg("selectedBg", line);
}
lines.push(line);
}
// Live preview of the quick-switch cycle, rendered with the exact
// segment track the ctrl+p status uses; the selected role's chip fills.
while (lines.length < rows - 1) lines.push("");
if (rows >= 2) {
const cycleKey = getKeybindings().getKeys("app.model.cycleForward")[0] ?? "ctrl+p";
if (cycleOrder.length > 0) {
const activeIndex = cycleOrder.indexOf(this.#roleIds[this.#roleIndex] ?? "");
const selectedRow = this.#rolesRows[this.#roleIndex];
const selectedRole =
selectedRow && (selectedRow.kind === "role" || selectedRow.kind === "fallback") ? selectedRow.role : "";
const activeIndex = cycleOrder.indexOf(selectedRole);
const track = renderSegmentTrack(
cycleOrder.map(role => ({ label: role })),
activeIndex,
@@ -1557,14 +1834,32 @@ export class ModelHubComponent implements Component {
? "←/→ choose · Enter assign/clear · Esc cancel"
: "←/→ thinking level · Enter apply · Esc keep";
}
if (this.#assigningRole !== null) {
return "Enter assign · ↑/↓ providers · type to search · Esc cancel";
if (this.#assigning !== null) {
switch (this.#assigning.kind) {
case "fallback":
return "Enter pick fallback · ↑/↓ providers · type to search · Esc cancel";
case "fallbackKey":
return "Enter pick the protected model · ↑/↓ providers · type to search · Esc cancel";
default:
return "Enter assign · ↑/↓ providers · type to search · Esc cancel";
}
}
const entry = this.#activeEntry();
if (entry.kind === "roles") {
return this.#focus === "list"
? "↑/↓ roles · Enter pick · x clear · t thinking · c cycle · [/] reorder · n new · ← providers"
: "↑/↓ providers · → roles · Esc close";
if (this.#focus !== "list") {
return "↑/↓ providers · → roles · Esc close";
}
const row = this.#rolesRows[this.#roleIndex];
if (row?.kind === "fallback") {
return "↑/↓ rows · Enter replace · f add another · x remove · [/] reorder · ← providers";
}
if (row?.kind === "chainKey") {
return "↑/↓ rows · Enter/f add fallback · x clear chain · ← providers";
}
if (row?.kind === "newFallback") {
return "↑/↓ rows · Enter new model/provider fallback chain · ← providers";
}
return "↑/↓ rows · Enter pick · f fallback · x clear · t thinking · c cycle · [/] reorder · n new";
}
if (entry.kind === "provider" && entry.locked) {
return entry.oauth ? "Enter log in · ↑/↓ providers · Esc close" : "↑/↓ providers · Esc close";
@@ -1656,9 +1951,9 @@ export class ModelHubComponent implements Component {
const entry = this.#activeEntry();
const bodyLines: string[] = [this.#statusRow(bodyWidth)];
if (entry.kind === "roles" && this.#assigningRole === null) {
if (entry.kind === "roles" && this.#assigning === null) {
bodyLines.push(...this.#renderRolesView(bodyWidth, contentRows - 1));
} else if (entry.kind === "provider" && entry.locked && this.#assigningRole === null) {
} else if (entry.kind === "provider" && entry.locked && this.#assigning === null) {
bodyLines.push(...this.#renderLockedView(entry, bodyWidth, contentRows - 1));
} else {
this.#browser.setMaxVisible(contentRows - 1 - 5);
@@ -616,7 +616,7 @@ export class SelectorController {
this.ctx.session.modelRegistry,
this.ctx.session.scopedModels,
{
onAssign: async (model, role, thinkingLevel, selector, action) => {
onAssign: async (model, role, thinkingLevel, selector) => {
// `auto` is session-global: never baked into a per-role model value
// (it can't round-trip through `model:<level>`). Apply it to the session
// separately and persist via `defaultThinkingLevel`.
@@ -624,18 +624,6 @@ export class SelectorController {
const concreteThinking = isAuto || thinkingLevel === undefined ? undefined : thinkingLevel;
const selectorValue = selector ?? `${model.provider}/${model.id}`;
try {
if (action === "retryFallback") {
const fallbackSelector = formatModelSelectorValue(selectorValue, concreteThinking);
const fallbackChains = this.ctx.settings.get("retry.fallbackChains");
const chain = Array.isArray(fallbackChains[role]) ? fallbackChains[role] : [];
this.ctx.settings.set("retry.fallbackChains", {
...fallbackChains,
[role]: [fallbackSelector, ...chain.filter(existing => existing !== fallbackSelector)],
});
const roleInfo = getRoleInfo(role, settings);
this.ctx.showStatus(`${roleInfo?.name ?? role} fallback model: ${fallbackSelector}`);
return;
}
if (role === "default") {
const { switched } = await this.ctx.session.setModel(model, role, {
selector,
@@ -679,6 +667,25 @@ export class SelectorController {
this.ctx.showError(error instanceof Error ? error.message : String(error));
}
},
onFallbackChainChange: (role, chain) => {
try {
const chains = { ...this.ctx.settings.get("retry.fallbackChains") };
if (chain.length === 0) {
delete chains[role];
} else {
chains[role] = chain;
}
this.ctx.settings.set("retry.fallbackChains", chains);
const roleInfo = getRoleInfo(role, settings);
this.ctx.showStatus(
chain.length > 0
? `${roleInfo?.name ?? role} fallbacks: ${chain.join(" → ")}`
: `${roleInfo?.name ?? role} fallbacks cleared`,
);
} catch (error) {
this.ctx.showError(error instanceof Error ? error.message : String(error));
}
},
onPick: async (model, selector) => {
try {
// Session-only: update agent state but don't persist the model to settings.
@@ -1060,6 +1060,7 @@ export interface FreshSessionResult {
/** Standard thinking levels */
/** `retry.fallbackChains` config: chain key (role name or model selector) → ordered fallback selectors. */
type RetryFallbackChains = Record<string, string[]>;
type RetryFallbackRevertPolicy = "never" | "cooldown-expiry";
@@ -1072,6 +1073,7 @@ interface RetryFallbackSelector {
}
interface ActiveRetryFallbackState {
/** Chain key that produced this fallback: a model-role name or a model-selector key. */
role: string;
originalSelector: string;
originalThinkingLevel: ConfiguredThinkingLevel | undefined;
@@ -1099,6 +1101,24 @@ function parseRetryFallbackSelector(
};
}
/**
* `retry.fallbackChains` keys are either model-role names (`smol`, `default`)
* or model selectors (`provider/model-id[:thinking]`). Role names never
* contain a slash, so its presence marks a model-keyed chain whose primary is
* the key itself — the chain follows the model across role reassignments.
*/
function isRetryFallbackModelKey(key: string): boolean {
return key.includes("/");
}
/**
* A `provider/*` fallback-chain key: matches any active model of that provider,
* so one entry covers every current and future model behind the provider.
*/
function isRetryFallbackWildcardKey(key: string): boolean {
return key.endsWith("/*");
}
function formatRetryFallbackSelector(model: Model, thinkingLevel: ThinkingLevel | undefined): string {
return formatModelSelectorValue(formatModelStringWithRouting(model), thinkingLevel);
}
@@ -13475,36 +13495,68 @@ export class AgentSession {
const configuredChains = this.settings.get("retry.fallbackChains");
if (configuredChains === undefined) return;
if (!configuredChains || typeof configuredChains !== "object" || Array.isArray(configuredChains)) {
const msg = "retry.fallbackChains must be a mapping of role names to selector arrays.";
const msg = "retry.fallbackChains must be a mapping of role names or model selectors to selector arrays.";
logger.warn(msg);
this.configWarnings.push(msg);
return;
}
for (const [role, chain] of Object.entries(configuredChains)) {
for (const key in configuredChains) {
const chain = (configuredChains as RetryFallbackChains)[key];
const keyKind = isRetryFallbackModelKey(key) ? "model" : "role";
if (keyKind === "model") {
if (isRetryFallbackWildcardKey(key)) {
const provider = key.slice(0, -2);
if (!this.#modelRegistry.getAll().some(model => model.provider === provider)) {
const msg = `retry.fallbackChains wildcard key references unknown provider: ${key}`;
logger.warn(msg);
this.configWarnings.push(msg);
}
} else {
const parsedKey = parseRetryFallbackSelector(key, this.#modelRegistry);
if (!parsedKey) {
const msg = `Invalid model selector key in retry.fallbackChains: ${key}`;
logger.warn(msg);
this.configWarnings.push(msg);
} else if (!this.#modelRegistry.find(parsedKey.provider, parsedKey.id)) {
const msg = `retry.fallbackChains key references unknown model: ${key}`;
logger.warn(msg);
this.configWarnings.push(msg);
}
}
}
if (!Array.isArray(chain)) {
const msg = `Fallback chain for role '${role}' must be an array of selector strings.`;
const msg = `Fallback chain for ${keyKind} '${key}' must be an array of selector strings.`;
logger.warn(msg);
this.configWarnings.push(msg);
continue;
}
for (const selectorStr of chain) {
if (typeof selectorStr !== "string") {
const msg = `Fallback chain for role '${role}' contains a non-string selector.`;
const msg = `Fallback chain for ${keyKind} '${key}' contains a non-string selector.`;
logger.warn(msg);
this.configWarnings.push(msg);
continue;
}
if (isRetryFallbackWildcardKey(selectorStr)) {
const provider = selectorStr.slice(0, -2);
if (!this.#modelRegistry.getAll().some(model => model.provider === provider)) {
const msg = `Fallback chain for ${keyKind} '${key}' references unknown provider: ${selectorStr}`;
logger.warn(msg);
this.configWarnings.push(msg);
}
continue;
}
const parsed = parseRetryFallbackSelector(selectorStr, this.#modelRegistry);
if (!parsed) {
const msg = `Invalid fallback selector format in role '${role}': ${selectorStr}`;
const msg = `Invalid fallback selector format in ${keyKind} '${key}': ${selectorStr}`;
logger.warn(msg);
this.configWarnings.push(msg);
continue;
}
const exists = this.#modelRegistry.find(parsed.provider, parsed.id);
if (!exists) {
const msg = `Fallback chain for role '${role}' references unknown model: ${selectorStr}`;
const msg = `Fallback chain for ${keyKind} '${key}' references unknown model: ${selectorStr}`;
logger.warn(msg);
this.configWarnings.push(msg);
}
@@ -13517,6 +13569,8 @@ export class AgentSession {
}
#getRetryFallbackPrimarySelector(role: string): RetryFallbackSelector | undefined {
if (isRetryFallbackWildcardKey(role)) return undefined;
if (isRetryFallbackModelKey(role)) return parseRetryFallbackSelector(role, this.#modelRegistry);
const configuredSelector = this.settings.getModelRole(role);
return configuredSelector ? parseRetryFallbackSelector(configuredSelector, this.#modelRegistry) : undefined;
}
@@ -13538,6 +13592,13 @@ export class AgentSession {
this.#modelRegistry.suppressSelector(currentSelector, Date.now() + cooldownMs);
}
/**
* Map the failing model selector to the chain key that owns it, by
* specificity: an exact model-selector key, then a `provider/*` wildcard,
* then a model role whose current assignment matches, then `default`.
* Model-oriented keys win over roles so a chain follows the model across
* role reassignments.
*/
#resolveRetryFallbackRole(currentSelector: string): string | undefined {
const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry);
if (!parsedCurrent) return undefined;
@@ -13551,18 +13612,33 @@ export class AgentSession {
? formatRetryFallbackBaseSelector(parseRetryFallbackSelector(currentPlainSelector) ?? parsedCurrent)
: undefined;
for (const role of Object.keys(chains)) {
const primarySelector = this.#getRetryFallbackPrimarySelector(role);
if (primarySelector?.raw === currentSelector) return role;
const exactModelKeys: string[] = [];
const roleKeys: string[] = [];
for (const key in chains) {
if (!isRetryFallbackModelKey(key)) roleKeys.push(key);
else if (!isRetryFallbackWildcardKey(key)) exactModelKeys.push(key);
}
for (const role of Object.keys(chains)) {
const primarySelector = this.#getRetryFallbackPrimarySelector(role);
if (!primarySelector) continue;
if (currentPlainSelector && primarySelector.raw === currentPlainSelector) return role;
const primaryBaseSelector = formatRetryFallbackBaseSelector(primarySelector);
if (primaryBaseSelector === currentBaseSelector) return role;
if (currentPlainBaseSelector && primaryBaseSelector === currentPlainBaseSelector) return role;
const matchesCurrent = (primary: RetryFallbackSelector | undefined): boolean => {
if (!primary) return false;
if (primary.raw === currentSelector || (currentPlainSelector && primary.raw === currentPlainSelector)) {
return true;
}
const base = formatRetryFallbackBaseSelector(primary);
return base === currentBaseSelector || (!!currentPlainBaseSelector && base === currentPlainBaseSelector);
};
// 1. Exact model-selector keys — most specific.
for (const key of exactModelKeys) {
if (matchesCurrent(this.#getRetryFallbackPrimarySelector(key))) return key;
}
// 2. Provider wildcard (`provider/*`) — any active model of this provider.
const wildcardKey = `${parsedCurrent.provider}/*`;
if (Array.isArray(chains[wildcardKey])) return wildcardKey;
// 3. Role keys — matched by the role's currently-assigned model.
for (const key of roleKeys) {
if (matchesCurrent(this.#getRetryFallbackPrimarySelector(key))) return key;
}
// 4. The default chain, when default has no explicit role primary.
const defaultChain = chains.default;
if (
Array.isArray(defaultChain) &&
@@ -13574,13 +13650,45 @@ export class AgentSession {
return undefined;
}
#getRetryFallbackEffectiveChain(role: string): RetryFallbackSelector[] {
const primarySelector = this.#getRetryFallbackPrimarySelector(role);
if (!primarySelector) return [];
const chain = [primarySelector];
const seen = new Set<string>([primarySelector.raw]);
/**
* Parse one configured chain entry. A `provider/*` entry keeps the failing
* model's id and swaps the provider (google-antigravity/x → google/x);
* ids the target provider lacks are skipped by the candidate loop's
* registry lookup.
*/
#parseRetryFallbackChainEntry(
entry: string,
current: RetryFallbackSelector | undefined,
): RetryFallbackSelector | undefined {
if (isRetryFallbackWildcardKey(entry)) {
if (!current) return undefined;
const provider = entry.slice(0, -2);
return { raw: `${provider}/${current.id}`, provider, id: current.id, thinkingLevel: undefined };
}
return parseRetryFallbackSelector(entry, this.#modelRegistry);
}
#getRetryFallbackEffectiveChain(role: string, currentSelector?: string): RetryFallbackSelector[] {
const parsedCurrent = currentSelector
? parseRetryFallbackSelector(currentSelector, this.#modelRegistry)
: undefined;
const seen = new Set<string>();
const chain: RetryFallbackSelector[] = [];
if (isRetryFallbackWildcardKey(role)) {
// A wildcard key has no fixed primary: the active model is the
// primary, followed by the configured provider-level fallbacks.
if (parsedCurrent) {
chain.push(parsedCurrent);
seen.add(parsedCurrent.raw);
}
} else {
const primarySelector = this.#getRetryFallbackPrimarySelector(role);
if (!primarySelector) return [];
chain.push(primarySelector);
seen.add(primarySelector.raw);
}
for (const selector of this.#getRetryFallbackChains()[role] ?? []) {
const parsed = parseRetryFallbackSelector(selector, this.#modelRegistry);
const parsed = this.#parseRetryFallbackChainEntry(selector, parsedCurrent);
if (!parsed || seen.has(parsed.raw)) continue;
seen.add(parsed.raw);
chain.push(parsed);
@@ -13589,7 +13697,7 @@ export class AgentSession {
}
#findRetryFallbackCandidates(role: string, currentSelector: string): RetryFallbackSelector[] {
let chain = this.#getRetryFallbackEffectiveChain(role);
let chain = this.#getRetryFallbackEffectiveChain(role, currentSelector);
const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry);
if (chain.length === 0 && role === "default" && parsedCurrent) {
const chains = this.#getRetryFallbackChains();
@@ -13602,7 +13710,7 @@ export class AgentSession {
const seen = new Set<string>([parsedCurrent.raw]);
chain = [parsedCurrent];
for (const selector of defaultChain) {
const parsed = parseRetryFallbackSelector(selector, this.#modelRegistry);
const parsed = this.#parseRetryFallbackChainEntry(selector, parsedCurrent);
if (!parsed || seen.has(parsed.raw)) continue;
seen.add(parsed.raw);
chain.push(parsed);
@@ -219,6 +219,215 @@ describe("AgentSession retry fallback", () => {
]);
});
it("activates a model-keyed fallback chain without any role assignment", async () => {
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
const fallbackModel = getBundledModel("openai", "gpt-4o-mini");
if (!primaryModel || !fallbackModel) {
throw new Error("Expected bundled test models to exist");
}
const requestedModels: string[] = [];
const fallbackAppliedEvents: Array<Extract<AgentSessionEvent, { type: "retry_fallback_applied" }>> = [];
const agent = createFallbackAgent(primaryModel, requestedModels);
const settings = Settings.isolated({
"compaction.enabled": false,
"retry.maxRetries": 1,
"retry.fallbackChains": {
[`${primaryModel.provider}/${primaryModel.id}`]: [`${fallbackModel.provider}/${fallbackModel.id}`],
},
});
session = new AgentSession({
agent,
sessionManager: SessionManager.inMemory(),
settings,
modelRegistry,
});
session.subscribe(event => {
if (event.type === "retry_fallback_applied") {
fallbackAppliedEvents.push(event);
}
});
await session.prompt("Recover via model-keyed chain");
await session.waitForIdle();
expect(requestedModels).toEqual([
`${primaryModel.provider}/${primaryModel.id}`,
`${fallbackModel.provider}/${fallbackModel.id}`,
]);
expect(session.model?.provider).toBe(fallbackModel.provider);
expect(session.model?.id).toBe(fallbackModel.id);
expect(fallbackAppliedEvents).toEqual([
{
type: "retry_fallback_applied",
from: `${primaryModel.provider}/${primaryModel.id}`,
to: `${fallbackModel.provider}/${fallbackModel.id}`,
role: `${primaryModel.provider}/${primaryModel.id}`,
},
]);
});
it("prefers a model-keyed chain over the matching role chain", async () => {
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
const modelKeyFallback = getBundledModel("openai", "gpt-4o-mini");
const roleChainFallback = getBundledModel("openai", "gpt-4o");
if (!primaryModel || !modelKeyFallback || !roleChainFallback) {
throw new Error("Expected bundled test models to exist");
}
const requestedModels: string[] = [];
const fallbackAppliedEvents: Array<Extract<AgentSessionEvent, { type: "retry_fallback_applied" }>> = [];
const agent = createFallbackAgent(primaryModel, requestedModels);
const settings = Settings.isolated({
"compaction.enabled": false,
"retry.maxRetries": 1,
"retry.fallbackChains": {
default: [`${roleChainFallback.provider}/${roleChainFallback.id}`],
[`${primaryModel.provider}/${primaryModel.id}`]: [`${modelKeyFallback.provider}/${modelKeyFallback.id}`],
},
});
settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`);
session = new AgentSession({
agent,
sessionManager: SessionManager.inMemory(),
settings,
modelRegistry,
});
session.subscribe(event => {
if (event.type === "retry_fallback_applied") {
fallbackAppliedEvents.push(event);
}
});
await session.prompt("Model-keyed chain wins");
await session.waitForIdle();
expect(requestedModels).toEqual([
`${primaryModel.provider}/${primaryModel.id}`,
`${modelKeyFallback.provider}/${modelKeyFallback.id}`,
]);
expect(fallbackAppliedEvents).toEqual([
{
type: "retry_fallback_applied",
from: `${primaryModel.provider}/${primaryModel.id}`,
to: `${modelKeyFallback.provider}/${modelKeyFallback.id}`,
role: `${primaryModel.provider}/${primaryModel.id}`,
},
]);
});
it("applies a provider-wildcard chain to any model of that provider", async () => {
const primaryModel = getBundledModel("anthropic", "claude-opus-4-1");
const fallbackModel = getBundledModel("openai", "gpt-4o-mini");
if (!primaryModel || !fallbackModel) {
throw new Error("Expected bundled test models to exist");
}
const requestedModels: string[] = [];
const fallbackAppliedEvents: Array<Extract<AgentSessionEvent, { type: "retry_fallback_applied" }>> = [];
const agent = createFallbackAgent(primaryModel, requestedModels);
// No exact key for this model and no role assignment: only the
// `anthropic/*` wildcard can match, proving provider-level coverage.
const settings = Settings.isolated({
"compaction.enabled": false,
"retry.maxRetries": 1,
"retry.fallbackChains": {
"anthropic/*": [`${fallbackModel.provider}/${fallbackModel.id}`],
},
});
session = new AgentSession({
agent,
sessionManager: SessionManager.inMemory(),
settings,
modelRegistry,
});
session.subscribe(event => {
if (event.type === "retry_fallback_applied") {
fallbackAppliedEvents.push(event);
}
});
await session.prompt("Recover via provider wildcard");
await session.waitForIdle();
expect(requestedModels).toEqual([
`${primaryModel.provider}/${primaryModel.id}`,
`${fallbackModel.provider}/${fallbackModel.id}`,
]);
expect(session.model?.provider).toBe(fallbackModel.provider);
expect(session.model?.id).toBe(fallbackModel.id);
expect(fallbackAppliedEvents).toEqual([
{
type: "retry_fallback_applied",
from: `${primaryModel.provider}/${primaryModel.id}`,
to: `${fallbackModel.provider}/${fallbackModel.id}`,
role: "anthropic/*",
},
]);
});
it("substitutes the failing model id into provider-wildcard chain entries", async () => {
const primaryModel = getBundledModel("google", "gemini-2.5-flash");
const fallbackModel = getBundledModel("google-vertex", "gemini-2.5-flash");
if (!primaryModel || !fallbackModel) {
throw new Error("Expected bundled test models to exist");
}
const requestedModels: string[] = [];
const fallbackAppliedEvents: Array<Extract<AgentSessionEvent, { type: "retry_fallback_applied" }>> = [];
const agent = createFallbackAgent(primaryModel, requestedModels);
// `google-vertex/*` is not a fixed target: it must adopt the failing
// model's id (google/gemini-2.5-flash -> google-vertex/gemini-2.5-flash).
const settings = Settings.isolated({
"compaction.enabled": false,
"retry.maxRetries": 1,
"retry.fallbackChains": {
"google/*": ["google-vertex/*"],
},
});
session = new AgentSession({
agent,
sessionManager: SessionManager.inMemory(),
settings,
modelRegistry,
});
session.subscribe(event => {
if (event.type === "retry_fallback_applied") {
fallbackAppliedEvents.push(event);
}
});
await session.prompt("Recover via id-preserving wildcard entry");
await session.waitForIdle();
expect(requestedModels).toEqual([
`${primaryModel.provider}/${primaryModel.id}`,
`${fallbackModel.provider}/${fallbackModel.id}`,
]);
expect(session.model?.provider).toBe("google-vertex");
expect(session.model?.id).toBe(primaryModel.id);
expect(fallbackAppliedEvents).toEqual([
{
type: "retry_fallback_applied",
from: `${primaryModel.provider}/${primaryModel.id}`,
to: `google-vertex/${primaryModel.id}`,
role: "google/*",
},
]);
});
it("uses the active initial model as the default fallback primary when other role fallback chains are configured", async () => {
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
const fallbackModel = getBundledModel("openai", "gpt-4o-mini");
@@ -1596,6 +1805,39 @@ describe("AgentSession retry fallback", () => {
);
});
it("warns on unknown or malformed model-selector chain keys at startup", () => {
const primaryModel = getBundledModel("openai", "gpt-4o-mini");
if (!primaryModel) {
throw new Error("Expected bundled OpenAI test model to exist");
}
const settings = Settings.isolated({
"compaction.enabled": false,
"retry.fallbackChains": {
"nonexistent-provider/nonexistent-model": [`${primaryModel.provider}/${primaryModel.id}`],
[`${primaryModel.provider}/${primaryModel.id}`]: ["openai/gpt-4o"],
},
});
const agent = new Agent({
getApiKey: model => `${model.provider}-test-key`,
initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] },
streamFn: () => {
throw new Error("Not exercised");
},
});
session = new AgentSession({
agent,
sessionManager: SessionManager.inMemory(),
settings,
modelRegistry,
});
expect(session.configWarnings).toContain(
"retry.fallbackChains key references unknown model: nonexistent-provider/nonexistent-model",
);
expect(session.configWarnings.filter(w => w.includes(`${primaryModel.provider}/${primaryModel.id}`))).toEqual([]);
});
it("normalizes suppression by base selector and clears it on model refresh", async () => {
const future = Date.now() + 60_000;
modelRegistry.suppressSelector("openai/gpt-4o:high", future);
+201 -11
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeAll, describe, expect, test, vi } from "bun:test";
import { afterEach, beforeAll, describe, expect, type Mock, test, vi } from "bun:test";
import { stripVTControlCharacters } from "node:util";
import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Model } from "@oh-my-pi/pi-ai";
@@ -78,6 +78,7 @@ interface HubHarness {
onPick: ReturnType<typeof vi.fn>;
onLoginRequest: ReturnType<typeof vi.fn>;
onCancel: ReturnType<typeof vi.fn>;
onFallbackChainChange: Mock<(role: string, chain: string[]) => void>;
}
const openHubs: ModelHubComponent[] = [];
@@ -100,6 +101,16 @@ function createHub(options: {
const onPick = vi.fn();
const onLoginRequest = vi.fn();
const onCancel = vi.fn();
// Mirror the controller: persist chain edits so the hub's re-read sees them.
const onFallbackChainChange = vi.fn((role: string, chain: string[]) => {
const chains = { ...settings.get("retry.fallbackChains") };
if (chain.length === 0) {
delete chains[role];
} else {
chains[role] = chain;
}
settings.override("retry.fallbackChains", chains);
});
const hub = new ModelHubComponent(
ui,
settings,
@@ -111,12 +122,13 @@ function createHub(options: {
onPick: options.callbacks?.onPick ?? onPick,
onLoginRequest: options.callbacks?.onLoginRequest ?? onLoginRequest,
onCycleOrderChange: options.callbacks?.onCycleOrderChange,
onFallbackChainChange: options.callbacks?.onFallbackChainChange ?? onFallbackChainChange,
onCancel: options.callbacks?.onCancel ?? onCancel,
},
options.hub,
);
openHubs.push(hub);
return { hub, onAssign, onUnassign, onPick, onLoginRequest, onCancel };
return { hub, onAssign, onUnassign, onPick, onLoginRequest, onCancel, onFallbackChainChange };
}
const DOWN = "\x1b[B";
@@ -304,7 +316,8 @@ describe("ModelHub", () => {
hub.handleInput(UP); // All models → Roles (since Recent is removed)
hub.handleInput("\n"); // dive into rows
hub.handleInput(UP); // wraps to the trailing "+ New role…" row
hub.handleInput(UP); // wraps to the trailing "+ New fallback…" row
hub.handleInput(UP); // skips the section divider up to "+ New role…"
hub.handleInput("\n");
expect(footerLine(hub.render(220))).toContain("New role name:");
@@ -317,7 +330,6 @@ describe("ModelHub", () => {
const call = onAssign.mock.calls[0];
expect(call?.[1]).toBe("reviewer");
expect(call?.[3]).toBe("test/reviewer-model");
expect(call?.[4]).toBe("modelRole");
});
});
@@ -340,7 +352,6 @@ describe("ModelHub", () => {
expect(call?.[1]).toBe("default");
expect(call?.[2]).toBe(ThinkingLevel.Inherit);
expect(call?.[3]).toBe("openai/gpt-5.5");
expect(call?.[4]).toBe("modelRole");
// The thinking strip follows immediately, scoped to the model's
// real ladder: gpt-5.5 tops out at xhigh — no invented max tier.
@@ -379,20 +390,24 @@ describe("ModelHub", () => {
expect(footerLine(hub.render(220))).not.toContain("inherit");
});
test("retry-fallback chip fires the retryFallback action without a thinking strip", () => {
test("retry-fallback chip appends the model to the default chain without a thinking strip", () => {
const model = makeModel("test", "retry-fallback-model");
const { hub, onAssign } = createHub({ models: [model], scoped: true });
const { hub, onAssign, onFallbackChainChange } = createHub({ models: [model], scoped: true });
installTestTheme();
hub.handleInput("\n");
hub.handleInput(LEFT); // wraps to the trailing retry-fallback chip
hub.handleInput("\n");
expect(onAssign).toHaveBeenCalledTimes(1);
const call = onAssign.mock.calls[0];
expect(call?.[1]).toBe("default");
expect(call?.[4]).toBe("retryFallback");
expect(onFallbackChainChange).toHaveBeenCalledWith("default", ["test/retry-fallback-model"]);
expect(onAssign).not.toHaveBeenCalled();
expect(footerLine(hub.render(220))).not.toContain("inherit");
// A second registration of the same model is a no-op, not a duplicate.
hub.handleInput("\n");
hub.handleInput(LEFT);
hub.handleInput("\n");
expect(onFallbackChainChange).toHaveBeenCalledTimes(1);
});
test("overflowing role strip scrolls left so the selected chip stays visible", () => {
@@ -417,6 +432,181 @@ describe("ModelHub", () => {
});
});
describe("fallback chains in the roles view", () => {
/** Hop to the Roles sidebar entry and dive into its rows. */
function enterRolesView(hub: ModelHubComponent): void {
hub.handleInput(UP); // All models → Roles
hub.handleInput("\n"); // dive into the rows
}
test("renders configured chain entries as indented rows under their role", () => {
const a = makeModel("test", "model-a");
const b = makeModel("test", "model-b");
const settings = Settings.isolated({
"retry.fallbackChains": { default: ["test/model-a", "test/model-b"] },
});
const { hub } = createHub({ models: [a, b], scoped: true, settings });
enterRolesView(hub);
const rendered = normalize(hub.render(220));
expect(rendered).toContain("↳ test/model-a");
expect(rendered).toContain("↳ test/model-b");
});
test("f on a role opens fallback assignment and Enter appends the picked model", () => {
const a = makeModel("test", "model-a");
const settings = Settings.isolated({});
const { hub, onFallbackChainChange, onAssign } = createHub({ models: [a], scoped: true, settings });
enterRolesView(hub);
hub.handleInput("f"); // add a fallback for the first role (default)
expect(normalize(hub.render(220))).toContain("Adding fallback for");
hub.handleInput("\n"); // pick the only model
expect(onFallbackChainChange).toHaveBeenCalledWith("default", ["test/model-a"]);
expect(onAssign).not.toHaveBeenCalled(); // no role assignment, no thinking strip
expect(normalize(hub.render(220))).toContain("↳ test/model-a");
});
test("x removes a chain entry and Enter on an entry replaces it", () => {
const a = makeModel("test", "model-a");
const b = makeModel("test", "model-b");
const settings = Settings.isolated({
"retry.fallbackChains": { default: ["test/model-a", "test/model-b"] },
});
const { hub, onFallbackChainChange } = createHub({ models: [a, b], scoped: true, settings });
enterRolesView(hub);
hub.handleInput(DOWN); // default → its first chain entry (model-a)
hub.handleInput("\n"); // replace this entry
expect(normalize(hub.render(220))).toContain("Replacing fallback of");
for (const ch of "model-b") hub.handleInput(ch); // search: arrows hop scopes in assign mode
hub.handleInput("\n");
expect(onFallbackChainChange).toHaveBeenLastCalledWith("default", ["test/model-b"]);
hub.handleInput("x"); // cursor landed on the replaced entry — remove it
expect(onFallbackChainChange).toHaveBeenLastCalledWith("default", []);
expect(normalize(hub.render(220))).not.toContain("↳");
});
test("] moves a chain entry later and the cursor follows it", () => {
const a = makeModel("test", "model-a");
const b = makeModel("test", "model-b");
const settings = Settings.isolated({
"retry.fallbackChains": { default: ["test/model-a", "test/model-b"] },
});
const { hub, onFallbackChainChange } = createHub({ models: [a, b], scoped: true, settings });
enterRolesView(hub);
hub.handleInput(DOWN); // first chain entry (model-a)
hub.handleInput("]");
expect(onFallbackChainChange).toHaveBeenLastCalledWith("default", ["test/model-b", "test/model-a"]);
// Cursor followed the moved entry: x removes model-a, not model-b.
hub.handleInput("x");
expect(onFallbackChainChange).toHaveBeenLastCalledWith("default", ["test/model-b"]);
});
test("clicking a roles row hits the row under the pointer", () => {
const a = makeModel("test", "model-a");
const { hub } = createHub({ models: [a], scoped: true });
hub.handleInput(UP); // All models → Roles
// Derive the pointer row from the frame itself: the fullscreen
// overlay paints from screen row 0, so frame index == screen row.
const frame = hub.render(220).map(line => stripVTControlCharacters(line));
const screenRow = frame.findIndex(line => line.includes("DEFAULT"));
expect(screenRow).toBeGreaterThan(0);
const sgr = `\x1b[<0;61;${screenRow + 1}M`; // SGR reports are 1-based
hub.handleInput(sgr); // select (dive into rows)
hub.handleInput(sgr); // click-again activates
expect(normalize(hub.render(220))).toContain("Assigning DEFAULT");
});
test("fallbacks chip keys a new chain by the selected model", () => {
const a = makeModel("test", "model-a");
const b = makeModel("test", "model-b");
const { hub, onFallbackChainChange } = createHub({ models: [a, b], scoped: true });
for (const ch of "model-a") hub.handleInput(ch);
hub.handleInput("\n"); // open the strip for model-a
hub.handleInput(LEFT); // retry-fallback
hub.handleInput(LEFT); // fallbacks:test/*
hub.handleInput(LEFT); // fallbacks:model-a
hub.handleInput("\n");
expect(normalize(hub.render(220))).toContain("Adding fallback for test/model-a");
for (const ch of "model-b") hub.handleInput(ch);
hub.handleInput("\n");
expect(onFallbackChainChange).toHaveBeenLastCalledWith("test/model-a", ["test/model-b"]);
const rendered = normalize(hub.render(220));
expect(rendered).toContain("test/model-a");
expect(rendered).toContain("↳ test/model-b");
});
test("provider chip keys the chain by provider/*", () => {
const a = makeModel("test", "model-a");
const b = makeModel("test", "model-b");
const { hub, onFallbackChainChange } = createHub({ models: [a, b], scoped: true });
for (const ch of "model-a") hub.handleInput(ch);
hub.handleInput("\n");
hub.handleInput(LEFT); // retry-fallback
hub.handleInput(LEFT); // fallbacks:test/*
hub.handleInput("\n");
expect(normalize(hub.render(220))).toContain("Adding fallback for test/*");
for (const ch of "model-b") hub.handleInput(ch);
hub.handleInput("\n");
expect(onFallbackChainChange).toHaveBeenLastCalledWith("test/*", ["test/model-b"]);
});
test("+ New fallback… picks the protected model, then keys the chain via the strip", () => {
const a = makeModel("test", "model-a");
const b = makeModel("test", "model-b");
const { hub, onFallbackChainChange } = createHub({ models: [a, b], scoped: true });
enterRolesView(hub);
hub.handleInput(UP); // wrap to the trailing "+ New fallback…"
hub.handleInput("\n");
expect(normalize(hub.render(220))).toContain("New fallback chain");
for (const ch of "model-a") hub.handleInput(ch);
hub.handleInput("\n"); // pick the protected model
const strip = footerLine(hub.render(220));
expect(strip).toContain("for test/model-a");
expect(strip).toContain("for test/*");
hub.handleInput("\n"); // key by the exact model
expect(normalize(hub.render(220))).toContain("Adding fallback for test/model-a");
for (const ch of "model-b") hub.handleInput(ch);
hub.handleInput("\n");
expect(onFallbackChainChange).toHaveBeenLastCalledWith("test/model-a", ["test/model-b"]);
});
test("model-keyed chains render below the separator and x clears the whole chain", () => {
const a = makeModel("test", "model-a");
const settings = Settings.isolated({
"retry.fallbackChains": { "test/*": ["test/model-a"] },
});
const { hub, onFallbackChainChange } = createHub({ models: [a], scoped: true, settings });
enterRolesView(hub);
const rendered = normalize(hub.render(220));
expect(rendered).toContain("test/*");
expect(rendered).toContain("↳ test/model-a");
expect(rendered).toContain("+ New fallback…");
expect(rendered).toMatch(/─{10,}/); // the roles/fallbacks divider
hub.handleInput(UP); // + New fallback…
hub.handleInput(UP); // ↳ test/model-a
hub.handleInput(UP); // test/* header (separator is skipped)
hub.handleInput("x");
expect(onFallbackChainChange).toHaveBeenLastCalledWith("test/*", []);
expect(normalize(hub.render(220))).not.toContain("↳ test/model-a");
});
});
describe("pick mode", () => {
test("disables models below the current context size and picks the first enabled one", () => {
const small = makeModel("test", "a-small", 4096);