mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-05 17:27:48 +08:00
- An error object sent inside the stream (OpenRouter's { code, message })
showed as "[object Object]"; its message and status code are read now.
- A problem+json "detail" is added to the message: NVIDIA only said
"Gone" for a retired model. 410 counts as model not found.
- "Cannot connect to API" from the SDK gets the connection hint.
- Text that is only whitespace (Kimi K2.6 sends a space before a tool
call) no longer shows an empty bubble.
- allowSystemInMessages stops the warning on every request. Our system
messages carry cache points; a client's own system messages are already
dropped by the empty-content filter.
158 lines
5.1 KiB
TypeScript
158 lines
5.1 KiB
TypeScript
import {
|
|
APICallError,
|
|
InvalidToolInputError,
|
|
LoadAPIKeyError,
|
|
NoSuchToolError,
|
|
RetryError,
|
|
ToolCallRepairError,
|
|
} from "ai"
|
|
|
|
/**
|
|
* What went wrong with a model call, for a hint the user can act on. The
|
|
* provider's own message always goes along, because a guess can be wrong.
|
|
*/
|
|
export type LLMErrorCode =
|
|
| "invalid_api_key"
|
|
| "forbidden"
|
|
| "model_not_found"
|
|
| "insufficient_quota"
|
|
| "rate_limited"
|
|
| "context_too_long"
|
|
| "images_unsupported"
|
|
| "tools_unsupported"
|
|
| "output_truncated"
|
|
| "provider_unavailable"
|
|
| "cannot_connect"
|
|
| "timeout"
|
|
| "unknown"
|
|
|
|
export interface LLMError {
|
|
type: "provider"
|
|
code: LLMErrorCode
|
|
message: string
|
|
}
|
|
|
|
// Texts that name the cause more precisely than the status code: a quota
|
|
// error can come as 403 or 429, a context or image error as a plain 400
|
|
const SPECIFIC_TEXTS: Array<[RegExp, LLMErrorCode]> = [
|
|
[
|
|
/context length|context window|maximum context|prompt is too long|input is too long|too many (?:input )?tokens/i,
|
|
"context_too_long",
|
|
],
|
|
[
|
|
/image content block|image_url|does not support image|image input is not supported/i,
|
|
"images_unsupported",
|
|
],
|
|
[
|
|
/does not support tools|tool use is not supported|tools? (?:are|is) not supported|function calling is not supported/i,
|
|
"tools_unsupported",
|
|
],
|
|
// Bedrock, when the output limit cut the tool call's JSON short
|
|
[/toolUse\.input is invalid/i, "output_truncated"],
|
|
[
|
|
/insufficient[_ ]quota|insufficient balance|exceeded your current quota|credit balance is too low|余额不足/i,
|
|
"insufficient_quota",
|
|
],
|
|
]
|
|
|
|
const STATUS_CODES: Record<number, LLMErrorCode> = {
|
|
401: "invalid_api_key",
|
|
402: "insufficient_quota",
|
|
// Not "invalid key": a valid key can lack access to a model or region
|
|
403: "forbidden",
|
|
404: "model_not_found",
|
|
408: "timeout",
|
|
// A retired model
|
|
410: "model_not_found",
|
|
413: "context_too_long",
|
|
429: "rate_limited",
|
|
}
|
|
|
|
const GENERAL_TEXTS: Array<[RegExp, LLMErrorCode]> = [
|
|
[
|
|
/model[_ ]not[_ ]found|model .*does not exist|unknown model|no such model/i,
|
|
"model_not_found",
|
|
],
|
|
[
|
|
/invalid[_ ]api[_ ]key|incorrect api key|unauthorized/i,
|
|
"invalid_api_key",
|
|
],
|
|
[/rate limit|too many requests/i, "rate_limited"],
|
|
[
|
|
/Cannot connect to API|ECONNREFUSED|ENOTFOUND|ECONNRESET|ETIMEDOUT|fetch failed/i,
|
|
"cannot_connect",
|
|
],
|
|
]
|
|
|
|
/** Secrets a provider may echo back: API keys, Bearer tokens, key=value */
|
|
function redact(text: string): string {
|
|
return text
|
|
.replace(/\b(sk|pk|rk|ak)-[A-Za-z0-9_-]{8,}/g, "$1-[redacted]")
|
|
.replace(/\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi, "Bearer [redacted]")
|
|
.replace(/\bAKIA[0-9A-Z]{16}\b/g, "[redacted]")
|
|
.replace(
|
|
/\b(api[_-]?key|access[_-]?key|secret|token|password|signature)(["']?\s*[:=]\s*["']?)[^\s"',&}]+/gi,
|
|
"$1$2[redacted]",
|
|
)
|
|
}
|
|
|
|
function problemDetail(body: string): string | undefined {
|
|
try {
|
|
const detail = JSON.parse(body)?.detail
|
|
return typeof detail === "string" ? detail : undefined
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Model and tool errors the SDK sends back to the model as the tool result,
|
|
* so it can fix its call. Their text has to stay as it is.
|
|
*/
|
|
export function isToolCallError(error: unknown): boolean {
|
|
return (
|
|
InvalidToolInputError.isInstance(error) ||
|
|
NoSuchToolError.isInstance(error) ||
|
|
ToolCallRepairError.isInstance(error)
|
|
)
|
|
}
|
|
|
|
export function classifyLLMError(error: unknown): LLMError {
|
|
// After the SDK's retries, the last attempt says what happened
|
|
const e = RetryError.isInstance(error) ? error.lastError : error
|
|
// Errors sent inside the stream can be plain objects like OpenRouter's
|
|
// { code: 503, message }
|
|
const plain = e as {
|
|
message?: unknown
|
|
code?: unknown
|
|
statusCode?: number
|
|
}
|
|
const raw =
|
|
e instanceof Error
|
|
? e.message
|
|
: typeof plain?.message === "string"
|
|
? plain.message
|
|
: String(e)
|
|
const body = APICallError.isInstance(e) ? (e.responseBody ?? "") : ""
|
|
// A problem+json body names the reason the SDK left out (NVIDIA: "Gone")
|
|
const detail = problemDetail(body)
|
|
const message = redact(detail ? `${raw}: ${detail}` : raw).slice(0, 500)
|
|
const text = `${raw} ${body}`
|
|
const status = APICallError.isInstance(e)
|
|
? e.statusCode
|
|
: (plain?.statusCode ??
|
|
(typeof plain?.code === "number" ? plain.code : undefined))
|
|
|
|
const find = (rules: Array<[RegExp, LLMErrorCode]>) =>
|
|
rules.find(([pattern]) => pattern.test(text))?.[1]
|
|
const code =
|
|
(e instanceof Error && e.name === "TimeoutError" && "timeout") ||
|
|
(LoadAPIKeyError.isInstance(e) && "invalid_api_key") ||
|
|
find(SPECIFIC_TEXTS) ||
|
|
(status && STATUS_CODES[status]) ||
|
|
(status && status >= 500 && "provider_unavailable") ||
|
|
find(GENERAL_TEXTS) ||
|
|
"unknown"
|
|
return { type: "provider", code, message }
|
|
}
|