antigravity-claude-proxy 1.1.1 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antigravity-claude-proxy",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
4
4
  "description": "Proxy server to use Antigravity's Claude models with Claude Code CLI",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -22,7 +22,8 @@ import {
22
22
  import {
23
23
  convertAnthropicToGoogle,
24
24
  convertGoogleToAnthropic
25
- } from './format-converter.js';
25
+ } from './format/index.js';
26
+ import { cacheSignature } from './format/signature-cache.js';
26
27
  import { formatDuration, sleep } from './utils/helpers.js';
27
28
  import { isRateLimitError, isAuthError } from './errors.js';
28
29
 
@@ -848,6 +849,8 @@ async function* streamSSEResponse(response, originalModel) {
848
849
  // Store the signature in the tool_use block for later retrieval
849
850
  if (functionCallSignature && functionCallSignature.length >= MIN_SIGNATURE_LENGTH) {
850
851
  toolUseBlock.thoughtSignature = functionCallSignature;
852
+ // Cache for future requests (Claude Code may strip this field)
853
+ cacheSignature(toolId, functionCallSignature);
851
854
  }
852
855
 
853
856
  yield {
package/src/constants.js CHANGED
@@ -87,6 +87,14 @@ export const MIN_SIGNATURE_LENGTH = 50; // Minimum valid thinking signature leng
87
87
  // Gemini-specific limits
88
88
  export const GEMINI_MAX_OUTPUT_TOKENS = 16384;
89
89
 
90
+ // Gemini signature handling
91
+ // Sentinel value to skip thought signature validation when Claude Code strips the field
92
+ // See: https://ai.google.dev/gemini-api/docs/thought-signatures
93
+ export const GEMINI_SKIP_SIGNATURE = 'skip_thought_signature_validator';
94
+
95
+ // Cache TTL for Gemini thoughtSignatures (2 hours)
96
+ export const GEMINI_SIGNATURE_CACHE_TTL_MS = 2 * 60 * 60 * 1000;
97
+
90
98
  /**
91
99
  * Get the model family from model name (dynamic detection, no hardcoded list).
92
100
  * @param {string} modelName - The model name from the request
@@ -152,6 +160,8 @@ export default {
152
160
  MAX_WAIT_BEFORE_ERROR_MS,
153
161
  MIN_SIGNATURE_LENGTH,
154
162
  GEMINI_MAX_OUTPUT_TOKENS,
163
+ GEMINI_SKIP_SIGNATURE,
164
+ GEMINI_SIGNATURE_CACHE_TTL_MS,
155
165
  getModelFamily,
156
166
  isThinkingModel,
157
167
  OAUTH_CONFIG,
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Content Converter
3
+ * Converts Anthropic message content to Google Generative AI parts format
4
+ */
5
+
6
+ import { MIN_SIGNATURE_LENGTH, GEMINI_SKIP_SIGNATURE } from '../constants.js';
7
+ import { getCachedSignature } from './signature-cache.js';
8
+
9
+ /**
10
+ * Convert Anthropic role to Google role
11
+ * @param {string} role - Anthropic role ('user', 'assistant')
12
+ * @returns {string} Google role ('user', 'model')
13
+ */
14
+ export function convertRole(role) {
15
+ if (role === 'assistant') return 'model';
16
+ if (role === 'user') return 'user';
17
+ return 'user'; // Default to user
18
+ }
19
+
20
+ /**
21
+ * Convert Anthropic message content to Google Generative AI parts
22
+ * @param {string|Array} content - Anthropic message content
23
+ * @param {boolean} isClaudeModel - Whether the model is a Claude model
24
+ * @param {boolean} isGeminiModel - Whether the model is a Gemini model
25
+ * @returns {Array} Google Generative AI parts array
26
+ */
27
+ export function convertContentToParts(content, isClaudeModel = false, isGeminiModel = false) {
28
+ if (typeof content === 'string') {
29
+ return [{ text: content }];
30
+ }
31
+
32
+ if (!Array.isArray(content)) {
33
+ return [{ text: String(content) }];
34
+ }
35
+
36
+ const parts = [];
37
+
38
+ for (const block of content) {
39
+ if (block.type === 'text') {
40
+ // Skip empty text blocks - they cause API errors
41
+ if (block.text && block.text.trim()) {
42
+ parts.push({ text: block.text });
43
+ }
44
+ } else if (block.type === 'image') {
45
+ // Handle image content
46
+ if (block.source?.type === 'base64') {
47
+ // Base64-encoded image
48
+ parts.push({
49
+ inlineData: {
50
+ mimeType: block.source.media_type,
51
+ data: block.source.data
52
+ }
53
+ });
54
+ } else if (block.source?.type === 'url') {
55
+ // URL-referenced image
56
+ parts.push({
57
+ fileData: {
58
+ mimeType: block.source.media_type || 'image/jpeg',
59
+ fileUri: block.source.url
60
+ }
61
+ });
62
+ }
63
+ } else if (block.type === 'document') {
64
+ // Handle document content (e.g. PDF)
65
+ if (block.source?.type === 'base64') {
66
+ parts.push({
67
+ inlineData: {
68
+ mimeType: block.source.media_type,
69
+ data: block.source.data
70
+ }
71
+ });
72
+ } else if (block.source?.type === 'url') {
73
+ parts.push({
74
+ fileData: {
75
+ mimeType: block.source.media_type || 'application/pdf',
76
+ fileUri: block.source.url
77
+ }
78
+ });
79
+ }
80
+ } else if (block.type === 'tool_use') {
81
+ // Convert tool_use to functionCall (Google format)
82
+ // For Claude models, include the id field
83
+ const functionCall = {
84
+ name: block.name,
85
+ args: block.input || {}
86
+ };
87
+
88
+ if (isClaudeModel && block.id) {
89
+ functionCall.id = block.id;
90
+ }
91
+
92
+ // Build the part with functionCall
93
+ const part = { functionCall };
94
+
95
+ // For Gemini models, include thoughtSignature at the part level
96
+ // This is required by Gemini 3+ for tool calls to work correctly
97
+ if (isGeminiModel) {
98
+ // Priority: block.thoughtSignature > cache > GEMINI_SKIP_SIGNATURE
99
+ let signature = block.thoughtSignature;
100
+
101
+ if (!signature && block.id) {
102
+ signature = getCachedSignature(block.id);
103
+ if (signature) {
104
+ console.log('[ContentConverter] Restored signature from cache for:', block.id);
105
+ }
106
+ }
107
+
108
+ part.thoughtSignature = signature || GEMINI_SKIP_SIGNATURE;
109
+ }
110
+
111
+ parts.push(part);
112
+ } else if (block.type === 'tool_result') {
113
+ // Convert tool_result to functionResponse (Google format)
114
+ let responseContent = block.content;
115
+ if (typeof responseContent === 'string') {
116
+ responseContent = { result: responseContent };
117
+ } else if (Array.isArray(responseContent)) {
118
+ const texts = responseContent
119
+ .filter(c => c.type === 'text')
120
+ .map(c => c.text)
121
+ .join('\n');
122
+ responseContent = { result: texts };
123
+ }
124
+
125
+ const functionResponse = {
126
+ name: block.tool_use_id || 'unknown',
127
+ response: responseContent
128
+ };
129
+
130
+ // For Claude models, the id field must match the tool_use_id
131
+ if (isClaudeModel && block.tool_use_id) {
132
+ functionResponse.id = block.tool_use_id;
133
+ }
134
+
135
+ parts.push({ functionResponse });
136
+ } else if (block.type === 'thinking') {
137
+ // Handle thinking blocks - only those with valid signatures
138
+ if (block.signature && block.signature.length >= MIN_SIGNATURE_LENGTH) {
139
+ // Convert to Gemini format with signature
140
+ parts.push({
141
+ text: block.thinking,
142
+ thought: true,
143
+ thoughtSignature: block.signature
144
+ });
145
+ }
146
+ // Unsigned thinking blocks are dropped upstream
147
+ }
148
+ }
149
+
150
+ return parts;
151
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Format Converter Module
3
+ * Converts between Anthropic Messages API format and Google Generative AI format
4
+ */
5
+
6
+ // Re-export all from each module
7
+ export * from './request-converter.js';
8
+ export * from './response-converter.js';
9
+ export * from './content-converter.js';
10
+ export * from './schema-sanitizer.js';
11
+ export * from './thinking-utils.js';
12
+
13
+ // Default export for backward compatibility
14
+ import { convertAnthropicToGoogle } from './request-converter.js';
15
+ import { convertGoogleToAnthropic } from './response-converter.js';
16
+
17
+ export default {
18
+ convertAnthropicToGoogle,
19
+ convertGoogleToAnthropic
20
+ };
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Request Converter
3
+ * Converts Anthropic Messages API requests to Google Generative AI format
4
+ */
5
+
6
+ import {
7
+ GEMINI_MAX_OUTPUT_TOKENS,
8
+ getModelFamily,
9
+ isThinkingModel
10
+ } from '../constants.js';
11
+ import { convertContentToParts, convertRole } from './content-converter.js';
12
+ import { sanitizeSchema, cleanSchemaForGemini } from './schema-sanitizer.js';
13
+ import {
14
+ restoreThinkingSignatures,
15
+ removeTrailingThinkingBlocks,
16
+ reorderAssistantContent,
17
+ filterUnsignedThinkingBlocks,
18
+ needsThinkingRecovery,
19
+ closeToolLoopForThinking
20
+ } from './thinking-utils.js';
21
+
22
+ /**
23
+ * Convert Anthropic Messages API request to the format expected by Cloud Code
24
+ *
25
+ * Uses Google Generative AI format, but for Claude models:
26
+ * - Keeps tool_result in Anthropic format (required by Claude API)
27
+ *
28
+ * @param {Object} anthropicRequest - Anthropic format request
29
+ * @returns {Object} Request body for Cloud Code API
30
+ */
31
+ export function convertAnthropicToGoogle(anthropicRequest) {
32
+ const { messages, system, max_tokens, temperature, top_p, top_k, stop_sequences, tools, tool_choice, thinking } = anthropicRequest;
33
+ const modelName = anthropicRequest.model || '';
34
+ const modelFamily = getModelFamily(modelName);
35
+ const isClaudeModel = modelFamily === 'claude';
36
+ const isGeminiModel = modelFamily === 'gemini';
37
+ const isThinking = isThinkingModel(modelName);
38
+
39
+ const googleRequest = {
40
+ contents: [],
41
+ generationConfig: {}
42
+ };
43
+
44
+ // Handle system instruction
45
+ if (system) {
46
+ let systemParts = [];
47
+ if (typeof system === 'string') {
48
+ systemParts = [{ text: system }];
49
+ } else if (Array.isArray(system)) {
50
+ // Filter for text blocks as system prompts are usually text
51
+ // Anthropic supports text blocks in system prompts
52
+ systemParts = system
53
+ .filter(block => block.type === 'text')
54
+ .map(block => ({ text: block.text }));
55
+ }
56
+
57
+ if (systemParts.length > 0) {
58
+ googleRequest.systemInstruction = {
59
+ parts: systemParts
60
+ };
61
+ }
62
+ }
63
+
64
+ // Add interleaved thinking hint for Claude thinking models with tools
65
+ if (isClaudeModel && isThinking && tools && tools.length > 0) {
66
+ 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.';
67
+ if (!googleRequest.systemInstruction) {
68
+ googleRequest.systemInstruction = { parts: [{ text: hint }] };
69
+ } else {
70
+ const lastPart = googleRequest.systemInstruction.parts[googleRequest.systemInstruction.parts.length - 1];
71
+ if (lastPart && lastPart.text) {
72
+ lastPart.text = `${lastPart.text}\n\n${hint}`;
73
+ } else {
74
+ googleRequest.systemInstruction.parts.push({ text: hint });
75
+ }
76
+ }
77
+ }
78
+
79
+ // Apply thinking recovery for Gemini thinking models when needed
80
+ // This handles corrupted tool loops where thinking blocks are stripped
81
+ // Claude models handle this differently and don't need this recovery
82
+ let processedMessages = messages;
83
+ if (isGeminiModel && isThinking && needsThinkingRecovery(messages)) {
84
+ console.log('[RequestConverter] Applying thinking recovery for Gemini');
85
+ processedMessages = closeToolLoopForThinking(messages);
86
+ }
87
+
88
+ // Convert messages to contents, then filter unsigned thinking blocks
89
+ for (let i = 0; i < processedMessages.length; i++) {
90
+ const msg = processedMessages[i];
91
+ let msgContent = msg.content;
92
+
93
+ // For assistant messages, process thinking blocks and reorder content
94
+ if ((msg.role === 'assistant' || msg.role === 'model') && Array.isArray(msgContent)) {
95
+ // First, try to restore signatures for unsigned thinking blocks from cache
96
+ msgContent = restoreThinkingSignatures(msgContent);
97
+ // Remove trailing unsigned thinking blocks
98
+ msgContent = removeTrailingThinkingBlocks(msgContent);
99
+ // Reorder: thinking first, then text, then tool_use
100
+ msgContent = reorderAssistantContent(msgContent);
101
+ }
102
+
103
+ const parts = convertContentToParts(msgContent, isClaudeModel, isGeminiModel);
104
+
105
+ // SAFETY: Google API requires at least one part per content message
106
+ // This happens when all thinking blocks are filtered out (unsigned)
107
+ if (parts.length === 0) {
108
+ console.log('[RequestConverter] WARNING: Empty parts array after filtering, adding placeholder');
109
+ parts.push({ text: '' });
110
+ }
111
+
112
+ const content = {
113
+ role: convertRole(msg.role),
114
+ parts: parts
115
+ };
116
+ googleRequest.contents.push(content);
117
+ }
118
+
119
+ // Filter unsigned thinking blocks for Claude models
120
+ if (isClaudeModel) {
121
+ googleRequest.contents = filterUnsignedThinkingBlocks(googleRequest.contents);
122
+ }
123
+
124
+ // Generation config
125
+ if (max_tokens) {
126
+ googleRequest.generationConfig.maxOutputTokens = max_tokens;
127
+ }
128
+ if (temperature !== undefined) {
129
+ googleRequest.generationConfig.temperature = temperature;
130
+ }
131
+ if (top_p !== undefined) {
132
+ googleRequest.generationConfig.topP = top_p;
133
+ }
134
+ if (top_k !== undefined) {
135
+ googleRequest.generationConfig.topK = top_k;
136
+ }
137
+ if (stop_sequences && stop_sequences.length > 0) {
138
+ googleRequest.generationConfig.stopSequences = stop_sequences;
139
+ }
140
+
141
+ // Enable thinking for thinking models (Claude and Gemini 3+)
142
+ if (isThinking) {
143
+ if (isClaudeModel) {
144
+ // Claude thinking config
145
+ const thinkingConfig = {
146
+ include_thoughts: true
147
+ };
148
+
149
+ // Only set thinking_budget if explicitly provided
150
+ const thinkingBudget = thinking?.budget_tokens;
151
+ if (thinkingBudget) {
152
+ thinkingConfig.thinking_budget = thinkingBudget;
153
+ console.log('[RequestConverter] Claude thinking enabled with budget:', thinkingBudget);
154
+ } else {
155
+ console.log('[RequestConverter] Claude thinking enabled (no budget specified)');
156
+ }
157
+
158
+ googleRequest.generationConfig.thinkingConfig = thinkingConfig;
159
+ } else if (isGeminiModel) {
160
+ // Gemini thinking config (uses camelCase)
161
+ const thinkingConfig = {
162
+ includeThoughts: true,
163
+ thinkingBudget: thinking?.budget_tokens || 16000
164
+ };
165
+ console.log('[RequestConverter] Gemini thinking enabled with budget:', thinkingConfig.thinkingBudget);
166
+
167
+ googleRequest.generationConfig.thinkingConfig = thinkingConfig;
168
+ }
169
+ }
170
+
171
+ // Convert tools to Google format
172
+ if (tools && tools.length > 0) {
173
+ const functionDeclarations = tools.map((tool, idx) => {
174
+ // Extract name from various possible locations
175
+ const name = tool.name || tool.function?.name || tool.custom?.name || `tool-${idx}`;
176
+
177
+ // Extract description from various possible locations
178
+ const description = tool.description || tool.function?.description || tool.custom?.description || '';
179
+
180
+ // Extract schema from various possible locations
181
+ const schema = tool.input_schema
182
+ || tool.function?.input_schema
183
+ || tool.function?.parameters
184
+ || tool.custom?.input_schema
185
+ || tool.parameters
186
+ || { type: 'object' };
187
+
188
+ // Sanitize schema for general compatibility
189
+ let parameters = sanitizeSchema(schema);
190
+
191
+ // For Gemini models, apply additional cleaning for VALIDATED mode
192
+ if (isGeminiModel) {
193
+ parameters = cleanSchemaForGemini(parameters);
194
+ }
195
+
196
+ return {
197
+ name: String(name).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64),
198
+ description: description,
199
+ parameters
200
+ };
201
+ });
202
+
203
+ googleRequest.tools = [{ functionDeclarations }];
204
+ console.log('[RequestConverter] Tools:', JSON.stringify(googleRequest.tools).substring(0, 300));
205
+ }
206
+
207
+ // Cap max tokens for Gemini models
208
+ if (isGeminiModel && googleRequest.generationConfig.maxOutputTokens > GEMINI_MAX_OUTPUT_TOKENS) {
209
+ console.log(`[RequestConverter] Capping Gemini max_tokens from ${googleRequest.generationConfig.maxOutputTokens} to ${GEMINI_MAX_OUTPUT_TOKENS}`);
210
+ googleRequest.generationConfig.maxOutputTokens = GEMINI_MAX_OUTPUT_TOKENS;
211
+ }
212
+
213
+ return googleRequest;
214
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Response Converter
3
+ * Converts Google Generative AI responses to Anthropic Messages API format
4
+ */
5
+
6
+ import crypto from 'crypto';
7
+ import { MIN_SIGNATURE_LENGTH } from '../constants.js';
8
+ import { cacheSignature } from './signature-cache.js';
9
+
10
+ /**
11
+ * Convert Google Generative AI response to Anthropic Messages API format
12
+ *
13
+ * @param {Object} googleResponse - Google format response (the inner response object)
14
+ * @param {string} model - The model name used
15
+ * @returns {Object} Anthropic format response
16
+ */
17
+ export function convertGoogleToAnthropic(googleResponse, model) {
18
+ // Handle the response wrapper
19
+ const response = googleResponse.response || googleResponse;
20
+
21
+ const candidates = response.candidates || [];
22
+ const firstCandidate = candidates[0] || {};
23
+ const content = firstCandidate.content || {};
24
+ const parts = content.parts || [];
25
+
26
+ // Convert parts to Anthropic content blocks
27
+ const anthropicContent = [];
28
+ let hasToolCalls = false;
29
+
30
+ for (const part of parts) {
31
+ if (part.text !== undefined) {
32
+ // Handle thinking blocks
33
+ if (part.thought === true) {
34
+ const signature = part.thoughtSignature || '';
35
+
36
+ // Include thinking blocks in the response for Claude Code
37
+ anthropicContent.push({
38
+ type: 'thinking',
39
+ thinking: part.text,
40
+ signature: signature
41
+ });
42
+ } else {
43
+ anthropicContent.push({
44
+ type: 'text',
45
+ text: part.text
46
+ });
47
+ }
48
+ } else if (part.functionCall) {
49
+ // Convert functionCall to tool_use
50
+ // Use the id from the response if available, otherwise generate one
51
+ const toolId = part.functionCall.id || `toolu_${crypto.randomBytes(12).toString('hex')}`;
52
+ const toolUseBlock = {
53
+ type: 'tool_use',
54
+ id: toolId,
55
+ name: part.functionCall.name,
56
+ input: part.functionCall.args || {}
57
+ };
58
+
59
+ // For Gemini 3+, include thoughtSignature from the part level
60
+ if (part.thoughtSignature && part.thoughtSignature.length >= MIN_SIGNATURE_LENGTH) {
61
+ toolUseBlock.thoughtSignature = part.thoughtSignature;
62
+ // Cache for future requests (Claude Code may strip this field)
63
+ cacheSignature(toolId, part.thoughtSignature);
64
+ }
65
+
66
+ anthropicContent.push(toolUseBlock);
67
+ hasToolCalls = true;
68
+ }
69
+ }
70
+
71
+ // Determine stop reason
72
+ const finishReason = firstCandidate.finishReason;
73
+ let stopReason = 'end_turn';
74
+ if (finishReason === 'STOP') {
75
+ stopReason = 'end_turn';
76
+ } else if (finishReason === 'MAX_TOKENS') {
77
+ stopReason = 'max_tokens';
78
+ } else if (finishReason === 'TOOL_USE' || hasToolCalls) {
79
+ stopReason = 'tool_use';
80
+ }
81
+
82
+ // Extract usage metadata
83
+ // Note: Antigravity's promptTokenCount is the TOTAL (includes cached),
84
+ // but Anthropic's input_tokens excludes cached. We subtract to match.
85
+ const usageMetadata = response.usageMetadata || {};
86
+ const promptTokens = usageMetadata.promptTokenCount || 0;
87
+ const cachedTokens = usageMetadata.cachedContentTokenCount || 0;
88
+
89
+ return {
90
+ id: `msg_${crypto.randomBytes(16).toString('hex')}`,
91
+ type: 'message',
92
+ role: 'assistant',
93
+ content: anthropicContent.length > 0 ? anthropicContent : [{ type: 'text', text: '' }],
94
+ model: model,
95
+ stop_reason: stopReason,
96
+ stop_sequence: null,
97
+ usage: {
98
+ input_tokens: promptTokens - cachedTokens,
99
+ output_tokens: usageMetadata.candidatesTokenCount || 0,
100
+ cache_read_input_tokens: cachedTokens,
101
+ cache_creation_input_tokens: 0
102
+ }
103
+ };
104
+ }