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);
+}