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
This commit is contained in:
committed by
YeonGyu-Kim
parent
f54888b2fe
commit
f501c47c49
@@ -2,6 +2,65 @@ import type { FallbackState, FallbackResult } from "./types"
|
|||||||
import { HOOK_NAME } from "./constants"
|
import { HOOK_NAME } from "./constants"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import type { RuntimeFallbackConfig } from "../../config"
|
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 dottedModelID
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
export function createFallbackState(originalModel: string): FallbackState {
|
||||||
return {
|
return {
|
||||||
@@ -28,10 +87,15 @@ export function findNextAvailableFallback(
|
|||||||
): string | undefined {
|
): string | undefined {
|
||||||
for (let i = state.fallbackIndex + 1; i < fallbackModels.length; i++) {
|
for (let i = state.fallbackIndex + 1; i < fallbackModels.length; i++) {
|
||||||
const candidate = fallbackModels[i]
|
const candidate = fallbackModels[i]
|
||||||
if (candidate === state.currentModel) {
|
if (isEquivalentModel(candidate, state.currentModel)) {
|
||||||
log(`[${HOOK_NAME}] Skipping fallback model (same as current)`, { model: candidate, index: i })
|
log(`[${HOOK_NAME}] Skipping equivalent fallback model`, {
|
||||||
|
model: candidate,
|
||||||
|
currentModel: state.currentModel,
|
||||||
|
index: i,
|
||||||
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isModelInCooldown(candidate, state, cooldownSeconds)) {
|
if (!isModelInCooldown(candidate, state, cooldownSeconds)) {
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1305,7 +1305,10 @@ describe("runtime-fallback", () => {
|
|||||||
|
|
||||||
expect(retriedModels.length).toBeGreaterThanOrEqual(2)
|
expect(retriedModels.length).toBeGreaterThanOrEqual(2)
|
||||||
expect(retriedModels[0]).toBe("github-copilot/claude-opus-4.7")
|
expect(retriedModels[0]).toBe("github-copilot/claude-opus-4.7")
|
||||||
expect(retriedModels[1]).toBe("anthropic/claude-opus-4-7")
|
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
|
void sessionErrorPromise
|
||||||
})
|
})
|
||||||
@@ -1374,7 +1377,7 @@ describe("runtime-fallback", () => {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
expect(retriedModels).toContain("github-copilot/claude-opus-4.7")
|
expect(retriedModels).toContain("github-copilot/claude-opus-4.7")
|
||||||
expect(retriedModels).toContain("anthropic/claude-opus-4-7")
|
expect(retriedModels).toContain("openai/gpt-5.4")
|
||||||
expect(abortCalls.some((call) => call.path?.id === sessionID)).toBe(true)
|
expect(abortCalls.some((call) => call.path?.id === sessionID)).toBe(true)
|
||||||
|
|
||||||
const timeoutLog = logCalls.find((c) => c.msg.includes("Session fallback timeout reached"))
|
const timeoutLog = logCalls.find((c) => c.msg.includes("Session fallback timeout reached"))
|
||||||
@@ -1452,7 +1455,7 @@ describe("runtime-fallback", () => {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
expect(retriedModels).toContain("github-copilot/claude-opus-4.7")
|
expect(retriedModels).toContain("github-copilot/claude-opus-4.7")
|
||||||
expect(retriedModels).toContain("anthropic/claude-opus-4-7")
|
expect(retriedModels).toContain("openai/gpt-5.4")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should abort in-flight fallback request before advancing on timeout", async () => {
|
test("should abort in-flight fallback request before advancing on timeout", async () => {
|
||||||
@@ -1526,7 +1529,7 @@ describe("runtime-fallback", () => {
|
|||||||
|
|
||||||
expect(abortCalls.some((call) => call.path?.id === sessionID)).toBe(true)
|
expect(abortCalls.some((call) => call.path?.id === sessionID)).toBe(true)
|
||||||
expect(retriedModels).toContain("github-copilot/claude-opus-4.7")
|
expect(retriedModels).toContain("github-copilot/claude-opus-4.7")
|
||||||
expect(retriedModels).toContain("anthropic/claude-opus-4-7")
|
expect(retriedModels).toContain("openai/gpt-5.4")
|
||||||
|
|
||||||
void sessionErrorPromise
|
void sessionErrorPromise
|
||||||
})
|
})
|
||||||
@@ -2451,7 +2454,10 @@ describe("runtime-fallback", () => {
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
config: createMockConfig({ notify_on_fallback: false }),
|
config: createMockConfig({ notify_on_fallback: false }),
|
||||||
pluginConfig: createMockPluginConfigWithAgentFallback("prometheus", ["github-copilot/claude-opus-4.7"]),
|
pluginConfig: createMockPluginConfigWithAgentFallback("prometheus", [
|
||||||
|
"github-copilot/claude-opus-4.7",
|
||||||
|
"openai/gpt-5.4",
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const sessionID = "test-preserve-agent-on-retry"
|
const sessionID = "test-preserve-agent-on-retry"
|
||||||
@@ -2471,7 +2477,7 @@ describe("runtime-fallback", () => {
|
|||||||
expect(promptCalls.length).toBe(1)
|
expect(promptCalls.length).toBe(1)
|
||||||
const callBody = promptCalls[0]?.body as Record<string, unknown>
|
const callBody = promptCalls[0]?.body as Record<string, unknown>
|
||||||
expect(callBody?.agent).toBe("prometheus")
|
expect(callBody?.agent).toBe("prometheus")
|
||||||
expect(callBody?.model).toEqual({ providerID: "github-copilot", modelID: "claude-opus-4.7" })
|
expect(callBody?.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("should not dispatch a second fallback prompt while the accepted retry session is still active", async () => {
|
test("should not dispatch a second fallback prompt while the accepted retry session is still active", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user