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:
@@ -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
@@ -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,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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user