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.
@@ -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
+ };