fix(clawrouter): normalize Perplexity tool schemas and harden dynamic model cache (#108758)

This commit is contained in:
Peter Steinberger
2026-07-16 02:39:33 -07:00
committed by GitHub
parent f5bb19e028
commit 91f57a3159
6 changed files with 455 additions and 4 deletions
+5 -2
View File
@@ -216,8 +216,11 @@ transport to use, so you never install every upstream company's auth plugin.
| `llm.stream` + streaming `google.generate_content` route | `google-generative-ai` |
The plugin also applies the matching replay and tool-schema policies for those
families (OpenAI/DeepSeek/Gemini tool-schema compat; native Anthropic and
Google Gemini replay policies). A catalog provider exposing only an
families (OpenAI/DeepSeek/Gemini/Perplexity tool-schema compat; native
Anthropic and Google Gemini replay policies). Perplexity models get a strict
schema rewrite: `patternProperties` and `additionalProperties` are removed and
every object schema declares `properties`, because Perplexity rejects tool
schemas without them. A catalog provider exposing only an
unsupported request format is intentionally not advertised as an OpenClaw
text model. Normalize those providers to one of the supported contracts in
ClawRouter rather than sending an incompatible payload.
+75
View File
@@ -55,6 +55,7 @@ describe("ClawRouter plugin", () => {
normalizeResolvedModel: expect.any(Function),
normalizeToolSchemas: expect.any(Function),
prepareDynamicModel: expect.any(Function),
preferRuntimeResolvedModel: expect.any(Function),
resolveDynamicModel: expect.any(Function),
resolveUsageAuth: expect.any(Function),
sanitizeReplayHistory: expect.any(Function),
@@ -337,8 +338,10 @@ describe("ClawRouter plugin", () => {
};
expect(provider?.resolveDynamicModel?.(context as never)).toBeUndefined();
expect(provider?.preferRuntimeResolvedModel?.(context as never)).toBe(false);
await provider?.prepareDynamicModel?.(context as never);
expect(provider?.preferRuntimeResolvedModel?.(context as never)).toBe(true);
expect(provider?.resolveDynamicModel?.(context as never)).toMatchObject({
id: "openai/gpt-5.5",
provider: "clawrouter",
@@ -368,9 +371,81 @@ describe("ClawRouter plugin", () => {
providerAuthRuntimeMocks.resolveApiKeyForProvider.mockResolvedValue(undefined);
await provider?.prepareDynamicModel?.(context as never);
expect(provider?.preferRuntimeResolvedModel?.(context as never)).toBe(false);
expect(provider?.resolveDynamicModel?.(context as never)).toBeUndefined();
});
it("keeps the previous dynamic model snapshot while rebuilding", async () => {
providerAuthRuntimeMocks.resolveApiKeyForProvider
.mockResolvedValueOnce({ apiKey: "decoy-token" })
.mockResolvedValueOnce({ apiKey: "changeme" });
let finishRefresh: ((response: Response) => void) | undefined;
const refreshResponse = new Promise<Response>((resolve) => {
finishRefresh = resolve;
});
const fetchMock = vi
.fn()
.mockResolvedValueOnce(Response.json(LIVE_CATALOG))
.mockReturnValueOnce(refreshResponse);
vi.stubGlobal("fetch", fetchMock);
const provider = await registerSingleProviderPlugin(plugin);
const context = {
config: { models: {} },
agentDir: "/agent",
workspaceDir: "/workspace",
provider: "clawrouter",
modelId: "openai/gpt-5.5",
modelRegistry: { find: vi.fn(() => null) },
authProfileId: "clawrouter-profile",
authProfileMode: "api_key",
};
await provider?.prepareDynamicModel?.(context as never);
const refresh = provider?.prepareDynamicModel?.(context as never);
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
expect(provider?.resolveDynamicModel?.(context as never)).toMatchObject({
id: "openai/gpt-5.5",
});
finishRefresh?.(Response.json({ providers: [] }));
await refresh;
expect(provider?.resolveDynamicModel?.(context as never)).toBeUndefined();
});
it("keeps the previous dynamic model snapshot when catalog refresh fails", async () => {
providerAuthRuntimeMocks.resolveApiKeyForProvider
.mockResolvedValueOnce({ apiKey: "decoy-token" })
.mockResolvedValueOnce({ apiKey: "changeme" });
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(Response.json(LIVE_CATALOG))
.mockRejectedValueOnce(new Error("catalog unavailable")),
);
const provider = await registerSingleProviderPlugin(plugin);
const context = {
config: { models: {} },
agentDir: "/agent",
workspaceDir: "/workspace",
provider: "clawrouter",
modelId: "openai/gpt-5.5",
modelRegistry: { find: vi.fn(() => null) },
authProfileId: "clawrouter-profile",
authProfileMode: "api_key",
};
await provider?.prepareDynamicModel?.(context as never);
await expect(provider?.prepareDynamicModel?.(context as never)).rejects.toThrow(
"catalog unavailable",
);
expect(provider?.resolveDynamicModel?.(context as never)).toMatchObject({
id: "openai/gpt-5.5",
});
});
it("dispatches replay and tool policies by upstream protocol family", async () => {
const provider = await registerSingleProviderPlugin(plugin);
+30 -1
View File
@@ -15,6 +15,7 @@ import {
normalizeClawRouterResolvedModel,
} from "./provider-catalog.js";
import { wrapClawRouterProviderStream } from "./stream.js";
import { inspectPerplexityToolSchemas, normalizePerplexityToolSchemas } from "./tool-schemas.js";
import { fetchClawRouterUsage } from "./usage.js";
const PROVIDER_ID = "clawrouter";
@@ -31,6 +32,10 @@ const googleReplay = buildProviderReplayFamilyHooks({ family: "google-gemini" })
const openAiTools = buildProviderToolCompatFamilyHooks("openai");
const deepSeekTools = buildProviderToolCompatFamilyHooks("deepseek");
const geminiTools = buildProviderToolCompatFamilyHooks("gemini");
const perplexityTools = {
normalizeToolSchemas: normalizePerplexityToolSchemas,
inspectToolSchemas: inspectPerplexityToolSchemas,
};
function buildApiKeyAuth(): ProviderAuthMethod {
return createProviderApiKeyAuthMethod({
@@ -105,6 +110,9 @@ function resolveToolFamily(modelId: string) {
if (normalized.startsWith("google/")) {
return geminiTools;
}
if (normalized.startsWith("perplexity/")) {
return perplexityTools;
}
return openAiTools;
}
@@ -157,9 +165,27 @@ export default definePluginEntry({
},
},
resolveDynamicModel: (ctx) => dynamicModels.get(dynamicModelScope(ctx))?.get(ctx.modelId),
// Match by agentDir/workspaceDir/baseUrl; the context carries no auth
// profile id, so any profile scope for the same deployment counts.
preferRuntimeResolvedModel: (ctx) => {
const agentDir = ctx.agentDir ?? "";
const workspaceDir = ctx.workspaceDir ?? "";
const rootUrl = normalizeClawRouterRootUrl(configuredBaseUrl(ctx.config));
for (const [scope, models] of dynamicModels) {
const [scopeAgentDir, scopeWorkspaceDir, , scopeRootUrl] = JSON.parse(scope) as string[];
if (
scopeAgentDir === agentDir &&
scopeWorkspaceDir === workspaceDir &&
scopeRootUrl === rootUrl &&
models.has(ctx.modelId)
) {
return true;
}
}
return false;
},
prepareDynamicModel: async (ctx) => {
const scope = dynamicModelScope(ctx);
dynamicModels.delete(scope);
const { resolveApiKeyForProvider } =
await import("openclaw/plugin-sdk/provider-auth-runtime");
const apiKey = (
@@ -172,6 +198,9 @@ export default definePluginEntry({
})
)?.apiKey;
if (!apiKey) {
// Rebuilds publish atomically so catalog errors keep the prior snapshot.
// Missing credentials are the sole fail-closed clearing path.
dynamicModels.delete(scope);
return;
}
const providerConfig = await buildClawRouterProviderConfig({
+198
View File
@@ -0,0 +1,198 @@
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
import { describe, expect, it } from "vitest";
import plugin from "./index.js";
import { inspectPerplexityToolSchemas, normalizePerplexityToolSchemas } from "./tool-schemas.js";
function schemaContext(modelId: string, tools: unknown[]) {
return {
provider: "clawrouter",
modelId,
modelApi: "openai-responses",
model: {
provider: "clawrouter",
api: "openai-responses",
baseUrl: "https://clawrouter.openclaw.ai/v1",
id: modelId,
},
tools,
} as never;
}
describe("ClawRouter Perplexity tool schemas", () => {
it("normalizes exec-like object maps", () => {
const tools = [
{
name: "exec",
description: "Run a command",
parameters: {
type: "object",
properties: {
env: {
type: "object",
patternProperties: { "^.*$": { type: "string" } },
additionalProperties: { type: "string" },
},
},
additionalProperties: false,
},
},
];
const normalized = normalizePerplexityToolSchemas(schemaContext("perplexity/sonar-pro", tools));
expect(normalized[0]?.parameters).toEqual({
type: "object",
properties: {
env: { type: "object", properties: {} },
},
});
expect(inspectPerplexityToolSchemas(schemaContext("perplexity/sonar-pro", tools))).toEqual([
{
toolName: "exec",
toolIndex: 0,
violations: [
"exec.parameters.properties.env.patternProperties",
"exec.parameters.properties.env.additionalProperties",
"exec.parameters.additionalProperties",
"exec.parameters.properties.env.properties",
],
},
]);
});
it("normalizes nested unions, arrays, and definitions", () => {
const tools = [
{
name: "nested",
description: "Nested schemas",
parameters: {
type: "object",
anyOf: [
{ type: "object", additionalProperties: true },
{
type: "array",
items: {
type: "object",
properties: {
value: {
anyOf: [{ type: "object" }, { type: "string" }],
},
},
},
},
],
$defs: {
metadata: { type: "object", patternProperties: { ".*": { type: "string" } } },
},
},
},
];
const normalized = normalizePerplexityToolSchemas(schemaContext("perplexity/sonar-pro", tools));
expect(normalized[0]?.parameters).toEqual({
type: "object",
properties: {},
anyOf: [
{ type: "object", properties: {} },
{
type: "array",
items: {
type: "object",
properties: {
value: {
anyOf: [{ type: "object", properties: {} }, { type: "string" }],
},
},
},
},
],
$defs: {
metadata: { type: "object", properties: {} },
},
});
});
it("treats union types containing object as object schemas", () => {
const tools = [
{
name: "union",
description: "Union typed root",
parameters: {
type: "object",
properties: {
payload: { type: ["object", "null"], additionalProperties: { type: "string" } },
},
},
},
];
const normalized = normalizePerplexityToolSchemas(schemaContext("perplexity/sonar-pro", tools));
expect(normalized[0]?.parameters).toEqual({
type: "object",
properties: {
payload: { type: ["object", "null"], properties: {} },
},
});
expect(inspectPerplexityToolSchemas(schemaContext("perplexity/sonar-pro", tools))).toEqual([
{
toolName: "union",
toolIndex: 0,
violations: [
"union.parameters.properties.payload.additionalProperties",
"union.parameters.properties.payload.properties",
],
},
]);
});
it("traverses dependentSchemas and unevaluatedProperties containers", () => {
const tools = [
{
name: "containers",
description: "Less common schema containers",
parameters: {
type: "object",
properties: {},
dependentSchemas: {
x: { type: "object", additionalProperties: false },
},
unevaluatedProperties: { type: "object" },
additionalItems: { type: "object", additionalProperties: false },
},
},
];
const normalized = normalizePerplexityToolSchemas(schemaContext("perplexity/sonar-pro", tools));
expect(normalized[0]?.parameters).toEqual({
type: "object",
properties: {},
dependentSchemas: {
x: { type: "object", properties: {} },
},
unevaluatedProperties: { type: "object", properties: {} },
additionalItems: { type: "object", properties: {} },
});
});
it("routes only Perplexity models through the plugin-local normalizer", async () => {
const provider = await registerSingleProviderPlugin(plugin);
const tools = [
{
name: "exec",
description: "Run a command",
parameters: { type: "object", patternProperties: { ".*": { type: "string" } } },
},
];
const perplexity = provider?.normalizeToolSchemas?.(
schemaContext("PeRpLeXiTy/sonar-pro", tools),
);
const openai = provider?.normalizeToolSchemas?.(schemaContext("openai/gpt-5.5", tools));
expect(perplexity?.[0]?.parameters).toEqual({ type: "object", properties: {} });
expect(openai).toBe(tools);
});
});
+146
View File
@@ -0,0 +1,146 @@
import type {
AnyAgentTool,
ProviderNormalizeToolSchemasContext,
ProviderToolSchemaDiagnostic,
} from "openclaw/plugin-sdk/plugin-entry";
import { findUnsupportedSchemaKeywords } from "openclaw/plugin-sdk/provider-tools";
const PERPLEXITY_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([
"patternProperties",
"additionalProperties",
]);
const SCHEMA_MAP_KEYS = new Set([
"properties",
"$defs",
"definitions",
"dependentSchemas",
// Legacy `dependencies` mixes schema values with property-name arrays; the
// walker leaves non-record values untouched, so both forms stay valid.
"dependencies",
]);
const SCHEMA_VALUE_KEYS = new Set([
"items",
"additionalItems",
"prefixItems",
"anyOf",
"oneOf",
"allOf",
"then",
"else",
"if",
"not",
"contains",
"propertyNames",
"unevaluatedItems",
"unevaluatedProperties",
"contentSchema",
]);
function readRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
// JSON Schema allows `type` to be an array; a union containing "object" still
// admits objects, so it needs `properties` for Perplexity too.
function isObjectType(type: unknown): boolean {
return type === "object" || (Array.isArray(type) && type.includes("object"));
}
function normalizeSchemaMap(value: unknown): unknown {
const record = readRecord(value);
if (!record) {
return value;
}
return Object.fromEntries(
Object.entries(record).map(([key, schema]) => [key, normalizePerplexitySchema(schema)]),
);
}
function normalizePerplexitySchema(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(normalizePerplexitySchema);
}
const record = readRecord(value);
if (!record) {
return value;
}
const normalized: Record<string, unknown> = {};
for (const [key, child] of Object.entries(record)) {
if (PERPLEXITY_UNSUPPORTED_SCHEMA_KEYWORDS.has(key)) {
continue;
}
normalized[key] = SCHEMA_MAP_KEYS.has(key)
? normalizeSchemaMap(child)
: SCHEMA_VALUE_KEYS.has(key)
? normalizePerplexitySchema(child)
: child;
}
if (isObjectType(normalized.type) && !("properties" in normalized)) {
normalized.properties = {};
}
return normalized;
}
function findObjectSchemasMissingProperties(schema: unknown, path: string): string[] {
if (Array.isArray(schema)) {
return schema.flatMap((child, index) =>
findObjectSchemasMissingProperties(child, `${path}[${index}]`),
);
}
const record = readRecord(schema);
if (!record) {
return [];
}
const violations =
isObjectType(record.type) && !("properties" in record) ? [`${path}.properties`] : [];
for (const [key, child] of Object.entries(record)) {
if (SCHEMA_MAP_KEYS.has(key)) {
const schemas = readRecord(child);
if (schemas) {
for (const [name, nestedSchema] of Object.entries(schemas)) {
violations.push(
...findObjectSchemasMissingProperties(nestedSchema, `${path}.${key}.${name}`),
);
}
}
continue;
}
if (SCHEMA_VALUE_KEYS.has(key)) {
violations.push(...findObjectSchemasMissingProperties(child, `${path}.${key}`));
}
}
return violations;
}
export function normalizePerplexityToolSchemas(
ctx: ProviderNormalizeToolSchemasContext,
): AnyAgentTool[] {
return ctx.tools.map((tool) => {
if (!tool.parameters || typeof tool.parameters !== "object") {
return tool;
}
return {
...tool,
parameters: normalizePerplexitySchema(tool.parameters) as AnyAgentTool["parameters"],
};
});
}
export function inspectPerplexityToolSchemas(
ctx: ProviderNormalizeToolSchemasContext,
): ProviderToolSchemaDiagnostic[] {
return ctx.tools.flatMap((tool, toolIndex) => {
const path = `${tool.name}.parameters`;
const violations = [
...findUnsupportedSchemaKeywords(
tool.parameters,
path,
PERPLEXITY_UNSUPPORTED_SCHEMA_KEYWORDS,
),
...findObjectSchemasMissingProperties(tool.parameters, path),
];
return violations.length > 0 ? [{ toolName: tool.name, toolIndex, violations }] : [];
});
}
+1 -1
View File
@@ -145,7 +145,7 @@ export async function fetchClawRouterUsage(params: {
? [{ type: "spend", amount: costMicros / 1_000_000, unit: "USD" }]
: undefined;
return {
provider: "clawrouter" as ProviderUsageSnapshot["provider"],
provider: "clawrouter",
displayName: "ClawRouter",
windows,
...(billing ? { billing } : {}),