diff --git a/CHANGELOG.md b/CHANGELOG.md index 3501f6755a7..e3308f9810b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/OpenAI: preserve structured provider error code, type, and redacted body metadata on boundary-aware transport failures. - CLI/agents: retry transient normal-close Gateway handshakes before falling back to embedded `openclaw agent` execution. - CLI/update: keep managed Gateway service stop/restart status lines out of `openclaw update --json` stdout so package-update automation can parse the JSON payload. - Plugins: resolve OpenClaw plugin SDK subpaths for native external plugin runtimes without mutating package installs or broadening process-wide module resolution. diff --git a/src/agents/openai-transport-stream.test.ts b/src/agents/openai-transport-stream.test.ts index 784a95059b9..ba3588fb687 100644 --- a/src/agents/openai-transport-stream.test.ts +++ b/src/agents/openai-transport-stream.test.ts @@ -1121,6 +1121,78 @@ describe("openai transport stream", () => { } }); + it("preserves OpenAI-compatible error metadata on failed chat requests", async () => { + const server = createServer((req, res) => { + req.resume(); + req.on("end", () => { + res.writeHead(429, { + "content-type": "application/json; charset=utf-8", + "x-request-id": "req_error_metadata", + }); + res.end( + JSON.stringify({ + error: { + message: "Quota exceeded for api_key=sk-secret1234567890abcd", + type: "rate_limit_error", + code: "insufficient_quota", + }, + }), + ); + }); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Missing loopback server address"); + } + const model = { + id: "gpt-5.4-mini", + name: "GPT-5.4 Mini", + api: "openai-completions", + provider: "openai", + baseUrl: `http://127.0.0.1:${address.port}/v1`, + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8192, + } satisfies Model<"openai-completions">; + const stream = createOpenAICompletionsTransportStreamFn()( + model, + { + systemPrompt: "system", + messages: [{ role: "user", content: "Reply OK", timestamp: Date.now() }], + tools: [], + } as never, + { apiKey: "test-key" } as never, + ); + + let errorPayload: Record | undefined; + for await (const event of stream as AsyncIterable<{ + type: string; + error?: Record; + }>) { + if (event.type === "error") { + errorPayload = event.error; + } + } + + expect(errorPayload).toMatchObject({ + stopReason: "error", + errorCode: "insufficient_quota", + errorType: "rate_limit_error", + }); + expect(String(errorPayload?.errorBody)).toContain("Quota exceeded"); + expect(String(errorPayload?.errorBody)).not.toContain("sk-secret1234567890abcd"); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); + it("preserves reasoning tokens without double-counting them", () => { const model = { id: "gpt-5", diff --git a/src/agents/openai-transport-stream.ts b/src/agents/openai-transport-stream.ts index 657ac52b80f..441c5c558a4 100644 --- a/src/agents/openai-transport-stream.ts +++ b/src/agents/openai-transport-stream.ts @@ -69,7 +69,11 @@ import { import { sanitizeResponsesImagePayload } from "./responses-image-payload-sanitizer.js"; import { stripSystemPromptCacheBoundary } from "./system-prompt-cache-boundary.js"; import { transformTransportMessages } from "./transport-message-transform.js"; -import { mergeTransportMetadata, sanitizeTransportPayloadText } from "./transport-stream-shared.js"; +import { + assignTransportErrorDetails, + mergeTransportMetadata, + sanitizeTransportPayloadText, +} from "./transport-stream-shared.js"; const DEFAULT_AZURE_OPENAI_API_VERSION = "preview"; const OPENAI_CODEX_RESPONSES_EMPTY_INPUT_TEXT = " "; @@ -212,6 +216,9 @@ type MutableAssistantOutput = { timestamp: number; responseId?: string; errorMessage?: string; + errorCode?: string; + errorType?: string; + errorBody?: string; }; export { sanitizeTransportPayloadText } from "./transport-stream-shared.js"; @@ -1813,8 +1820,7 @@ export function createOpenAIResponsesTransportStreamFn(): StreamFn { `[responses] error provider=${model.provider} api=${model.api} model=${model.id} ` + summarizeOpenAITransportError(error), ); - output.stopReason = options?.signal?.aborted ? "aborted" : "error"; - output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + assignTransportErrorDetails(output, error, options?.signal); stream.push({ type: "error", reason: output.stopReason as never, error: output as never }); stream.end(); } @@ -2217,8 +2223,7 @@ export function createAzureOpenAIResponsesTransportStreamFn(): StreamFn { `[responses] error provider=${model.provider} api=${model.api} model=${model.id} ` + summarizeOpenAITransportError(error), ); - output.stopReason = options?.signal?.aborted ? "aborted" : "error"; - output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + assignTransportErrorDetails(output, error, options?.signal); stream.push({ type: "error", reason: output.stopReason as never, error: output as never }); stream.end(); } @@ -2412,8 +2417,7 @@ export function createOpenAICompletionsTransportStreamFn(): StreamFn { stream.push({ type: "done", reason: output.stopReason as never, message: output as never }); stream.end(); } catch (error) { - output.stopReason = options?.signal?.aborted ? "aborted" : "error"; - output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + assignTransportErrorDetails(output, error, options?.signal); stream.push({ type: "error", reason: output.stopReason as never, error: output as never }); stream.end(); } diff --git a/src/agents/provider-http-errors.test.ts b/src/agents/provider-http-errors.test.ts index bf97a54e7e3..8c537ec5f18 100644 --- a/src/agents/provider-http-errors.test.ts +++ b/src/agents/provider-http-errors.test.ts @@ -3,7 +3,9 @@ import { assertOkOrThrowProviderError, assertOkOrThrowHttpError, extractProviderErrorDetail, + extractProviderErrorInfo, extractProviderRequestId, + ProviderHttpError, readProviderJsonResponse, } from "./provider-http-errors.js"; @@ -37,6 +39,44 @@ describe("provider error utils", () => { expect(extractProviderRequestId(response)).toBe("fallback_req"); }); + it("attaches structured provider error metadata", async () => { + const response = new Response( + JSON.stringify({ + error: { + message: "Quota exceeded for api_key=sk-secret1234567890abcd", + type: "rate_limit_error", + code: "insufficient_quota", + }, + }), + { + status: 429, + headers: { "x-request-id": "req_456" }, + }, + ); + + const info = await extractProviderErrorInfo(response.clone()); + expect(info).toMatchObject({ + code: "insufficient_quota", + type: "rate_limit_error", + requestId: "req_456", + }); + expect(info.detail).toContain("Quota exceeded"); + expect(info.body).toContain("Quota exceeded"); + expect(info.body).not.toContain("sk-secret1234567890abcd"); + + await expect( + assertOkOrThrowProviderError(response, "Provider API error"), + ).rejects.toMatchObject({ + name: "ProviderHttpError", + status: 429, + statusCode: 429, + code: "insufficient_quota", + errorCode: "insufficient_quota", + errorType: "rate_limit_error", + requestId: "req_456", + } satisfies Partial); + }); + it("keeps legacy HTTP status formatting while sharing provider parsing", async () => { const response = new Response( JSON.stringify({ diff --git a/src/agents/provider-http-errors.ts b/src/agents/provider-http-errors.ts index 6d1f2c867bf..a5f4849f823 100644 --- a/src/agents/provider-http-errors.ts +++ b/src/agents/provider-http-errors.ts @@ -1,7 +1,10 @@ export { asFiniteNumber } from "../shared/number-coercion.js"; +import { redactSensitiveText } from "../logging/redact.js"; import { normalizeOptionalString as trimToUndefined } from "../shared/string-coerce.js"; export { normalizeOptionalString as trimToUndefined } from "../shared/string-coerce.js"; +const ERROR_BODY_METADATA_LIMIT = 500; + export function asBoolean(value: unknown): boolean | undefined { return typeof value === "boolean" ? value : undefined; } @@ -16,6 +19,10 @@ export function truncateErrorDetail(detail: string, limit = 220): string { return detail.length <= limit ? detail : `${detail.slice(0, limit - 1)}…`; } +export function redactProviderErrorBody(body: string): string { + return truncateErrorDetail(redactSensitiveText(body), ERROR_BODY_METADATA_LIMIT); +} + export async function readResponseTextLimited( response: Response, limitBytes = 16 * 1024, @@ -95,18 +102,67 @@ export function formatProviderErrorPayload(payload: unknown): string | undefined return undefined; } -export async function extractProviderErrorDetail(response: Response): Promise { +type ProviderErrorPayloadMetadata = { + detail?: string; + code?: string; + type?: string; +}; + +function extractProviderErrorPayloadMetadata(payload: unknown): ProviderErrorPayloadMetadata { + const root = asObject(payload); + const detailObject = asObject(root?.detail); + const subject = asObject(root?.error) ?? detailObject ?? root; + if (!subject) { + return {}; + } + + const detail = formatProviderErrorPayload(payload); + const type = trimToUndefined(subject.type); + const code = trimToUndefined(subject.code) ?? trimToUndefined(subject.status); + return { + ...(detail ? { detail: redactSensitiveText(detail) } : {}), + ...(code ? { code } : {}), + ...(type ? { type } : {}), + }; +} + +export type ProviderHttpErrorInfo = { + detail?: string; + code?: string; + type?: string; + body?: string; + requestId?: string; +}; + +export async function extractProviderErrorInfo(response: Response): Promise { const rawBody = trimToUndefined(await readResponseTextLimited(response)); + const requestId = extractProviderRequestId(response); if (!rawBody) { - return undefined; + return requestId ? { requestId } : {}; } + const body = redactProviderErrorBody(rawBody); try { - return formatProviderErrorPayload(JSON.parse(rawBody)) ?? truncateErrorDetail(rawBody); + const metadata = extractProviderErrorPayloadMetadata(JSON.parse(rawBody)); + return { + ...(metadata.detail ? { detail: metadata.detail } : { detail: body }), + ...(metadata.code ? { code: metadata.code } : {}), + ...(metadata.type ? { type: metadata.type } : {}), + body, + ...(requestId ? { requestId } : {}), + }; } catch { - return truncateErrorDetail(rawBody); + return { + detail: body, + body, + ...(requestId ? { requestId } : {}), + }; } } +export async function extractProviderErrorDetail(response: Response): Promise { + return (await extractProviderErrorInfo(response)).detail; +} + export function extractProviderRequestId(response: Response): string | undefined { return ( trimToUndefined(response.headers.get("x-request-id")) ?? @@ -114,6 +170,37 @@ export function extractProviderRequestId(response: Response): string | undefined ); } +export class ProviderHttpError extends Error { + readonly status: number; + readonly statusCode: number; + readonly code?: string; + readonly errorCode?: string; + readonly errorType?: string; + readonly errorBody?: string; + readonly requestId?: string; + + constructor( + message: string, + params: { + status: number; + code?: string; + type?: string; + body?: string; + requestId?: string; + }, + ) { + super(message); + this.name = "ProviderHttpError"; + this.status = params.status; + this.statusCode = params.status; + this.code = params.code; + this.errorCode = params.code; + this.errorType = params.type; + this.errorBody = params.body; + this.requestId = params.requestId; + } +} + export function formatProviderHttpErrorMessage(params: { label: string; status: number; @@ -134,16 +221,22 @@ export async function createProviderHttpError( label: string, options?: { statusPrefix?: string }, ): Promise { - const detail = await extractProviderErrorDetail(response); - const requestId = extractProviderRequestId(response); - return new Error( + const info = await extractProviderErrorInfo(response); + return new ProviderHttpError( formatProviderHttpErrorMessage({ label, status: response.status, - detail, - requestId, + detail: info.detail, + requestId: info.requestId, statusPrefix: options?.statusPrefix, }), + { + status: response.status, + code: info.code, + type: info.type, + body: info.body, + requestId: info.requestId, + }, ); } diff --git a/src/agents/transport-stream-shared.ts b/src/agents/transport-stream-shared.ts index e297a3fbc5e..ac7ba712d52 100644 --- a/src/agents/transport-stream-shared.ts +++ b/src/agents/transport-stream-shared.ts @@ -1,4 +1,6 @@ import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"; +import { redactSensitiveText } from "../logging/redact.js"; +import { truncateErrorDetail } from "./provider-http-errors.js"; type TransportUsage = { input: number; @@ -17,6 +19,9 @@ export type WritableTransportStream = { type TransportOutputShape = { stopReason: string; errorMessage?: string; + errorCode?: string; + errorType?: string; + errorBody?: string; }; const EMPTY_TOOL_RESULT_TEXT = "(no output)"; @@ -120,6 +125,93 @@ export function finalizeTransportStream(params: { stream.end(); } +type TransportErrorDetails = { + errorCode?: string; + errorType?: string; + errorBody?: string; +}; + +function readStringLikeProperty(value: unknown, key: string): string | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const raw = (value as Record)[key]; + if (typeof raw === "string") { + const trimmed = raw.trim(); + return trimmed || undefined; + } + if (typeof raw === "number" && Number.isFinite(raw)) { + return String(raw); + } + return undefined; +} + +function readObjectProperty(value: unknown, key: string): Record | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const raw = (value as Record)[key]; + return raw && typeof raw === "object" && !Array.isArray(raw) + ? (raw as Record) + : undefined; +} + +function stringifyErrorBody(value: unknown): string | undefined { + if (typeof value === "string") { + return value; + } + if (value === undefined || value === null) { + return undefined; + } + try { + return JSON.stringify(value); + } catch { + return undefined; + } +} + +function normalizeTransportErrorBody(value: unknown): string | undefined { + const text = stringifyErrorBody(value); + if (!text?.trim()) { + return undefined; + } + return truncateErrorDetail(redactSensitiveText(text), 500); +} + +export function extractTransportErrorDetails(error: unknown): TransportErrorDetails { + const errorObject = error && typeof error === "object" ? error : undefined; + const nestedError = readObjectProperty(errorObject, "error"); + const errorCode = + readStringLikeProperty(errorObject, "errorCode") ?? + readStringLikeProperty(errorObject, "code") ?? + readStringLikeProperty(nestedError, "code"); + const errorType = + readStringLikeProperty(errorObject, "errorType") ?? + readStringLikeProperty(errorObject, "type") ?? + readStringLikeProperty(nestedError, "type"); + const errorBody = + normalizeTransportErrorBody(readStringLikeProperty(errorObject, "errorBody")) ?? + normalizeTransportErrorBody(readStringLikeProperty(errorObject, "body")) ?? + normalizeTransportErrorBody(readObjectProperty(errorObject, "body")) ?? + normalizeTransportErrorBody(nestedError); + + return { + ...(errorCode ? { errorCode } : {}), + ...(errorType ? { errorType } : {}), + ...(errorBody ? { errorBody } : {}), + }; +} + +export function assignTransportErrorDetails( + output: TransportOutputShape, + error: unknown, + signal?: AbortSignal, +): void { + output.stopReason = signal?.aborted ? "aborted" : "error"; + output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + Object.assign(output, extractTransportErrorDetails(error)); +} + export function failTransportStream(params: { stream: WritableTransportStream; output: TransportOutputShape; @@ -129,8 +221,7 @@ export function failTransportStream(params: { }): void { const { stream, output, signal, error, cleanup } = params; cleanup?.(); - output.stopReason = signal?.aborted ? "aborted" : "error"; - output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + assignTransportErrorDetails(output, error, signal); stream.push({ type: "error", reason: output.stopReason as never, error: output as never }); stream.end(); }