Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions apps/server/src/provider/Layers/CodexAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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 [
{
Expand Down
5 changes: 3 additions & 2 deletions apps/server/src/provider/Layers/CodexSessionRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
12 changes: 8 additions & 4 deletions apps/web/src/components/chat/ThreadErrorBanner.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
<div className="pt-3 mx-auto max-w-3xl">
<Alert variant="error">
<CircleAlertIcon />
<Tooltip>
<TooltipTrigger render={<AlertDescription className="line-clamp-3" />}>
{error}
<TooltipTrigger render={<AlertDescription className="line-clamp-3 break-words" />}>
{message}
</TooltipTrigger>
<TooltipPopup side="top" className="max-w-96 whitespace-pre-wrap">
{error}
<TooltipPopup side="top" className="max-w-96 whitespace-pre-wrap break-words">
{message}
</TooltipPopup>
</Tooltip>
{onDismiss && (
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@
"./httpReadiness": {
"types": "./src/httpReadiness.ts",
"import": "./src/httpReadiness.ts"
},
"./providerError": {
"types": "./src/providerError.ts",
"import": "./src/providerError.ts"
}
},
"scripts": {
Expand Down
48 changes: 48 additions & 0 deletions packages/shared/src/providerError.test.ts
Original file line number Diff line number Diff line change
@@ -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]");
});
});
36 changes: 36 additions & 0 deletions packages/shared/src/providerError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
function readMessage(value: unknown): string | undefined {
if (typeof value !== "object" || value === null) {
return undefined;
}
const record = value as Record<string, unknown>;
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);
}
Loading