mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-06 09:47:49 +08:00
- lib/llm-errors.ts sorts an error into about a dozen kinds (key
rejected, no access, unknown model, no credit, rate limited, context
too long, no image input, no tool calls, output cut off, provider down,
cannot connect, timeout): first texts that name the cause precisely,
then the HTTP status code, then general texts. It unwraps RetryError and
hides keys and Bearer tokens in the provider's message
- The chat route uses it for errors before the stream and, through
toUIMessageStreamResponse's onError, for errors in the stream. Errors
of the model's own tool call stay as they are: the same text goes back
to the model so it can fix the call
- The chat shows the hint in the user's language, then the provider's
message; a rejected key, missing access or unknown model adds an "Open
model settings" button. The Test button shows the same hints
- Fixes: our message "API key is required when using a custom base URL"
was replaced by "Authentication failed" because it contains "key"; a
provider's "Rate limit exceeded" opened this site's quota toast; an
error body like {"error": ...} was shown as raw JSON; the Test button
matched "401" in the message, where providers rarely put it
- Remove the string matching fallbacks in the chat panel
129 lines
4.2 KiB
TypeScript
129 lines
4.2 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",
|
|
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"],
|
|
[/ECONNREFUSED|ENOTFOUND|ECONNRESET|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]",
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
const raw = e instanceof Error ? e.message : String(e)
|
|
const message = redact(raw).slice(0, 500)
|
|
const body = APICallError.isInstance(e) ? (e.responseBody ?? "") : ""
|
|
const text = `${raw} ${body}`
|
|
const status = APICallError.isInstance(e)
|
|
? e.statusCode
|
|
: (e as { statusCode?: number })?.statusCode
|
|
|
|
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 }
|
|
}
|