antigravity-claude-proxy 1.0.0
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/LICENSE +21 -0
- package/README.md +289 -0
- package/bin/cli.js +109 -0
- package/package.json +54 -0
- package/src/account-manager.js +633 -0
- package/src/accounts-cli.js +437 -0
- package/src/cloudcode-client.js +1018 -0
- package/src/constants.js +164 -0
- package/src/errors.js +159 -0
- package/src/format-converter.js +731 -0
- package/src/index.js +40 -0
- package/src/oauth.js +346 -0
- package/src/server.js +517 -0
- package/src/token-extractor.js +146 -0
- package/src/utils/helpers.js +33 -0
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format Converter
|
|
3
|
+
* Converts between Anthropic Messages API format and Google Generative AI format
|
|
4
|
+
*
|
|
5
|
+
* Based on patterns from:
|
|
6
|
+
* - https://github.com/NoeFabris/opencode-antigravity-auth
|
|
7
|
+
* - https://github.com/1rgs/claude-code-proxy
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import crypto from 'crypto';
|
|
11
|
+
import {
|
|
12
|
+
MODEL_MAPPINGS,
|
|
13
|
+
CLAUDE_THINKING_MAX_OUTPUT_TOKENS,
|
|
14
|
+
MIN_SIGNATURE_LENGTH
|
|
15
|
+
} from './constants.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Map Anthropic model name to Antigravity model name
|
|
19
|
+
* @param {string} anthropicModel - Anthropic format model name (e.g., 'claude-3-5-sonnet-20241022')
|
|
20
|
+
* @returns {string} Antigravity format model name (e.g., 'claude-sonnet-4-5')
|
|
21
|
+
*/
|
|
22
|
+
export function mapModelName(anthropicModel) {
|
|
23
|
+
return MODEL_MAPPINGS[anthropicModel] || anthropicModel;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Check if a part is a thinking block
|
|
28
|
+
* @param {Object} part - Content part to check
|
|
29
|
+
* @returns {boolean} True if the part is a thinking block
|
|
30
|
+
*/
|
|
31
|
+
function isThinkingPart(part) {
|
|
32
|
+
return part.type === 'thinking' ||
|
|
33
|
+
part.type === 'redacted_thinking' ||
|
|
34
|
+
part.thinking !== undefined ||
|
|
35
|
+
part.thought === true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check if a thinking part has a valid signature (>= MIN_SIGNATURE_LENGTH chars)
|
|
40
|
+
*/
|
|
41
|
+
function hasValidSignature(part) {
|
|
42
|
+
const signature = part.thought === true ? part.thoughtSignature : part.signature;
|
|
43
|
+
return typeof signature === 'string' && signature.length >= MIN_SIGNATURE_LENGTH;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Sanitize a thinking part by keeping only allowed fields
|
|
48
|
+
*/
|
|
49
|
+
function sanitizeThinkingPart(part) {
|
|
50
|
+
// Gemini-style thought blocks: { thought: true, text, thoughtSignature }
|
|
51
|
+
if (part.thought === true) {
|
|
52
|
+
const sanitized = { thought: true };
|
|
53
|
+
if (part.text !== undefined) sanitized.text = part.text;
|
|
54
|
+
if (part.thoughtSignature !== undefined) sanitized.thoughtSignature = part.thoughtSignature;
|
|
55
|
+
return sanitized;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Anthropic-style thinking blocks: { type: "thinking", thinking, signature }
|
|
59
|
+
if (part.type === 'thinking' || part.thinking !== undefined) {
|
|
60
|
+
const sanitized = { type: 'thinking' };
|
|
61
|
+
if (part.thinking !== undefined) sanitized.thinking = part.thinking;
|
|
62
|
+
if (part.signature !== undefined) sanitized.signature = part.signature;
|
|
63
|
+
return sanitized;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return part;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Filter content array, keeping only thinking blocks with valid signatures.
|
|
71
|
+
* Since signature_delta transmits signatures properly, cache is no longer needed.
|
|
72
|
+
*/
|
|
73
|
+
function filterContentArray(contentArray) {
|
|
74
|
+
const filtered = [];
|
|
75
|
+
|
|
76
|
+
for (const item of contentArray) {
|
|
77
|
+
if (!item || typeof item !== 'object') {
|
|
78
|
+
filtered.push(item);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!isThinkingPart(item)) {
|
|
83
|
+
filtered.push(item);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Keep items with valid signatures
|
|
88
|
+
if (hasValidSignature(item)) {
|
|
89
|
+
filtered.push(sanitizeThinkingPart(item));
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Drop unsigned thinking blocks
|
|
94
|
+
console.log('[FormatConverter] Dropping unsigned thinking block');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return filtered;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Filter unsigned thinking blocks from contents (Gemini format)
|
|
102
|
+
*
|
|
103
|
+
* @param {Array<{role: string, parts: Array}>} contents - Array of content objects in Gemini format
|
|
104
|
+
* @returns {Array<{role: string, parts: Array}>} Filtered contents with unsigned thinking blocks removed
|
|
105
|
+
*/
|
|
106
|
+
export function filterUnsignedThinkingBlocks(contents) {
|
|
107
|
+
return contents.map(content => {
|
|
108
|
+
if (!content || typeof content !== 'object') return content;
|
|
109
|
+
|
|
110
|
+
if (Array.isArray(content.parts)) {
|
|
111
|
+
return { ...content, parts: filterContentArray(content.parts) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return content;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Remove trailing unsigned thinking blocks from assistant messages.
|
|
120
|
+
* Claude/Gemini APIs require that assistant messages don't end with unsigned thinking blocks.
|
|
121
|
+
* This function removes thinking blocks from the end of content arrays.
|
|
122
|
+
*
|
|
123
|
+
* @param {Array<Object>} content - Array of content blocks
|
|
124
|
+
* @returns {Array<Object>} Content array with trailing unsigned thinking blocks removed
|
|
125
|
+
*/
|
|
126
|
+
export function removeTrailingThinkingBlocks(content) {
|
|
127
|
+
if (!Array.isArray(content)) return content;
|
|
128
|
+
if (content.length === 0) return content;
|
|
129
|
+
|
|
130
|
+
// Work backwards from the end, removing thinking blocks
|
|
131
|
+
let endIndex = content.length;
|
|
132
|
+
for (let i = content.length - 1; i >= 0; i--) {
|
|
133
|
+
const block = content[i];
|
|
134
|
+
if (!block || typeof block !== 'object') break;
|
|
135
|
+
|
|
136
|
+
// Check if it's a thinking block (any format)
|
|
137
|
+
const isThinking = isThinkingPart(block);
|
|
138
|
+
|
|
139
|
+
if (isThinking) {
|
|
140
|
+
// Check if it has a valid signature
|
|
141
|
+
if (!hasValidSignature(block)) {
|
|
142
|
+
endIndex = i;
|
|
143
|
+
} else {
|
|
144
|
+
break; // Stop at signed thinking block
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
break; // Stop at first non-thinking block
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (endIndex < content.length) {
|
|
152
|
+
console.log('[FormatConverter] Removed', content.length - endIndex, 'trailing unsigned thinking blocks');
|
|
153
|
+
return content.slice(0, endIndex);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return content;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Sanitize a thinking block by removing extra fields like cache_control.
|
|
161
|
+
* Only keeps: type, thinking, signature (for thinking) or type, data (for redacted_thinking)
|
|
162
|
+
*/
|
|
163
|
+
function sanitizeAnthropicThinkingBlock(block) {
|
|
164
|
+
if (!block) return block;
|
|
165
|
+
|
|
166
|
+
if (block.type === 'thinking') {
|
|
167
|
+
const sanitized = { type: 'thinking' };
|
|
168
|
+
if (block.thinking !== undefined) sanitized.thinking = block.thinking;
|
|
169
|
+
if (block.signature !== undefined) sanitized.signature = block.signature;
|
|
170
|
+
return sanitized;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (block.type === 'redacted_thinking') {
|
|
174
|
+
const sanitized = { type: 'redacted_thinking' };
|
|
175
|
+
if (block.data !== undefined) sanitized.data = block.data;
|
|
176
|
+
return sanitized;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return block;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Filter thinking blocks: keep only those with valid signatures.
|
|
184
|
+
* Blocks without signatures are dropped (API requires signatures).
|
|
185
|
+
* Also sanitizes blocks to remove extra fields like cache_control.
|
|
186
|
+
*
|
|
187
|
+
* @param {Array<Object>} content - Array of content blocks
|
|
188
|
+
* @returns {Array<Object>} Filtered content with only valid signed thinking blocks
|
|
189
|
+
*/
|
|
190
|
+
export function restoreThinkingSignatures(content) {
|
|
191
|
+
if (!Array.isArray(content)) return content;
|
|
192
|
+
|
|
193
|
+
const originalLength = content.length;
|
|
194
|
+
const filtered = [];
|
|
195
|
+
|
|
196
|
+
for (const block of content) {
|
|
197
|
+
if (!block || block.type !== 'thinking') {
|
|
198
|
+
filtered.push(block);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Keep blocks with valid signatures (>= MIN_SIGNATURE_LENGTH chars), sanitized
|
|
203
|
+
if (block.signature && block.signature.length >= MIN_SIGNATURE_LENGTH) {
|
|
204
|
+
filtered.push(sanitizeAnthropicThinkingBlock(block));
|
|
205
|
+
}
|
|
206
|
+
// Unsigned thinking blocks are dropped
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (filtered.length < originalLength) {
|
|
210
|
+
console.log(`[FormatConverter] Dropped ${originalLength - filtered.length} unsigned thinking block(s)`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return filtered;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Reorder content so that:
|
|
218
|
+
* 1. Thinking blocks come first (required when thinking is enabled)
|
|
219
|
+
* 2. Text blocks come in the middle (filtering out empty/useless ones)
|
|
220
|
+
* 3. Tool_use blocks come at the end (required before tool_result)
|
|
221
|
+
*
|
|
222
|
+
* Claude API requires that when thinking is enabled, assistant messages must start with thinking.
|
|
223
|
+
*
|
|
224
|
+
* @param {Array<Object>} content - Array of content blocks
|
|
225
|
+
* @returns {Array<Object>} Reordered content array
|
|
226
|
+
*/
|
|
227
|
+
export function reorderAssistantContent(content) {
|
|
228
|
+
if (!Array.isArray(content)) return content;
|
|
229
|
+
|
|
230
|
+
// Even for single-element arrays, we need to sanitize thinking blocks
|
|
231
|
+
if (content.length === 1) {
|
|
232
|
+
const block = content[0];
|
|
233
|
+
if (block && (block.type === 'thinking' || block.type === 'redacted_thinking')) {
|
|
234
|
+
return [sanitizeAnthropicThinkingBlock(block)];
|
|
235
|
+
}
|
|
236
|
+
return content;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const thinkingBlocks = [];
|
|
240
|
+
const textBlocks = [];
|
|
241
|
+
const toolUseBlocks = [];
|
|
242
|
+
let droppedEmptyBlocks = 0;
|
|
243
|
+
|
|
244
|
+
for (const block of content) {
|
|
245
|
+
if (!block) continue;
|
|
246
|
+
|
|
247
|
+
if (block.type === 'thinking' || block.type === 'redacted_thinking') {
|
|
248
|
+
// Sanitize thinking blocks to remove cache_control and other extra fields
|
|
249
|
+
thinkingBlocks.push(sanitizeAnthropicThinkingBlock(block));
|
|
250
|
+
} else if (block.type === 'tool_use') {
|
|
251
|
+
toolUseBlocks.push(block);
|
|
252
|
+
} else if (block.type === 'text') {
|
|
253
|
+
// Only keep text blocks with meaningful content
|
|
254
|
+
if (block.text && block.text.trim().length > 0) {
|
|
255
|
+
textBlocks.push(block);
|
|
256
|
+
} else {
|
|
257
|
+
droppedEmptyBlocks++;
|
|
258
|
+
}
|
|
259
|
+
} else {
|
|
260
|
+
// Other block types go in the text position
|
|
261
|
+
textBlocks.push(block);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (droppedEmptyBlocks > 0) {
|
|
266
|
+
console.log(`[FormatConverter] Dropped ${droppedEmptyBlocks} empty text block(s)`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const reordered = [...thinkingBlocks, ...textBlocks, ...toolUseBlocks];
|
|
270
|
+
|
|
271
|
+
// Log only if actual reordering happened (not just filtering)
|
|
272
|
+
if (reordered.length === content.length) {
|
|
273
|
+
const originalOrder = content.map(b => b?.type || 'unknown').join(',');
|
|
274
|
+
const newOrder = reordered.map(b => b?.type || 'unknown').join(',');
|
|
275
|
+
if (originalOrder !== newOrder) {
|
|
276
|
+
console.log('[FormatConverter] Reordered assistant content');
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return reordered;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Convert Anthropic message content to Google Generative AI parts
|
|
285
|
+
*/
|
|
286
|
+
function convertContentToParts(content, isClaudeModel = false) {
|
|
287
|
+
if (typeof content === 'string') {
|
|
288
|
+
return [{ text: content }];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (!Array.isArray(content)) {
|
|
292
|
+
return [{ text: String(content) }];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const parts = [];
|
|
296
|
+
|
|
297
|
+
for (const block of content) {
|
|
298
|
+
if (block.type === 'text') {
|
|
299
|
+
// Skip empty text blocks - they cause API errors
|
|
300
|
+
if (block.text && block.text.trim()) {
|
|
301
|
+
parts.push({ text: block.text });
|
|
302
|
+
}
|
|
303
|
+
} else if (block.type === 'image') {
|
|
304
|
+
// Handle image content
|
|
305
|
+
if (block.source?.type === 'base64') {
|
|
306
|
+
// Base64-encoded image
|
|
307
|
+
parts.push({
|
|
308
|
+
inlineData: {
|
|
309
|
+
mimeType: block.source.media_type,
|
|
310
|
+
data: block.source.data
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
} else if (block.source?.type === 'url') {
|
|
314
|
+
// URL-referenced image
|
|
315
|
+
parts.push({
|
|
316
|
+
fileData: {
|
|
317
|
+
mimeType: block.source.media_type || 'image/jpeg',
|
|
318
|
+
fileUri: block.source.url
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
} else if (block.type === 'document') {
|
|
323
|
+
// Handle document content (e.g. PDF)
|
|
324
|
+
if (block.source?.type === 'base64') {
|
|
325
|
+
parts.push({
|
|
326
|
+
inlineData: {
|
|
327
|
+
mimeType: block.source.media_type,
|
|
328
|
+
data: block.source.data
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
} else if (block.source?.type === 'url') {
|
|
332
|
+
parts.push({
|
|
333
|
+
fileData: {
|
|
334
|
+
mimeType: block.source.media_type || 'application/pdf',
|
|
335
|
+
fileUri: block.source.url
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
} else if (block.type === 'tool_use') {
|
|
340
|
+
// Convert tool_use to functionCall (Google format)
|
|
341
|
+
// For Claude models, include the id field
|
|
342
|
+
const functionCall = {
|
|
343
|
+
name: block.name,
|
|
344
|
+
args: block.input || {}
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
if (isClaudeModel && block.id) {
|
|
348
|
+
functionCall.id = block.id;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
parts.push({ functionCall });
|
|
352
|
+
} else if (block.type === 'tool_result') {
|
|
353
|
+
// Convert tool_result to functionResponse (Google format)
|
|
354
|
+
let responseContent = block.content;
|
|
355
|
+
if (typeof responseContent === 'string') {
|
|
356
|
+
responseContent = { result: responseContent };
|
|
357
|
+
} else if (Array.isArray(responseContent)) {
|
|
358
|
+
const texts = responseContent
|
|
359
|
+
.filter(c => c.type === 'text')
|
|
360
|
+
.map(c => c.text)
|
|
361
|
+
.join('\n');
|
|
362
|
+
responseContent = { result: texts };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const functionResponse = {
|
|
366
|
+
name: block.tool_use_id || 'unknown',
|
|
367
|
+
response: responseContent
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
// For Claude models, the id field must match the tool_use_id
|
|
371
|
+
if (isClaudeModel && block.tool_use_id) {
|
|
372
|
+
functionResponse.id = block.tool_use_id;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
parts.push({ functionResponse });
|
|
376
|
+
} else if (block.type === 'thinking') {
|
|
377
|
+
// Handle thinking blocks - only those with valid signatures
|
|
378
|
+
if (block.signature && block.signature.length >= MIN_SIGNATURE_LENGTH) {
|
|
379
|
+
// Convert to Gemini format with signature
|
|
380
|
+
parts.push({
|
|
381
|
+
text: block.thinking,
|
|
382
|
+
thought: true,
|
|
383
|
+
thoughtSignature: block.signature
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
// Unsigned thinking blocks are dropped upstream
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return parts;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Convert Anthropic role to Google role
|
|
395
|
+
*/
|
|
396
|
+
function convertRole(role) {
|
|
397
|
+
if (role === 'assistant') return 'model';
|
|
398
|
+
if (role === 'user') return 'user';
|
|
399
|
+
return 'user'; // Default to user
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Convert Anthropic Messages API request to the format expected by Cloud Code
|
|
404
|
+
*
|
|
405
|
+
* Uses Google Generative AI format, but for Claude models:
|
|
406
|
+
* - Keeps tool_result in Anthropic format (required by Claude API)
|
|
407
|
+
*
|
|
408
|
+
* @param {Object} anthropicRequest - Anthropic format request
|
|
409
|
+
* @returns {Object} Request body for Cloud Code API
|
|
410
|
+
*/
|
|
411
|
+
export function convertAnthropicToGoogle(anthropicRequest) {
|
|
412
|
+
const { messages, system, max_tokens, temperature, top_p, top_k, stop_sequences, tools, tool_choice, thinking } = anthropicRequest;
|
|
413
|
+
const modelName = anthropicRequest.model || '';
|
|
414
|
+
const isClaudeModel = modelName.toLowerCase().includes('claude');
|
|
415
|
+
const isClaudeThinkingModel = isClaudeModel && modelName.toLowerCase().includes('thinking');
|
|
416
|
+
|
|
417
|
+
const googleRequest = {
|
|
418
|
+
contents: [],
|
|
419
|
+
generationConfig: {}
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
// Handle system instruction
|
|
423
|
+
if (system) {
|
|
424
|
+
let systemParts = [];
|
|
425
|
+
if (typeof system === 'string') {
|
|
426
|
+
systemParts = [{ text: system }];
|
|
427
|
+
} else if (Array.isArray(system)) {
|
|
428
|
+
// Filter for text blocks as system prompts are usually text
|
|
429
|
+
// Anthropic supports text blocks in system prompts
|
|
430
|
+
systemParts = system
|
|
431
|
+
.filter(block => block.type === 'text')
|
|
432
|
+
.map(block => ({ text: block.text }));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (systemParts.length > 0) {
|
|
436
|
+
googleRequest.systemInstruction = {
|
|
437
|
+
parts: systemParts
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Add interleaved thinking hint for Claude thinking models with tools
|
|
443
|
+
if (isClaudeThinkingModel && tools && tools.length > 0) {
|
|
444
|
+
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.';
|
|
445
|
+
if (!googleRequest.systemInstruction) {
|
|
446
|
+
googleRequest.systemInstruction = { parts: [{ text: hint }] };
|
|
447
|
+
} else {
|
|
448
|
+
const lastPart = googleRequest.systemInstruction.parts[googleRequest.systemInstruction.parts.length - 1];
|
|
449
|
+
if (lastPart && lastPart.text) {
|
|
450
|
+
lastPart.text = `${lastPart.text}\n\n${hint}`;
|
|
451
|
+
} else {
|
|
452
|
+
googleRequest.systemInstruction.parts.push({ text: hint });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Convert messages to contents, then filter unsigned thinking blocks
|
|
458
|
+
for (let i = 0; i < messages.length; i++) {
|
|
459
|
+
const msg = messages[i];
|
|
460
|
+
let msgContent = msg.content;
|
|
461
|
+
|
|
462
|
+
// For assistant messages, process thinking blocks and reorder content
|
|
463
|
+
if ((msg.role === 'assistant' || msg.role === 'model') && Array.isArray(msgContent)) {
|
|
464
|
+
// First, try to restore signatures for unsigned thinking blocks from cache
|
|
465
|
+
msgContent = restoreThinkingSignatures(msgContent);
|
|
466
|
+
// Remove trailing unsigned thinking blocks
|
|
467
|
+
msgContent = removeTrailingThinkingBlocks(msgContent);
|
|
468
|
+
// Reorder: thinking first, then text, then tool_use
|
|
469
|
+
msgContent = reorderAssistantContent(msgContent);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const parts = convertContentToParts(msgContent, isClaudeModel);
|
|
473
|
+
const content = {
|
|
474
|
+
role: convertRole(msg.role),
|
|
475
|
+
parts: parts
|
|
476
|
+
};
|
|
477
|
+
googleRequest.contents.push(content);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Filter unsigned thinking blocks for Claude models
|
|
481
|
+
if (isClaudeModel) {
|
|
482
|
+
googleRequest.contents = filterUnsignedThinkingBlocks(googleRequest.contents);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Generation config
|
|
486
|
+
if (max_tokens) {
|
|
487
|
+
googleRequest.generationConfig.maxOutputTokens = max_tokens;
|
|
488
|
+
}
|
|
489
|
+
if (temperature !== undefined) {
|
|
490
|
+
googleRequest.generationConfig.temperature = temperature;
|
|
491
|
+
}
|
|
492
|
+
if (top_p !== undefined) {
|
|
493
|
+
googleRequest.generationConfig.topP = top_p;
|
|
494
|
+
}
|
|
495
|
+
if (top_k !== undefined) {
|
|
496
|
+
googleRequest.generationConfig.topK = top_k;
|
|
497
|
+
}
|
|
498
|
+
if (stop_sequences && stop_sequences.length > 0) {
|
|
499
|
+
googleRequest.generationConfig.stopSequences = stop_sequences;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Enable thinking for Claude thinking models
|
|
503
|
+
if (isClaudeThinkingModel) {
|
|
504
|
+
const thinkingConfig = {
|
|
505
|
+
include_thoughts: true
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
// Only set thinking_budget if explicitly provided
|
|
509
|
+
const thinkingBudget = thinking?.budget_tokens;
|
|
510
|
+
if (thinkingBudget) {
|
|
511
|
+
thinkingConfig.thinking_budget = thinkingBudget;
|
|
512
|
+
|
|
513
|
+
// Ensure maxOutputTokens is large enough when budget is specified
|
|
514
|
+
if (!googleRequest.generationConfig.maxOutputTokens ||
|
|
515
|
+
googleRequest.generationConfig.maxOutputTokens <= thinkingBudget) {
|
|
516
|
+
googleRequest.generationConfig.maxOutputTokens = CLAUDE_THINKING_MAX_OUTPUT_TOKENS;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
console.log('[FormatConverter] Thinking enabled with budget:', thinkingBudget);
|
|
520
|
+
} else {
|
|
521
|
+
console.log('[FormatConverter] Thinking enabled (no budget specified)');
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
googleRequest.generationConfig.thinkingConfig = thinkingConfig;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Convert tools to Google format
|
|
528
|
+
if (tools && tools.length > 0) {
|
|
529
|
+
const functionDeclarations = tools.map((tool, idx) => {
|
|
530
|
+
// Extract name from various possible locations
|
|
531
|
+
const name = tool.name || tool.function?.name || tool.custom?.name || `tool-${idx}`;
|
|
532
|
+
|
|
533
|
+
// Extract description from various possible locations
|
|
534
|
+
const description = tool.description || tool.function?.description || tool.custom?.description || '';
|
|
535
|
+
|
|
536
|
+
// Extract schema from various possible locations
|
|
537
|
+
const schema = tool.input_schema
|
|
538
|
+
|| tool.function?.input_schema
|
|
539
|
+
|| tool.function?.parameters
|
|
540
|
+
|| tool.custom?.input_schema
|
|
541
|
+
|| tool.parameters
|
|
542
|
+
|| { type: 'object' };
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
name: String(name).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64),
|
|
546
|
+
description: description,
|
|
547
|
+
parameters: sanitizeSchema(schema)
|
|
548
|
+
};
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
googleRequest.tools = [{ functionDeclarations }];
|
|
552
|
+
console.log('[FormatConverter] Tools:', JSON.stringify(googleRequest.tools).substring(0, 300));
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return googleRequest;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Sanitize JSON Schema for Antigravity API compatibility.
|
|
560
|
+
* Uses allowlist approach - only permit known-safe JSON Schema features.
|
|
561
|
+
* Converts "const" to equivalent "enum" for compatibility.
|
|
562
|
+
* Generates placeholder schema for empty tool schemas.
|
|
563
|
+
*/
|
|
564
|
+
function sanitizeSchema(schema) {
|
|
565
|
+
if (!schema || typeof schema !== 'object') {
|
|
566
|
+
// Empty/missing schema - generate placeholder with reason property
|
|
567
|
+
return {
|
|
568
|
+
type: 'object',
|
|
569
|
+
properties: {
|
|
570
|
+
reason: {
|
|
571
|
+
type: 'string',
|
|
572
|
+
description: 'Reason for calling this tool'
|
|
573
|
+
}
|
|
574
|
+
},
|
|
575
|
+
required: ['reason']
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Allowlist of permitted JSON Schema fields
|
|
580
|
+
const ALLOWED_FIELDS = new Set([
|
|
581
|
+
'type',
|
|
582
|
+
'description',
|
|
583
|
+
'properties',
|
|
584
|
+
'required',
|
|
585
|
+
'items',
|
|
586
|
+
'enum',
|
|
587
|
+
'title'
|
|
588
|
+
]);
|
|
589
|
+
|
|
590
|
+
const sanitized = {};
|
|
591
|
+
|
|
592
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
593
|
+
// Convert "const" to "enum" for compatibility
|
|
594
|
+
if (key === 'const') {
|
|
595
|
+
sanitized.enum = [value];
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Skip fields not in allowlist
|
|
600
|
+
if (!ALLOWED_FIELDS.has(key)) {
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (key === 'properties' && value && typeof value === 'object') {
|
|
605
|
+
sanitized.properties = {};
|
|
606
|
+
for (const [propKey, propValue] of Object.entries(value)) {
|
|
607
|
+
sanitized.properties[propKey] = sanitizeSchema(propValue);
|
|
608
|
+
}
|
|
609
|
+
} else if (key === 'items' && value && typeof value === 'object') {
|
|
610
|
+
if (Array.isArray(value)) {
|
|
611
|
+
sanitized.items = value.map(item => sanitizeSchema(item));
|
|
612
|
+
} else {
|
|
613
|
+
sanitized.items = sanitizeSchema(value);
|
|
614
|
+
}
|
|
615
|
+
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
616
|
+
sanitized[key] = sanitizeSchema(value);
|
|
617
|
+
} else {
|
|
618
|
+
sanitized[key] = value;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Ensure we have at least a type
|
|
623
|
+
if (!sanitized.type) {
|
|
624
|
+
sanitized.type = 'object';
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// If object type with no properties, add placeholder
|
|
628
|
+
if (sanitized.type === 'object' && (!sanitized.properties || Object.keys(sanitized.properties).length === 0)) {
|
|
629
|
+
sanitized.properties = {
|
|
630
|
+
reason: {
|
|
631
|
+
type: 'string',
|
|
632
|
+
description: 'Reason for calling this tool'
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
sanitized.required = ['reason'];
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return sanitized;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Convert Google Generative AI response to Anthropic Messages API format
|
|
643
|
+
*
|
|
644
|
+
* @param {Object} googleResponse - Google format response (the inner response object)
|
|
645
|
+
* @param {string} model - The model name used
|
|
646
|
+
* @returns {Object} Anthropic format response
|
|
647
|
+
*/
|
|
648
|
+
export function convertGoogleToAnthropic(googleResponse, model) {
|
|
649
|
+
// Handle the response wrapper
|
|
650
|
+
const response = googleResponse.response || googleResponse;
|
|
651
|
+
|
|
652
|
+
const candidates = response.candidates || [];
|
|
653
|
+
const firstCandidate = candidates[0] || {};
|
|
654
|
+
const content = firstCandidate.content || {};
|
|
655
|
+
const parts = content.parts || [];
|
|
656
|
+
|
|
657
|
+
// Convert parts to Anthropic content blocks
|
|
658
|
+
const anthropicContent = [];
|
|
659
|
+
let hasToolCalls = false;
|
|
660
|
+
|
|
661
|
+
for (const part of parts) {
|
|
662
|
+
if (part.text !== undefined) {
|
|
663
|
+
// Handle thinking blocks
|
|
664
|
+
if (part.thought === true) {
|
|
665
|
+
const signature = part.thoughtSignature || '';
|
|
666
|
+
|
|
667
|
+
// Include thinking blocks in the response for Claude Code
|
|
668
|
+
anthropicContent.push({
|
|
669
|
+
type: 'thinking',
|
|
670
|
+
thinking: part.text,
|
|
671
|
+
signature: signature
|
|
672
|
+
});
|
|
673
|
+
} else {
|
|
674
|
+
anthropicContent.push({
|
|
675
|
+
type: 'text',
|
|
676
|
+
text: part.text
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
} else if (part.functionCall) {
|
|
680
|
+
// Convert functionCall to tool_use
|
|
681
|
+
// Use the id from the response if available, otherwise generate one
|
|
682
|
+
anthropicContent.push({
|
|
683
|
+
type: 'tool_use',
|
|
684
|
+
id: part.functionCall.id || `toolu_${crypto.randomBytes(12).toString('hex')}`,
|
|
685
|
+
name: part.functionCall.name,
|
|
686
|
+
input: part.functionCall.args || {}
|
|
687
|
+
});
|
|
688
|
+
hasToolCalls = true;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Determine stop reason
|
|
693
|
+
const finishReason = firstCandidate.finishReason;
|
|
694
|
+
let stopReason = 'end_turn';
|
|
695
|
+
if (finishReason === 'STOP') {
|
|
696
|
+
stopReason = 'end_turn';
|
|
697
|
+
} else if (finishReason === 'MAX_TOKENS') {
|
|
698
|
+
stopReason = 'max_tokens';
|
|
699
|
+
} else if (finishReason === 'TOOL_USE' || hasToolCalls) {
|
|
700
|
+
stopReason = 'tool_use';
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Extract usage metadata
|
|
704
|
+
// Note: Antigravity's promptTokenCount is the TOTAL (includes cached),
|
|
705
|
+
// but Anthropic's input_tokens excludes cached. We subtract to match.
|
|
706
|
+
const usageMetadata = response.usageMetadata || {};
|
|
707
|
+
const promptTokens = usageMetadata.promptTokenCount || 0;
|
|
708
|
+
const cachedTokens = usageMetadata.cachedContentTokenCount || 0;
|
|
709
|
+
|
|
710
|
+
return {
|
|
711
|
+
id: `msg_${crypto.randomBytes(16).toString('hex')}`,
|
|
712
|
+
type: 'message',
|
|
713
|
+
role: 'assistant',
|
|
714
|
+
content: anthropicContent.length > 0 ? anthropicContent : [{ type: 'text', text: '' }],
|
|
715
|
+
model: model,
|
|
716
|
+
stop_reason: stopReason,
|
|
717
|
+
stop_sequence: null,
|
|
718
|
+
usage: {
|
|
719
|
+
input_tokens: promptTokens - cachedTokens,
|
|
720
|
+
output_tokens: usageMetadata.candidatesTokenCount || 0,
|
|
721
|
+
cache_read_input_tokens: cachedTokens,
|
|
722
|
+
cache_creation_input_tokens: 0
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
export default {
|
|
728
|
+
mapModelName,
|
|
729
|
+
convertAnthropicToGoogle,
|
|
730
|
+
convertGoogleToAnthropic
|
|
731
|
+
};
|