fix(plugin-sdk): guard provider tool schema helpers

This commit is contained in:
Vincent Koc
2026-06-04 09:53:00 +02:00
parent 51474b6f15
commit a3030e6d6f
2 changed files with 204 additions and 31 deletions

View File

@@ -29,6 +29,39 @@ describe("buildProviderToolCompatFamilyHooks", () => {
return normalized[0]?.parameters;
}
function makeUnreadableParameterTool() {
const tool = {
name: "broken_tool",
description: "",
parameters: {},
};
Object.defineProperty(tool, "parameters", {
enumerable: true,
get() {
throw new Error("fuzzplugin parameters getter exploded");
},
});
return tool;
}
function makeUnreadableNestedSchemaTool() {
const tool = {
name: "nested_broken_tool",
description: "",
parameters: {
type: "object",
properties: {},
},
};
Object.defineProperty(tool.parameters, "properties", {
enumerable: true,
get() {
throw new Error("fuzzplugin properties getter exploded");
},
});
return tool;
}
it("covers the tool compat family matrix", () => {
const cases = [
{
@@ -56,6 +89,85 @@ describe("buildProviderToolCompatFamilyHooks", () => {
}
});
it("skips unreadable tool schemas while normalizing provider compat families", () => {
const broken = makeUnreadableParameterTool();
const nestedBroken = makeUnreadableNestedSchemaTool();
const healthy = {
name: "healthy_tool",
description: "",
parameters: {
type: "object",
properties: {
mode: {
anyOf: [{ const: "a", type: "string" }, { const: "b", type: "string" }],
},
},
},
};
const hooks = buildProviderToolCompatFamilyHooks("deepseek");
const normalized = hooks.normalizeToolSchemas({
provider: "deepseek",
modelId: "deepseek-v4-pro",
modelApi: "openai-completions",
model: {
provider: "deepseek",
api: "openai-completions",
id: "deepseek-v4-pro",
} as never,
tools: [broken, nestedBroken, healthy] as never,
});
expect(normalized[0]).toBe(broken);
expect(normalized[1]).toBe(nestedBroken);
expect(normalized[2]?.parameters).toEqual({
type: "object",
properties: {
mode: {
type: "string",
enum: ["a", "b"],
},
},
});
});
it("reports provider schema diagnostics without crashing on unreadable tools", () => {
const broken = makeUnreadableParameterTool();
const nestedBroken = makeUnreadableNestedSchemaTool();
const healthy = {
name: "healthy_tool",
description: "",
parameters: {
type: "object",
properties: {
nested: { anyOf: [{ type: "string" }, { type: "number" }] },
},
},
};
const hooks = buildProviderToolCompatFamilyHooks("deepseek");
expect(
hooks.inspectToolSchemas({
provider: "deepseek",
modelId: "deepseek-v4-pro",
modelApi: "openai-completions",
model: {
provider: "deepseek",
api: "openai-completions",
id: "deepseek-v4-pro",
} as never,
tools: [broken, nestedBroken, healthy] as never,
}),
).toEqual([
{
toolName: "healthy_tool",
toolIndex: 2,
violations: ["healthy_tool.parameters.properties.nested.anyOf"],
},
]);
});
it("normalizes canonical OpenAI Codex Responses tool schemas", () => {
const hooks = buildProviderToolCompatFamilyHooks("openai");
const tools = [{ name: "demo", description: "", parameters: {} }] as never;

View File

@@ -12,6 +12,40 @@ import type {
export { cleanSchemaForGemini, GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS, stripUnsupportedSchemaKeywords };
type ProviderToolSchemaSnapshot = {
name: string;
parameters: unknown;
};
function readProviderToolSchemaSnapshot(
tool: AnyAgentTool,
toolIndex: number,
): ProviderToolSchemaSnapshot | undefined {
try {
const rawName = tool.name;
const name = typeof rawName === "string" && rawName.trim() ? rawName : `tool[${toolIndex}]`;
return { name, parameters: tool.parameters };
} catch {
return undefined;
}
}
function isSchemaRecord(schema: unknown): schema is Record<string, unknown> {
return Boolean(schema) && typeof schema === "object" && !Array.isArray(schema);
}
function findUnsupportedSchemaKeywordsSafe(
schema: unknown,
path: string,
unsupportedKeywords: ReadonlySet<string>,
): string[] | undefined {
try {
return findUnsupportedSchemaKeywords(schema, path, unsupportedKeywords);
} catch {
return undefined;
}
}
/**
* Finds unsupported JSON-schema keywords and reports their nested schema paths.
*/
@@ -67,14 +101,19 @@ export function normalizeGeminiToolSchemas(
/** Provider tool-schema normalization context containing the active tool list. */
ctx: ProviderNormalizeToolSchemasContext,
): AnyAgentTool[] {
return ctx.tools.map((tool) => {
if (!tool.parameters || typeof tool.parameters !== "object") {
return ctx.tools.map((tool, toolIndex) => {
const snapshot = readProviderToolSchemaSnapshot(tool, toolIndex);
if (!snapshot || !isSchemaRecord(snapshot.parameters)) {
return tool;
}
try {
return {
...tool,
parameters: cleanSchemaForGemini(snapshot.parameters),
};
} catch {
return tool;
}
return {
...tool,
parameters: cleanSchemaForGemini(tool.parameters),
};
});
}
@@ -86,15 +125,19 @@ export function inspectGeminiToolSchemas(
ctx: ProviderNormalizeToolSchemasContext,
): ProviderToolSchemaDiagnostic[] {
return ctx.tools.flatMap((tool, toolIndex) => {
const violations = findUnsupportedSchemaKeywords(
tool.parameters,
`${tool.name}.parameters`,
GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS,
);
if (violations.length === 0) {
const snapshot = readProviderToolSchemaSnapshot(tool, toolIndex);
if (!snapshot) {
return [];
}
return [{ toolName: tool.name, toolIndex, violations }];
const violations = findUnsupportedSchemaKeywordsSafe(
snapshot.parameters,
`${snapshot.name}.parameters`,
GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS,
);
if (!violations || violations.length === 0) {
return [];
}
return [{ toolName: snapshot.name, toolIndex, violations }];
});
}
@@ -108,20 +151,28 @@ export function normalizeOpenAIToolSchemas(
if (!shouldApplyOpenAIToolCompat(ctx)) {
return ctx.tools;
}
return ctx.tools.map((tool) => {
if (tool.parameters == null) {
return ctx.tools.map((tool, toolIndex) => {
const snapshot = readProviderToolSchemaSnapshot(tool, toolIndex);
if (!snapshot) {
return tool;
}
if (snapshot.parameters == null) {
return {
...tool,
parameters: normalizeOpenAIStrictCompatSchema({}),
};
}
if (typeof tool.parameters !== "object") {
if (!isSchemaRecord(snapshot.parameters)) {
return tool;
}
try {
return {
...tool,
parameters: normalizeOpenAIStrictCompatSchema(snapshot.parameters),
};
} catch {
return tool;
}
return {
...tool,
parameters: normalizeOpenAIStrictCompatSchema(tool.parameters),
};
});
}
@@ -503,12 +554,18 @@ export function normalizeDeepSeekToolSchemas(
/** Provider tool-schema normalization context containing the active tool list. */
ctx: ProviderNormalizeToolSchemasContext,
): AnyAgentTool[] {
return ctx.tools.map((tool) => {
if (!tool.parameters || typeof tool.parameters !== "object") {
return ctx.tools.map((tool, toolIndex) => {
const snapshot = readProviderToolSchemaSnapshot(tool, toolIndex);
if (!snapshot || !isSchemaRecord(snapshot.parameters)) {
return tool;
}
const parameters = normalizeDeepSeekSchema(tool.parameters);
return parameters === tool.parameters
let parameters: unknown;
try {
parameters = normalizeDeepSeekSchema(snapshot.parameters);
} catch {
return tool;
}
return parameters === snapshot.parameters
? tool
: {
...tool,
@@ -525,15 +582,19 @@ export function inspectDeepSeekToolSchemas(
ctx: ProviderNormalizeToolSchemasContext,
): ProviderToolSchemaDiagnostic[] {
return ctx.tools.flatMap((tool, toolIndex) => {
const violations = findUnsupportedSchemaKeywords(
tool.parameters,
`${tool.name}.parameters`,
DEEPSEEK_UNSUPPORTED_SCHEMA_KEYWORDS,
);
if (violations.length === 0) {
const snapshot = readProviderToolSchemaSnapshot(tool, toolIndex);
if (!snapshot) {
return [];
}
return [{ toolName: tool.name, toolIndex, violations }];
const violations = findUnsupportedSchemaKeywordsSafe(
snapshot.parameters,
`${snapshot.name}.parameters`,
DEEPSEEK_UNSUPPORTED_SCHEMA_KEYWORDS,
);
if (!violations || violations.length === 0) {
return [];
}
return [{ toolName: snapshot.name, toolIndex, violations }];
});
}