refactor: share provider HTTP error parsing

This commit is contained in:
Peter Steinberger
2026-04-24 19:58:28 +01:00
parent 37d5c34749
commit 2c516fe516
16 changed files with 83 additions and 55 deletions

View File

@@ -1,2 +1,2 @@
b758a1c5503c08325113e0d6c9f1ac2db5a5fd9992a3902706ebe0f0dbbc1213 plugin-sdk-api-baseline.json
2c9d0a00e526dcd47d131261b8ceddd8e59faa8530b129d108a3721a4cbcbea7 plugin-sdk-api-baseline.jsonl
b86e6785acbd0f8f0f012691ce823c2e8d52bfd2507c2258408503162abb9adf plugin-sdk-api-baseline.json
e10acbed6c7c1b21700e358411c73877bdc0cb59ce102bafe680e210a2ac741b plugin-sdk-api-baseline.jsonl

View File

@@ -1,4 +1,5 @@
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { assertOkOrThrowProviderError } from "openclaw/plugin-sdk/provider-http";
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
import type {
SpeechDirectiveTokenParseContext,
@@ -297,9 +298,7 @@ export async function listElevenLabsVoices(params: {
"xi-api-key": params.apiKey,
},
});
if (!res.ok) {
throw new Error(`ElevenLabs voices API error (${res.status})`);
}
await assertOkOrThrowProviderError(res, "ElevenLabs voices API error");
const json = (await res.json()) as {
voices?: Array<{
voice_id?: string;

View File

@@ -6,6 +6,7 @@ import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
assertOkOrThrowHttpError,
assertOkOrThrowProviderError,
resolveProviderHttpRequestConfig,
} from "openclaw/plugin-sdk/provider-http";
import {
@@ -251,12 +252,7 @@ async function fetchImageBuffer(
auditContext: "fal-image-download",
});
try {
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(
`fal image download failed (${response.status}): ${text || response.statusText}`,
);
}
await assertOkOrThrowProviderError(response, "fal image download failed");
const mimeType = response.headers.get("content-type")?.trim() || "image/png";
const arrayBuffer = await response.arrayBuffer();
return { buffer: Buffer.from(arrayBuffer), mimeType };

View File

@@ -9,6 +9,7 @@ import {
sanitizeAndNormalizeEmbedding,
withRemoteHttpResponse,
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { createProviderHttpError } from "openclaw/plugin-sdk/provider-http";
import type { GeminiEmbeddingClient, GeminiTextEmbeddingRequest } from "./embedding-provider.js";
export type GeminiBatchRequest = {
@@ -179,8 +180,7 @@ async function fetchGeminiBatchStatus(params: {
},
onResponse: async (res) => {
if (!res.ok) {
const text = await res.text();
throw new Error(`gemini batch status failed: ${res.status} ${text}`);
throw await createProviderHttpError(res, "gemini batch status failed");
}
return (await res.json()) as GeminiBatchStatus;
},
@@ -203,8 +203,7 @@ async function fetchGeminiFileContent(params: {
},
onResponse: async (res) => {
if (!res.ok) {
const text = await res.text();
throw new Error(`gemini batch file content failed: ${res.status} ${text}`);
throw await createProviderHttpError(res, "gemini batch file content failed");
}
return await res.text();
},

View File

@@ -15,6 +15,7 @@ import {
requireApiKey,
resolveApiKeyForProvider,
} from "openclaw/plugin-sdk/provider-auth-runtime";
import { createProviderHttpError } from "openclaw/plugin-sdk/provider-http";
import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
@@ -189,8 +190,7 @@ async function fetchGeminiEmbeddingPayload(params: {
},
onResponse: async (res) => {
if (!res.ok) {
const text = await res.text();
throw new Error(`gemini embeddings failed: ${res.status} ${text}`);
throw await createProviderHttpError(res, "gemini embeddings failed");
}
return (await res.json()) as {
embedding?: { values?: number[] };

View File

@@ -1,3 +1,7 @@
import {
createProviderHttpError,
formatProviderHttpErrorMessage,
} from "openclaw/plugin-sdk/provider-http";
import {
buildSearchCacheKey,
buildUnsupportedSearchFilterResponse,
@@ -81,11 +85,8 @@ async function runGeminiSearch(params: {
},
async (res) => {
if (!res.ok) {
const safeDetail = ((await res.text()) || res.statusText).replace(
/key=[^&\s]+/giu,
"key=***",
);
throw new Error(`Gemini API error (${res.status}): ${safeDetail}`);
const error = await createProviderHttpError(res, "Gemini API error");
throw new Error(error.message.replace(/key=[^&\s]+/giu, "key=***"));
}
let data: GeminiGroundingResponse;
@@ -99,7 +100,11 @@ async function runGeminiSearch(params: {
if (data.error) {
const rawMessage = data.error.message || data.error.status || "unknown";
throw new Error(
`Gemini API error (${data.error.code}): ${rawMessage.replace(/key=[^&\s]+/giu, "key=***")}`,
formatProviderHttpErrorMessage({
label: "Gemini API error",
status: data.error.code ?? 0,
detail: rawMessage.replace(/key=[^&\s]+/giu, "key=***"),
}),
);
}

View File

@@ -7,6 +7,7 @@ import {
type SimpleStreamOptions,
type ThinkingLevel,
} from "@mariozechner/pi-ai";
import { createProviderHttpError } from "openclaw/plugin-sdk/provider-http";
import {
buildGuardedModelFetch,
coerceTransportToolCallArguments,
@@ -631,8 +632,7 @@ export function createGoogleGenerativeAiTransportStreamFn(): StreamFn {
signal: options?.signal,
});
if (!response.ok) {
const message = await response.text().catch(() => "");
throw new Error(`Google Generative AI API error (${response.status}): ${message}`);
throw await createProviderHttpError(response, "Google Generative AI API error");
}
stream.push({ type: "start", partial: output as never });
let currentBlockIndex = -1;

View File

@@ -6,6 +6,7 @@ import {
generateSecMsGecToken,
} from "node-edge-tts/dist/drm.js";
import { isVoiceCompatibleAudio } from "openclaw/plugin-sdk/media-runtime";
import { assertOkOrThrowProviderError } from "openclaw/plugin-sdk/provider-http";
import {
captureHttpExchange,
isDebugProxyGlobalFetchPatchInstalled,
@@ -153,9 +154,7 @@ export async function listMicrosoftVoices(): Promise<SpeechVoiceOption[]> {
},
});
}
if (!response.ok) {
throw new Error(`Microsoft voices API error (${response.status})`);
}
await assertOkOrThrowProviderError(response, "Microsoft voices API error");
const voices = (await response.json()) as MicrosoftVoiceListEntry[];
return Array.isArray(voices)
? voices

View File

@@ -1,3 +1,7 @@
import {
createProviderHttpError,
formatProviderHttpErrorMessage,
} from "openclaw/plugin-sdk/provider-http";
import {
DEFAULT_SEARCH_COUNT,
buildSearchCacheKey,
@@ -134,15 +138,18 @@ async function runMiniMaxSearch(params: {
},
async (res) => {
if (!res.ok) {
const detail = await res.text();
throw new Error(`MiniMax Search API error (${res.status}): ${detail || res.statusText}`);
throw await createProviderHttpError(res, "MiniMax Search API error");
}
const data = (await res.json()) as MiniMaxSearchResponse;
if (data.base_resp?.status_code && data.base_resp.status_code !== 0) {
throw new Error(
`MiniMax Search API error (${data.base_resp.status_code}): ${data.base_resp.status_msg || "unknown error"}`,
formatProviderHttpErrorMessage({
label: "MiniMax Search API error",
status: data.base_resp.status_code,
detail: data.base_resp.status_msg || "unknown error",
}),
);
}

View File

@@ -1,3 +1,5 @@
import { assertOkOrThrowProviderError } from "openclaw/plugin-sdk/provider-http";
export const DEFAULT_MINIMAX_TTS_BASE_URL = "https://api.minimax.io";
export const MINIMAX_TTS_MODELS = ["speech-2.8-hd", "speech-01-240228"] as const;
@@ -72,10 +74,7 @@ export async function minimaxTTS(params: {
signal: controller.signal,
});
if (!response.ok) {
const errBody = await response.text().catch(() => "");
throw new Error(`MiniMax TTS API error (${response.status})${errBody ? `: ${errBody}` : ""}`);
}
await assertOkOrThrowProviderError(response, "MiniMax TTS API error");
const body = (await response.json()) as { data?: { audio?: string } };
const hexAudio = body?.data?.audio;

View File

@@ -1,3 +1,4 @@
import { createProviderHttpError } from "openclaw/plugin-sdk/provider-http";
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
import {
buildSearchCacheKey,
@@ -196,8 +197,7 @@ async function runKimiSearch(params: {
res,
): Promise<{ done: true; content: string; citations: string[] } | { done: false }> => {
if (!res.ok) {
const detail = await res.text();
throw new Error(`Kimi API error (${res.status}): ${detail || res.statusText}`);
throw await createProviderHttpError(res, "Kimi API error");
}
const data = (await res.json()) as KimiSearchResponse;

View File

@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import { createProviderHttpError } from "openclaw/plugin-sdk/provider-http";
import {
captureWsEvent,
createDebugProxyWebSocketAgent,
@@ -634,10 +635,7 @@ async function createOpenAIRealtimeBrowserSession(
const payload = await (async () => {
try {
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(
`OpenAI Realtime browser session failed (${response.status}): ${detail || response.statusText}`,
);
throw await createProviderHttpError(response, "OpenAI Realtime browser session failed");
}
return (await response.json()) as unknown;
} finally {

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
assertOkOrThrowProviderError,
assertOkOrThrowHttpError,
extractProviderErrorDetail,
extractProviderRequestId,
} from "./provider-http-errors.js";
@@ -34,4 +35,23 @@ describe("provider error utils", () => {
expect(await extractProviderErrorDetail(response)).toBe("Invalid API key");
expect(extractProviderRequestId(response)).toBe("fallback_req");
});
it("keeps legacy HTTP status formatting while sharing provider parsing", async () => {
const response = new Response(
JSON.stringify({
error: {
message: "Bad request",
code: "invalid_request",
},
}),
{
status: 400,
headers: { "x-request-id": "req_legacy" },
},
);
await expect(assertOkOrThrowHttpError(response, "Legacy provider error")).rejects.toThrow(
"Legacy provider error (HTTP 400): Bad request [code=invalid_request] [request_id=req_legacy]",
);
});
});

View File

@@ -119,16 +119,21 @@ export function formatProviderHttpErrorMessage(params: {
status: number;
detail?: string;
requestId?: string;
statusPrefix?: string;
}): string {
const { label, status, detail, requestId } = params;
const { label, status, detail, requestId, statusPrefix = "" } = params;
return (
`${label} (${status})` +
`${label} (${statusPrefix}${status})` +
(detail ? `: ${detail}` : "") +
(requestId ? ` [request_id=${requestId}]` : "")
);
}
export async function createProviderHttpError(response: Response, label: string): Promise<Error> {
export async function createProviderHttpError(
response: Response,
label: string,
options?: { statusPrefix?: string },
): Promise<Error> {
const detail = await extractProviderErrorDetail(response);
const requestId = extractProviderRequestId(response);
return new Error(
@@ -137,6 +142,7 @@ export async function createProviderHttpError(response: Response, label: string)
status: response.status,
detail,
requestId,
statusPrefix: options?.statusPrefix,
}),
);
}
@@ -150,3 +156,10 @@ export async function assertOkOrThrowProviderError(
}
throw await createProviderHttpError(response, label);
}
export async function assertOkOrThrowHttpError(response: Response, label: string): Promise<void> {
if (response.ok) {
return;
}
throw await createProviderHttpError(response, label, { statusPrefix: "HTTP " });
}

View File

@@ -1,4 +1,6 @@
import path from "node:path";
import { assertOkOrThrowHttpError } from "../agents/provider-http-errors.js";
export { assertOkOrThrowHttpError } from "../agents/provider-http-errors.js";
import type {
ProviderRequestCapability,
ProviderRequestTransport,
@@ -20,9 +22,9 @@ export { fetchWithTimeout };
export { normalizeBaseUrl } from "../agents/provider-request-config.js";
export { sanitizeConfiguredModelProviderRequest } from "../agents/provider-request-config.js";
const DEFAULT_GUARDED_HTTP_TIMEOUT_MS = 60_000;
const MAX_ERROR_CHARS = 300;
const MAX_ERROR_RESPONSE_BYTES = 4096;
const DEFAULT_GUARDED_HTTP_TIMEOUT_MS = 60_000;
const MAX_AUDIT_CONTEXT_CHARS = 80;
export function resolveAudioTranscriptionUploadFileName(fileName?: string, mime?: string): string {
@@ -512,15 +514,6 @@ export async function readErrorResponse(res: Response): Promise<string | undefin
}
}
export async function assertOkOrThrowHttpError(res: Response, label: string): Promise<void> {
if (res.ok) {
return;
}
const detail = await readErrorResponse(res);
const suffix = detail ? `: ${detail}` : "";
throw new Error(`${label} (HTTP ${res.status})${suffix}`);
}
export function requireTranscriptionText(
value: string | undefined,
missingMessage: string,

View File

@@ -2,6 +2,7 @@
// capability SDKs do not depend on each other.
export {
assertOkOrThrowHttpError,
assertOkOrThrowProviderError,
createProviderHttpError,
extractProviderErrorDetail,
@@ -12,7 +13,6 @@ export {
truncateErrorDetail,
} from "../agents/provider-http-errors.js";
export {
assertOkOrThrowHttpError,
buildAudioTranscriptionFormData,
createProviderOperationDeadline,
fetchWithTimeout,