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:
dayuan.jiang
2026-10-04 20:15:51 +09:00
parent 6f5f7b668b
commit 6a99915446
5 changed files with 110 additions and 5 deletions
+4
View File
@@ -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.
+5 -1
View File
@@ -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
View File
@@ -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]
+27
View File
@@ -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)
})
+41
View File
@@ -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", () => {