fix: convert schema types to Google uppercase format (fixes #82)
The /compact command was failing with 'Proto field is not repeating, cannot start list' error for Claude models because tool schemas were sent with lowercase JSON Schema types (array, object, string) but Google's Cloud Code API expects uppercase protobuf types (ARRAY, OBJECT, STRING). Changes: - Add toGoogleType() function to convert JSON Schema types to Google format - Add Phase 5 to cleanSchemaForGemini() for type conversion - Apply cleanSchemaForGemini() for ALL models (not just Gemini) since all requests go through Cloud Code API which validates schema format - Add comprehensive test suite with 10 tests covering nested arrays, complex schemas, and real-world Claude Code tool scenarios Fixes #82
This commit is contained in:
@@ -210,10 +210,11 @@ export function convertAnthropicToGoogle(anthropicRequest) {
|
||||
// Sanitize schema for general compatibility
|
||||
let parameters = sanitizeSchema(schema);
|
||||
|
||||
// For Gemini models, apply additional cleaning for VALIDATED mode
|
||||
if (isGeminiModel) {
|
||||
parameters = cleanSchemaForGemini(parameters);
|
||||
}
|
||||
// Apply Google-format cleaning for ALL models since they all go through
|
||||
// Cloud Code API which validates schemas using Google's protobuf format.
|
||||
// This fixes issue #82: /compact command fails with schema transformation error
|
||||
// "Proto field is not repeating, cannot start list" for Claude models.
|
||||
parameters = cleanSchemaForGemini(parameters);
|
||||
|
||||
return {
|
||||
name: String(name).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64),
|
||||
|
||||
@@ -564,6 +564,27 @@ export function sanitizeSchema(schema) {
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert JSON Schema type names to Google's Protobuf-style uppercase type names.
|
||||
* Google's Generative AI API expects uppercase types: STRING, OBJECT, ARRAY, etc.
|
||||
*
|
||||
* @param {string} type - JSON Schema type name (lowercase)
|
||||
* @returns {string} Google-format type name (uppercase)
|
||||
*/
|
||||
function toGoogleType(type) {
|
||||
if (!type || typeof type !== 'string') return type;
|
||||
const typeMap = {
|
||||
'string': 'STRING',
|
||||
'number': 'NUMBER',
|
||||
'integer': 'INTEGER',
|
||||
'boolean': 'BOOLEAN',
|
||||
'array': 'ARRAY',
|
||||
'object': 'OBJECT',
|
||||
'null': 'STRING' // Fallback for null type
|
||||
};
|
||||
return typeMap[type.toLowerCase()] || type.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans JSON schema for Gemini API compatibility.
|
||||
* Uses a multi-phase pipeline matching opencode-antigravity-auth approach.
|
||||
@@ -642,5 +663,11 @@ export function cleanSchemaForGemini(schema) {
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 5: Convert type to Google's uppercase format (STRING, OBJECT, ARRAY, etc.)
|
||||
// Only convert at current level - nested types already converted by recursive cleanSchemaForGemini calls
|
||||
if (result.type && typeof result.type === 'string') {
|
||||
result.type = toGoogleType(result.type);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
269
tests/test-schema-sanitizer.cjs
Normal file
269
tests/test-schema-sanitizer.cjs
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Test Schema Sanitizer - Tests for schema transformation for Google API
|
||||
*
|
||||
* Verifies that complex nested array schemas are properly converted to
|
||||
* Google's protobuf format (uppercase type names) to fix issue #82:
|
||||
* "Proto field is not repeating, cannot start list"
|
||||
*/
|
||||
|
||||
// Import the schema sanitizer functions
|
||||
const path = require('path');
|
||||
|
||||
// Since we're in CommonJS and the module is ESM, we need to use dynamic import
|
||||
async function runTests() {
|
||||
console.log('╔══════════════════════════════════════════════════════════════╗');
|
||||
console.log('║ SCHEMA SANITIZER TEST SUITE ║');
|
||||
console.log('╚══════════════════════════════════════════════════════════════╝\n');
|
||||
|
||||
// Dynamic import for ESM module
|
||||
const { sanitizeSchema, cleanSchemaForGemini } = await import('../src/format/schema-sanitizer.js');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(`✓ ${name}`);
|
||||
passed++;
|
||||
} catch (e) {
|
||||
console.log(`✗ ${name}`);
|
||||
console.log(` Error: ${e.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
function assertEqual(actual, expected, message = '') {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(`${message}\nExpected: ${JSON.stringify(expected, null, 2)}\nActual: ${JSON.stringify(actual, null, 2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertIncludes(actual, substring, message = '') {
|
||||
if (!JSON.stringify(actual).includes(substring)) {
|
||||
throw new Error(`${message}\nExpected to include: ${substring}\nActual: ${JSON.stringify(actual, null, 2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test 1: Basic type conversion to uppercase
|
||||
test('Basic type conversion to uppercase', () => {
|
||||
const schema = { type: 'string', description: 'A test string' };
|
||||
const result = cleanSchemaForGemini(sanitizeSchema(schema));
|
||||
assertEqual(result.type, 'STRING', 'Type should be uppercase STRING');
|
||||
});
|
||||
|
||||
// Test 2: Object type conversion
|
||||
test('Object type conversion', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
age: { type: 'integer' }
|
||||
}
|
||||
};
|
||||
const result = cleanSchemaForGemini(sanitizeSchema(schema));
|
||||
assertEqual(result.type, 'OBJECT', 'Object type should be uppercase');
|
||||
assertEqual(result.properties.name.type, 'STRING', 'Nested string type should be uppercase');
|
||||
assertEqual(result.properties.age.type, 'INTEGER', 'Nested integer type should be uppercase');
|
||||
});
|
||||
|
||||
// Test 3: Array type conversion (the main bug fix)
|
||||
test('Array type conversion with items', () => {
|
||||
const schema = {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string'
|
||||
}
|
||||
};
|
||||
const result = cleanSchemaForGemini(sanitizeSchema(schema));
|
||||
assertEqual(result.type, 'ARRAY', 'Array type should be uppercase ARRAY');
|
||||
assertEqual(result.items.type, 'STRING', 'Items type should be uppercase STRING');
|
||||
});
|
||||
|
||||
// Test 4: Nested array inside object (the actual bug scenario)
|
||||
test('Nested array inside object (Claude Code TodoWrite-style schema)', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
todos: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
title: { type: 'string' },
|
||||
status: { type: 'string' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const result = cleanSchemaForGemini(sanitizeSchema(schema));
|
||||
|
||||
assertEqual(result.type, 'OBJECT', 'Root type should be OBJECT');
|
||||
assertEqual(result.properties.todos.type, 'ARRAY', 'Todos type should be ARRAY');
|
||||
assertEqual(result.properties.todos.items.type, 'OBJECT', 'Items type should be OBJECT');
|
||||
assertEqual(result.properties.todos.items.properties.id.type, 'INTEGER', 'id type should be INTEGER');
|
||||
assertEqual(result.properties.todos.items.properties.title.type, 'STRING', 'title type should be STRING');
|
||||
});
|
||||
|
||||
// Test 5: Complex nested structure (simulating Claude Code tools)
|
||||
test('Complex nested structure with multiple array levels', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tasks: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
subtasks: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
step: { type: 'string' },
|
||||
completed: { type: 'boolean' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
count: { type: 'number' }
|
||||
}
|
||||
};
|
||||
const result = cleanSchemaForGemini(sanitizeSchema(schema));
|
||||
|
||||
assertEqual(result.type, 'OBJECT');
|
||||
assertEqual(result.properties.tasks.type, 'ARRAY');
|
||||
assertEqual(result.properties.tasks.items.type, 'OBJECT');
|
||||
assertEqual(result.properties.tasks.items.properties.subtasks.type, 'ARRAY');
|
||||
assertEqual(result.properties.tasks.items.properties.subtasks.items.type, 'OBJECT');
|
||||
assertEqual(result.properties.tasks.items.properties.subtasks.items.properties.completed.type, 'BOOLEAN');
|
||||
assertEqual(result.properties.count.type, 'NUMBER');
|
||||
});
|
||||
|
||||
// Test 6: cleanSchemaForGemini handles anyOf (when not stripped by sanitizeSchema)
|
||||
test('cleanSchemaForGemini handles anyOf and converts types', () => {
|
||||
// Test cleanSchemaForGemini directly with anyOf (bypassing sanitizeSchema)
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
value: {
|
||||
anyOf: [
|
||||
{ type: 'string' },
|
||||
{ type: 'object', properties: { name: { type: 'string' } } }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
const result = cleanSchemaForGemini(schema);
|
||||
|
||||
assertEqual(result.type, 'OBJECT');
|
||||
// anyOf gets flattened to best option (object type scores highest)
|
||||
assertEqual(result.properties.value.type, 'OBJECT');
|
||||
});
|
||||
|
||||
// Test 7: Schema with type array (nullable)
|
||||
test('Schema with type array (nullable) gets flattened and converted', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
optional: {
|
||||
type: ['string', 'null']
|
||||
}
|
||||
}
|
||||
};
|
||||
const result = cleanSchemaForGemini(sanitizeSchema(schema));
|
||||
|
||||
assertEqual(result.type, 'OBJECT');
|
||||
assertEqual(result.properties.optional.type, 'STRING');
|
||||
});
|
||||
|
||||
// Test 8: All primitive types
|
||||
test('All primitive types converted correctly', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
str: { type: 'string' },
|
||||
num: { type: 'number' },
|
||||
int: { type: 'integer' },
|
||||
bool: { type: 'boolean' },
|
||||
arr: { type: 'array', items: { type: 'string' } },
|
||||
obj: { type: 'object', properties: { x: { type: 'string' } } }
|
||||
}
|
||||
};
|
||||
const result = cleanSchemaForGemini(sanitizeSchema(schema));
|
||||
|
||||
assertEqual(result.properties.str.type, 'STRING');
|
||||
assertEqual(result.properties.num.type, 'NUMBER');
|
||||
assertEqual(result.properties.int.type, 'INTEGER');
|
||||
assertEqual(result.properties.bool.type, 'BOOLEAN');
|
||||
assertEqual(result.properties.arr.type, 'ARRAY');
|
||||
assertEqual(result.properties.obj.type, 'OBJECT');
|
||||
});
|
||||
|
||||
// Test 9: Empty schema gets placeholder with correct types
|
||||
test('Empty schema gets placeholder with uppercase types', () => {
|
||||
const result = cleanSchemaForGemini(sanitizeSchema(null));
|
||||
|
||||
assertEqual(result.type, 'OBJECT');
|
||||
assertEqual(result.properties.reason.type, 'STRING');
|
||||
});
|
||||
|
||||
// Test 10: Real-world Claude Code tool schema simulation
|
||||
test('Real-world Claude Code ManageTodoList-style schema', () => {
|
||||
// Simulates the type of schema that caused issue #82
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
operation: {
|
||||
type: 'string',
|
||||
enum: ['write', 'read']
|
||||
},
|
||||
todoList: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'number' },
|
||||
title: { type: 'string' },
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['not-started', 'in-progress', 'completed']
|
||||
}
|
||||
},
|
||||
required: ['id', 'title', 'status']
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['operation']
|
||||
};
|
||||
|
||||
const result = cleanSchemaForGemini(sanitizeSchema(schema));
|
||||
|
||||
// Verify all types are uppercase
|
||||
assertEqual(result.type, 'OBJECT');
|
||||
assertEqual(result.properties.operation.type, 'STRING');
|
||||
assertEqual(result.properties.todoList.type, 'ARRAY');
|
||||
assertEqual(result.properties.todoList.items.type, 'OBJECT');
|
||||
assertEqual(result.properties.todoList.items.properties.id.type, 'NUMBER');
|
||||
assertEqual(result.properties.todoList.items.properties.title.type, 'STRING');
|
||||
assertEqual(result.properties.todoList.items.properties.status.type, 'STRING');
|
||||
});
|
||||
|
||||
// Summary
|
||||
console.log('\n' + '═'.repeat(60));
|
||||
console.log(`Tests completed: ${passed} passed, ${failed} failed`);
|
||||
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runTests().catch(err => {
|
||||
console.error('Test suite failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user