mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
refactor: share provider HTTP error parsing
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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[] };
|
||||
|
||||
@@ -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=***"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 " });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user