From 6a99915446810dad9808fec8328588b21e1b6bdb Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sun, 4 Oct 2026 20:15:51 +0900 Subject: [PATCH] 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. --- app/api/chat/route.ts | 4 +++ components/chat-message-display.tsx | 6 ++++- lib/llm-errors.ts | 37 +++++++++++++++++++++++--- tests/e2e/diagram-content.spec.ts | 27 +++++++++++++++++++ tests/unit/llm-errors.test.ts | 41 +++++++++++++++++++++++++++++ 5 files changed, 110 insertions(+), 5 deletions(-) diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 8d743ed6..e11b789e 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -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. diff --git a/components/chat-message-display.tsx b/components/chat-message-display.tsx index ef120afe..d86abadb 100644 --- a/components/chat-message-display.tsx +++ b/components/chat-message-display.tsx @@ -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) { diff --git a/lib/llm-errors.ts b/lib/llm-errors.ts index 1cc15118..fb87d809 100644 --- a/lib/llm-errors.ts +++ b/lib/llm-errors.ts @@ -62,6 +62,8 @@ const STATUS_CODES: Record = { 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] diff --git a/tests/e2e/diagram-content.spec.ts b/tests/e2e/diagram-content.spec.ts index 6e1cb457..f5368b7c 100644 --- a/tests/e2e/diagram-content.spec.ts +++ b/tests/e2e/diagram-content.spec.ts @@ -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) +}) diff --git a/tests/unit/llm-errors.test.ts b/tests/unit/llm-errors.test.ts index a0326922..65903cf9 100644 --- a/tests/unit/llm-errors.test.ts +++ b/tests/unit/llm-errors.test.ts @@ -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", () => {