diff --git a/src/hooks/runtime-fallback/error-classifier.ts b/src/hooks/runtime-fallback/error-classifier.ts index f1cc9609c..f35819b76 100644 --- a/src/hooks/runtime-fallback/error-classifier.ts +++ b/src/hooks/runtime-fallback/error-classifier.ts @@ -133,6 +133,21 @@ export function extractAutoRetrySignal(info: Record | 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) diff --git a/src/hooks/runtime-fallback/index.test.ts b/src/hooks/runtime-fallback/index.test.ts index 4ce288c8b..2e394db6d 100644 --- a/src/hooks/runtime-fallback/index.test.ts +++ b/src/hooks/runtime-fallback/index.test.ts @@ -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", () => { diff --git a/src/hooks/runtime-fallback/message-update-handler.ts b/src/hooks/runtime-fallback/message-update-handler.ts index e826de60d..7e6130955 100644 --- a/src/hooks/runtime-fallback/message-update-handler.ts +++ b/src/hooks/runtime-fallback/message-update-handler.ts @@ -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