diff --git a/packages/coding-agent/src/config/model-resolver.ts b/packages/coding-agent/src/config/model-resolver.ts index 70dca51a3..1a596de30 100644 --- a/packages/coding-agent/src/config/model-resolver.ts +++ b/packages/coding-agent/src/config/model-resolver.ts @@ -92,6 +92,33 @@ export function formatModelString(model: Model): string { return `${model.provider}/${model.id}`; } +function getSingleRoutingOnly(routing: unknown): string | undefined { + if (!routing || typeof routing !== "object" || !("only" in routing) || !Array.isArray(routing.only)) { + return undefined; + } + if (routing.only.length !== 1) return undefined; + const upstream = routing.only[0]; + return typeof upstream === "string" && upstream ? upstream : undefined; +} + +function getSingleUpstreamRoute(model: Model): string | undefined { + const compat = model.compat; + if (!compat || typeof compat !== "object") return undefined; + if (modelMatchesHost(model, "vercelAIGateway") && "vercelGatewayRouting" in compat) { + return getSingleRoutingOnly(compat.vercelGatewayRouting); + } + if (modelMatchesHost(model, "openrouter") && "openRouterRouting" in compat) { + return getSingleRoutingOnly(compat.openRouterRouting); + } + return undefined; +} + +export function formatModelStringWithRouting(model: Model): string { + const selector = formatModelString(model); + const upstream = getSingleUpstreamRoute(model); + return upstream ? `${selector}@${upstream}` : selector; +} + export function formatModelSelectorValue(selector: string, thinkingLevel: ThinkingLevel | undefined): string { return thinkingLevel && thinkingLevel !== ThinkingLevel.Inherit ? `${selector}:${thinkingLevel}` : selector; } @@ -161,7 +188,7 @@ const UPSTREAM_ROUTING_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i; * `@` or the suffix is not a bare provider slug, so model ids that legitimately * contain `@` (`claude-opus-4-8@default`, `workers-ai/@cf/...`) are never split. */ -function splitUpstreamRouting(pattern: string): { base: string; upstream: string } | undefined { +export function splitUpstreamRouting(pattern: string): { base: string; upstream: string } | undefined { const at = pattern.lastIndexOf("@"); if (at <= 0) return undefined; const rest = pattern.slice(at + 1); diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index a5f30688c..12b4cb13f 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -139,11 +139,14 @@ import { filterAvailableModelsByEnabledPatterns, formatModelSelectorValue, formatModelString, + formatModelStringWithRouting, getModelMatchPreferences, parseModelString, type ResolvedModelRoleValue, + resolveModelOverride, resolveModelRoleValue, resolveRoleSelection, + splitUpstreamRouting, } from "../config/model-resolver"; import { MODEL_ROLE_IDS } from "../config/model-roles"; import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates"; @@ -615,6 +618,7 @@ interface RetryFallbackSelector { raw: string; provider: string; id: string; + upstream: string | undefined; thinkingLevel: ThinkingLevel | undefined; } @@ -629,19 +633,20 @@ interface ActiveRetryFallbackState { function parseRetryFallbackSelector(selector: string): RetryFallbackSelector | undefined { const trimmed = selector.trim(); if (!trimmed) return undefined; - const parsed = parseModelString(trimmed); + const routing = splitUpstreamRouting(trimmed); + const parsed = parseModelString(routing?.base ?? trimmed); if (!parsed) return undefined; return { raw: trimmed, provider: parsed.provider, id: parsed.id, + upstream: routing?.upstream, thinkingLevel: parsed.thinkingLevel, }; } function formatRetryFallbackSelector(model: Model, thinkingLevel: ThinkingLevel | undefined): string { - const selector = formatModelString(model); - return thinkingLevel ? `${selector}:${thinkingLevel}` : selector; + return formatModelSelectorValue(formatModelStringWithRouting(model), thinkingLevel); } function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): string { @@ -9338,7 +9343,8 @@ export class AgentSession { currentSelector: string, options?: { pinFallback?: boolean }, ): Promise { - const candidate = this.#modelRegistry.find(selector.provider, selector.id); + const resolved = resolveModelOverride([selector.raw], this.#modelRegistry, this.settings); + const candidate = resolved.model ?? this.#modelRegistry.find(selector.provider, selector.id); if (!candidate) { throw new Error(`Retry fallback model not found: ${selector.raw}`); } diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 6d04e0819..27f3b1a9c 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -12,7 +12,8 @@ import { logger, popLoopPhase, prompt, pushLoopPhase, untilAborted } from "@oh-m import type { Rule } from "../capability/rule"; import { ModelRegistry } from "../config/model-registry"; import { - formatModelString, + formatModelSelectorValue, + formatModelStringWithRouting, resolveModelOverride, resolveModelOverrideWithAuthFallback, } from "../config/model-resolver"; @@ -131,11 +132,6 @@ interface SubagentRetryFallbackCandidate { selector: string; } -function formatSubagentRetryFallbackSelector(model: Model, thinkingLevel: ThinkingLevel | undefined): string { - const selector = formatModelString(model); - return thinkingLevel ? `${selector}:${thinkingLevel}` : selector; -} - function resolveSubagentRetryFallbackCandidates( modelPatterns: string[], modelRegistry: ModelRegistry, @@ -146,10 +142,9 @@ function resolveSubagentRetryFallbackCandidates( for (const pattern of modelPatterns) { const resolved = resolveModelOverride([pattern], modelRegistry, settings); if (!resolved.model) continue; - const selector = formatSubagentRetryFallbackSelector( - resolved.model, - resolved.explicitThinkingLevel ? resolved.thinkingLevel : undefined, - ); + const selector = resolved.explicitThinkingLevel + ? formatModelSelectorValue(formatModelStringWithRouting(resolved.model), resolved.thinkingLevel) + : formatModelStringWithRouting(resolved.model); if (seen.has(selector)) continue; seen.add(selector); candidates.push({ model: resolved.model, selector }); @@ -1923,8 +1918,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise { id, name: id, api: "openai-completions", - baseUrl: `https://${provider}.example.test`, + baseUrl: provider === "openrouter" ? "https://openrouter.ai/api/v1" : `https://${provider}.example.test`, reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, @@ -119,4 +119,33 @@ describe("issue #2750: subagent runtime model fallback", () => { expect(result.modelOverride).toEqual(["primary/bad-runtime-model", "fallback/working-model"]); expect(result.resolvedModel).toBe("fallback/working-model"); }); + + it("preserves upstream routing selectors in the child retry fallback chain", async () => { + const routedModel = model("openrouter", "z-ai/glm-4.7"); + let childFallbackChains: Record | undefined; + vi.spyOn(sdkModule, "createAgentSession").mockImplementation(async options => { + if (!options) throw new Error("Expected createAgentSession options"); + childFallbackChains = options.settings?.get("retry.fallbackChains") as Record | undefined; + return { session: createYieldingSession(), extensionsResult: {}, setToolUIContext: () => {} } as never; + }); + + const agent: AgentDefinition = { name: "task", description: "test", systemPrompt: "test", source: "bundled" }; + await runSubprocess({ + cwd: "/tmp", + agent, + task: "work", + index: 0, + id: "issue-2750-routed", + modelOverride: ["openrouter/z-ai/glm-4.7@cerebras", "openrouter/z-ai/glm-4.7@fireworks"], + settings: Settings.isolated(), + modelRegistry: { + refresh: async () => {}, + getAvailable: () => [routedModel], + getApiKey: async () => "test-key", + } as never, + enableLsp: false, + }); + + expect(childFallbackChains?.["subagent:issue-2750-routed"]).toEqual(["openrouter/z-ai/glm-4.7@fireworks"]); + }); });