This commit is contained in:
decolua
2026-01-27 10:49:16 +07:00
parent d3dd8686fe
commit 79342c0c3e
17 changed files with 1586 additions and 214 deletions

View File

@@ -96,82 +96,289 @@ export function generateProjectId() {
return `${adj}-${noun}-${crypto.randomUUID().slice(0, 5)}`;
}
// Helper: Walk recursively through object/array and collect all paths for a given key
function walkAndCollectPaths(obj, currentPath, targetKey, paths) {
if (!obj || typeof obj !== "object") return;
if (Array.isArray(obj)) {
obj.forEach((item, index) => {
const newPath = currentPath ? `${currentPath}[${index}]` : `[${index}]`;
walkAndCollectPaths(item, newPath, targetKey, paths);
});
} else {
for (const [key, value] of Object.entries(obj)) {
const newPath = currentPath ? `${currentPath}.${key}` : key;
if (key === targetKey) {
paths.push(newPath);
}
if (value && typeof value === "object") {
walkAndCollectPaths(value, newPath, targetKey, paths);
}
}
}
}
// Helper: Get value at path
function getAtPath(obj, path) {
const parts = path.split(".");
let current = obj;
for (const part of parts) {
if (!current || typeof current !== "object") return undefined;
current = current[part];
}
return current;
}
// Helper: Set value at path
function setAtPath(obj, path, value) {
const parts = path.split(".");
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!current[part]) current[part] = {};
current = current[part];
}
current[parts[parts.length - 1]] = value;
}
// Helper: Delete a key at a specific path in nested object
function deleteAtPath(obj, path) {
const parts = path.split(".");
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!current[part]) return;
current = current[part];
}
const lastKey = parts[parts.length - 1];
delete current[lastKey];
}
// Convert const to enum
function convertConstToEnum(obj) {
if (!obj || typeof obj !== "object") return;
if (obj.const !== undefined && !obj.enum) {
obj.enum = [obj.const];
delete obj.const;
}
for (const value of Object.values(obj)) {
if (value && typeof value === "object") {
convertConstToEnum(value);
}
}
}
// Convert enum values to strings (Gemini requires string enum values)
function convertEnumValuesToStrings(obj) {
if (!obj || typeof obj !== "object") return;
if (obj.enum && Array.isArray(obj.enum)) {
obj.enum = obj.enum.map(v => String(v));
}
for (const value of Object.values(obj)) {
if (value && typeof value === "object") {
convertEnumValuesToStrings(value);
}
}
}
// Merge allOf schemas
function mergeAllOf(obj) {
if (!obj || typeof obj !== "object") return;
if (obj.allOf && Array.isArray(obj.allOf)) {
const merged = {};
for (const item of obj.allOf) {
if (item.properties) {
if (!merged.properties) merged.properties = {};
Object.assign(merged.properties, item.properties);
}
if (item.required && Array.isArray(item.required)) {
if (!merged.required) merged.required = [];
for (const req of item.required) {
if (!merged.required.includes(req)) {
merged.required.push(req);
}
}
}
}
delete obj.allOf;
if (merged.properties) obj.properties = { ...obj.properties, ...merged.properties };
if (merged.required) obj.required = [...(obj.required || []), ...merged.required];
}
for (const value of Object.values(obj)) {
if (value && typeof value === "object") {
mergeAllOf(value);
}
}
}
// Select best schema from anyOf/oneOf
function selectBest(items) {
let bestIdx = 0;
let bestScore = -1;
for (let i = 0; i < items.length; i++) {
const item = items[i];
let score = 0;
const type = item.type;
if (type === "object" || item.properties) {
score = 3;
} else if (type === "array" || item.items) {
score = 2;
} else if (type && type !== "null") {
score = 1;
}
if (score > bestScore) {
bestScore = score;
bestIdx = i;
}
}
return bestIdx;
}
// Flatten anyOf/oneOf
function flattenAnyOfOneOf(obj) {
if (!obj || typeof obj !== "object") return;
if (obj.anyOf && Array.isArray(obj.anyOf) && obj.anyOf.length > 0) {
const nonNullSchemas = obj.anyOf.filter(s => s && s.type !== "null");
if (nonNullSchemas.length > 0) {
const bestIdx = selectBest(nonNullSchemas);
const selected = nonNullSchemas[bestIdx];
delete obj.anyOf;
Object.assign(obj, selected);
}
}
if (obj.oneOf && Array.isArray(obj.oneOf) && obj.oneOf.length > 0) {
const nonNullSchemas = obj.oneOf.filter(s => s && s.type !== "null");
if (nonNullSchemas.length > 0) {
const bestIdx = selectBest(nonNullSchemas);
const selected = nonNullSchemas[bestIdx];
delete obj.oneOf;
Object.assign(obj, selected);
}
}
for (const value of Object.values(obj)) {
if (value && typeof value === "object") {
flattenAnyOfOneOf(value);
}
}
}
// Flatten type arrays
function flattenTypeArrays(obj) {
if (!obj || typeof obj !== "object") return;
if (obj.type && Array.isArray(obj.type)) {
const nonNullTypes = obj.type.filter(t => t !== "null");
obj.type = nonNullTypes.length > 0 ? nonNullTypes[0] : "string";
}
for (const value of Object.values(obj)) {
if (value && typeof value === "object") {
flattenTypeArrays(value);
}
}
}
// Clean JSON Schema for Antigravity API compatibility - removes unsupported keywords recursively
// Reference: CLIProxyAPI/internal/util/gemini_schema.go
export function cleanJSONSchemaForAntigravity(schema) {
if (!schema || typeof schema !== "object") return schema;
// Handle anyOf/oneOf - extract the first non-null schema
if (schema.anyOf && Array.isArray(schema.anyOf)) {
const nonNullSchema = schema.anyOf.find(s => s.type !== "null" && s.type !== null);
if (nonNullSchema) {
const baseSchema = { ...nonNullSchema };
// Copy other properties from parent schema (except unsupported ones)
for (const [key, value] of Object.entries(schema)) {
if (!UNSUPPORTED_SCHEMA_CONSTRAINTS.includes(key)) {
baseSchema[key] = value;
}
}
return cleanJSONSchemaForAntigravity(baseSchema);
}
}
// Deep clone to avoid mutating original
let cleaned = JSON.parse(JSON.stringify(schema));
if (schema.oneOf && Array.isArray(schema.oneOf)) {
const nonNullSchema = schema.oneOf.find(s => s.type !== "null" && s.type !== null);
if (nonNullSchema) {
const baseSchema = { ...nonNullSchema };
// Copy other properties from parent schema (except unsupported ones)
for (const [key, value] of Object.entries(schema)) {
if (!UNSUPPORTED_SCHEMA_CONSTRAINTS.includes(key)) {
baseSchema[key] = value;
}
}
return cleanJSONSchemaForAntigravity(baseSchema);
}
}
// Phase 1: Convert and prepare
convertConstToEnum(cleaned);
convertEnumValuesToStrings(cleaned);
const cleaned = Array.isArray(schema) ? [] : {};
// Phase 2: Flatten complex structures
mergeAllOf(cleaned);
flattenAnyOfOneOf(cleaned);
flattenTypeArrays(cleaned);
for (const [key, value] of Object.entries(schema)) {
if (UNSUPPORTED_SCHEMA_CONSTRAINTS.includes(key)) continue;
// Phase 3: Remove all unsupported keywords at ALL levels
for (const keyword of UNSUPPORTED_SCHEMA_CONSTRAINTS) {
const paths = [];
walkAndCollectPaths(cleaned, "", keyword, paths);
// Handle type array like ["string", "null"] - Gemini only supports single type
if (key === "type" && Array.isArray(value)) {
const nonNullType = value.find(t => t !== "null") || "string";
cleaned[key] = nonNullType;
continue;
// Sort by depth (deepest first) to avoid path invalidation
paths.sort((a, b) => b.split(".").length - a.split(".").length);
for (const path of paths) {
deleteAtPath(cleaned, path);
}
}
// Phase 4: Cleanup required fields recursively
function cleanupRequired(obj) {
if (!obj || typeof obj !== "object") return;
if (obj.required && Array.isArray(obj.required) && obj.properties) {
const validRequired = obj.required.filter(field =>
Object.prototype.hasOwnProperty.call(obj.properties, field)
);
if (validRequired.length === 0) {
delete obj.required;
} else {
obj.required = validRequired;
}
}
if (value && typeof value === "object") {
cleaned[key] = cleanJSONSchemaForAntigravity(value);
} else {
cleaned[key] = value;
// Recurse into nested objects
for (const value of Object.values(obj)) {
if (value && typeof value === "object") {
cleanupRequired(value);
}
}
}
// Cleanup required fields - only keep fields that exist in properties
if (cleaned.required && Array.isArray(cleaned.required) && cleaned.properties) {
const validRequired = cleaned.required.filter(field =>
Object.prototype.hasOwnProperty.call(cleaned.properties, field)
);
if (validRequired.length === 0) {
delete cleaned.required;
} else {
cleaned.required = validRequired;
cleanupRequired(cleaned);
// Phase 5: Add placeholder for empty object schemas (Antigravity requirement)
function addPlaceholders(obj) {
if (!obj || typeof obj !== "object") return;
if (obj.type === "object") {
if (!obj.properties || Object.keys(obj.properties).length === 0) {
obj.properties = {
reason: {
type: "string",
description: "Brief explanation of why you are calling this tool"
}
};
obj.required = ["reason"];
}
}
// Recurse into nested objects
for (const value of Object.values(obj)) {
if (value && typeof value === "object") {
addPlaceholders(value);
}
}
}
// Add placeholder for empty object schemas (Antigravity requirement)
if (cleaned.type === "object") {
if (!cleaned.properties || Object.keys(cleaned.properties).length === 0) {
cleaned.properties = {
reason: {
type: "string",
description: "Brief explanation of why you are calling this tool"
}
};
cleaned.required = ["reason"];
}
}
addPlaceholders(cleaned);
return cleaned;
}