fix(agents): preserve OpenAI transport error metadata

This commit is contained in:
Peter Steinberger
2026-05-22 12:32:10 +01:00
parent 0a95e53602
commit 89c59a89fb
6 changed files with 319 additions and 18 deletions

View File

@@ -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.

View File

@@ -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<void>((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<string, unknown> | undefined;
for await (const event of stream as AsyncIterable<{
type: string;
error?: Record<string, unknown>;
}>) {
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<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
it("preserves reasoning tokens without double-counting them", () => {
const model = {
id: "gpt-5",

View File

@@ -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();
}

View File

@@ -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<ProviderHttpError>);
});
it("keeps legacy HTTP status formatting while sharing provider parsing", async () => {
const response = new Response(
JSON.stringify({

View File

@@ -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<string | undefined> {
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<ProviderHttpErrorInfo> {
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<string | undefined> {
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<Error> {
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,
},
);
}

View File

@@ -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<string, unknown>)[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<string, unknown> | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const raw = (value as Record<string, unknown>)[key];
return raw && typeof raw === "object" && !Array.isArray(raw)
? (raw as Record<string, unknown>)
: 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();
}