mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 03:00:34 +00:00
fix(agents): preserve OpenAI transport error metadata
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user