2026-04-18 01:53:03 +09:00
|
|
|
import type { FallbackEntry } from "../../shared/model-requirements"
|
|
|
|
|
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
|
|
|
|
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
|
|
|
|
|
import { log } from "../../shared/logger"
|
|
|
|
|
import { getNextReachableFallback } from "./next-fallback"
|
|
|
|
|
|
|
|
|
|
type ModelFallbackStateLike = {
|
|
|
|
|
providerID: string
|
|
|
|
|
modelID: string
|
|
|
|
|
fallbackChain: FallbackEntry[]
|
|
|
|
|
attemptCount: number
|
|
|
|
|
pending: boolean
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 01:03:19 +09:00
|
|
|
function canonicalizeModelIDForDuplicateCheck(modelID: string): string {
|
|
|
|
|
return modelID.toLowerCase().replace(/\./g, "-")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isSameFailedModel(
|
|
|
|
|
state: ModelFallbackStateLike,
|
|
|
|
|
providerID: string,
|
|
|
|
|
modelID: string,
|
|
|
|
|
): boolean {
|
|
|
|
|
return state.providerID.toLowerCase() === providerID.toLowerCase()
|
|
|
|
|
&& canonicalizeModelIDForDuplicateCheck(state.modelID) === canonicalizeModelIDForDuplicateCheck(modelID)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 01:53:03 +09:00
|
|
|
export type ModelFallbackStateController = {
|
|
|
|
|
lastToastKey: Map<string, string>
|
|
|
|
|
setSessionFallbackChain: (sessionID: string, fallbackChain: FallbackEntry[] | undefined) => void
|
2026-04-28 15:27:34 +09:00
|
|
|
getSessionFallbackChain: (sessionID: string) => FallbackEntry[] | undefined
|
2026-04-18 01:53:03 +09:00
|
|
|
clearSessionFallbackChain: (sessionID: string) => void
|
|
|
|
|
setPendingModelFallback: (
|
|
|
|
|
sessionID: string,
|
|
|
|
|
agentName: string,
|
|
|
|
|
currentProviderID: string,
|
|
|
|
|
currentModelID: string,
|
|
|
|
|
) => boolean
|
|
|
|
|
getNextFallback: (sessionID: string) => ReturnType<typeof getNextReachableFallback>
|
|
|
|
|
clearPendingModelFallback: (sessionID: string) => void
|
|
|
|
|
hasPendingModelFallback: (sessionID: string) => boolean
|
|
|
|
|
getFallbackState: (sessionID: string) => ModelFallbackStateLike | undefined
|
|
|
|
|
reset: () => void
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function createModelFallbackStateController(input: {
|
|
|
|
|
pendingModelFallbacks: Map<string, ModelFallbackStateLike>
|
|
|
|
|
lastToastKey: Map<string, string>
|
|
|
|
|
sessionFallbackChains: Map<string, FallbackEntry[]>
|
|
|
|
|
}): ModelFallbackStateController {
|
|
|
|
|
const { pendingModelFallbacks, lastToastKey, sessionFallbackChains } = input
|
|
|
|
|
|
|
|
|
|
function setSessionFallbackChain(sessionID: string, fallbackChain: FallbackEntry[] | undefined): void {
|
|
|
|
|
if (!sessionID) return
|
2026-04-28 21:43:10 +09:00
|
|
|
sessionFallbackChains.set(sessionID, fallbackChain?.length ? [...fallbackChain] : [])
|
2026-04-18 01:53:03 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearSessionFallbackChain(sessionID: string): void {
|
|
|
|
|
sessionFallbackChains.delete(sessionID)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 15:27:34 +09:00
|
|
|
function getSessionFallbackChain(sessionID: string): FallbackEntry[] | undefined {
|
2026-04-28 21:43:10 +09:00
|
|
|
const fallbackChain = sessionFallbackChains.get(sessionID)
|
|
|
|
|
return fallbackChain ? [...fallbackChain] : undefined
|
2026-04-28 15:27:34 +09:00
|
|
|
}
|
|
|
|
|
|
2026-04-18 01:53:03 +09:00
|
|
|
function setPendingModelFallback(
|
|
|
|
|
sessionID: string,
|
|
|
|
|
agentName: string,
|
|
|
|
|
currentProviderID: string,
|
|
|
|
|
currentModelID: string,
|
|
|
|
|
): boolean {
|
|
|
|
|
const agentKey = getAgentConfigKey(agentName)
|
|
|
|
|
const requirements = AGENT_MODEL_REQUIREMENTS[agentKey]
|
2026-04-18 03:01:51 +09:00
|
|
|
const fallbackChain = sessionFallbackChains.get(sessionID) ?? requirements?.fallbackChain
|
2026-04-18 01:53:03 +09:00
|
|
|
|
|
|
|
|
if (!fallbackChain?.length) {
|
2026-04-28 21:43:10 +09:00
|
|
|
log(`[model-fallback] No fallback chain for agent: ${agentName} (key: ${agentKey})`)
|
2026-04-18 01:53:03 +09:00
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const existing = pendingModelFallbacks.get(sessionID)
|
2026-04-18 03:01:51 +09:00
|
|
|
if (!existing) {
|
|
|
|
|
pendingModelFallbacks.set(sessionID, {
|
|
|
|
|
providerID: currentProviderID,
|
|
|
|
|
modelID: currentModelID,
|
|
|
|
|
fallbackChain,
|
|
|
|
|
attemptCount: 0,
|
|
|
|
|
pending: true,
|
|
|
|
|
})
|
2026-04-28 21:43:10 +09:00
|
|
|
log(`[model-fallback] Set pending fallback for session: ${sessionID}, agent: ${agentName}`)
|
2026-04-18 01:53:03 +09:00
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 03:01:51 +09:00
|
|
|
if (existing.pending) {
|
2026-04-28 21:43:10 +09:00
|
|
|
log(`[model-fallback] Pending fallback already armed for session: ${sessionID}`)
|
2026-04-18 03:01:51 +09:00
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 01:03:19 +09:00
|
|
|
if (existing.attemptCount > 0 && isSameFailedModel(existing, currentProviderID, currentModelID)) {
|
|
|
|
|
log(`[model-fallback] Ignoring duplicate fallback arm for already handled model in session: ${sessionID}`)
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 03:01:51 +09:00
|
|
|
existing.providerID = currentProviderID
|
|
|
|
|
existing.modelID = currentModelID
|
|
|
|
|
existing.pending = true
|
|
|
|
|
if (existing.attemptCount >= existing.fallbackChain.length) {
|
2026-04-28 21:43:10 +09:00
|
|
|
log(`[model-fallback] Fallback chain exhausted for session: ${sessionID}`)
|
2026-04-18 03:01:51 +09:00
|
|
|
return false
|
|
|
|
|
}
|
2026-04-28 21:43:10 +09:00
|
|
|
log(`[model-fallback] Re-armed pending fallback for session: ${sessionID}`)
|
2026-04-18 01:53:03 +09:00
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getNextFallback(sessionID: string): ReturnType<typeof getNextReachableFallback> {
|
|
|
|
|
const state = pendingModelFallbacks.get(sessionID)
|
|
|
|
|
if (!state?.pending) return null
|
|
|
|
|
|
|
|
|
|
const fallback = getNextReachableFallback(sessionID, state)
|
|
|
|
|
if (fallback) return fallback
|
|
|
|
|
|
2026-04-28 21:43:10 +09:00
|
|
|
log(`[model-fallback] No more fallbacks for session: ${sessionID}`)
|
2026-04-18 01:53:03 +09:00
|
|
|
pendingModelFallbacks.delete(sessionID)
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearPendingModelFallback(sessionID: string): void {
|
|
|
|
|
pendingModelFallbacks.delete(sessionID)
|
|
|
|
|
lastToastKey.delete(sessionID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hasPendingModelFallback(sessionID: string): boolean {
|
|
|
|
|
return pendingModelFallbacks.get(sessionID)?.pending === true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getFallbackState(sessionID: string): ModelFallbackStateLike | undefined {
|
|
|
|
|
return pendingModelFallbacks.get(sessionID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function reset(): void {
|
|
|
|
|
pendingModelFallbacks.clear()
|
|
|
|
|
lastToastKey.clear()
|
|
|
|
|
sessionFallbackChains.clear()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
lastToastKey,
|
|
|
|
|
setSessionFallbackChain,
|
2026-04-28 15:27:34 +09:00
|
|
|
getSessionFallbackChain,
|
2026-04-18 01:53:03 +09:00
|
|
|
clearSessionFallbackChain,
|
|
|
|
|
setPendingModelFallback,
|
|
|
|
|
getNextFallback,
|
|
|
|
|
clearPendingModelFallback,
|
|
|
|
|
hasPendingModelFallback,
|
|
|
|
|
getFallbackState,
|
|
|
|
|
reset,
|
|
|
|
|
}
|
|
|
|
|
}
|