fix(runtime-fallback): detect type:error message parts for fallback progression
This commit is contained in:
parent
f82e65fdd1
commit
fcaaa11a06
@ -133,6 +133,21 @@ export function extractAutoRetrySignal(info: Record<string, unknown> | undefined
|
|||||||
return 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 {
|
export function isRetryableError(error: unknown, retryOnErrors: number[]): boolean {
|
||||||
const statusCode = extractStatusCode(error, retryOnErrors)
|
const statusCode = extractStatusCode(error, retryOnErrors)
|
||||||
const message = getErrorMessage(error)
|
const message = getErrorMessage(error)
|
||||||
|
|||||||
@ -1616,6 +1616,165 @@ describe("runtime-fallback", () => {
|
|||||||
|
|
||||||
expect(retriedModels).toContain("openai/gpt-5.3-codex")
|
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", () => {
|
describe("edge cases", () => {
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import type { HookDeps } from "./types"
|
|||||||
import type { AutoRetryHelpers } from "./auto-retry"
|
import type { AutoRetryHelpers } from "./auto-retry"
|
||||||
import { HOOK_NAME } from "./constants"
|
import { HOOK_NAME } from "./constants"
|
||||||
import { log } from "../../shared/logger"
|
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 { createFallbackState, prepareFallback } from "./fallback-state"
|
||||||
import { getFallbackModelsForSession } from "./fallback-models"
|
import { getFallbackModelsForSession } from "./fallback-models"
|
||||||
|
|
||||||
@ -60,7 +60,11 @@ export function createMessageUpdateHandler(deps: HookDeps, helpers: AutoRetryHel
|
|||||||
const retrySignalResult = extractAutoRetrySignal(info)
|
const retrySignalResult = extractAutoRetrySignal(info)
|
||||||
const retrySignal = retrySignalResult?.signal
|
const retrySignal = retrySignalResult?.signal
|
||||||
const timeoutEnabled = config.timeout_seconds > 0
|
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 role = info?.role as string | undefined
|
||||||
const model = info?.model as string | undefined
|
const model = info?.model as string | undefined
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user