fix(providers): harden model catalog response schemas

This commit is contained in:
Vincent Koc
2026-05-16 12:16:27 +08:00
parent c8c6df73a9
commit bc81d243ba
13 changed files with 239 additions and 46 deletions

View File

@@ -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.

View File

@@ -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()

View File

@@ -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<DeepInfraModelEntry>;
if (typeof entry.id !== "string") {
throw new Error("DeepInfra model list: malformed JSON response");
}
return value as DeepInfraModelEntry;
}
export async function discoverDeepInfraModels(): Promise<ModelDefinitionConfig[]> {
if (process.env.NODE_ENV === "test" || process.env.VITEST) {
return staticCatalog();
@@ -122,15 +132,16 @@ export async function discoverDeepInfraModels(): Promise<ModelDefinitionConfig[]
return staticCatalog();
}
const body = (await response.json()) as DeepInfraModelsResponse;
if (!Array.isArray(body.data) || body.data.length === 0) {
const data = await readProviderJsonArrayFieldResponse(response, "DeepInfra model list", "data");
if (data.length === 0) {
log.warn("No models found from DeepInfra API, using static catalog");
return staticCatalog();
}
const seen = new Set<string>();
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;

View File

@@ -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();

View File

@@ -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<string>();
const out: ModelDefinitionConfig[] = [];
for (const entry of data) {
for (const rawEntry of data) {
const entry = asCopilotApiModelEntry(rawEntry);
const def = mapCopilotApiModelToDefinition(entry);
if (!def) {
continue;

View File

@@ -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,

View File

@@ -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<GatewayModelEntry>;
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<GatewayModelEntry>).id;
return typeof id === "string" ? id.trim() : "";
}
export async function discoverKilocodeModels(): Promise<ModelDefinitionConfig[]> {
if (process.env.NODE_ENV === "test" || process.env.VITEST) {
return buildStaticCatalog();
@@ -154,8 +175,12 @@ export async function discoverKilocodeModels(): Promise<ModelDefinitionConfig[]>
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<ModelDefinitionConfig[]>
const models: ModelDefinitionConfig[] = [];
const discoveredIds = new Set<string>();
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) {

View File

@@ -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();

View File

@@ -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 () => {

View File

@@ -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<ModelDefinitionConfig, "cost"> & {
cost?: Partial<ModelDefinitionConfig["cost"]>;
};
@@ -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<ModelDefinitionConfig[]> {
if (process.env.VITEST || process.env.NODE_ENV === "test") {
return getStaticVercelAiGatewayModelCatalog();
@@ -202,8 +206,13 @@ export async function discoverVercelAiGatewayModels(): Promise<ModelDefinitionCo
log.warn(`Failed to discover Vercel AI Gateway models: HTTP ${response.status}`);
return getStaticVercelAiGatewayModelCatalog();
}
const data = (await response.json()) as VercelGatewayModelsResponse;
const discovered = (data.data ?? [])
const data = await readProviderJsonArrayFieldResponse(
response,
"Vercel AI Gateway model list",
"data",
);
const discovered = data
.map(asVercelGatewayModelShape)
.map(buildDiscoveredModelDefinition)
.filter((entry): entry is ModelDefinitionConfig => entry !== null);
return discovered.length > 0 ? discovered : getStaticVercelAiGatewayModelCatalog();

View File

@@ -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<T>(run: () => Promise<T>): Promise<T> {
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(),
);
});
}
});
});

View File

@@ -184,6 +184,19 @@ export async function readProviderJsonObjectResponse(
return object;
}
export async function readProviderJsonArrayFieldResponse(
response: Response,
label: string,
field: string,
): Promise<unknown[]> {
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;

View File

@@ -11,6 +11,7 @@ export {
formatProviderErrorPayload,
formatProviderHttpErrorMessage,
readProviderBinaryResponse,
readProviderJsonArrayFieldResponse,
readProviderJsonObjectResponse,
readProviderJsonResponse,
readResponseTextLimited,