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