Gemini schema fix and modularize format converter

This commit is contained in:
Badri Narayanan S
2025-12-28 03:28:05 +05:30
parent 21c17d55f3
commit 1eb2329f7c
9 changed files with 1250 additions and 830 deletions

View File

@@ -49,7 +49,13 @@ Claude Code CLI → Express Server (server.js) → CloudCode Client → Antigrav
- **src/server.js**: Express server exposing Anthropic-compatible endpoints (`/v1/messages`, `/v1/models`, `/health`, `/account-limits`)
- **src/cloudcode-client.js**: Makes requests to Antigravity Cloud Code API with retry/failover logic, handles both streaming and non-streaming
- **src/format-converter.js**: Bidirectional conversion between Anthropic and Google Generative AI formats, including thinking blocks and tool calls
- **src/format/**: Format conversion module (Anthropic Google Generative AI)
- `index.js` - Re-exports all converters
- `request-converter.js` - Anthropic → Google request conversion
- `response-converter.js` - Google → Anthropic response conversion
- `content-converter.js` - Message content and role conversion
- `schema-sanitizer.js` - JSON Schema cleaning for Gemini API compatibility
- `thinking-utils.js` - Thinking block validation, filtering, and reordering
- **src/account-manager.js**: Multi-account pool with sticky selection, rate limit handling, and automatic cooldown
- **src/oauth.js**: Google OAuth implementation for adding accounts
- **src/token-extractor.js**: Extracts tokens from local Antigravity app installation (legacy single-account mode)

View File

@@ -22,7 +22,7 @@ import {
import {
convertAnthropicToGoogle,
convertGoogleToAnthropic
} from './format-converter.js';
} from './format/index.js';
import { formatDuration, sleep } from './utils/helpers.js';
import { isRateLimitError, isAuthError } from './errors.js';

View File

@@ -1,828 +0,0 @@
/**
* Format Converter
* Converts between Anthropic Messages API format and Google Generative AI format
*
* Based on patterns from:
* - https://github.com/NoeFabris/opencode-antigravity-auth
* - https://github.com/1rgs/claude-code-proxy
*/
import crypto from 'crypto';
import {
MIN_SIGNATURE_LENGTH,
GEMINI_MAX_OUTPUT_TOKENS,
getModelFamily,
isThinkingModel
} from './constants.js';
/**
* Sentinel value to skip thought signature validation for Gemini models.
* Per Google documentation, this value can be used when Claude Code strips
* the thoughtSignature field from tool_use blocks in multi-turn requests.
* See: https://ai.google.dev/gemini-api/docs/thought-signatures
*/
const GEMINI_SKIP_SIGNATURE = 'skip_thought_signature_validator';
/**
* Check if a part is a thinking block
* @param {Object} part - Content part to check
* @returns {boolean} True if the part is a thinking block
*/
function isThinkingPart(part) {
return part.type === 'thinking' ||
part.type === 'redacted_thinking' ||
part.thinking !== undefined ||
part.thought === true;
}
/**
* Check if a thinking part has a valid signature (>= MIN_SIGNATURE_LENGTH chars)
*/
function hasValidSignature(part) {
const signature = part.thought === true ? part.thoughtSignature : part.signature;
return typeof signature === 'string' && signature.length >= MIN_SIGNATURE_LENGTH;
}
/**
* Sanitize a thinking part by keeping only allowed fields
*/
function sanitizeThinkingPart(part) {
// Gemini-style thought blocks: { thought: true, text, thoughtSignature }
if (part.thought === true) {
const sanitized = { thought: true };
if (part.text !== undefined) sanitized.text = part.text;
if (part.thoughtSignature !== undefined) sanitized.thoughtSignature = part.thoughtSignature;
return sanitized;
}
// Anthropic-style thinking blocks: { type: "thinking", thinking, signature }
if (part.type === 'thinking' || part.thinking !== undefined) {
const sanitized = { type: 'thinking' };
if (part.thinking !== undefined) sanitized.thinking = part.thinking;
if (part.signature !== undefined) sanitized.signature = part.signature;
return sanitized;
}
return part;
}
/**
* Filter content array, keeping only thinking blocks with valid signatures.
* Since signature_delta transmits signatures properly, cache is no longer needed.
*/
function filterContentArray(contentArray) {
const filtered = [];
for (const item of contentArray) {
if (!item || typeof item !== 'object') {
filtered.push(item);
continue;
}
if (!isThinkingPart(item)) {
filtered.push(item);
continue;
}
// Keep items with valid signatures
if (hasValidSignature(item)) {
filtered.push(sanitizeThinkingPart(item));
continue;
}
// Drop unsigned thinking blocks
console.log('[FormatConverter] Dropping unsigned thinking block');
}
return filtered;
}
/**
* Filter unsigned thinking blocks from contents (Gemini format)
*
* @param {Array<{role: string, parts: Array}>} contents - Array of content objects in Gemini format
* @returns {Array<{role: string, parts: Array}>} Filtered contents with unsigned thinking blocks removed
*/
export function filterUnsignedThinkingBlocks(contents) {
return contents.map(content => {
if (!content || typeof content !== 'object') return content;
if (Array.isArray(content.parts)) {
return { ...content, parts: filterContentArray(content.parts) };
}
return content;
});
}
/**
* Remove trailing unsigned thinking blocks from assistant messages.
* Claude/Gemini APIs require that assistant messages don't end with unsigned thinking blocks.
* This function removes thinking blocks from the end of content arrays.
*
* @param {Array<Object>} content - Array of content blocks
* @returns {Array<Object>} Content array with trailing unsigned thinking blocks removed
*/
export function removeTrailingThinkingBlocks(content) {
if (!Array.isArray(content)) return content;
if (content.length === 0) return content;
// Work backwards from the end, removing thinking blocks
let endIndex = content.length;
for (let i = content.length - 1; i >= 0; i--) {
const block = content[i];
if (!block || typeof block !== 'object') break;
// Check if it's a thinking block (any format)
const isThinking = isThinkingPart(block);
if (isThinking) {
// Check if it has a valid signature
if (!hasValidSignature(block)) {
endIndex = i;
} else {
break; // Stop at signed thinking block
}
} else {
break; // Stop at first non-thinking block
}
}
if (endIndex < content.length) {
console.log('[FormatConverter] Removed', content.length - endIndex, 'trailing unsigned thinking blocks');
return content.slice(0, endIndex);
}
return content;
}
/**
* Sanitize a thinking block by removing extra fields like cache_control.
* Only keeps: type, thinking, signature (for thinking) or type, data (for redacted_thinking)
*/
function sanitizeAnthropicThinkingBlock(block) {
if (!block) return block;
if (block.type === 'thinking') {
const sanitized = { type: 'thinking' };
if (block.thinking !== undefined) sanitized.thinking = block.thinking;
if (block.signature !== undefined) sanitized.signature = block.signature;
return sanitized;
}
if (block.type === 'redacted_thinking') {
const sanitized = { type: 'redacted_thinking' };
if (block.data !== undefined) sanitized.data = block.data;
return sanitized;
}
return block;
}
/**
* Filter thinking blocks: keep only those with valid signatures.
* Blocks without signatures are dropped (API requires signatures).
* Also sanitizes blocks to remove extra fields like cache_control.
*
* @param {Array<Object>} content - Array of content blocks
* @returns {Array<Object>} Filtered content with only valid signed thinking blocks
*/
export function restoreThinkingSignatures(content) {
if (!Array.isArray(content)) return content;
const originalLength = content.length;
const filtered = [];
for (const block of content) {
if (!block || block.type !== 'thinking') {
filtered.push(block);
continue;
}
// Keep blocks with valid signatures (>= MIN_SIGNATURE_LENGTH chars), sanitized
if (block.signature && block.signature.length >= MIN_SIGNATURE_LENGTH) {
filtered.push(sanitizeAnthropicThinkingBlock(block));
}
// Unsigned thinking blocks are dropped
}
if (filtered.length < originalLength) {
console.log(`[FormatConverter] Dropped ${originalLength - filtered.length} unsigned thinking block(s)`);
}
return filtered;
}
/**
* Reorder content so that:
* 1. Thinking blocks come first (required when thinking is enabled)
* 2. Text blocks come in the middle (filtering out empty/useless ones)
* 3. Tool_use blocks come at the end (required before tool_result)
*
* Claude API requires that when thinking is enabled, assistant messages must start with thinking.
*
* @param {Array<Object>} content - Array of content blocks
* @returns {Array<Object>} Reordered content array
*/
export function reorderAssistantContent(content) {
if (!Array.isArray(content)) return content;
// Even for single-element arrays, we need to sanitize thinking blocks
if (content.length === 1) {
const block = content[0];
if (block && (block.type === 'thinking' || block.type === 'redacted_thinking')) {
return [sanitizeAnthropicThinkingBlock(block)];
}
return content;
}
const thinkingBlocks = [];
const textBlocks = [];
const toolUseBlocks = [];
let droppedEmptyBlocks = 0;
for (const block of content) {
if (!block) continue;
if (block.type === 'thinking' || block.type === 'redacted_thinking') {
// Sanitize thinking blocks to remove cache_control and other extra fields
thinkingBlocks.push(sanitizeAnthropicThinkingBlock(block));
} else if (block.type === 'tool_use') {
toolUseBlocks.push(block);
} else if (block.type === 'text') {
// Only keep text blocks with meaningful content
if (block.text && block.text.trim().length > 0) {
textBlocks.push(block);
} else {
droppedEmptyBlocks++;
}
} else {
// Other block types go in the text position
textBlocks.push(block);
}
}
if (droppedEmptyBlocks > 0) {
console.log(`[FormatConverter] Dropped ${droppedEmptyBlocks} empty text block(s)`);
}
const reordered = [...thinkingBlocks, ...textBlocks, ...toolUseBlocks];
// Log only if actual reordering happened (not just filtering)
if (reordered.length === content.length) {
const originalOrder = content.map(b => b?.type || 'unknown').join(',');
const newOrder = reordered.map(b => b?.type || 'unknown').join(',');
if (originalOrder !== newOrder) {
console.log('[FormatConverter] Reordered assistant content');
}
}
return reordered;
}
/**
* Convert Anthropic message content to Google Generative AI parts
*/
function convertContentToParts(content, isClaudeModel = false, isGeminiModel = false) {
if (typeof content === 'string') {
return [{ text: content }];
}
if (!Array.isArray(content)) {
return [{ text: String(content) }];
}
const parts = [];
for (const block of content) {
if (block.type === 'text') {
// Skip empty text blocks - they cause API errors
if (block.text && block.text.trim()) {
parts.push({ text: block.text });
}
} else if (block.type === 'image') {
// Handle image content
if (block.source?.type === 'base64') {
// Base64-encoded image
parts.push({
inlineData: {
mimeType: block.source.media_type,
data: block.source.data
}
});
} else if (block.source?.type === 'url') {
// URL-referenced image
parts.push({
fileData: {
mimeType: block.source.media_type || 'image/jpeg',
fileUri: block.source.url
}
});
}
} else if (block.type === 'document') {
// Handle document content (e.g. PDF)
if (block.source?.type === 'base64') {
parts.push({
inlineData: {
mimeType: block.source.media_type,
data: block.source.data
}
});
} else if (block.source?.type === 'url') {
parts.push({
fileData: {
mimeType: block.source.media_type || 'application/pdf',
fileUri: block.source.url
}
});
}
} else if (block.type === 'tool_use') {
// Convert tool_use to functionCall (Google format)
// For Claude models, include the id field
const functionCall = {
name: block.name,
args: block.input || {}
};
if (isClaudeModel && block.id) {
functionCall.id = block.id;
}
// Build the part with functionCall
const part = { functionCall };
// For Gemini models, include thoughtSignature at the part level
// This is required by Gemini 3+ for tool calls to work correctly
if (isGeminiModel) {
// Use thoughtSignature from the block if Claude Code preserved it
// Otherwise, use the sentinel value to skip validation (Claude Code strips non-standard fields)
// See: https://ai.google.dev/gemini-api/docs/thought-signatures
part.thoughtSignature = block.thoughtSignature || GEMINI_SKIP_SIGNATURE;
}
parts.push(part);
} else if (block.type === 'tool_result') {
// Convert tool_result to functionResponse (Google format)
let responseContent = block.content;
if (typeof responseContent === 'string') {
responseContent = { result: responseContent };
} else if (Array.isArray(responseContent)) {
const texts = responseContent
.filter(c => c.type === 'text')
.map(c => c.text)
.join('\n');
responseContent = { result: texts };
}
const functionResponse = {
name: block.tool_use_id || 'unknown',
response: responseContent
};
// For Claude models, the id field must match the tool_use_id
if (isClaudeModel && block.tool_use_id) {
functionResponse.id = block.tool_use_id;
}
parts.push({ functionResponse });
} else if (block.type === 'thinking') {
// Handle thinking blocks - only those with valid signatures
if (block.signature && block.signature.length >= MIN_SIGNATURE_LENGTH) {
// Convert to Gemini format with signature
parts.push({
text: block.thinking,
thought: true,
thoughtSignature: block.signature
});
}
// Unsigned thinking blocks are dropped upstream
}
}
return parts;
}
/**
* Convert Anthropic role to Google role
*/
function convertRole(role) {
if (role === 'assistant') return 'model';
if (role === 'user') return 'user';
return 'user'; // Default to user
}
/**
* Convert Anthropic Messages API request to the format expected by Cloud Code
*
* Uses Google Generative AI format, but for Claude models:
* - Keeps tool_result in Anthropic format (required by Claude API)
*
* @param {Object} anthropicRequest - Anthropic format request
* @returns {Object} Request body for Cloud Code API
*/
export function convertAnthropicToGoogle(anthropicRequest) {
const { messages, system, max_tokens, temperature, top_p, top_k, stop_sequences, tools, tool_choice, thinking } = anthropicRequest;
const modelName = anthropicRequest.model || '';
const modelFamily = getModelFamily(modelName);
const isClaudeModel = modelFamily === 'claude';
const isGeminiModel = modelFamily === 'gemini';
const isThinking = isThinkingModel(modelName);
const googleRequest = {
contents: [],
generationConfig: {}
};
// Handle system instruction
if (system) {
let systemParts = [];
if (typeof system === 'string') {
systemParts = [{ text: system }];
} else if (Array.isArray(system)) {
// Filter for text blocks as system prompts are usually text
// Anthropic supports text blocks in system prompts
systemParts = system
.filter(block => block.type === 'text')
.map(block => ({ text: block.text }));
}
if (systemParts.length > 0) {
googleRequest.systemInstruction = {
parts: systemParts
};
}
}
// Add interleaved thinking hint for Claude thinking models with tools
if (isClaudeModel && isThinking && tools && tools.length > 0) {
const hint = 'Interleaved thinking is enabled. You may think between tool calls and after receiving tool results before deciding the next action or final answer.';
if (!googleRequest.systemInstruction) {
googleRequest.systemInstruction = { parts: [{ text: hint }] };
} else {
const lastPart = googleRequest.systemInstruction.parts[googleRequest.systemInstruction.parts.length - 1];
if (lastPart && lastPart.text) {
lastPart.text = `${lastPart.text}\n\n${hint}`;
} else {
googleRequest.systemInstruction.parts.push({ text: hint });
}
}
}
// Convert messages to contents, then filter unsigned thinking blocks
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
let msgContent = msg.content;
// For assistant messages, process thinking blocks and reorder content
if ((msg.role === 'assistant' || msg.role === 'model') && Array.isArray(msgContent)) {
// First, try to restore signatures for unsigned thinking blocks from cache
msgContent = restoreThinkingSignatures(msgContent);
// Remove trailing unsigned thinking blocks
msgContent = removeTrailingThinkingBlocks(msgContent);
// Reorder: thinking first, then text, then tool_use
msgContent = reorderAssistantContent(msgContent);
}
const parts = convertContentToParts(msgContent, isClaudeModel, isGeminiModel);
const content = {
role: convertRole(msg.role),
parts: parts
};
googleRequest.contents.push(content);
}
// Filter unsigned thinking blocks for Claude models
if (isClaudeModel) {
googleRequest.contents = filterUnsignedThinkingBlocks(googleRequest.contents);
}
// Generation config
if (max_tokens) {
googleRequest.generationConfig.maxOutputTokens = max_tokens;
}
if (temperature !== undefined) {
googleRequest.generationConfig.temperature = temperature;
}
if (top_p !== undefined) {
googleRequest.generationConfig.topP = top_p;
}
if (top_k !== undefined) {
googleRequest.generationConfig.topK = top_k;
}
if (stop_sequences && stop_sequences.length > 0) {
googleRequest.generationConfig.stopSequences = stop_sequences;
}
// Enable thinking for thinking models (Claude and Gemini 3+)
if (isThinking) {
if (isClaudeModel) {
// Claude thinking config
const thinkingConfig = {
include_thoughts: true
};
// Only set thinking_budget if explicitly provided
const thinkingBudget = thinking?.budget_tokens;
if (thinkingBudget) {
thinkingConfig.thinking_budget = thinkingBudget;
console.log('[FormatConverter] Claude thinking enabled with budget:', thinkingBudget);
} else {
console.log('[FormatConverter] Claude thinking enabled (no budget specified)');
}
googleRequest.generationConfig.thinkingConfig = thinkingConfig;
} else if (isGeminiModel) {
// Gemini thinking config (uses camelCase)
const thinkingConfig = {
includeThoughts: true,
thinkingBudget: thinking?.budget_tokens || 16000
};
console.log('[FormatConverter] Gemini thinking enabled with budget:', thinkingConfig.thinkingBudget);
googleRequest.generationConfig.thinkingConfig = thinkingConfig;
}
}
// Convert tools to Google format
if (tools && tools.length > 0) {
const functionDeclarations = tools.map((tool, idx) => {
// Extract name from various possible locations
const name = tool.name || tool.function?.name || tool.custom?.name || `tool-${idx}`;
// Extract description from various possible locations
const description = tool.description || tool.function?.description || tool.custom?.description || '';
// Extract schema from various possible locations
const schema = tool.input_schema
|| tool.function?.input_schema
|| tool.function?.parameters
|| tool.custom?.input_schema
|| tool.parameters
|| { type: 'object' };
// Sanitize schema for general compatibility
let parameters = sanitizeSchema(schema);
// For Gemini models, apply additional cleaning for VALIDATED mode
if (isGeminiModel) {
parameters = cleanSchemaForGemini(parameters);
}
return {
name: String(name).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64),
description: description,
parameters
};
});
googleRequest.tools = [{ functionDeclarations }];
console.log('[FormatConverter] Tools:', JSON.stringify(googleRequest.tools).substring(0, 300));
}
// Cap max tokens for Gemini models
if (isGeminiModel && googleRequest.generationConfig.maxOutputTokens > GEMINI_MAX_OUTPUT_TOKENS) {
console.log(`[FormatConverter] Capping Gemini max_tokens from ${googleRequest.generationConfig.maxOutputTokens} to ${GEMINI_MAX_OUTPUT_TOKENS}`);
googleRequest.generationConfig.maxOutputTokens = GEMINI_MAX_OUTPUT_TOKENS;
}
return googleRequest;
}
/**
* Sanitize JSON Schema for Antigravity API compatibility.
* Uses allowlist approach - only permit known-safe JSON Schema features.
* Converts "const" to equivalent "enum" for compatibility.
* Generates placeholder schema for empty tool schemas.
*/
function sanitizeSchema(schema) {
if (!schema || typeof schema !== 'object') {
// Empty/missing schema - generate placeholder with reason property
return {
type: 'object',
properties: {
reason: {
type: 'string',
description: 'Reason for calling this tool'
}
},
required: ['reason']
};
}
// Allowlist of permitted JSON Schema fields
const ALLOWED_FIELDS = new Set([
'type',
'description',
'properties',
'required',
'items',
'enum',
'title'
]);
const sanitized = {};
for (const [key, value] of Object.entries(schema)) {
// Convert "const" to "enum" for compatibility
if (key === 'const') {
sanitized.enum = [value];
continue;
}
// Skip fields not in allowlist
if (!ALLOWED_FIELDS.has(key)) {
continue;
}
if (key === 'properties' && value && typeof value === 'object') {
sanitized.properties = {};
for (const [propKey, propValue] of Object.entries(value)) {
sanitized.properties[propKey] = sanitizeSchema(propValue);
}
} else if (key === 'items' && value && typeof value === 'object') {
if (Array.isArray(value)) {
sanitized.items = value.map(item => sanitizeSchema(item));
} else {
sanitized.items = sanitizeSchema(value);
}
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
sanitized[key] = sanitizeSchema(value);
} else {
sanitized[key] = value;
}
}
// Ensure we have at least a type
if (!sanitized.type) {
sanitized.type = 'object';
}
// If object type with no properties, add placeholder
if (sanitized.type === 'object' && (!sanitized.properties || Object.keys(sanitized.properties).length === 0)) {
sanitized.properties = {
reason: {
type: 'string',
description: 'Reason for calling this tool'
}
};
sanitized.required = ['reason'];
}
return sanitized;
}
/**
* Cleans JSON schema for Gemini API compatibility.
* Removes unsupported fields that cause VALIDATED mode errors.
*
* Gemini's VALIDATED mode rejects schemas with certain JSON Schema keywords
* that are not supported by the Gemini API.
*
* @param {Object} schema - The JSON schema to clean
* @returns {Object} Cleaned schema safe for Gemini API
*/
function cleanSchemaForGemini(schema) {
if (!schema || typeof schema !== 'object') return schema;
if (Array.isArray(schema)) return schema.map(cleanSchemaForGemini);
const result = { ...schema };
// Remove unsupported keywords that cause VALIDATED mode errors
const unsupported = [
'additionalProperties', 'default', '$schema', '$defs',
'definitions', '$ref', '$id', '$comment', 'title',
'minLength', 'maxLength', 'pattern', 'format',
'minItems', 'maxItems', 'examples'
];
for (const key of unsupported) {
delete result[key];
}
// Check for unsupported 'format' in string types
if (result.type === 'string' && result.format) {
const allowed = ['enum', 'date-time'];
if (!allowed.includes(result.format)) {
delete result.format;
}
}
// Recursively clean nested schemas
for (const [key, value] of Object.entries(result)) {
if (typeof value === 'object' && value !== null) {
result[key] = cleanSchemaForGemini(value);
}
}
// Validate that required array only contains properties that exist
// Gemini's VALIDATED mode requires this
if (result.required && Array.isArray(result.required) && result.properties) {
const definedProps = new Set(Object.keys(result.properties));
result.required = result.required.filter(prop => definedProps.has(prop));
// If required is now empty, remove it
if (result.required.length === 0) {
delete result.required;
}
}
return result;
}
/**
* Convert Google Generative AI response to Anthropic Messages API format
*
* @param {Object} googleResponse - Google format response (the inner response object)
* @param {string} model - The model name used
* @returns {Object} Anthropic format response
*/
export function convertGoogleToAnthropic(googleResponse, model) {
// Handle the response wrapper
const response = googleResponse.response || googleResponse;
const candidates = response.candidates || [];
const firstCandidate = candidates[0] || {};
const content = firstCandidate.content || {};
const parts = content.parts || [];
// Convert parts to Anthropic content blocks
const anthropicContent = [];
let hasToolCalls = false;
for (const part of parts) {
if (part.text !== undefined) {
// Handle thinking blocks
if (part.thought === true) {
const signature = part.thoughtSignature || '';
// Include thinking blocks in the response for Claude Code
anthropicContent.push({
type: 'thinking',
thinking: part.text,
signature: signature
});
} else {
anthropicContent.push({
type: 'text',
text: part.text
});
}
} else if (part.functionCall) {
// Convert functionCall to tool_use
// Use the id from the response if available, otherwise generate one
const toolId = part.functionCall.id || `toolu_${crypto.randomBytes(12).toString('hex')}`;
const toolUseBlock = {
type: 'tool_use',
id: toolId,
name: part.functionCall.name,
input: part.functionCall.args || {}
};
// For Gemini 3+, include thoughtSignature from the part level
if (part.thoughtSignature && part.thoughtSignature.length >= MIN_SIGNATURE_LENGTH) {
toolUseBlock.thoughtSignature = part.thoughtSignature;
}
anthropicContent.push(toolUseBlock);
hasToolCalls = true;
}
}
// Determine stop reason
const finishReason = firstCandidate.finishReason;
let stopReason = 'end_turn';
if (finishReason === 'STOP') {
stopReason = 'end_turn';
} else if (finishReason === 'MAX_TOKENS') {
stopReason = 'max_tokens';
} else if (finishReason === 'TOOL_USE' || hasToolCalls) {
stopReason = 'tool_use';
}
// Extract usage metadata
// Note: Antigravity's promptTokenCount is the TOTAL (includes cached),
// but Anthropic's input_tokens excludes cached. We subtract to match.
const usageMetadata = response.usageMetadata || {};
const promptTokens = usageMetadata.promptTokenCount || 0;
const cachedTokens = usageMetadata.cachedContentTokenCount || 0;
return {
id: `msg_${crypto.randomBytes(16).toString('hex')}`,
type: 'message',
role: 'assistant',
content: anthropicContent.length > 0 ? anthropicContent : [{ type: 'text', text: '' }],
model: model,
stop_reason: stopReason,
stop_sequence: null,
usage: {
input_tokens: promptTokens - cachedTokens,
output_tokens: usageMetadata.candidatesTokenCount || 0,
cache_read_input_tokens: cachedTokens,
cache_creation_input_tokens: 0
}
};
}
export default {
convertAnthropicToGoogle,
convertGoogleToAnthropic
};

