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
+17 -48
View File
@@ -4,7 +4,6 @@ import {
createUIMessageStream,
createUIMessageStreamResponse,
InvalidToolInputError,
LoadAPIKeyError,
stepCountIs,
streamText,
} from "ai"
@@ -39,6 +38,7 @@ import {
setTraceOutput,
wrapWithObserve,
} from "@/lib/langfuse"
import { classifyLLMError, isToolCallError } from "@/lib/llm-errors"
import {
resolveMaxOutputTokens,
withOutputTokenLimitFallback,
@@ -720,6 +720,12 @@ Call this tool to get shape names and usage syntax for a specific library.`,
const response = result.toUIMessageStreamResponse({
sendReasoning: true,
// The same text goes back to the model when its tool call was
// invalid, so it can fix it: keep that one as it is
onError: (error) =>
isToolCallError(error)
? (error as Error).message
: JSON.stringify(classifyLLMError(error)),
messageMetadata: ({ part }) => {
if (part.type === "finish") {
const usage = (part as any).totalUsage
@@ -736,61 +742,24 @@ Call this tool to get shape names and usage syntax for a specific library.`,
return response
}
// Helper to categorize errors and return appropriate response
// Errors before the stream starts, as JSON the chat panel reads
function handleError(error: unknown): Response {
console.error("Error in chat route:", error)
const isDev = process.env.NODE_ENV === "development"
// Check for specific AI SDK error types
if (APICallError.isInstance(error)) {
return Response.json(
{
error: error.message,
...(isDev && {
details: error.responseBody,
stack: error.stack,
}),
},
{ status: error.statusCode || 500 },
)
}
if (LoadAPIKeyError.isInstance(error)) {
return Response.json(
{
error: "Authentication failed. Please check your API key.",
...(isDev && {
stack: error.stack,
}),
},
{ status: 401 },
)
}
// Fallback for other errors with safety filter
const message =
error instanceof Error ? error.message : "An unexpected error occurred"
const status = (error as any)?.statusCode || (error as any)?.status || 500
// Prevent leaking API keys, tokens, or other sensitive data
const lowerMessage = message.toLowerCase()
const safeMessage =
lowerMessage.includes("key") ||
lowerMessage.includes("token") ||
lowerMessage.includes("sig") ||
lowerMessage.includes("signature") ||
lowerMessage.includes("secret") ||
lowerMessage.includes("password") ||
lowerMessage.includes("credential")
? "Authentication failed. Please check your credentials."
: message
const classified = classifyLLMError(error)
const status =
(error as { statusCode?: number })?.statusCode ||
(error as { status?: number })?.status ||
(classified.code === "invalid_api_key" ? 401 : 500)
return Response.json(
{
error: safeMessage,
...classified,
...(isDev && {
details: message,
details: APICallError.isInstance(error)
? error.responseBody
: undefined,
stack: error instanceof Error ? error.stack : undefined,
}),
},
+3 -28
View File
@@ -3,6 +3,7 @@ import { NextResponse } from "next/server"
import { z } from "zod"
import { checkAccessCode } from "@/lib/access-code"
import { getAIModel } from "@/lib/ai-providers"
import { classifyLLMError } from "@/lib/llm-errors"
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
export const runtime = "nodejs"
@@ -146,35 +147,9 @@ export async function POST(req: Request) {
} catch (error) {
console.error("[validate-model] Error:", error)
let errorMessage = "Validation failed"
if (error instanceof Error) {
// Extract meaningful error message
if (error.name === "TimeoutError") {
errorMessage = `No answer within ${TEST_TIMEOUT_MS / 1000} seconds`
} else if (
error.message.includes("401") ||
error.message.includes("Unauthorized")
) {
errorMessage = "Invalid API key"
} else if (
error.message.includes("404") ||
error.message.includes("not found")
) {
errorMessage = "Model not found"
} else if (
error.message.includes("429") ||
error.message.includes("rate limit")
) {
errorMessage = "Rate limited - try again later"
} else if (error.message.includes("ECONNREFUSED")) {
errorMessage = "Cannot connect to server"
} else {
errorMessage = error.message.slice(0, 100)
}
}
const { code, message } = classifyLLMError(error)
return NextResponse.json(
{ valid: false, error: errorMessage },
{ valid: false, code, error: message },
{ status: 200 }, // Return 200 so client can read error message
)
}