feat(coding-agent): added id-prefixed wildcard support to retry fallback chains

- Implemented parsing of id-prefixed wildcard keys and entries, allowing provider-specific prefixes in retry fallback configuration.
- Added logic to re-prefix failing model IDs and to match id-prefixed keys, with validation of provider existence.
- Updated settings schema description and changelog, and added tests covering the new behavior.
This commit is contained in:
can1357
2026-07-17 03:49:43 +02:00
parent 6e0b9d34f2
commit 1424cae066
4 changed files with 180 additions and 15 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- `retry.fallbackChains` wildcards now support id-prefixed targets and keys: a chain entry like `"openrouter/google/*"` re-prefixes the failing model's bare id (`google-antigravity/gemini-x` → `openrouter/google/gemini-x`), a plain `"provider/*"` entry falling back *from* an aggregator strips the vendor prefix when the target provider only knows the bare id (`openrouter/google/x` → `google-vertex/x`), and an id-prefixed key (`"openrouter/google/*"`) scopes a chain to that provider's ids under the prefix.
## [17.0.1] - 2026-07-16
### Changed
@@ -1439,7 +1439,7 @@ export const SETTINGS_SCHEMA = {
group: "Retry & Fallback",
label: "Retry Fallback Chains",
description:
'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.',
'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. An id-prefixed wildcard ("openrouter/google/*") re-prefixes the failing model\'s bare id (google-antigravity/gemini-x -> openrouter/google/gemini-x) and, used as a key, matches only that provider\'s ids under the prefix.',
},
},
"retry.fallbackRevertPolicy": {
@@ -1229,13 +1229,31 @@ function isRetryFallbackModelKey(key: string): boolean {
}
/**
* A `provider/*` fallback-chain key: matches any active model of that provider,
* so one entry covers every current and future model behind the provider.
* A wildcard fallback-chain key/entry: `provider/*` matches any model of that
* provider; an id-prefixed `provider/prefix/*` (e.g. `openrouter/google/*`)
* scopes it to ids under that prefix — aggregators namespace model ids by
* upstream vendor.
*/
function isRetryFallbackWildcardKey(key: string): boolean {
return key.endsWith("/*");
}
/**
* Split a `…/*` wildcard key/entry into its provider and optional id prefix
* (`google-vertex/*` → provider only; `openrouter/google/*` → provider
* `openrouter`, prefix `google`). A template that names a known provider in
* full wins over the split, so provider ids containing `/` keep working.
*/
function parseRetryFallbackWildcard(
key: string,
isKnownProvider: (provider: string) => boolean,
): { provider: string; idPrefix: string | undefined } {
const template = key.slice(0, -2);
const slash = template.indexOf("/");
if (slash < 0 || isKnownProvider(template)) return { provider: template, idPrefix: undefined };
return { provider: template.slice(0, slash), idPrefix: template.slice(slash + 1) };
}
function formatRetryFallbackSelector(model: Model, thinkingLevel: ThinkingLevel | undefined): string {
return formatModelSelectorValue(formatModelStringWithRouting(model), thinkingLevel);
}
@@ -13851,6 +13869,11 @@ export class AgentSession {
return stopType === "refusal" || stopType === "sensitive";
}
/** True when any registered model belongs to `provider`. */
#hasProviderModels(provider: string): boolean {
return this.#modelRegistry.getAll().some(model => model.provider === provider);
}
#getRetryFallbackChains(): RetryFallbackChains {
const configuredChains = this.settings.get("retry.fallbackChains");
if (!configuredChains || typeof configuredChains !== "object") return {};
@@ -13881,8 +13904,8 @@ export class AgentSession {
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 { provider } = parseRetryFallbackWildcard(key, p => this.#hasProviderModels(p));
if (!this.#hasProviderModels(provider)) {
const msg = `retry.fallbackChains wildcard key references unknown provider: ${key}`;
logger.warn(msg);
this.configWarnings.push(msg);
@@ -13914,8 +13937,8 @@ export class AgentSession {
continue;
}
if (isRetryFallbackWildcardKey(selectorStr)) {
const provider = selectorStr.slice(0, -2);
if (!this.#modelRegistry.getAll().some(model => model.provider === provider)) {
const { provider } = parseRetryFallbackWildcard(selectorStr, p => this.#hasProviderModels(p));
if (!this.#hasProviderModels(provider)) {
const msg = `Fallback chain for ${keyKind} '${key}' references unknown provider: ${selectorStr}`;
logger.warn(msg);
this.configWarnings.push(msg);
@@ -14006,9 +14029,22 @@ export class AgentSession {
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;
// 2. Provider wildcards — an id-prefixed key (`openrouter/google/*`)
// beats the plain `provider/*` key for ids under its prefix.
let wildcardMatch: string | undefined;
let wildcardPrefixLength = -1;
for (const key in chains) {
if (!isRetryFallbackWildcardKey(key) || !Array.isArray(chains[key])) continue;
const { provider, idPrefix } = parseRetryFallbackWildcard(key, p => this.#hasProviderModels(p));
if (provider !== parsedCurrent.provider) continue;
if (idPrefix !== undefined && !parsedCurrent.id.startsWith(`${idPrefix}/`)) continue;
const prefixLength = idPrefix === undefined ? 0 : idPrefix.length;
if (prefixLength > wildcardPrefixLength) {
wildcardMatch = key;
wildcardPrefixLength = prefixLength;
}
}
if (wildcardMatch) return wildcardMatch;
// 3. Role keys — matched by the role's currently-assigned model.
for (const key of roleKeys) {
if (matchesCurrent(this.#getRetryFallbackPrimarySelector(key))) return key;
@@ -14027,9 +14063,11 @@ export class AgentSession {
/**
* 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.
* model's id and swaps the provider (google-antigravity/x → google/x); an
* id-prefixed `provider/prefix/*` entry re-prefixes the failing model's
* bare id instead (openrouter/google/* : google-antigravity/x →
* openrouter/google/x). Ids the target provider lacks are skipped by the
* candidate loop's registry lookup.
*/
#parseRetryFallbackChainEntry(
entry: string,
@@ -14037,8 +14075,23 @@ export class AgentSession {
): 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 };
const { provider, idPrefix } = parseRetryFallbackWildcard(entry, p => this.#hasProviderModels(p));
const bareId = current.id.slice(current.id.lastIndexOf("/") + 1);
let id: string;
if (idPrefix !== undefined) {
id = `${idPrefix}/${bareId}`;
} else if (
bareId !== current.id &&
!this.#modelRegistry.find(provider, current.id) &&
this.#modelRegistry.find(provider, bareId)
) {
// Aggregator → direct: the failing id carries a vendor prefix the
// target provider does not use (openrouter/google/x → google-vertex/x).
id = bareId;
} else {
id = current.id;
}
return { raw: `${provider}/${id}`, provider, id, thinkingLevel: undefined };
}
return parseRetryFallbackSelector(entry, this.#modelRegistry);
}
@@ -631,6 +631,114 @@ describe("AgentSession retry fallback", () => {
]);
});
it("re-prefixes the failing model's bare id for id-prefixed wildcard chain entries", async () => {
const primaryModel = getBundledModel("google", "gemini-2.5-flash");
const fallbackModel = getBundledModel("openrouter", "google/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);
// `openrouter/google/*` splits into provider `openrouter` + id prefix
// `google`: the failing bare id is re-prefixed into the aggregator's
// namespace (google/gemini-2.5-flash -> openrouter/google/gemini-2.5-flash).
const settings = Settings.isolated({
"compaction.enabled": false,
"retry.maxRetries": 1,
"retry.fallbackChains": {
"google/*": ["openrouter/google/*"],
},
});
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-prefixed wildcard entry");
await session.waitForIdle();
expect(requestedModels).toEqual([
`${primaryModel.provider}/${primaryModel.id}`,
`${fallbackModel.provider}/${fallbackModel.id}`,
]);
expect(session.model?.provider).toBe("openrouter");
expect(session.model?.id).toBe(`google/${primaryModel.id}`);
expect(fallbackAppliedEvents).toEqual([
{
type: "retry_fallback_applied",
from: `${primaryModel.provider}/${primaryModel.id}`,
to: `openrouter/google/${primaryModel.id}`,
role: "google/*",
},
]);
});
it("matches id-prefixed wildcard keys and strips the vendor prefix for direct-provider targets", async () => {
const primaryModel = getBundledModel("openrouter", "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);
// Key `openrouter/google/*` covers only openrouter's google-namespaced
// ids; the plain `google-vertex/*` target drops the aggregator's vendor
// prefix because vertex only knows the bare id.
const settings = Settings.isolated({
"compaction.enabled": false,
"retry.maxRetries": 1,
"retry.fallbackChains": {
"openrouter/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-prefixed wildcard key");
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(fallbackModel.id);
expect(fallbackAppliedEvents).toEqual([
{
type: "retry_fallback_applied",
from: `${primaryModel.provider}/${primaryModel.id}`,
to: `google-vertex/${fallbackModel.id}`,
role: "openrouter/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");