View File

@@ -0,0 +1,151 @@
/**
* Content Converter
* Converts Anthropic message content to Google Generative AI parts format
*/
import { MIN_SIGNATURE_LENGTH } from '../constants.js';
/**
* Sentinel value to skip thought signature validation for Gemini models.
* Per Google documentation, this value can be used when Claude Code strips
* the thoughtSignature field from tool_use blocks in multi-turn requests.
* See: https://ai.google.dev/gemini-api/docs/thought-signatures
*/
const GEMINI_SKIP_SIGNATURE = 'skip_thought_signature_validator';
/**
* Convert Anthropic role to Google role
* @param {string} role - Anthropic role ('user', 'assistant')
* @returns {string} Google role ('user', 'model')
*/
export function convertRole(role) {
if (role === 'assistant') return 'model';
if (role === 'user') return 'user';
return 'user'; // Default to user
}
/**
* Convert Anthropic message content to Google Generative AI parts
* @param {string|Array} content - Anthropic message content
* @param {boolean} isClaudeModel - Whether the model is a Claude model
* @param {boolean} isGeminiModel - Whether the model is a Gemini model
* @returns {Array} Google Generative AI parts array
*/
export function convertContentToParts(content, isClaudeModel = false, isGeminiModel = false) {
if (typeof content === 'string') {
return [{ text: content }];
}
if (!Array.isArray(content)) {
return [{ text: String(content) }];
}
const parts = [];
for (const block of content) {
if (block.type === 'text') {
// Skip empty text blocks - they cause API errors
if (block.text && block.text.trim()) {
parts.push({ text: block.text });
}
} else if (block.type === 'image') {
// Handle image content
if (block.source?.type === 'base64') {
// Base64-encoded image
parts.push({
inlineData: {
mimeType: block.source.media_type,
data: block.source.data
}
});
} else if (block.source?.type === 'url') {
// URL-referenced image
parts.push({
fileData: {
mimeType: block.source.media_type || 'image/jpeg',
fileUri: block.source.url
}
});
}
} else if (block.type === 'document') {
// Handle document content (e.g. PDF)
if (block.source?.type === 'base64') {
parts.push({
inlineData: {
mimeType: block.source.media_type,
data: block.source.data
}
});
} else if (block.source?.type === 'url') {
parts.push({
fileData: {
mimeType: block.source.media_type || 'application/pdf',
fileUri: block.source.url
}
});
}
} else if (block.type === 'tool_use') {
// Convert tool_use to functionCall (Google format)
// For Claude models, include the id field
const functionCall = {
name: block.name,
args: block.input || {}
};
if (isClaudeModel && block.id) {
functionCall.id = block.id;
}
// Build the part with functionCall
const part = { functionCall };
// For Gemini models, include thoughtSignature at the part level
// This is required by Gemini 3+ for tool calls to work correctly
if (isGeminiModel) {
// Use thoughtSignature from the block if Claude Code preserved it
// Otherwise, use the sentinel value to skip validation (Claude Code strips non-standard fields)
// See: https://ai.google.dev/gemini-api/docs/thought-signatures
part.thoughtSignature = block.thoughtSignature || GEMINI_SKIP_SIGNATURE;
}
parts.push(part);
} else if (block.type === 'tool_result') {
// Convert tool_result to functionResponse (Google format)
let responseContent = block.content;
if (typeof responseContent === 'string') {
responseContent = { result: responseContent };
} else if (Array.isArray(responseContent)) {
const texts = responseContent
.filter(c => c.type === 'text')
.map(c => c.text)
.join('\n');
responseContent = { result: texts };
}
const functionResponse = {
name: block.tool_use_id || 'unknown',
response: responseContent
};
// For Claude models, the id field must match the tool_use_id
if (isClaudeModel && block.tool_use_id) {
functionResponse.id = block.tool_use_id;
}
parts.push({ functionResponse });
} else if (block.type === 'thinking') {
// Handle thinking blocks - only those with valid signatures
if (block.signature && block.signature.length >= MIN_SIGNATURE_LENGTH) {
// Convert to Gemini format with signature
parts.push({
text: block.thinking,
thought: true,
thoughtSignature: block.signature
});
}
// Unsigned thinking blocks are dropped upstream
}
}
return parts;
}

