mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-11 12:09:53 +08:00
fix(chat): clearer provider errors and no empty bubble, found with real models
- 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.
This commit is contained in:
@@ -524,6 +524,10 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
||||
|
||||
const result = streamText({
|
||||
model,
|
||||
// The system messages carry cache points, so they go in messages.
|
||||
// A client's own system messages have string content and were
|
||||
// dropped by the empty-content filter above.
|
||||
allowSystemInMessages: true,
|
||||
abortSignal: req.signal,
|
||||
// Must be sent: unset means the provider's own default, and Bedrock's is
|
||||
// 4096, enough for a small diagram, so larger ones were cut off mid-attribute.
|
||||
|
||||
@@ -845,8 +845,12 @@ export function ChatMessageDisplay({
|
||||
part.type?.startsWith(
|
||||
"tool-",
|
||||
)
|
||||
// Blank text (some models send
|
||||
// a lone space) gets no bubble
|
||||
const isContentPart =
|
||||
part.type === "text" ||
|
||||
(part.type === "text" &&
|
||||
part.text.trim() !==
|
||||
"") ||
|
||||
part.type === "file"
|
||||
|
||||
if (isToolPart) {
|
||||
|
||||
+33
-4
@@ -62,6 +62,8 @@ const STATUS_CODES: Record<number, LLMErrorCode> = {
|
||||
403: "forbidden",
|
||||
404: "model_not_found",
|
||||
408: "timeout",
|
||||
// A retired model
|
||||
410: "model_not_found",
|
||||
413: "context_too_long",
|
||||
429: "rate_limited",
|
||||
}
|
||||
@@ -76,7 +78,10 @@ const GENERAL_TEXTS: Array<[RegExp, LLMErrorCode]> = [
|
||||
"invalid_api_key",
|
||||
],
|
||||
[/rate limit|too many requests/i, "rate_limited"],
|
||||
[/ECONNREFUSED|ENOTFOUND|ECONNRESET|fetch failed/i, "cannot_connect"],
|
||||
[
|
||||
/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 */
|
||||
@@ -91,6 +96,15 @@ function redact(text: string): string {
|
||||
)
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -106,13 +120,28 @@ export function isToolCallError(error: unknown): boolean {
|
||||
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)
|
||||
// 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
|
||||
: (e as { statusCode?: number })?.statusCode
|
||||
: (plain?.statusCode ??
|
||||
(typeof plain?.code === "number" ? plain.code : undefined))
|
||||
|
||||
const find = (rules: Array<[RegExp, LLMErrorCode]>) =>
|
||||
rules.find(([pattern]) => pattern.test(text))?.[1]
|
||||
|
||||
@@ -136,3 +136,30 @@ test("edit_diagram applies all operations or none", async ({ page: p }) => {
|
||||
await expect(canvas.getByText("Gamma", { exact: true })).toBeVisible()
|
||||
await expect(canvas.getByText("Broken", { exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("blank text before a tool call shows no empty bubble", async ({
|
||||
page: p,
|
||||
}) => {
|
||||
// Kimi K2.6 sends a lone space before calling the tool
|
||||
const blankText = [
|
||||
{ type: "text-start", id: "t1" },
|
||||
{ type: "text-delta", id: "t1", delta: " " },
|
||||
{ type: "text-end", id: "t1" },
|
||||
]
|
||||
.map((e) => `data: ${JSON.stringify(e)}\n\n`)
|
||||
.join("")
|
||||
const reply = streamedToolCall("display_diagram", {
|
||||
xml: cell("2", "Alpha", 40),
|
||||
}).replace(
|
||||
'data: {"type":"tool-input-start"',
|
||||
`${blankText}data: {"type":"tool-input-start"`,
|
||||
)
|
||||
const canvas = await mockReplies(p, [reply])
|
||||
await sendMessage(p, "Draw a box")
|
||||
await waitForCompleteCount(p, 1)
|
||||
await expect(canvas.getByText("Alpha", { exact: true })).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
// Assistant text bubbles have this background
|
||||
await expect(p.locator("div.rounded-2xl.bg-muted\\/60")).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -88,6 +88,47 @@ describe("classifyLLMError", () => {
|
||||
timeout.name = "TimeoutError"
|
||||
expect(classifyLLMError(timeout).code).toBe("timeout")
|
||||
})
|
||||
|
||||
it("names a network error the SDK wrapped", () => {
|
||||
const error = new APICallError({
|
||||
message:
|
||||
"Cannot connect to API: Connect Timeout Error (attempted address: api.example.com:443, timeout: 10000ms)",
|
||||
url: "https://api.example.com/v1/chat/completions",
|
||||
requestBodyValues: {},
|
||||
})
|
||||
expect(classifyLLMError(error).code).toBe("cannot_connect")
|
||||
})
|
||||
|
||||
it("reads an error object sent in the stream", () => {
|
||||
// OpenRouter, when the upstream provider is overloaded
|
||||
const error = {
|
||||
code: 503,
|
||||
message:
|
||||
"Upstream error from Nvidia: Service temporarily overloaded",
|
||||
metadata: { error_type: "provider_overloaded" },
|
||||
}
|
||||
expect(classifyLLMError(error)).toEqual({
|
||||
type: "provider",
|
||||
code: "provider_unavailable",
|
||||
message:
|
||||
"Upstream error from Nvidia: Service temporarily overloaded",
|
||||
})
|
||||
})
|
||||
|
||||
it("adds the reason from a problem+json body", () => {
|
||||
// NVIDIA, for a retired model; the SDK's message is only "Gone"
|
||||
const body = JSON.stringify({
|
||||
title: "Gone",
|
||||
status: 410,
|
||||
detail: "The model 'deepseek-v4-flash' has reached its end of life",
|
||||
})
|
||||
expect(classifyLLMError(apiError(410, "Gone", body))).toEqual({
|
||||
type: "provider",
|
||||
code: "model_not_found",
|
||||
message:
|
||||
"Gone: The model 'deepseek-v4-flash' has reached its end of life",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("isToolCallError", () => {
|
||||
|
||||
Reference in New Issue
Block a user