Gemini schema fix and modularize format converter
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
151
src/format/content-converter.js
Normal file
151
src/format/content-converter.js
Normal 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
20
src/format/index.js
Normal 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
|
||||
};
|
||||
195
src/format/request-converter.js
Normal file
195
src/format/request-converter.js
Normal 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;
|
||||
}
|
||||
101
src/format/response-converter.js
Normal file
101
src/format/response-converter.js
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
516
src/format/schema-sanitizer.js
Normal file
516
src/format/schema-sanitizer.js
Normal 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;
|
||||
}
|
||||
259
src/format/thinking-utils.js
Normal file
259
src/format/thinking-utils.js
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user