fix(runtime-fallback): detect type:error message parts for fallback progression

This commit is contained in:
IYODA Atsushi
2026-02-19 12:13:42 +09:00
committed by YeonGyu-Kim
parent f82e65fdd1
commit fcaaa11a06
3 changed files with 180 additions and 2 deletions

View File

@@ -133,6 +133,21 @@ export function extractAutoRetrySignal(info: Record<string, unknown> | undefined
return undefined
}
export function containsErrorContent(
parts: Array<{ type?: string; text?: string }> | undefined
): { hasError: boolean; errorMessage?: string } {
if (!parts || parts.length === 0) return { hasError: false }
const errorParts = parts.filter((p) => p.type === "error")
if (errorParts.length > 0) {
const errorMessages = errorParts.map((p) => p.text).filter((text): text is string => typeof text === "string")
const errorMessage = errorMessages.length > 0 ? errorMessages.join("\n") : undefined
return { hasError: true, errorMessage }
}
return { hasError: false }
}
export function isRetryableError(error: unknown, retryOnErrors: number[]): boolean {
const statusCode = extractStatusCode(error, retryOnErrors)
const message = getErrorMessage(error)

View File

@@ -1616,6 +1616,165 @@ describe("runtime-fallback", () => {
expect(retriedModels).toContain("openai/gpt-5.3-codex")
})
test("triggers fallback when message contains type:error parts (e.g. Minimax insufficient balance)", async () => {
const retriedModels: string[] = []
const hook = createRuntimeFallbackHook(
createMockPluginInput({
session: {
messages: async () => ({
data: [{ info: { role: "user" }, parts: [{ type: "text", text: "test" }] }],
}),
promptAsync: async (args: unknown) => {
const model = (args as { body?: { model?: { providerID?: string; modelID?: string } } })?.body?.model
if (model?.providerID && model?.modelID) {
retriedModels.push(`${model.providerID}/${model.modelID}`)
}
return {}
},
},
}),
{
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.2"]),
}
)
const sessionID = "test-session-error-content"
SessionCategoryRegistry.register(sessionID, "test")
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "minimax/minimax-text-01" } },
},
})
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
sessionID,
role: "assistant",
model: "minimax/minimax-text-01",
},
parts: [{ type: "error", text: "Upstream error from Minimax: insufficient balance (1008)" }],
},
},
})
expect(retriedModels).toContain("openai/gpt-5.2")
})
test("triggers fallback when message has mixed text and error parts", async () => {
const retriedModels: string[] = []
const hook = createRuntimeFallbackHook(
createMockPluginInput({
session: {
messages: async () => ({
data: [{ info: { role: "user" }, parts: [{ type: "text", text: "test" }] }],
}),
promptAsync: async (args: unknown) => {
const model = (args as { body?: { model?: { providerID?: string; modelID?: string } } })?.body?.model
if (model?.providerID && model?.modelID) {
retriedModels.push(`${model.providerID}/${model.modelID}`)
}
return {}
},
},
}),
{
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryFallback(["anthropic/claude-opus-4-6"]),
}
)
const sessionID = "test-session-mixed-content"
SessionCategoryRegistry.register(sessionID, "test")
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
},
})
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
sessionID,
role: "assistant",
model: "google/gemini-2.5-pro",
},
parts: [
{ type: "text", text: "Hello" },
{ type: "error", text: "Rate limit exceeded" },
],
},
},
})
expect(retriedModels).toContain("anthropic/claude-opus-4-6")
})
test("does NOT trigger fallback for normal type:error-free messages", async () => {
const retriedModels: string[] = []
const hook = createRuntimeFallbackHook(
createMockPluginInput({
session: {
messages: async () => ({
data: [
{ info: { role: "user" }, parts: [{ type: "text", text: "test" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Normal response" }] },
],
}),
promptAsync: async (args: unknown) => {
const model = (args as { body?: { model?: { providerID?: string; modelID?: string } } })?.body?.model
if (model?.providerID && model?.modelID) {
retriedModels.push(`${model.providerID}/${model.modelID}`)
}
return {}
},
},
}),
{
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.2"]),
}
)
const sessionID = "test-session-normal-content"
SessionCategoryRegistry.register(sessionID, "test")
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "anthropic/claude-opus-4-5" } },
},
})
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
sessionID,
role: "assistant",
model: "anthropic/claude-opus-4-5",
},
parts: [{ type: "text", text: "Normal response" }],
},
},
})
expect(retriedModels).toHaveLength(0)
})
})
describe("edge cases", () => {

View File

@@ -2,7 +2,7 @@ import type { HookDeps } from "./types"
import type { AutoRetryHelpers } from "./auto-retry"
import { HOOK_NAME } from "./constants"
import { log } from "../../shared/logger"
import { extractStatusCode, extractErrorName, classifyErrorType, isRetryableError, extractAutoRetrySignal } from "./error-classifier"
import { extractStatusCode, extractErrorName, classifyErrorType, isRetryableError, extractAutoRetrySignal, containsErrorContent } from "./error-classifier"
import { createFallbackState, prepareFallback } from "./fallback-state"
import { getFallbackModelsForSession } from "./fallback-models"
@@ -60,7 +60,11 @@ export function createMessageUpdateHandler(deps: HookDeps, helpers: AutoRetryHel
const retrySignalResult = extractAutoRetrySignal(info)
const retrySignal = retrySignalResult?.signal
const timeoutEnabled = config.timeout_seconds > 0
const error = info?.error ?? (retrySignal && timeoutEnabled ? { name: "ProviderRateLimitError", message: retrySignal } : undefined)
const parts = props?.parts as Array<{ type?: string; text?: string }> | undefined
const errorContentResult = containsErrorContent(parts)
const error = info?.error ??
(retrySignal && timeoutEnabled ? { name: "ProviderRateLimitError", message: retrySignal } : undefined) ??
(errorContentResult.hasError ? { name: "MessageContentError", message: errorContentResult.errorMessage || "Message contains error content" } : undefined)
const role = info?.role as string | undefined
const model = info?.model as string | undefined