diff --git a/CHANGELOG.md b/CHANGELOG.md index 880905b1116..e1a757188a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ Docs: https://docs.openclaw.ai - Providers/videos: reject malformed successful xAI, OpenRouter, and fal video create, poll, and result responses with provider-owned errors instead of raw parser failures or long bogus polling. - Providers/audio: reject malformed successful OpenAI-compatible, ElevenLabs, and Deepgram speech responses with provider-owned errors instead of raw parser failures, wrong-shaped transcripts, or JSON/text bodies treated as audio. - Providers/embeddings: reject malformed successful OpenAI-compatible, Google Gemini, and Amazon Bedrock embedding responses instead of silently returning empty or coerced vectors. +- Providers/catalogs: reject malformed successful LM Studio, GitHub Copilot, DeepInfra, Vercel AI Gateway, and Kilocode model-list responses with provider-owned errors instead of raw parser/type failures or silent fallback catalogs. - Trajectory export: skip and report malformed session/runtime JSONL rows in `manifest.json` instead of letting wrong-shaped session rows crash support bundle export. - Voice calls: persist rejected inbound-call replay keys so duplicate carrier webhook retries stay ignored after a Gateway restart. - Config/doctor: copy fallback-enabled channel `allowFrom` entries into explicit `groupAllowFrom` allowlists during `openclaw doctor --fix`, preserving current group access without adding runtime fallback-transition flags. diff --git a/extensions/deepinfra/provider-models.test.ts b/extensions/deepinfra/provider-models.test.ts index c485fbb740c..0e8d4a84b57 100644 --- a/extensions/deepinfra/provider-models.test.ts +++ b/extensions/deepinfra/provider-models.test.ts @@ -177,6 +177,25 @@ describe("discoverDeepInfraModels", () => { }); }); + it("falls back without caching malformed successful model list payloads", async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: {} }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: [makeModelEntry({ id: "recovered/model" })] }), + }); + + await withFetchPathTest(mockFetch, async () => { + expect(await discoverDeepInfraModels()).toStrictEqual(expectedStaticCatalog()); + expect((await discoverDeepInfraModels()).map((m) => m.id)).toEqual(["recovered/model"]); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); + it("caches successful discovery responses only", async () => { const mockFetch = vi .fn() diff --git a/extensions/deepinfra/provider-models.ts b/extensions/deepinfra/provider-models.ts index 15fa4f81894..7d35a884527 100644 --- a/extensions/deepinfra/provider-models.ts +++ b/extensions/deepinfra/provider-models.ts @@ -1,5 +1,8 @@ import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-shared"; -import { fetchWithTimeout } from "openclaw/plugin-sdk/provider-http"; +import { + fetchWithTimeout, + readProviderJsonArrayFieldResponse, +} from "openclaw/plugin-sdk/provider-http"; import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import manifest from "./openclaw.plugin.json" with { type: "json" }; @@ -51,10 +54,6 @@ interface DeepInfraModelEntry { metadata: DeepInfraModelMetadata | null; } -interface DeepInfraModelsResponse { - data?: DeepInfraModelEntry[]; -} - function parseModality(metadata: DeepInfraModelMetadata): Array<"text" | "image"> { return metadata.tags?.includes("vision") ? ["text", "image"] : ["text"]; } @@ -100,6 +99,17 @@ function staticCatalog(): ModelDefinitionConfig[] { return DEEPINFRA_MODEL_CATALOG.map(buildDeepInfraModelDefinition); } +function asDeepInfraModelEntry(value: unknown): DeepInfraModelEntry { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("DeepInfra model list: malformed JSON response"); + } + const entry = value as Partial; + if (typeof entry.id !== "string") { + throw new Error("DeepInfra model list: malformed JSON response"); + } + return value as DeepInfraModelEntry; +} + export async function discoverDeepInfraModels(): Promise { if (process.env.NODE_ENV === "test" || process.env.VITEST) { return staticCatalog(); @@ -122,15 +132,16 @@ export async function discoverDeepInfraModels(): Promise(); const models: ModelDefinitionConfig[] = []; - for (const entry of body.data) { + for (const rawEntry of data) { + const entry = asDeepInfraModelEntry(rawEntry); const id = typeof entry?.id === "string" ? entry.id.trim() : ""; if (!id || seen.has(id) || !entry.metadata) { continue; diff --git a/extensions/github-copilot/models.test.ts b/extensions/github-copilot/models.test.ts index 35a584cf094..f4c47d167eb 100644 --- a/extensions/github-copilot/models.test.ts +++ b/extensions/github-copilot/models.test.ts @@ -646,6 +646,24 @@ describe("fetchCopilotModelCatalog", () => { ).rejects.toThrow(/HTTP 401/); }); + it("throws provider-owned errors for malformed successful /models payloads", async () => { + for (const payload of [[], { data: {} }, { data: [null] }]) { + const fetchImpl = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => payload, + }); + + await expect( + fetchCopilotModelCatalog({ + copilotApiToken: "tid=test", + baseUrl: "https://api.githubcopilot.com", + fetchImpl: fetchImpl as unknown as typeof fetch, + }), + ).rejects.toThrow("Copilot /models: malformed JSON response"); + } + }); + it("rejects empty token / baseUrl synchronously before fetching", async () => { const fetchImpl = vi.fn(); diff --git a/extensions/github-copilot/models.ts b/extensions/github-copilot/models.ts index 3e07d91aabd..8dc6bf72337 100644 --- a/extensions/github-copilot/models.ts +++ b/extensions/github-copilot/models.ts @@ -3,6 +3,7 @@ import type { ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; import { buildCopilotIdeHeaders, COPILOT_INTEGRATION_ID } from "openclaw/plugin-sdk/provider-auth"; +import { readProviderJsonArrayFieldResponse } from "openclaw/plugin-sdk/provider-http"; import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared"; import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-model-shared"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -191,6 +192,13 @@ function mapCopilotApiModelToDefinition( return definition; } +function asCopilotApiModelEntry(value: unknown): CopilotApiModelEntry { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("Copilot /models: malformed JSON response"); + } + return value as CopilotApiModelEntry; +} + export type FetchCopilotModelCatalogParams = { /** Short-lived Copilot API token (from `resolveCopilotApiToken`). */ copilotApiToken: string; @@ -242,11 +250,11 @@ export async function fetchCopilotModelCatalog( if (!res.ok) { throw new Error(`Copilot /models fetch failed: HTTP ${res.status}`); } - const json = (await res.json()) as { data?: CopilotApiModelEntry[] }; - const data = Array.isArray(json?.data) ? json.data : []; + const data = await readProviderJsonArrayFieldResponse(res, "Copilot /models", "data"); const seen = new Set(); const out: ModelDefinitionConfig[] = []; - for (const entry of data) { + for (const rawEntry of data) { + const entry = asCopilotApiModelEntry(rawEntry); const def = mapCopilotApiModelToDefinition(entry); if (!def) { continue; diff --git a/extensions/kilocode/provider-models.test.ts b/extensions/kilocode/provider-models.test.ts index 6466c4de614..9a10e253850 100644 --- a/extensions/kilocode/provider-models.test.ts +++ b/extensions/kilocode/provider-models.test.ts @@ -226,6 +226,19 @@ describe("discoverKilocodeModels (fetch path)", () => { }); }); + it("falls back to static catalog for malformed successful model list payloads", async () => { + for (const payload of [[], { data: {} }, { data: [null] }]) { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(payload), + }); + await withFetchPathTest(mockFetch, async () => { + const models = await discoverKilocodeModels(); + expect(models).toStrictEqual(EXPECTED_STATIC_KILOCODE_MODELS); + }); + } + }); + it("ensures kilo/auto is present even when API doesn't return it", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, diff --git a/extensions/kilocode/provider-models.ts b/extensions/kilocode/provider-models.ts index 293a0cbf9aa..1f5df5edf08 100644 --- a/extensions/kilocode/provider-models.ts +++ b/extensions/kilocode/provider-models.ts @@ -1,3 +1,4 @@ +import { readProviderJsonArrayFieldResponse } from "openclaw/plugin-sdk/provider-http"; import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { @@ -70,10 +71,6 @@ interface GatewayModelEntry { supported_parameters?: string[]; } -interface GatewayModelsResponse { - data: GatewayModelEntry[]; -} - function toPricePerMillion(perToken: string | undefined): number { if (!perToken) { return 0; @@ -133,6 +130,30 @@ function buildStaticCatalog(): ModelDefinitionConfig[] { })); } +function asGatewayModelEntry(value: unknown): GatewayModelEntry { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("Kilocode model list: malformed JSON response"); + } + const entry = value as Partial; + if ( + typeof entry.id !== "string" || + typeof entry.pricing !== "object" || + entry.pricing === null || + Array.isArray(entry.pricing) + ) { + throw new Error("Kilocode model list: malformed JSON response"); + } + return value as GatewayModelEntry; +} + +function readGatewayModelId(value: unknown): string { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return ""; + } + const id = (value as Partial).id; + return typeof id === "string" ? id.trim() : ""; +} + export async function discoverKilocodeModels(): Promise { if (process.env.NODE_ENV === "test" || process.env.VITEST) { return buildStaticCatalog(); @@ -154,8 +175,12 @@ export async function discoverKilocodeModels(): Promise return buildStaticCatalog(); } - const data = (await response.json()) as GatewayModelsResponse; - if (!Array.isArray(data.data) || data.data.length === 0) { + const data = await readProviderJsonArrayFieldResponse( + response, + "Kilocode model list", + "data", + ); + if (data.length === 0) { log.warn("No models found from gateway API, using static catalog"); return buildStaticCatalog(); } @@ -163,15 +188,13 @@ export async function discoverKilocodeModels(): Promise const models: ModelDefinitionConfig[] = []; const discoveredIds = new Set(); - for (const entry of data.data) { - if (!entry || typeof entry !== "object") { - continue; - } - const id = typeof entry.id === "string" ? entry.id.trim() : ""; - if (!id || discoveredIds.has(id)) { - continue; - } + for (const rawEntry of data) { + const id = readGatewayModelId(rawEntry); try { + const entry = asGatewayModelEntry(rawEntry); + if (!id || discoveredIds.has(id)) { + continue; + } models.push(toModelDefinition(entry)); discoveredIds.add(id); } catch (e) { diff --git a/extensions/lmstudio/src/models.fetch.ts b/extensions/lmstudio/src/models.fetch.ts index 325c4d79eaa..36b2f297f84 100644 --- a/extensions/lmstudio/src/models.fetch.ts +++ b/extensions/lmstudio/src/models.fetch.ts @@ -1,4 +1,5 @@ import { createSubsystemLogger } from "openclaw/plugin-sdk/logging-core"; +import { readProviderJsonArrayFieldResponse } from "openclaw/plugin-sdk/provider-http"; import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared"; import { SELF_HOSTED_DEFAULT_COST } from "openclaw/plugin-sdk/provider-setup"; import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; @@ -25,10 +26,6 @@ type FetchLmstudioModelsResult = { error?: unknown; }; -type LmstudioModelsResponseWire = { - models?: LmstudioModelWire[]; -}; - type DiscoverLmstudioModelsParams = { baseUrl: string; apiKey: string; @@ -66,6 +63,13 @@ async function fetchLmstudioEndpoint(params: { }; } +function asLmstudioModelWire(value: unknown): LmstudioModelWire { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("LM Studio model list: malformed JSON response"); + } + return value as LmstudioModelWire; +} + /** Fetches /api/v1/models and reports transport reachability separately from HTTP status. */ export async function fetchLmstudioModels(params: { baseUrl?: string; @@ -100,17 +104,15 @@ export async function fetchLmstudioModels(params: { models: [], }; } - let payload: LmstudioModelsResponseWire; - try { - // External service payload is untrusted JSON; parse with a permissive wire type. - payload = (await response.json()) as LmstudioModelsResponseWire; - } catch (cause) { - throw new Error("LM Studio model list returned malformed JSON", { cause }); - } + const models = await readProviderJsonArrayFieldResponse( + response, + "LM Studio model list", + "models", + ); return { reachable: true, status: response.status, - models: Array.isArray(payload.models) ? payload.models : [], + models: models.map(asLmstudioModelWire), }; } finally { await release(); diff --git a/extensions/lmstudio/src/models.test.ts b/extensions/lmstudio/src/models.test.ts index c0dca46f8e7..b0483300f8a 100644 --- a/extensions/lmstudio/src/models.test.ts +++ b/extensions/lmstudio/src/models.test.ts @@ -313,7 +313,25 @@ describe("lmstudio-models", () => { }); expect(result.reachable).toBe(false); - expect((result.error as Error).message).toBe("LM Studio model list returned malformed JSON"); + expect((result.error as Error).message).toBe("LM Studio model list: malformed JSON response"); + }); + + it("reports wrong-shaped model list payloads with owned errors", async () => { + for (const payload of [[], { models: {} }, { models: [null] }]) { + const fetchMock = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => payload, + })); + + const result = await fetchLmstudioModels({ + baseUrl: "http://localhost:1234/v1", + fetchImpl: asFetch(fetchMock), + }); + + expect(result.reachable).toBe(false); + expect((result.error as Error).message).toBe("LM Studio model list: malformed JSON response"); + } }); it("skips model load when already loaded", async () => { diff --git a/extensions/vercel-ai-gateway/models.ts b/extensions/vercel-ai-gateway/models.ts index a7ac3ffb703..cbba784ecd2 100644 --- a/extensions/vercel-ai-gateway/models.ts +++ b/extensions/vercel-ai-gateway/models.ts @@ -1,3 +1,4 @@ +import { readProviderJsonArrayFieldResponse } from "openclaw/plugin-sdk/provider-http"; import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; @@ -33,10 +34,6 @@ type VercelGatewayModelShape = { pricing?: VercelPricingShape; }; -type VercelGatewayModelsResponse = { - data?: VercelGatewayModelShape[]; -}; - type StaticVercelGatewayModel = Omit & { cost?: Partial; }; @@ -186,6 +183,13 @@ function buildDiscoveredModelDefinition( }; } +function asVercelGatewayModelShape(value: unknown): VercelGatewayModelShape { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("Vercel AI Gateway model list: malformed JSON response"); + } + return value as VercelGatewayModelShape; +} + export async function discoverVercelAiGatewayModels(): Promise { if (process.env.VITEST || process.env.NODE_ENV === "test") { return getStaticVercelAiGatewayModelCatalog(); @@ -202,8 +206,13 @@ export async function discoverVercelAiGatewayModels(): Promise entry !== null); return discovered.length > 0 ? discovered : getStaticVercelAiGatewayModelCatalog(); diff --git a/extensions/vercel-ai-gateway/provider-catalog.test.ts b/extensions/vercel-ai-gateway/provider-catalog.test.ts index 7cc09bb06b9..3ec3ea0e287 100644 --- a/extensions/vercel-ai-gateway/provider-catalog.test.ts +++ b/extensions/vercel-ai-gateway/provider-catalog.test.ts @@ -1,5 +1,18 @@ -import { describe, expect, it } from "vitest"; -import { getStaticVercelAiGatewayModelCatalog, VERCEL_AI_GATEWAY_BASE_URL } from "./api.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({ + fetchWithSsrFGuardMock: vi.fn(), +})); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ + fetchWithSsrFGuard: fetchWithSsrFGuardMock, +})); + +import { + discoverVercelAiGatewayModels, + getStaticVercelAiGatewayModelCatalog, + VERCEL_AI_GATEWAY_BASE_URL, +} from "./api.js"; import { buildStaticVercelAiGatewayProvider, buildVercelAiGatewayProvider, @@ -12,6 +25,31 @@ const STATIC_MODEL_IDS = [ "moonshotai/kimi-k2.6", ]; +function restoreEnvVar(name: "NODE_ENV" | "VITEST", value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} + +async function withLiveDiscovery(run: () => Promise): Promise { + const oldNodeEnv = process.env.NODE_ENV; + const oldVitest = process.env.VITEST; + delete process.env.NODE_ENV; + delete process.env.VITEST; + try { + return await run(); + } finally { + restoreEnvVar("NODE_ENV", oldNodeEnv); + restoreEnvVar("VITEST", oldVitest); + } +} + +afterEach(() => { + fetchWithSsrFGuardMock.mockReset(); +}); + describe("vercel ai gateway provider catalog", () => { it("builds the bundled Vercel AI Gateway defaults", async () => { const provider = await buildVercelAiGatewayProvider(); @@ -36,4 +74,23 @@ describe("vercel ai gateway provider catalog", () => { models: getStaticVercelAiGatewayModelCatalog(), }); }); + + it("falls back to the static catalog for malformed successful model list payloads", async () => { + for (const payload of [[], { data: {} }, { data: [null] }]) { + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: { + ok: true, + status: 200, + json: async () => payload, + }, + release: async () => {}, + }); + + await withLiveDiscovery(async () => { + expect(await discoverVercelAiGatewayModels()).toStrictEqual( + getStaticVercelAiGatewayModelCatalog(), + ); + }); + } + }); }); diff --git a/src/agents/provider-http-errors.ts b/src/agents/provider-http-errors.ts index ef35b2eba7a..6d1f2c867bf 100644 --- a/src/agents/provider-http-errors.ts +++ b/src/agents/provider-http-errors.ts @@ -184,6 +184,19 @@ export async function readProviderJsonObjectResponse( return object; } +export async function readProviderJsonArrayFieldResponse( + response: Response, + label: string, + field: string, +): Promise { + const payload = await readProviderJsonObjectResponse(response, label); + const value = payload[field]; + if (!Array.isArray(value)) { + throw new Error(`${label}: malformed JSON response`); + } + return value; +} + function normalizeContentType(response: Response): string | undefined { const contentType = response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase(); return contentType || undefined; diff --git a/src/plugin-sdk/provider-http.ts b/src/plugin-sdk/provider-http.ts index 4ae5a6423fd..5d23f75d58e 100644 --- a/src/plugin-sdk/provider-http.ts +++ b/src/plugin-sdk/provider-http.ts @@ -11,6 +11,7 @@ export { formatProviderErrorPayload, formatProviderHttpErrorMessage, readProviderBinaryResponse, + readProviderJsonArrayFieldResponse, readProviderJsonObjectResponse, readProviderJsonResponse, readResponseTextLimited,