20
src/format/index.js Normal file
View File

@@ -0,0 +1,20 @@
/**
* Format Converter Module
* Converts between Anthropic Messages API format and Google Generative AI format
*/
// Re-export all from each module
export * from './request-converter.js';
export * from './response-converter.js';
export * from './content-converter.js';
export * from './schema-sanitizer.js';
export * from './thinking-utils.js';
// Default export for backward compatibility
import { convertAnthropicToGoogle } from './request-converter.js';
import { convertGoogleToAnthropic } from './response-converter.js';
export default {
convertAnthropicToGoogle,
convertGoogleToAnthropic
};

View File

@@ -0,0 +1,195 @@
/**
* Request Converter
* Converts Anthropic Messages API requests to Google Generative AI format
*/
import {
GEMINI_MAX_OUTPUT_TOKENS,
getModelFamily,
isThinkingModel
} from '../constants.js';
import { convertContentToParts, convertRole } from './content-converter.js';
import { sanitizeSchema, cleanSchemaForGemini } from './schema-sanitizer.js';
import {
restoreThinkingSignatures,
removeTrailingThinkingBlocks,
reorderAssistantContent,
filterUnsignedThinkingBlocks
} from './thinking-utils.js';
/**
* Convert Anthropic Messages API request to the format expected by Cloud Code
*
* Uses Google Generative AI format, but for Claude models:
* - Keeps tool_result in Anthropic format (required by Claude API)
*
* @param {Object} anthropicRequest - Anthropic format request
* @returns {Object} Request body for Cloud Code API
*/
export function convertAnthropicToGoogle(anthropicRequest) {
const { messages, system, max_tokens, temperature, top_p, top_k, stop_sequences, tools, tool_choice, thinking } = anthropicRequest;
const modelName = anthropicRequest.model || '';
const modelFamily = getModelFamily(modelName);
const isClaudeModel = modelFamily === 'claude';
const isGeminiModel = modelFamily === 'gemini';
const isThinking = isThinkingModel(modelName);
const googleRequest = {
contents: [],
generationConfig: {}
};
// Handle system instruction
if (system) {
let systemParts = [];
if (typeof system === 'string') {
systemParts = [{ text: system }];
} else if (Array.isArray(system)) {
// Filter for text blocks as system prompts are usually text
// Anthropic supports text blocks in system prompts
systemParts = system
.filter(block => block.type === 'text')
.map(block => ({ text: block.text }));
}
if (systemParts.length > 0) {
googleRequest.systemInstruction = {
parts: systemParts
};
}
}
// Add interleaved thinking hint for Claude thinking models with tools
if (isClaudeModel && isThinking && tools && tools.length > 0) {
const hint = 'Interleaved thinking is enabled. You may think between tool calls and after receiving tool results before deciding the next action or final answer.';
if (!googleRequest.systemInstruction) {
googleRequest.systemInstruction = { parts: [{ text: hint }] };
} else {
const lastPart = googleRequest.systemInstruction.parts[googleRequest.systemInstruction.parts.length - 1];
if (lastPart && lastPart.text) {
lastPart.text = `${lastPart.text}\n\n${hint}`;
} else {
googleRequest.systemInstruction.parts.push({ text: hint });
}
}
}
// Convert messages to contents, then filter unsigned thinking blocks
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
let msgContent = msg.content;
// For assistant messages, process thinking blocks and reorder content
if ((msg.role === 'assistant' || msg.role === 'model') && Array.isArray(msgContent)) {
// First, try to restore signatures for unsigned thinking blocks from cache
msgContent = restoreThinkingSignatures(msgContent);
// Remove trailing unsigned thinking blocks
msgContent = removeTrailingThinkingBlocks(msgContent);
// Reorder: thinking first, then text, then tool_use
msgContent = reorderAssistantContent(msgContent);
}
const parts = convertContentToParts(msgContent, isClaudeModel, isGeminiModel);
const content = {
role: convertRole(msg.role),
parts: parts
};
googleRequest.contents.push(content);
}
// Filter unsigned thinking blocks for Claude models
if (isClaudeModel) {
googleRequest.contents = filterUnsignedThinkingBlocks(googleRequest.contents);
}
// Generation config
if (max_tokens) {
googleRequest.generationConfig.maxOutputTokens = max_tokens;
}
if (temperature !== undefined) {
googleRequest.generationConfig.temperature = temperature;
}
if (top_p !== undefined) {
googleRequest.generationConfig.topP = top_p;
}
if (top_k !== undefined) {
googleRequest.generationConfig.topK = top_k;
}
if (stop_sequences && stop_sequences.length > 0) {
googleRequest.generationConfig.stopSequences = stop_sequences;
}
// Enable thinking for thinking models (Claude and Gemini 3+)
if (isThinking) {
if (isClaudeModel) {
// Claude thinking config
const thinkingConfig = {
include_thoughts: true
};
// Only set thinking_budget if explicitly provided
const thinkingBudget = thinking?.budget_tokens;
if (thinkingBudget) {
thinkingConfig.thinking_budget = thinkingBudget;
console.log('[RequestConverter] Claude thinking enabled with budget:', thinkingBudget);
} else {
console.log('[RequestConverter] Claude thinking enabled (no budget specified)');
}
googleRequest.generationConfig.thinkingConfig = thinkingConfig;
} else if (isGeminiModel) {
// Gemini thinking config (uses camelCase)
const thinkingConfig = {
includeThoughts: true,
thinkingBudget: thinking?.budget_tokens || 16000
};
console.log('[RequestConverter] Gemini thinking enabled with budget:', thinkingConfig.thinkingBudget);
googleRequest.generationConfig.thinkingConfig = thinkingConfig;
}
}
// Convert tools to Google format
if (tools && tools.length > 0) {
const functionDeclarations = tools.map((tool, idx) => {
// Extract name from various possible locations
const name = tool.name || tool.function?.name || tool.custom?.name || `tool-${idx}`;
// Extract description from various possible locations
const description = tool.description || tool.function?.description || tool.custom?.description || '';
// Extract schema from various possible locations
const schema = tool.input_schema
|| tool.function?.input_schema
|| tool.function?.parameters
|| tool.custom?.input_schema
|| tool.parameters
|| { type: 'object' };
// Sanitize schema for general compatibility
let parameters = sanitizeSchema(schema);
// For Gemini models, apply additional cleaning for VALIDATED mode
if (isGeminiModel) {
parameters = cleanSchemaForGemini(parameters);
}
return {
name: String(name).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64),
description: description,
parameters
};
});
googleRequest.tools = [{ functionDeclarations }];
console.log('[RequestConverter] Tools:', JSON.stringify(googleRequest.tools).substring(0, 300));
}
// Cap max tokens for Gemini models
if (isGeminiModel && googleRequest.generationConfig.maxOutputTokens > GEMINI_MAX_OUTPUT_TOKENS) {
console.log(`[RequestConverter] Capping Gemini max_tokens from ${googleRequest.generationConfig.maxOutputTokens} to ${GEMINI_MAX_OUTPUT_TOKENS}`);
googleRequest.generationConfig.maxOutputTokens = GEMINI_MAX_OUTPUT_TOKENS;
}
return googleRequest;
}

