feat(errors): classify provider errors and show a hint the user can act on

- 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
This commit is contained in:
dayuan.jiang
2026-10-04 13:49:34 +09:00
parent 99890e9e37
commit ab1a58f999
12 changed files with 487 additions and 142 deletions
+16 -1
View File
@@ -186,7 +186,22 @@
"failedToLoadExample": "Error loading example image",
"failedToRecordFeedback": "Failed to record your feedback. Please try again.",
"storageUpdateFailed": "Chat cleared but browser storage could not be updated",
"sessionSaveFailed": "Could not save this chat. Browser storage may be full: delete old chats from history and try again."
"sessionSaveFailed": "Could not save this chat. Browser storage may be full: delete old chats from history and try again.",
"llm": {
"invalid_api_key": "The provider rejected the API key. Check it in model settings.",
"forbidden": "The provider refused the request. The key may not have access to this model or region.",
"model_not_found": "The provider does not know this model. Check the model ID in model settings.",
"insufficient_quota": "The provider account has no credit or quota left.",
"rate_limited": "The provider is limiting requests. Wait a moment and try again.",
"context_too_long": "The conversation is too long for this model. Start a new chat or pick a model with a larger context.",
"images_unsupported": "This model doesn't support image input.",
"tools_unsupported": "This model doesn't support tool calls, which drawing needs. Pick another model.",
"output_truncated": "The output was cut off before the diagram was complete. Try a simpler request or raise the output limit in settings.",
"provider_unavailable": "The provider is having problems. Try again later.",
"cannot_connect": "Could not reach the provider. Check the base URL and your network.",
"timeout": "The provider did not answer in time.",
"openModelSettings": "Open model settings"
}
},
"quota": {
"dailyLimit": "Daily Quota Reached",
+16 -1
View File
@@ -186,7 +186,22 @@
"failedToLoadExample": "例の画像の読み込みエラー",
"failedToRecordFeedback": "フィードバックの記録に失敗しました。もう一度お試しください。",
"storageUpdateFailed": "チャットはクリアされましたが、ブラウザストレージを更新できませんでした",
"sessionSaveFailed": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。履歴から古いチャットを削除して、もう一度お試しください。"
"sessionSaveFailed": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。履歴から古いチャットを削除して、もう一度お試しください。",
"llm": {
"invalid_api_key": "プロバイダーが API キーを拒否しました。モデル設定で確認してください。",
"forbidden": "プロバイダーがリクエストを拒否しました。このキーにはこのモデルまたはリージョンの利用権限がない可能性があります。",
"model_not_found": "プロバイダーがこのモデルを認識できません。モデル設定でモデル ID を確認してください。",
"insufficient_quota": "プロバイダーのアカウントの残高または利用枠がなくなりました。",
"rate_limited": "プロバイダーがリクエスト数を制限しています。少し待ってから再試行してください。",
"context_too_long": "会話がこのモデルで扱える長さを超えています。新しいチャットを始めるか、より長いコンテキストに対応したモデルを選んでください。",
"images_unsupported": "このモデルは画像入力に対応していません。",
"tools_unsupported": "このモデルはツール呼び出しに対応していません。作図にはツール呼び出しが必要です。別のモデルを選んでください。",
"output_truncated": "ダイアグラムが完成する前に出力が途中で切れました。リクエストを簡単にするか、設定で出力上限を上げてください。",
"provider_unavailable": "プロバイダーで問題が発生しています。しばらくしてから再試行してください。",
"cannot_connect": "プロバイダーに接続できません。Base URL とネットワークを確認してください。",
"timeout": "プロバイダーから時間内に応答がありませんでした。",
"openModelSettings": "モデル設定を開く"
}
},
"quota": {
"dailyLimit": "1日の割当量に達しました",
+16 -1
View File
@@ -186,7 +186,22 @@
"failedToLoadExample": "載入範例圖片時出錯",
"failedToRecordFeedback": "記錄您的回饋失敗。請重試。",
"storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間",
"sessionSaveFailed": "無法儲存這個對話。瀏覽器儲存空間可能已滿,請在歷史紀錄裡刪除舊對話後重試。"
"sessionSaveFailed": "無法儲存這個對話。瀏覽器儲存空間可能已滿,請在歷史紀錄裡刪除舊對話後重試。",
"llm": {
"invalid_api_key": "服務商拒絕了這個 API Key,請在模型設定中檢查。",
"forbidden": "服務商拒絕了這次請求。這個 Key 可能沒有使用該模型或該地區的權限。",
"model_not_found": "服務商找不到這個模型,請在模型設定中檢查模型 ID。",
"insufficient_quota": "服務商帳戶的餘額或額度已經用完。",
"rate_limited": "服務商正在限制請求頻率,請稍候再試。",
"context_too_long": "對話內容超過了這個模型能處理的長度。請開啟新的對話,或換一個上下文更長的模型。",
"images_unsupported": "這個模型不支援圖片輸入。",
"tools_unsupported": "這個模型不支援工具呼叫,而繪圖需要工具呼叫。請換一個模型。",
"output_truncated": "輸出在圖表完成之前就被截斷了。請簡化請求,或在設定中調高輸出上限。",
"provider_unavailable": "服務商發生問題,請稍後再試。",
"cannot_connect": "無法連線到服務商,請檢查 Base URL 和網路。",
"timeout": "服務商沒有及時回應。",
"openModelSettings": "開啟模型設定"
}
},
"quota": {
"dailyLimit": "已達每日配額",
+16 -1
View File
@@ -186,7 +186,22 @@
"failedToLoadExample": "加载示例图片时出错",
"failedToRecordFeedback": "记录您的反馈失败。请重试。",
"storageUpdateFailed": "聊天已清除,但无法更新浏览器存储",
"sessionSaveFailed": "无法保存这个对话。浏览器存储空间可能已满,请在历史记录里删除旧对话后重试。"
"sessionSaveFailed": "无法保存这个对话。浏览器存储空间可能已满,请在历史记录里删除旧对话后重试。",
"llm": {
"invalid_api_key": "服务商拒绝了这个 API Key,请在模型设置里检查。",
"forbidden": "服务商拒绝了这次请求。这个 Key 可能没有使用该模型或该地区的权限。",
"model_not_found": "服务商找不到这个模型,请在模型设置里检查模型 ID。",
"insufficient_quota": "服务商账户的余额或额度已经用完。",
"rate_limited": "服务商正在限制请求频率,请稍等片刻再试。",
"context_too_long": "对话内容超过了这个模型能处理的长度。请新开一个对话,或换一个上下文更长的模型。",
"images_unsupported": "这个模型不支持图片输入。",
"tools_unsupported": "这个模型不支持工具调用,而画图需要工具调用。请换一个模型。",
"output_truncated": "输出在图画完之前就被截断了。请简化请求,或在设置里调高输出上限。",
"provider_unavailable": "服务商出了问题,请稍后再试。",
"cannot_connect": "连接不上服务商,请检查 Base URL 和网络。",
"timeout": "服务商没有及时响应。",
"openModelSettings": "打开模型设置"
}
},
"quota": {
"dailyLimit": "已达每日配额",
+128
View File
@@ -0,0 +1,128 @@
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 }
}