Merge pull request #63 from Parasitic-Hollow/fix/webfetch-format-validation

fix: WebFetch tool format parameter validation with Claude models
This commit is contained in:
Tinh To
2025-12-27 23:27:24 +07:00
committed by GitHub
3 changed files with 256 additions and 11 deletions

View File

@@ -43,7 +43,7 @@ If the agent only installs the plugin, ask it to also add models under `provider
```json
{
"plugin": ["opencode-antigravity-auth@1.2.4"]
"plugin": ["opencode-antigravity-auth@1.2.6"]
}
```
@@ -61,7 +61,7 @@ If the agent only installs the plugin, ask it to also add models under `provider
```json
{
"plugin": ["opencode-antigravity-auth@1.2.4"],
"plugin": ["opencode-antigravity-auth@1.2.6"],
"provider": {
"google": {
"models": {
@@ -111,14 +111,14 @@ mkdir -p ~/.config/opencode
if [ -f ~/.config/opencode/opencode.json ]; then
if command -v jq &> /dev/null; then
jq '.plugin = ((.plugin // []) + ["opencode-antigravity-auth@1.2.4"] | unique)' \
jq '.plugin = ((.plugin // []) + ["opencode-antigravity-auth@1.2.6"] | unique)' \
~/.config/opencode/opencode.json > /tmp/oc.json && \
mv /tmp/oc.json ~/.config/opencode/opencode.json
else
echo "Add \"opencode-antigravity-auth@1.2.4\" to the plugin array manually"
echo "Add \"opencode-antigravity-auth@1.2.6\" to the plugin array manually"
fi
else
echo '{"plugin":["opencode-antigravity-auth@1.2.4"]}' > ~/.config/opencode/opencode.json
echo '{"plugin":["opencode-antigravity-auth@1.2.6"]}' > ~/.config/opencode/opencode.json
fi
```
@@ -207,7 +207,7 @@ Version 1.2.5 introduces significant reliability improvements for Claude models.
```json
{
"plugin": ["opencode-antigravity-auth@1.2.5"]
"plugin": ["opencode-antigravity-auth@1.2.6"]
}
```

View File

@@ -17,6 +17,7 @@ import {
findOrphanedToolUseIds,
fixClaudeToolPairing,
validateAndFixClaudeToolPairing,
cleanJSONSchemaForAntigravity,
} from "./request-helpers";
describe("sanitizeThinkingPart (covered via filtering)", () => {
@@ -1107,3 +1108,161 @@ describe("validateAndFixClaudeToolPairing", () => {
expect(validateAndFixClaudeToolPairing([])).toEqual([]);
});
});
describe("cleanJSONSchemaForAntigravity", () => {
describe("enum merging from anyOf/oneOf", () => {
it("merges anyOf with const values into enum (WebFetch format pattern)", () => {
// This is the exact pattern used by WebFetch's format parameter
const schema = {
type: "object",
properties: {
format: {
anyOf: [
{ const: "text" },
{ const: "markdown" },
{ const: "html" },
],
},
},
};
const result = cleanJSONSchemaForAntigravity(schema);
expect(result.properties.format.enum).toEqual(["text", "markdown", "html"]);
expect(result.properties.format.anyOf).toBeUndefined();
expect(result.properties.format.type).toBe("string");
});
it("merges oneOf with const values into enum", () => {
const schema = {
type: "object",
properties: {
status: {
oneOf: [
{ const: "pending" },
{ const: "active" },
{ const: "completed" },
],
},
},
};
const result = cleanJSONSchemaForAntigravity(schema);
expect(result.properties.status.enum).toEqual(["pending", "active", "completed"]);
expect(result.properties.status.oneOf).toBeUndefined();
});
it("merges anyOf with single-value enums into combined enum", () => {
const schema = {
type: "object",
properties: {
level: {
anyOf: [
{ enum: ["low"] },
{ enum: ["medium"] },
{ enum: ["high"] },
],
},
},
};
const result = cleanJSONSchemaForAntigravity(schema);
expect(result.properties.level.enum).toEqual(["low", "medium", "high"]);
});
it("merges anyOf with multi-value enums", () => {
const schema = {
type: "object",
properties: {
color: {
anyOf: [
{ enum: ["red", "blue"] },
{ enum: ["green", "yellow"] },
],
},
},
};
const result = cleanJSONSchemaForAntigravity(schema);
expect(result.properties.color.enum).toEqual(["red", "blue", "green", "yellow"]);
});
it("does not merge anyOf with complex types (not enum pattern)", () => {
const schema = {
type: "object",
properties: {
data: {
anyOf: [
{ type: "string" },
{ type: "number" },
],
},
},
};
const result = cleanJSONSchemaForAntigravity(schema);
// Should flatten to first option, not create enum
expect(result.properties.data.enum).toBeUndefined();
expect(result.properties.data.type).toBe("string");
});
it("preserves parent description when merging enum", () => {
const schema = {
type: "object",
properties: {
format: {
description: "Output format for the content",
anyOf: [
{ const: "text" },
{ const: "markdown" },
],
},
},
};
const result = cleanJSONSchemaForAntigravity(schema);
expect(result.properties.format.enum).toEqual(["text", "markdown"]);
expect(result.properties.format.description).toContain("Output format");
});
});
it("adds enum hints to description", () => {
const schema = {
type: "object",
properties: {
status: {
type: "string",
enum: ["active", "inactive", "pending"],
},
},
};
const result = cleanJSONSchemaForAntigravity(schema);
expect(result.properties.status.description).toContain("Allowed:");
expect(result.properties.status.description).toContain("active");
expect(result.properties.status.description).toContain("inactive");
expect(result.properties.status.description).toContain("pending");
});
it("preserves existing enum array", () => {
const schema = {
type: "object",
properties: {
level: {
type: "string",
enum: ["low", "medium", "high"],
},
},
};
const result = cleanJSONSchemaForAntigravity(schema);
expect(result.properties.level.enum).toEqual(["low", "medium", "high"]);
});
});