View File

@@ -0,0 +1,101 @@
/**
* Response Converter
* Converts Google Generative AI responses to Anthropic Messages API format
*/
import crypto from 'crypto';
import { MIN_SIGNATURE_LENGTH } from '../constants.js';
/**
* Convert Google Generative AI response to Anthropic Messages API format
*
* @param {Object} googleResponse - Google format response (the inner response object)
* @param {string} model - The model name used
* @returns {Object} Anthropic format response
*/
export function convertGoogleToAnthropic(googleResponse, model) {
// Handle the response wrapper
const response = googleResponse.response || googleResponse;
const candidates = response.candidates || [];
const firstCandidate = candidates[0] || {};
const content = firstCandidate.content || {};
const parts = content.parts || [];
// Convert parts to Anthropic content blocks
const anthropicContent = [];
let hasToolCalls = false;
for (const part of parts) {
if (part.text !== undefined) {
// Handle thinking blocks
if (part.thought === true) {
const signature = part.thoughtSignature || '';
// Include thinking blocks in the response for Claude Code
anthropicContent.push({
type: 'thinking',
thinking: part.text,
signature: signature
});
} else {
anthropicContent.push({
type: 'text',
text: part.text
});
}
} else if (part.functionCall) {
// Convert functionCall to tool_use
// Use the id from the response if available, otherwise generate one
const toolId = part.functionCall.id || `toolu_${crypto.randomBytes(12).toString('hex')}`;
const toolUseBlock = {
type: 'tool_use',
id: toolId,
name: part.functionCall.name,
input: part.functionCall.args || {}
};
// For Gemini 3+, include thoughtSignature from the part level
if (part.thoughtSignature && part.thoughtSignature.length >= MIN_SIGNATURE_LENGTH) {
toolUseBlock.thoughtSignature = part.thoughtSignature;
}
anthropicContent.push(toolUseBlock);
hasToolCalls = true;
}
}
// Determine stop reason
const finishReason = firstCandidate.finishReason;
let stopReason = 'end_turn';
if (finishReason === 'STOP') {
stopReason = 'end_turn';
} else if (finishReason === 'MAX_TOKENS') {
stopReason = 'max_tokens';
} else if (finishReason === 'TOOL_USE' || hasToolCalls) {
stopReason = 'tool_use';
}
// Extract usage metadata
// Note: Antigravity's promptTokenCount is the TOTAL (includes cached),
// but Anthropic's input_tokens excludes cached. We subtract to match.
const usageMetadata = response.usageMetadata || {};
const promptTokens = usageMetadata.promptTokenCount || 0;
const cachedTokens = usageMetadata.cachedContentTokenCount || 0;
return {
id: `msg_${crypto.randomBytes(16).toString('hex')}`,
type: 'message',
role: 'assistant',
content: anthropicContent.length > 0 ? anthropicContent : [{ type: 'text', text: '' }],
model: model,
stop_reason: stopReason,
stop_sequence: null,
usage: {
input_tokens: promptTokens - cachedTokens,
output_tokens: usageMetadata.candidatesTokenCount || 0,
cache_read_input_tokens: cachedTokens,
cache_creation_input_tokens: 0
}
};
}

