From 8d8118eeb2ffef4494118ea37a565d4f17056cae Mon Sep 17 00:00:00 2001 From: xxashxx-svg Date: Thu, 9 Jul 2026 19:17:08 +0530 Subject: [PATCH] fix(chat): unwrap raw JSON provider error payloads into readable messages Codex surfaces upstream error bodies verbatim as the error message string, so the chat error banner rendered raw JSON like {"type":"error","status":400,"error":{...}} and the unbroken token overflowed its container. - add extractProviderErrorMessage to @t3tools/shared that pulls the nested human-readable message out of JSON-shaped error strings (no-op otherwise) - apply it where codex errors become session.lastError / runtime.error - unwrap defensively in ThreadErrorBanner for already-persisted errors and add break-words so long unbreakable strings wrap instead of clipping Fixes #3747 Co-Authored-By: Claude Fable 5 --- .../src/provider/Layers/CodexAdapter.ts | 8 +++- .../provider/Layers/CodexSessionRuntime.ts | 5 +- .../src/components/chat/ThreadErrorBanner.tsx | 12 +++-- packages/shared/package.json | 4 ++ packages/shared/src/providerError.test.ts | 48 +++++++++++++++++++ packages/shared/src/providerError.ts | 36 ++++++++++++++ 6 files changed, 105 insertions(+), 8 deletions(-) create mode 100644 packages/shared/src/providerError.test.ts create mode 100644 packages/shared/src/providerError.ts diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 270126e934b..4501cd5fefe 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -38,6 +38,7 @@ import * as CodexErrors from "effect-codex-app-server/errors"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { extractProviderErrorMessage } from "@t3tools/shared/providerError"; import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; @@ -764,7 +765,8 @@ function mapToRuntimeEvents( if (!payload) { return []; } - const errorMessage = trimText(payload.turn.error?.message); + const rawErrorMessage = trimText(payload.turn.error?.message); + const errorMessage = rawErrorMessage ? extractProviderErrorMessage(rawErrorMessage) : undefined; return [ { ...runtimeEventBase(event, canonicalThreadId), @@ -1241,7 +1243,9 @@ function mapToRuntimeEvents( if (event.method === "error") { const payload = readPayload(EffectCodexSchema.V2ErrorNotification, event.payload); - const message = payload?.error.message ?? event.message ?? "Provider runtime error"; + const message = extractProviderErrorMessage( + payload?.error.message ?? event.message ?? "Provider runtime error", + ); const willRetry = payload?.willRetry === true; return [ { diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 99ac498f0c3..62541f5cae0 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -18,6 +18,7 @@ import { } from "@t3tools/contracts"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { normalizeModelSlug } from "@t3tools/shared/model"; +import { extractProviderErrorMessage } from "@t3tools/shared/providerError"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -921,7 +922,7 @@ export const makeCodexSessionRuntime = ( } const lastError = payload.turn.status === "failed" && "error" in payload.turn && payload.turn.error - ? payload.turn.error.message + ? extractProviderErrorMessage(payload.turn.error.message) : undefined; return updateSession(sessionRef, { status: payload.turn.status === "failed" ? "error" : "ready", @@ -939,7 +940,7 @@ export const makeCodexSessionRuntime = ( if (providerThreadId && payloadThreadId && payloadThreadId !== providerThreadId) { return Effect.void; } - const errorMessage = payload.error.message; + const errorMessage = extractProviderErrorMessage(payload.error.message); const willRetry = payload.willRetry; return updateSession(sessionRef, { status: willRetry ? "running" : "error", diff --git a/apps/web/src/components/chat/ThreadErrorBanner.tsx b/apps/web/src/components/chat/ThreadErrorBanner.tsx index d0b3ac80b8a..560bcea9c97 100644 --- a/apps/web/src/components/chat/ThreadErrorBanner.tsx +++ b/apps/web/src/components/chat/ThreadErrorBanner.tsx @@ -1,4 +1,5 @@ import { memo } from "react"; +import { extractProviderErrorMessage } from "@t3tools/shared/providerError"; import { Alert, AlertAction, AlertDescription } from "../ui/alert"; import { Button } from "../ui/button"; import { CircleAlertIcon, XIcon } from "lucide-react"; @@ -12,16 +13,19 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({ onDismiss?: () => void; }) { if (!error) return null; + // errors persisted before the server started unwrapping provider payloads + // can still be raw JSON, so unwrap here too + const message = extractProviderErrorMessage(error); return (
- }> - {error} + }> + {message} - - {error} + + {message} {onDismiss && ( diff --git a/packages/shared/package.json b/packages/shared/package.json index e08844cbfae..fce4308530e 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -182,6 +182,10 @@ "./httpReadiness": { "types": "./src/httpReadiness.ts", "import": "./src/httpReadiness.ts" + }, + "./providerError": { + "types": "./src/providerError.ts", + "import": "./src/providerError.ts" } }, "scripts": { diff --git a/packages/shared/src/providerError.test.ts b/packages/shared/src/providerError.test.ts new file mode 100644 index 00000000000..24d258f0b65 --- /dev/null +++ b/packages/shared/src/providerError.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { extractProviderErrorMessage } from "./providerError.ts"; + +describe("extractProviderErrorMessage", () => { + it("returns plain messages unchanged", () => { + expect(extractProviderErrorMessage("Something went wrong")).toBe("Something went wrong"); + }); + + it("extracts the nested message from a codex/upstream error body", () => { + const raw = + '{"type":"error","status":400,"error":{"type":"invalid_request_error","message":"The \'gpt-5.3-codex\' model is not supported when using Codex with a ChatGPT account."}}'; + expect(extractProviderErrorMessage(raw)).toBe( + "The 'gpt-5.3-codex' model is not supported when using Codex with a ChatGPT account.", + ); + }); + + it("extracts a top-level message field", () => { + expect(extractProviderErrorMessage('{"message":"rate limited","code":429}')).toBe( + "rate limited", + ); + }); + + it("unwraps double-encoded payloads", () => { + const inner = '{"error":{"message":"quota exceeded"}}'; + const outer = JSON.stringify({ message: inner }); + expect(extractProviderErrorMessage(outer)).toBe("quota exceeded"); + }); + + it("returns invalid JSON unchanged", () => { + expect(extractProviderErrorMessage('{"type":"error", broken')).toBe('{"type":"error", broken'); + }); + + it("returns JSON without a usable message unchanged", () => { + expect(extractProviderErrorMessage('{"status":500}')).toBe('{"status":500}'); + }); + + it("ignores empty or whitespace-only message fields", () => { + expect(extractProviderErrorMessage('{"message":" ","error":{"message":"real cause"}}')).toBe( + "real cause", + ); + }); + + it("does not touch non-object JSON", () => { + expect(extractProviderErrorMessage('"just a string"')).toBe('"just a string"'); + expect(extractProviderErrorMessage("[1,2,3]")).toBe("[1,2,3]"); + }); +}); diff --git a/packages/shared/src/providerError.ts b/packages/shared/src/providerError.ts new file mode 100644 index 00000000000..2bde4375149 --- /dev/null +++ b/packages/shared/src/providerError.ts @@ -0,0 +1,36 @@ +function readMessage(value: unknown): string | undefined { + if (typeof value !== "object" || value === null) { + return undefined; + } + const record = value as Record; + if (typeof record.message === "string" && record.message.trim().length > 0) { + return record.message.trim(); + } + // upstream errors often nest the useful part, e.g. {"type":"error","status":400,"error":{"message":...}} + return readMessage(record.error); +} + +/** + * Providers sometimes surface the raw upstream error body as the error + * message string (e.g. `{"type":"error","status":400,"error":{"type": + * "invalid_request_error","message":"..."}}`). Pull out the human readable + * message when that happens; otherwise return the input unchanged. + */ +export function extractProviderErrorMessage(message: string): string { + const trimmed = message.trim(); + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) { + return message; + } + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return message; + } + const extracted = readMessage(parsed); + if (extracted === undefined || extracted === trimmed) { + return message; + } + // handles double-encoded payloads; the inequality check above guarantees progress + return extractProviderErrorMessage(extracted); +}