From 2c516fe5169c9ba9883a7c3539dd362a9cc1dbcc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 24 Apr 2026 19:58:28 +0100 Subject: [PATCH] refactor: share provider HTTP error parsing --- .../.generated/plugin-sdk-api-baseline.sha256 | 4 ++-- extensions/elevenlabs/speech-provider.ts | 5 ++--- extensions/fal/image-generation-provider.ts | 8 ++------ extensions/google/embedding-batch.ts | 7 +++---- extensions/google/embedding-provider.ts | 4 ++-- .../src/gemini-web-search-provider.runtime.ts | 17 ++++++++++------ extensions/google/transport-stream.ts | 4 ++-- extensions/microsoft/speech-provider.ts | 5 ++--- .../minimax-web-search-provider.runtime.ts | 13 +++++++++--- extensions/minimax/tts.ts | 7 +++---- .../src/kimi-web-search-provider.runtime.ts | 4 ++-- extensions/openai/realtime-voice-provider.ts | 6 ++---- src/agents/provider-http-errors.test.ts | 20 +++++++++++++++++++ src/agents/provider-http-errors.ts | 19 +++++++++++++++--- src/media-understanding/shared.ts | 13 +++--------- src/plugin-sdk/provider-http.ts | 2 +- 16 files changed, 83 insertions(+), 55 deletions(-) diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 4542180de21..66a2a097918 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/extensions/elevenlabs/speech-provider.ts b/extensions/elevenlabs/speech-provider.ts index 85b401bb82d..f1c42d65288 100644 --- a/extensions/elevenlabs/speech-provider.ts +++ b/extensions/elevenlabs/speech-provider.ts @@ -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; diff --git a/extensions/fal/image-generation-provider.ts b/extensions/fal/image-generation-provider.ts index 13261aaa842..7edf2ac1e0d 100644 --- a/extensions/fal/image-generation-provider.ts +++ b/extensions/fal/image-generation-provider.ts @@ -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 }; diff --git a/extensions/google/embedding-batch.ts b/extensions/google/embedding-batch.ts index d7fe2cdf36c..10ac7217d42 100644 --- a/extensions/google/embedding-batch.ts +++ b/extensions/google/embedding-batch.ts @@ -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(); }, diff --git a/extensions/google/embedding-provider.ts b/extensions/google/embedding-provider.ts index 26911cf685f..d01eb569238 100644 --- a/extensions/google/embedding-provider.ts +++ b/extensions/google/embedding-provider.ts @@ -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[] }; diff --git a/extensions/google/src/gemini-web-search-provider.runtime.ts b/extensions/google/src/gemini-web-search-provider.runtime.ts index c26c9d896f0..dfb904a3397 100644 --- a/extensions/google/src/gemini-web-search-provider.runtime.ts +++ b/extensions/google/src/gemini-web-search-provider.runtime.ts @@ -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=***"), + }), ); } diff --git a/extensions/google/transport-stream.ts b/extensions/google/transport-stream.ts index 2a41ca4c627..1f1b6041f3c 100644 --- a/extensions/google/transport-stream.ts +++ b/extensions/google/transport-stream.ts @@ -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; diff --git a/extensions/microsoft/speech-provider.ts b/extensions/microsoft/speech-provider.ts index f0ca2c9a68a..7e7ad2fdbd7 100644 --- a/extensions/microsoft/speech-provider.ts +++ b/extensions/microsoft/speech-provider.ts @@ -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 { }, }); } - 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 diff --git a/extensions/minimax/src/minimax-web-search-provider.runtime.ts b/extensions/minimax/src/minimax-web-search-provider.runtime.ts index 10e3a0bd742..45c3c026b0e 100644 --- a/extensions/minimax/src/minimax-web-search-provider.runtime.ts +++ b/extensions/minimax/src/minimax-web-search-provider.runtime.ts @@ -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", + }), ); } diff --git a/extensions/minimax/tts.ts b/extensions/minimax/tts.ts index cd749638a40..2801a5c2e83 100644 --- a/extensions/minimax/tts.ts +++ b/extensions/minimax/tts.ts @@ -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; diff --git a/extensions/moonshot/src/kimi-web-search-provider.runtime.ts b/extensions/moonshot/src/kimi-web-search-provider.runtime.ts index 2aa506353bc..8627a95378b 100644 --- a/extensions/moonshot/src/kimi-web-search-provider.runtime.ts +++ b/extensions/moonshot/src/kimi-web-search-provider.runtime.ts @@ -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; diff --git a/extensions/openai/realtime-voice-provider.ts b/extensions/openai/realtime-voice-provider.ts index a16e2b59ece..bb24feb8885 100644 --- a/extensions/openai/realtime-voice-provider.ts +++ b/extensions/openai/realtime-voice-provider.ts @@ -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 { diff --git a/src/agents/provider-http-errors.test.ts b/src/agents/provider-http-errors.test.ts index 422d4d4e7ad..925399f695a 100644 --- a/src/agents/provider-http-errors.test.ts +++ b/src/agents/provider-http-errors.test.ts @@ -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]", + ); + }); }); diff --git a/src/agents/provider-http-errors.ts b/src/agents/provider-http-errors.ts index 5d99dc453e7..9ef6756d1ff 100644 --- a/src/agents/provider-http-errors.ts +++ b/src/agents/provider-http-errors.ts @@ -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 { +export async function createProviderHttpError( + response: Response, + label: string, + options?: { statusPrefix?: string }, +): Promise { 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 { + if (response.ok) { + return; + } + throw await createProviderHttpError(response, label, { statusPrefix: "HTTP " }); +} diff --git a/src/media-understanding/shared.ts b/src/media-understanding/shared.ts index f2f291027d0..409c70d1113 100644 --- a/src/media-understanding/shared.ts +++ b/src/media-understanding/shared.ts @@ -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 { - 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, diff --git a/src/plugin-sdk/provider-http.ts b/src/plugin-sdk/provider-http.ts index b897ce0f20c..e3b7f566fa9 100644 --- a/src/plugin-sdk/provider-http.ts +++ b/src/plugin-sdk/provider-http.ts @@ -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,