fix(runtime-fallback): detect type:error message parts for fallback progression
This commit is contained in:
committed by
YeonGyu-Kim
parent
f82e65fdd1
commit
fcaaa11a06
@@ -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)
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user