View File

@@ -0,0 +1,516 @@
/**
* Schema Sanitizer
* Cleans and transforms JSON schemas for Gemini/Antigravity API compatibility
*
* Uses a multi-phase pipeline matching opencode-antigravity-auth approach:
* - Phase 1: Convert $refs to description hints
* - Phase 2a: Merge allOf schemas
* - Phase 2b: Flatten anyOf/oneOf (select best option)
* - Phase 2c: Flatten type arrays + update required for nullable
* - Phase 3: Remove unsupported keywords
* - Phase 4: Final cleanup (required array validation)
*/
/**
* Append a hint to a schema's description field.
* Format: "existing (hint)" or just "hint" if no existing description.
*
* @param {Object} schema - Schema object to modify
* @param {string} hint - Hint text to append
* @returns {Object} Modified schema with appended description
*/
function appendDescriptionHint(schema, hint) {
if (!schema || typeof schema !== 'object') return schema;
const result = { ...schema };
result.description = result.description
? `${result.description} (${hint})`
: hint;
return result;
}
/**
* Score a schema option for anyOf/oneOf selection.
* Higher scores = more preferred schemas.
*
* @param {Object} schema - Schema option to score
* @returns {number} Score (0-3)
*/
function scoreSchemaOption(schema) {
if (!schema || typeof schema !== 'object') return 0;
// Score 3: Object types with properties (most informative)
if (schema.type === 'object' || schema.properties) return 3;
// Score 2: Array types with items
if (schema.type === 'array' || schema.items) return 2;
// Score 1: Any other non-null type
if (schema.type && schema.type !== 'null') return 1;
// Score 0: Null or no type
return 0;
}
/**
* Convert $ref references to description hints.
* Replaces { $ref: "#/$defs/Foo" } with { type: "object", description: "See: Foo" }
*
* @param {Object} schema - Schema to process
* @returns {Object} Schema with refs converted to hints
*/
function convertRefsToHints(schema) {
if (!schema || typeof schema !== 'object') return schema;
if (Array.isArray(schema)) return schema.map(convertRefsToHints);
const result = { ...schema };
// Handle $ref at this level
if (result.$ref && typeof result.$ref === 'string') {
// Extract definition name from ref path (e.g., "#/$defs/Foo" -> "Foo")
const parts = result.$ref.split('/');
const defName = parts[parts.length - 1] || 'unknown';
const hint = `See: ${defName}`;
// Merge with existing description if present
const description = result.description
? `${result.description} (${hint})`
: hint;
// Replace with object type and hint
return { type: 'object', description };
}
// Recursively process properties
if (result.properties && typeof result.properties === 'object') {
result.properties = {};
for (const [key, value] of Object.entries(schema.properties)) {
result.properties[key] = convertRefsToHints(value);
}
}
// Recursively process items
if (result.items) {
if (Array.isArray(result.items)) {
result.items = result.items.map(convertRefsToHints);
} else if (typeof result.items === 'object') {
result.items = convertRefsToHints(result.items);
}
}
// Recursively process anyOf/oneOf/allOf
for (const key of ['anyOf', 'oneOf', 'allOf']) {
if (Array.isArray(result[key])) {
result[key] = result[key].map(convertRefsToHints);
}
}
return result;
}
/**
* Merge all schemas in an allOf array into a single schema.
* Properties and required arrays are merged; other fields use first occurrence.
*
* @param {Object} schema - Schema with potential allOf to merge
* @returns {Object} Schema with allOf merged
*/
function mergeAllOf(schema) {
if (!schema || typeof schema !== 'object') return schema;
if (Array.isArray(schema)) return schema.map(mergeAllOf);
let result = { ...schema };
// Process allOf if present
if (Array.isArray(result.allOf) && result.allOf.length > 0) {
const mergedProperties = {};
const mergedRequired = new Set();
const otherFields = {};
for (const subSchema of result.allOf) {
if (!subSchema || typeof subSchema !== 'object') continue;
// Merge properties (later overrides earlier)
if (subSchema.properties) {
for (const [key, value] of Object.entries(subSchema.properties)) {
mergedProperties[key] = value;
}
}
// Union required arrays
if (Array.isArray(subSchema.required)) {
for (const req of subSchema.required) {
mergedRequired.add(req);
}
}
// Copy other fields (first occurrence wins)
for (const [key, value] of Object.entries(subSchema)) {
if (key !== 'properties' && key !== 'required' && !(key in otherFields)) {
otherFields[key] = value;
}
}
}
// Apply merged content
delete result.allOf;
// Merge other fields first (parent takes precedence)
for (const [key, value] of Object.entries(otherFields)) {
if (!(key in result)) {
result[key] = value;
}
}
// Merge properties (allOf properties override parent for same keys)
if (Object.keys(mergedProperties).length > 0) {
result.properties = { ...mergedProperties, ...(result.properties || {}) };
}
// Merge required
if (mergedRequired.size > 0) {
const parentRequired = Array.isArray(result.required) ? result.required : [];
result.required = [...new Set([...mergedRequired, ...parentRequired])];
}
}
// Recursively process properties
if (result.properties && typeof result.properties === 'object') {
const newProps = {};
for (const [key, value] of Object.entries(result.properties)) {
newProps[key] = mergeAllOf(value);
}
result.properties = newProps;
}
// Recursively process items
if (result.items) {
if (Array.isArray(result.items)) {
result.items = result.items.map(mergeAllOf);
} else if (typeof result.items === 'object') {
result.items = mergeAllOf(result.items);
}
}
return result;
}
/**
* Flatten anyOf/oneOf by selecting the best option based on scoring.
* Adds type hints to description when multiple types existed.
*
* @param {Object} schema - Schema with potential anyOf/oneOf
* @returns {Object} Flattened schema
*/
function flattenAnyOfOneOf(schema) {
if (!schema || typeof schema !== 'object') return schema;
if (Array.isArray(schema)) return schema.map(flattenAnyOfOneOf);
let result = { ...schema };
// Handle anyOf or oneOf
for (const unionKey of ['anyOf', 'oneOf']) {
if (Array.isArray(result[unionKey]) && result[unionKey].length > 0) {
const options = result[unionKey];
// Collect type names for hint
const typeNames = [];
let bestOption = null;
let bestScore = -1;
for (const option of options) {
if (!option || typeof option !== 'object') continue;
// Collect type name
const typeName = option.type || (option.properties ? 'object' : null);
if (typeName && typeName !== 'null') {
typeNames.push(typeName);
}
// Score and track best option
const score = scoreSchemaOption(option);
if (score > bestScore) {
bestScore = score;
bestOption = option;
}
}
// Remove the union key
delete result[unionKey];
// Merge best option into result
if (bestOption) {
// Preserve parent description
const parentDescription = result.description;
// Recursively flatten the best option
const flattenedOption = flattenAnyOfOneOf(bestOption);
// Merge fields from selected option
for (const [key, value] of Object.entries(flattenedOption)) {
if (key === 'description') {
// Merge descriptions if different
if (value && value !== parentDescription) {
result.description = parentDescription
? `${parentDescription} (${value})`
: value;
}
} else if (!(key in result) || key === 'type' || key === 'properties' || key === 'items') {
result[key] = value;
}
}
// Add type hint if multiple types existed
if (typeNames.length > 1) {
const uniqueTypes = [...new Set(typeNames)];
result = appendDescriptionHint(result, `Accepts: ${uniqueTypes.join(' | ')}`);
}
}
}
}
// Recursively process properties
if (result.properties && typeof result.properties === 'object') {
const newProps = {};
for (const [key, value] of Object.entries(result.properties)) {
newProps[key] = flattenAnyOfOneOf(value);
}
result.properties = newProps;
}
// Recursively process items
if (result.items) {
if (Array.isArray(result.items)) {
result.items = result.items.map(flattenAnyOfOneOf);
} else if (typeof result.items === 'object') {
result.items = flattenAnyOfOneOf(result.items);
}
}
return result;
}
/**
* Flatten array type fields and track nullable properties.
* Converts { type: ["string", "null"] } to { type: "string" } with nullable hint.
*
* @param {Object} schema - Schema to process
* @param {Set<string>} nullableProps - Set to collect nullable property names (mutated)
* @param {string} currentPropName - Current property name (for tracking)
* @returns {Object} Flattened schema
*/
function flattenTypeArrays(schema, nullableProps = null, currentPropName = null) {
if (!schema || typeof schema !== 'object') return schema;
if (Array.isArray(schema)) return schema.map(s => flattenTypeArrays(s, nullableProps));
let result = { ...schema };
// Handle array type fields
if (Array.isArray(result.type)) {
const types = result.type;
const hasNull = types.includes('null');
const nonNullTypes = types.filter(t => t !== 'null' && t);
// Select first non-null type, or 'string' as fallback
const firstType = nonNullTypes.length > 0 ? nonNullTypes[0] : 'string';
result.type = firstType;
// Add hint for multiple types
if (nonNullTypes.length > 1) {
result = appendDescriptionHint(result, `Accepts: ${nonNullTypes.join(' | ')}`);
}
// Track nullable and add hint
if (hasNull) {
result = appendDescriptionHint(result, 'nullable');
// Track this property as nullable for required array update
if (nullableProps && currentPropName) {
nullableProps.add(currentPropName);
}
}
}
// Recursively process properties, tracking nullable ones
if (result.properties && typeof result.properties === 'object') {
const childNullableProps = new Set();
const newProps = {};
for (const [key, value] of Object.entries(result.properties)) {
newProps[key] = flattenTypeArrays(value, childNullableProps, key);
}
result.properties = newProps;
// Remove nullable properties from required array
if (Array.isArray(result.required) && childNullableProps.size > 0) {
result.required = result.required.filter(prop => !childNullableProps.has(prop));
if (result.required.length === 0) {
delete result.required;
}
}
}
// Recursively process items
if (result.items) {
if (Array.isArray(result.items)) {
result.items = result.items.map(item => flattenTypeArrays(item, nullableProps));
} else if (typeof result.items === 'object') {
result.items = flattenTypeArrays(result.items, nullableProps);
}
}
return result;
}
/**
* Sanitize JSON Schema for Antigravity API compatibility.
* Uses allowlist approach - only permit known-safe JSON Schema features.
* Converts "const" to equivalent "enum" for compatibility.
* Generates placeholder schema for empty tool schemas.
*/
export function sanitizeSchema(schema) {
if (!schema || typeof schema !== 'object') {
// Empty/missing schema - generate placeholder with reason property
return {
type: 'object',
properties: {
reason: {
type: 'string',
description: 'Reason for calling this tool'
}
},
required: ['reason']
};
}
// Allowlist of permitted JSON Schema fields
const ALLOWED_FIELDS = new Set([
'type',
'description',
'properties',
'required',
'items',
'enum',
'title'
]);
const sanitized = {};
for (const [key, value] of Object.entries(schema)) {
// Convert "const" to "enum" for compatibility
if (key === 'const') {
sanitized.enum = [value];
continue;
}
// Skip fields not in allowlist
if (!ALLOWED_FIELDS.has(key)) {
continue;
}
if (key === 'properties' && value && typeof value === 'object') {
sanitized.properties = {};
for (const [propKey, propValue] of Object.entries(value)) {
sanitized.properties[propKey] = sanitizeSchema(propValue);
}
} else if (key === 'items' && value && typeof value === 'object') {
if (Array.isArray(value)) {
sanitized.items = value.map(item => sanitizeSchema(item));
} else {
sanitized.items = sanitizeSchema(value);
}
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
sanitized[key] = sanitizeSchema(value);
} else {
sanitized[key] = value;
}
}
// Ensure we have at least a type
if (!sanitized.type) {
sanitized.type = 'object';
}
// If object type with no properties, add placeholder
if (sanitized.type === 'object' && (!sanitized.properties || Object.keys(sanitized.properties).length === 0)) {
sanitized.properties = {
reason: {
type: 'string',
description: 'Reason for calling this tool'
}
};
sanitized.required = ['reason'];
}
return sanitized;
}
/**
* Cleans JSON schema for Gemini API compatibility.
* Uses a multi-phase pipeline matching opencode-antigravity-auth approach.
*
* @param {Object} schema - The JSON schema to clean
* @returns {Object} Cleaned schema safe for Gemini API
*/
export function cleanSchemaForGemini(schema) {
if (!schema || typeof schema !== 'object') return schema;
if (Array.isArray(schema)) return schema.map(cleanSchemaForGemini);
// Phase 1: Convert $refs to hints
let result = convertRefsToHints(schema);
// Phase 2a: Merge allOf schemas
result = mergeAllOf(result);
// Phase 2b: Flatten anyOf/oneOf
result = flattenAnyOfOneOf(result);
// Phase 2c: Flatten type arrays and update required for nullable
result = flattenTypeArrays(result);
// Phase 3: Remove unsupported keywords
const unsupported = [
'additionalProperties', 'default', '$schema', '$defs',
'definitions', '$ref', '$id', '$comment', 'title',
'minLength', 'maxLength', 'pattern', 'format',
'minItems', 'maxItems', 'examples', 'allOf', 'anyOf', 'oneOf'
];
for (const key of unsupported) {
delete result[key];
}
// Check for unsupported 'format' in string types
if (result.type === 'string' && result.format) {
const allowed = ['enum', 'date-time'];
if (!allowed.includes(result.format)) {
delete result.format;
}
}
// Phase 4: Final cleanup - recursively clean nested schemas and validate required
if (result.properties && typeof result.properties === 'object') {
const newProps = {};
for (const [key, value] of Object.entries(result.properties)) {
newProps[key] = cleanSchemaForGemini(value);
}
result.properties = newProps;
}
if (result.items) {
if (Array.isArray(result.items)) {
result.items = result.items.map(cleanSchemaForGemini);
} else if (typeof result.items === 'object') {
result.items = cleanSchemaForGemini(result.items);
}
}
// Validate that required array only contains properties that exist
if (result.required && Array.isArray(result.required) && result.properties) {
const definedProps = new Set(Object.keys(result.properties));
result.required = result.required.filter(prop => definedProps.has(prop));
if (result.required.length === 0) {
delete result.required;
}
}
return result;
}