View File

@@ -293,10 +293,70 @@ function scoreSchemaOption(schema: any): { score: number; typeName: string } {
return { score: 0, typeName: type || "null" };
}
/**
* Checks if an anyOf/oneOf array represents enum choices.
* Returns the merged enum values if so, otherwise null.
*
* Handles patterns like:
* - anyOf: [{ const: "a" }, { const: "b" }]
* - anyOf: [{ enum: ["a"] }, { enum: ["b"] }]
* - anyOf: [{ type: "string", const: "a" }, { type: "string", const: "b" }]
*/
function tryMergeEnumFromUnion(options: any[]): string[] | null {
if (!Array.isArray(options) || options.length === 0) {
return null;
}
const enumValues: string[] = [];
for (const option of options) {
if (!option || typeof option !== "object") {
return null;
}
// Check for const value
if (option.const !== undefined) {
enumValues.push(String(option.const));
continue;
}
// Check for single-value enum
if (Array.isArray(option.enum) && option.enum.length === 1) {
enumValues.push(String(option.enum[0]));
continue;
}
// Check for multi-value enum (merge all values)
if (Array.isArray(option.enum) && option.enum.length > 0) {
for (const val of option.enum) {
enumValues.push(String(val));
}
continue;
}
// If option has complex structure (properties, items, etc.), it's not a simple enum
if (option.properties || option.items || option.anyOf || option.oneOf || option.allOf) {
return null;
}
// If option has only type (no const/enum), it's not an enum pattern
if (option.type && !option.const && !option.enum) {
return null;
}
}
// Only return if we found actual enum values
return enumValues.length > 0 ? enumValues : null;
}
/**
* Phase 2b: Flattens anyOf/oneOf to the best option with type hints.
* { anyOf: [{ type: "string" }, { type: "number" }] }
* → { type: "string", description: "(Accepts: string | number)" }
*
* Special handling for enum patterns:
* { anyOf: [{ const: "a" }, { const: "b" }] }
* → { type: "string", enum: ["a", "b"] }
*/
function flattenAnyOfOneOf(schema: any): any {
if (!schema || typeof schema !== "object") {
@@ -315,6 +375,25 @@ function flattenAnyOfOneOf(schema: any): any {
const options = result[unionKey];
const parentDesc = typeof result.description === "string" ? result.description : "";
// First, check if this is an enum pattern (anyOf with const/enum values)
// This is crucial for tools like WebFetch where format: anyOf[{const:"text"},{const:"markdown"},{const:"html"}]
const mergedEnum = tryMergeEnumFromUnion(options);
if (mergedEnum !== null) {
// This is an enum pattern - merge all values into a single enum
const { [unionKey]: _, ...rest } = result;
result = {
...rest,
type: "string",
enum: mergedEnum,
};
// Preserve parent description
if (parentDesc) {
result.description = parentDesc;
}
continue;
}
// Not an enum pattern - use standard flattening logic
// Score each option and find the best
let bestIdx = 0;
let bestScore = -1;
@@ -448,26 +527,33 @@ function flattenTypeArrays(schema: any, nullableFields?: Map<string, string[]>,
/**
* Phase 3: Removes unsupported keywords after hints have been extracted.
*
* IMPORTANT: Only removes JSON Schema keywords at the schema level, NOT property names.
* For example, the JSON Schema `format` keyword (like `format: "email"`) should be removed,
* but a property named `format` inside `properties` should be preserved.
*/
function removeUnsupportedKeywords(schema: any): any {
function removeUnsupportedKeywords(schema: any, isInsideProperties = false): any {
if (!schema || typeof schema !== "object") {
return schema;
}
if (Array.isArray(schema)) {
return schema.map(item => removeUnsupportedKeywords(item));
return schema.map(item => removeUnsupportedKeywords(item, false));
}
const result: any = {};
for (const [key, value] of Object.entries(schema)) {
// Skip unsupported keywords
if ((UNSUPPORTED_KEYWORDS as readonly string[]).includes(key)) {
// Skip unsupported keywords, BUT NOT if we're inside a "properties" object
// because those are property NAMES, not JSON Schema keywords
if (!isInsideProperties && (UNSUPPORTED_KEYWORDS as readonly string[]).includes(key)) {
continue;
}
// Recursively process nested objects
// When processing "properties", mark that we're inside properties so child keys aren't filtered
if (typeof value === "object" && value !== null) {
result[key] = removeUnsupportedKeywords(value);
const isProperties = key === "properties";
result[key] = removeUnsupportedKeywords(value, isProperties);
} else {
result[key] = value;
}