mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(clawrouter): normalize Perplexity tool schemas and harden dynamic model cache (#108758)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }] : [];
|
||||
});
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
|
||||
Reference in New Issue
Block a user