View File

@@ -0,0 +1,259 @@
/**
* Thinking Block Utilities
* Handles thinking block processing, validation, and filtering
*/
import { MIN_SIGNATURE_LENGTH } from '../constants.js';
/**
* Check if a part is a thinking block
* @param {Object} part - Content part to check
* @returns {boolean} True if the part is a thinking block
*/
export function isThinkingPart(part) {
return part.type === 'thinking' ||
part.type === 'redacted_thinking' ||
part.thinking !== undefined ||
part.thought === true;
}
/**
* Check if a thinking part has a valid signature (>= MIN_SIGNATURE_LENGTH chars)
*/
export function hasValidSignature(part) {
const signature = part.thought === true ? part.thoughtSignature : part.signature;
return typeof signature === 'string' && signature.length >= MIN_SIGNATURE_LENGTH;
}
/**
* Sanitize a thinking part by keeping only allowed fields
*/
export function sanitizeThinkingPart(part) {
// Gemini-style thought blocks: { thought: true, text, thoughtSignature }
if (part.thought === true) {
const sanitized = { thought: true };
if (part.text !== undefined) sanitized.text = part.text;
if (part.thoughtSignature !== undefined) sanitized.thoughtSignature = part.thoughtSignature;
return sanitized;
}
// Anthropic-style thinking blocks: { type: "thinking", thinking, signature }
if (part.type === 'thinking' || part.thinking !== undefined) {
const sanitized = { type: 'thinking' };
if (part.thinking !== undefined) sanitized.thinking = part.thinking;
if (part.signature !== undefined) sanitized.signature = part.signature;
return sanitized;
}
return part;
}
/**
* Sanitize a thinking block by removing extra fields like cache_control.
* Only keeps: type, thinking, signature (for thinking) or type, data (for redacted_thinking)
*/
export function sanitizeAnthropicThinkingBlock(block) {
if (!block) return block;
if (block.type === 'thinking') {
const sanitized = { type: 'thinking' };
if (block.thinking !== undefined) sanitized.thinking = block.thinking;
if (block.signature !== undefined) sanitized.signature = block.signature;
return sanitized;
}
if (block.type === 'redacted_thinking') {
const sanitized = { type: 'redacted_thinking' };
if (block.data !== undefined) sanitized.data = block.data;
return sanitized;
}
return block;
}
/**
* Filter content array, keeping only thinking blocks with valid signatures.
*/
function filterContentArray(contentArray) {
const filtered = [];
for (const item of contentArray) {
if (!item || typeof item !== 'object') {
filtered.push(item);
continue;
}
if (!isThinkingPart(item)) {
filtered.push(item);
continue;
}
// Keep items with valid signatures
if (hasValidSignature(item)) {
filtered.push(sanitizeThinkingPart(item));
continue;
}
// Drop unsigned thinking blocks
console.log('[ThinkingUtils] Dropping unsigned thinking block');
}
return filtered;
}
/**
* Filter unsigned thinking blocks from contents (Gemini format)
*
* @param {Array<{role: string, parts: Array}>} contents - Array of content objects in Gemini format
* @returns {Array<{role: string, parts: Array}>} Filtered contents with unsigned thinking blocks removed
*/
export function filterUnsignedThinkingBlocks(contents) {
return contents.map(content => {
if (!content || typeof content !== 'object') return content;
if (Array.isArray(content.parts)) {
return { ...content, parts: filterContentArray(content.parts) };
}
return content;
});
}
/**
* Remove trailing unsigned thinking blocks from assistant messages.
* Claude/Gemini APIs require that assistant messages don't end with unsigned thinking blocks.
*
* @param {Array<Object>} content - Array of content blocks
* @returns {Array<Object>} Content array with trailing unsigned thinking blocks removed
*/
export function removeTrailingThinkingBlocks(content) {
if (!Array.isArray(content)) return content;
if (content.length === 0) return content;
// Work backwards from the end, removing thinking blocks
let endIndex = content.length;
for (let i = content.length - 1; i >= 0; i--) {
const block = content[i];
if (!block || typeof block !== 'object') break;
// Check if it's a thinking block (any format)
const isThinking = isThinkingPart(block);
if (isThinking) {
// Check if it has a valid signature
if (!hasValidSignature(block)) {
endIndex = i;
} else {
break; // Stop at signed thinking block
}
} else {
break; // Stop at first non-thinking block
}
}
if (endIndex < content.length) {
console.log('[ThinkingUtils] Removed', content.length - endIndex, 'trailing unsigned thinking blocks');
return content.slice(0, endIndex);
}
return content;
}
/**
* Filter thinking blocks: keep only those with valid signatures.
* Blocks without signatures are dropped (API requires signatures).
* Also sanitizes blocks to remove extra fields like cache_control.
*
* @param {Array<Object>} content - Array of content blocks
* @returns {Array<Object>} Filtered content with only valid signed thinking blocks
*/
export function restoreThinkingSignatures(content) {
if (!Array.isArray(content)) return content;
const originalLength = content.length;
const filtered = [];
for (const block of content) {
if (!block || block.type !== 'thinking') {
filtered.push(block);
continue;
}
// Keep blocks with valid signatures (>= MIN_SIGNATURE_LENGTH chars), sanitized
if (block.signature && block.signature.length >= MIN_SIGNATURE_LENGTH) {
filtered.push(sanitizeAnthropicThinkingBlock(block));
}
// Unsigned thinking blocks are dropped
}
if (filtered.length < originalLength) {
console.log(`[ThinkingUtils] Dropped ${originalLength - filtered.length} unsigned thinking block(s)`);
}
return filtered;
}
/**
* Reorder content so that:
* 1. Thinking blocks come first (required when thinking is enabled)
* 2. Text blocks come in the middle (filtering out empty/useless ones)
* 3. Tool_use blocks come at the end (required before tool_result)
*
* @param {Array<Object>} content - Array of content blocks
* @returns {Array<Object>} Reordered content array
*/
export function reorderAssistantContent(content) {
if (!Array.isArray(content)) return content;
// Even for single-element arrays, we need to sanitize thinking blocks
if (content.length === 1) {
const block = content[0];
if (block && (block.type === 'thinking' || block.type === 'redacted_thinking')) {
return [sanitizeAnthropicThinkingBlock(block)];
}
return content;
}
const thinkingBlocks = [];
const textBlocks = [];
const toolUseBlocks = [];
let droppedEmptyBlocks = 0;
for (const block of content) {
if (!block) continue;
if (block.type === 'thinking' || block.type === 'redacted_thinking') {
// Sanitize thinking blocks to remove cache_control and other extra fields
thinkingBlocks.push(sanitizeAnthropicThinkingBlock(block));
} else if (block.type === 'tool_use') {
toolUseBlocks.push(block);
} else if (block.type === 'text') {
// Only keep text blocks with meaningful content
if (block.text && block.text.trim().length > 0) {
textBlocks.push(block);
} else {
droppedEmptyBlocks++;
}
} else {
// Other block types go in the text position
textBlocks.push(block);
}
}
if (droppedEmptyBlocks > 0) {
console.log(`[ThinkingUtils] Dropped ${droppedEmptyBlocks} empty text block(s)`);
}
const reordered = [...thinkingBlocks, ...textBlocks, ...toolUseBlocks];
// Log only if actual reordering happened (not just filtering)
if (reordered.length === content.length) {
const originalOrder = content.map(b => b?.type || 'unknown').join(',');
const newOrder = reordered.map(b => b?.type || 'unknown').join(',');
if (originalOrder !== newOrder) {
console.log('[ThinkingUtils] Reordered assistant content');
}
}
return reordered;
}