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/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/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/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/oauth.js**: Google OAuth implementation for adding accounts
|
||||||
- **src/token-extractor.js**: Extracts tokens from local Antigravity app installation (legacy single-account mode)
|
- **src/token-extractor.js**: Extracts tokens from local Antigravity app installation (legacy single-account mode)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
convertAnthropicToGoogle,
|
convertAnthropicToGoogle,
|
||||||
convertGoogleToAnthropic
|
convertGoogleToAnthropic
|
||||||
} from './format-converter.js';
|
} from './format/index.js';
|
||||||
import { formatDuration, sleep } from './utils/helpers.js';
|
import { formatDuration, sleep } from './utils/helpers.js';
|
||||||
import { isRateLimitError, isAuthError } from './errors.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