From cac1d01947a53b9a504ab961ba2f03e848dd7b4a Mon Sep 17 00:00:00 2001 From: Ravi Tharuma Date: Fri, 10 Apr 2026 23:34:38 +0200 Subject: [PATCH 1/3] fix(runtime-fallback): skip equivalent claude aliases Prevent runtime fallback from cycling through provider aliases that resolve to the same underlying Claude family model. This keeps retry handling moving toward a genuinely distinct fallback model instead of appearing to fallback while staying on the same effective model. Constraint: Live retry/fallback bug is in /Users/ravi/Code/personal/oh-my-opencode, while oh-my-openagent contribution work remains isolated to /Users/ravi/Code/forks/oh-my-openagent Rejected: Change fallback chain precedence (category vs agent) first | lower-confidence root cause than equivalent-model retry Confidence: high Scope-risk: narrow Directive: Keep alias-equivalence logic limited to model families that are intentionally interchangeable for runtime failover, and expand with targeted tests before broadening provider-family collapsing Tested: bun run typecheck Tested: bun test src/hooks/runtime-fallback/index.test.ts src/hooks/runtime-fallback/error-classifier.test.ts src/plugin/event.model-fallback.test.ts Not-tested: Full live end-to-end session repro against external provider outages --- src/hooks/runtime-fallback/fallback-state.ts | 59 ++++++++++++++++++++ src/hooks/runtime-fallback/index.test.ts | 18 ++++-- 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/hooks/runtime-fallback/fallback-state.ts b/src/hooks/runtime-fallback/fallback-state.ts index 15348a21d6..b5d66258e3 100644 --- a/src/hooks/runtime-fallback/fallback-state.ts +++ b/src/hooks/runtime-fallback/fallback-state.ts @@ -2,6 +2,56 @@ import type { FallbackState, FallbackResult } from "./types" import { HOOK_NAME } from "./constants" import { log } from "../../shared/logger" import type { RuntimeFallbackConfig } from "../../config" +import { parseModelString } from "../../tools/delegate-task/model-string-parser" + +function canonicalizeModelID(modelID: string): string { + return modelID + .toLowerCase() + .replace(/\./g, "-") + .replace(/-thinking$/i, "") + .replace(/-max$/i, "") + .replace(/-high$/i, "") +} + +function canonicalizeProviderFamily(providerID: string, modelID: string): string { + const canonicalModelID = canonicalizeModelID(modelID) + + if ( + canonicalModelID.startsWith("claude-opus-") || + canonicalModelID.startsWith("claude-sonnet-") || + canonicalModelID.startsWith("claude-haiku-") + ) { + return "anthropic-compatible-claude" + } + + return providerID.toLowerCase() +} + +function parseCanonicalModel(model: string): { providerID: string; modelID: string } | undefined { + const parsed = parseModelString(model) + if (!parsed?.providerID || !parsed.modelID) return undefined + + const canonicalModelID = canonicalizeModelID(parsed.modelID) + + return { + providerID: canonicalizeProviderFamily(parsed.providerID, parsed.modelID), + modelID: canonicalModelID, + } +} + +function isEquivalentModel(candidate: string, current: string): boolean { + const parsedCandidate = parseCanonicalModel(candidate) + const parsedCurrent = parseCanonicalModel(current) + + if (!parsedCandidate || !parsedCurrent) { + return candidate.toLowerCase() === current.toLowerCase() + } + + return ( + parsedCandidate.providerID === parsedCurrent.providerID && + parsedCandidate.modelID === parsedCurrent.modelID + ) +} export function createFallbackState(originalModel: string): FallbackState { return { @@ -28,6 +78,15 @@ export function findNextAvailableFallback( ): string | undefined { for (let i = state.fallbackIndex + 1; i < fallbackModels.length; i++) { const candidate = fallbackModels[i] + if (isEquivalentModel(candidate, state.currentModel)) { + log(`[${HOOK_NAME}] Skipping equivalent fallback model`, { + model: candidate, + currentModel: state.currentModel, + index: i, + }) + continue + } + if (!isModelInCooldown(candidate, state, cooldownSeconds)) { return candidate } diff --git a/src/hooks/runtime-fallback/index.test.ts b/src/hooks/runtime-fallback/index.test.ts index 3956d26c81..e6401f86c0 100644 --- a/src/hooks/runtime-fallback/index.test.ts +++ b/src/hooks/runtime-fallback/index.test.ts @@ -1245,7 +1245,10 @@ describe("runtime-fallback", () => { expect(retriedModels.length).toBeGreaterThanOrEqual(2) expect(retriedModels[0]).toBe("github-copilot/claude-opus-4.6") - expect(retriedModels[1]).toBe("anthropic/claude-opus-4-6") + expect(retriedModels[1]).toBe("openai/gpt-5.4") + + const equivalentSkipLog = logCalls.find((c) => c.msg.includes("Skipping equivalent fallback model")) + expect(equivalentSkipLog).toBeDefined() void sessionErrorPromise }) @@ -1314,7 +1317,7 @@ describe("runtime-fallback", () => { await new Promise((resolve) => setTimeout(resolve, 50)) expect(retriedModels).toContain("github-copilot/claude-opus-4.6") - expect(retriedModels).toContain("anthropic/claude-opus-4-6") + expect(retriedModels).toContain("openai/gpt-5.4") expect(abortCalls.some((call) => call.path?.id === sessionID)).toBe(true) const timeoutLog = logCalls.find((c) => c.msg.includes("Session fallback timeout reached")) @@ -1392,7 +1395,7 @@ describe("runtime-fallback", () => { await new Promise((resolve) => setTimeout(resolve, 50)) expect(retriedModels).toContain("github-copilot/claude-opus-4.6") - expect(retriedModels).toContain("anthropic/claude-opus-4-6") + expect(retriedModels).toContain("openai/gpt-5.4") }) test("should abort in-flight fallback request before advancing on timeout", async () => { @@ -1466,7 +1469,7 @@ describe("runtime-fallback", () => { expect(abortCalls.some((call) => call.path?.id === sessionID)).toBe(true) expect(retriedModels).toContain("github-copilot/claude-opus-4.6") - expect(retriedModels).toContain("anthropic/claude-opus-4-6") + expect(retriedModels).toContain("openai/gpt-5.4") void sessionErrorPromise }) @@ -2385,7 +2388,10 @@ describe("runtime-fallback", () => { }), { config: createMockConfig({ notify_on_fallback: false }), - pluginConfig: createMockPluginConfigWithAgentFallback("prometheus", ["github-copilot/claude-opus-4.6"]), + pluginConfig: createMockPluginConfigWithAgentFallback("prometheus", [ + "github-copilot/claude-opus-4.6", + "openai/gpt-5.4", + ]), }, ) const sessionID = "test-preserve-agent-on-retry" @@ -2405,7 +2411,7 @@ describe("runtime-fallback", () => { expect(promptCalls.length).toBe(1) const callBody = promptCalls[0]?.body as Record expect(callBody?.agent).toBe("prometheus") - expect(callBody?.model).toEqual({ providerID: "github-copilot", modelID: "claude-opus-4.6" }) + expect(callBody?.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" }) }) }) From edb2d7f2b02a8a77bbc702a059da16454baea974 Mon Sep 17 00:00:00 2001 From: Ravi Tharuma Date: Sat, 11 Apr 2026 13:44:20 +0200 Subject: [PATCH 2/3] fix(runtime-fallback): keep non-claude suffixes distinct Limit runtime-fallback equivalence collapsing to Claude-family aliases only so explicit non-Claude suffixes like -high remain meaningful fallback targets. Constraint: Preserve the live Claude alias skip fix while addressing Codex review feedback on PR #3322 Rejected: Keep global -high/-max/-thinking stripping | would collapse distinct non-Claude fallback targets Confidence: high Scope-risk: narrow Directive: Only broaden equivalence collapsing when a provider/model family has explicit tests proving the variants are interchangeable for runtime failover Tested: bun run typecheck Tested: bun test src/hooks/runtime-fallback/index.test.ts src/hooks/runtime-fallback/error-classifier.test.ts src/plugin/event.model-fallback.test.ts Not-tested: Full live outage repro across non-Claude provider suffix variants --- src/hooks/runtime-fallback/fallback-state.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/hooks/runtime-fallback/fallback-state.ts b/src/hooks/runtime-fallback/fallback-state.ts index b5d66258e3..508f26a561 100644 --- a/src/hooks/runtime-fallback/fallback-state.ts +++ b/src/hooks/runtime-fallback/fallback-state.ts @@ -5,12 +5,23 @@ import type { RuntimeFallbackConfig } from "../../config" import { parseModelString } from "../../tools/delegate-task/model-string-parser" function canonicalizeModelID(modelID: string): string { + const loweredModelID = modelID.toLowerCase() + const dottedModelID = loweredModelID.replace(/\./g, "-") + + if ( + dottedModelID.startsWith("claude-opus-") || + dottedModelID.startsWith("claude-sonnet-") || + dottedModelID.startsWith("claude-haiku-") + ) { + return dottedModelID + .replace(/-thinking$/i, "") + .replace(/-max$/i, "") + .replace(/-high$/i, "") + } + return modelID .toLowerCase() .replace(/\./g, "-") - .replace(/-thinking$/i, "") - .replace(/-max$/i, "") - .replace(/-high$/i, "") } function canonicalizeProviderFamily(providerID: string, modelID: string): string { From 669afe22d2282bda0b9f2e380a1f5d1722e400c2 Mon Sep 17 00:00:00 2001 From: Ravi Tharuma Date: Sat, 11 Apr 2026 14:13:34 +0200 Subject: [PATCH 3/3] fix(runtime-fallback): keep variant in equivalence Treat parsed variant as part of runtime-fallback model equivalence so variant-only fallback hops remain distinct while preserving the existing Claude-family alias handling. Constraint: Oracle verification flagged unresolved PR #3322 review concerns about variant equivalence and remote state Rejected: Preserve provider identity in equivalence | contradicted the original live-loop fix for equivalent Claude aliases Confidence: medium Scope-risk: narrow Directive: Any future equivalence broadening must prove both live retry-loop behavior and variant/provider semantics with targeted tests before merging Tested: bun run typecheck Tested: bun test src/hooks/runtime-fallback/index.test.ts src/hooks/runtime-fallback/error-classifier.test.ts src/plugin/event.model-fallback.test.ts Not-tested: Full live end-to-end repro across all provider redundancy policies --- src/hooks/runtime-fallback/fallback-state.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/hooks/runtime-fallback/fallback-state.ts b/src/hooks/runtime-fallback/fallback-state.ts index 508f26a561..a1eb122de5 100644 --- a/src/hooks/runtime-fallback/fallback-state.ts +++ b/src/hooks/runtime-fallback/fallback-state.ts @@ -43,10 +43,11 @@ function parseCanonicalModel(model: string): { providerID: string; modelID: stri if (!parsed?.providerID || !parsed.modelID) return undefined const canonicalModelID = canonicalizeModelID(parsed.modelID) + const variant = parsed.variant?.toLowerCase() return { providerID: canonicalizeProviderFamily(parsed.providerID, parsed.modelID), - modelID: canonicalModelID, + modelID: variant ? `${canonicalModelID}::${variant}` : canonicalModelID, } }