free-antigravity-cli 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 +201 -0
- package/README.md +142 -0
- package/dist/chat.d.ts +2 -0
- package/dist/chat.d.ts.map +1 -0
- package/dist/chat.js +212 -0
- package/dist/chat.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +216 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +29 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +125 -0
- package/dist/config.js.map +1 -0
- package/dist/crypto.d.ts +5 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +93 -0
- package/dist/crypto.js.map +1 -0
- package/dist/logger.d.ts +9 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +10 -0
- package/dist/logger.js.map +1 -0
- package/dist/proxy/modelUtils.d.ts +31 -0
- package/dist/proxy/modelUtils.d.ts.map +1 -0
- package/dist/proxy/modelUtils.js +55 -0
- package/dist/proxy/modelUtils.js.map +1 -0
- package/dist/proxy/registry.d.ts +40 -0
- package/dist/proxy/registry.d.ts.map +1 -0
- package/dist/proxy/registry.js +176 -0
- package/dist/proxy/registry.js.map +1 -0
- package/dist/proxy/shared.d.ts +39 -0
- package/dist/proxy/shared.d.ts.map +1 -0
- package/dist/proxy/shared.js +74 -0
- package/dist/proxy/shared.js.map +1 -0
- package/dist/proxy/translators/anthropic.d.ts +119 -0
- package/dist/proxy/translators/anthropic.d.ts.map +1 -0
- package/dist/proxy/translators/anthropic.js +273 -0
- package/dist/proxy/translators/anthropic.js.map +1 -0
- package/dist/proxy/translators/google.d.ts +86 -0
- package/dist/proxy/translators/google.d.ts.map +1 -0
- package/dist/proxy/translators/google.js +111 -0
- package/dist/proxy/translators/google.js.map +1 -0
- package/dist/proxy/translators/ollama.d.ts +27 -0
- package/dist/proxy/translators/ollama.d.ts.map +1 -0
- package/dist/proxy/translators/ollama.js +82 -0
- package/dist/proxy/translators/ollama.js.map +1 -0
- package/dist/proxy/translators/openai.d.ts +132 -0
- package/dist/proxy/translators/openai.d.ts.map +1 -0
- package/dist/proxy/translators/openai.js +396 -0
- package/dist/proxy/translators/openai.js.map +1 -0
- package/dist/proxy/translators/utils.d.ts +60 -0
- package/dist/proxy/translators/utils.d.ts.map +1 -0
- package/dist/proxy/translators/utils.js +504 -0
- package/dist/proxy/translators/utils.js.map +1 -0
- package/dist/proxy.d.ts +22 -0
- package/dist/proxy.d.ts.map +1 -0
- package/dist/proxy.js +576 -0
- package/dist/proxy.js.map +1 -0
- package/dist/schemaValidator.d.ts +50 -0
- package/dist/schemaValidator.d.ts.map +1 -0
- package/dist/schemaValidator.js +208 -0
- package/dist/schemaValidator.js.map +1 -0
- package/install.cmd +33 -0
- package/package.json +46 -0
- package/src/chat.ts +184 -0
- package/src/cli.ts +184 -0
- package/src/config.ts +99 -0
- package/src/crypto.ts +49 -0
- package/src/logger.ts +8 -0
- package/src/proxy/modelUtils.ts +86 -0
- package/src/proxy/registry.ts +196 -0
- package/src/proxy/shared.ts +102 -0
- package/src/proxy/translators/anthropic.ts +420 -0
- package/src/proxy/translators/google.ts +162 -0
- package/src/proxy/translators/ollama.ts +88 -0
- package/src/proxy/translators/openai.ts +556 -0
- package/src/proxy/translators/utils.ts +552 -0
- package/src/proxy.ts +573 -0
- package/src/schemaValidator.ts +215 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared state module for proxy orchestration.
|
|
3
|
+
* Extracted from proxy.js to decouple translators from main orchestration.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ─── Types ────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export interface StreamContext {
|
|
9
|
+
accumulatedText: string;
|
|
10
|
+
accumulatedReasoning: string;
|
|
11
|
+
toolCalls: Record<number, { id: string; name: string; arguments: string }>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface StateTimestamps {
|
|
15
|
+
streamCtx: Map<string, number>;
|
|
16
|
+
toolCallIds: Map<string, number>;
|
|
17
|
+
translatedCalls: Map<string, number>;
|
|
18
|
+
reasoning: Map<string, number>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface TranslatedCallInfo {
|
|
22
|
+
originalName: string;
|
|
23
|
+
translatedName: string;
|
|
24
|
+
cmd: string;
|
|
25
|
+
cwd: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ─── State ────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/** modelName → { "functionName": "original_tool_call_id" } */
|
|
31
|
+
export const modelToolCallIds = new Map<string, Record<string, string>>();
|
|
32
|
+
|
|
33
|
+
/** modelName → preserved reasoning_content from previous turn */
|
|
34
|
+
export const modelReasoningContent = new Map<string, string>();
|
|
35
|
+
|
|
36
|
+
/** streamId → { accumulatedText, accumulatedReasoning, toolCalls } */
|
|
37
|
+
export const activeStreamContexts = new Map<string, StreamContext>();
|
|
38
|
+
|
|
39
|
+
/** toolCallId → { originalName, translatedName, cmd, cwd } */
|
|
40
|
+
export const translatedToolCalls = new Map<string, TranslatedCallInfo>();
|
|
41
|
+
|
|
42
|
+
/** State entry timestamps for periodic cleanup */
|
|
43
|
+
export const stateTimestamps: StateTimestamps = {
|
|
44
|
+
streamCtx: new Map(),
|
|
45
|
+
toolCallIds: new Map(),
|
|
46
|
+
translatedCalls: new Map(),
|
|
47
|
+
reasoning: new Map(),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export function touchStateTimestamp(map: Map<string, number>, key: string): void {
|
|
53
|
+
map.set(key, Date.now());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── Periodic Cleanup (managed lifecycle) ─────────────────────────────────
|
|
57
|
+
|
|
58
|
+
let cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
|
59
|
+
|
|
60
|
+
export function startCleanupInterval(): void {
|
|
61
|
+
if (cleanupInterval) return; // already running
|
|
62
|
+
cleanupInterval = setInterval(() => {
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
const STREAM_TTL = 600_000; // 10 minutes for active stream contexts
|
|
65
|
+
const TOOL_TTL = 1_800_000; // 30 minutes for tool call IDs & reasoning
|
|
66
|
+
|
|
67
|
+
for (const [key, ts] of stateTimestamps.streamCtx) {
|
|
68
|
+
if (now - ts > STREAM_TTL) {
|
|
69
|
+
activeStreamContexts.delete(key);
|
|
70
|
+
stateTimestamps.streamCtx.delete(key);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const [key, ts] of stateTimestamps.toolCallIds) {
|
|
74
|
+
if (now - ts > TOOL_TTL) {
|
|
75
|
+
modelToolCallIds.delete(key);
|
|
76
|
+
stateTimestamps.toolCallIds.delete(key);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const [key, ts] of stateTimestamps.translatedCalls) {
|
|
80
|
+
if (now - ts > TOOL_TTL) {
|
|
81
|
+
translatedToolCalls.delete(key);
|
|
82
|
+
stateTimestamps.translatedCalls.delete(key);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
for (const [key, ts] of stateTimestamps.reasoning) {
|
|
86
|
+
if (now - ts > TOOL_TTL) {
|
|
87
|
+
modelReasoningContent.delete(key);
|
|
88
|
+
stateTimestamps.reasoning.delete(key);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}, 300_000);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function stopCleanupInterval(): void {
|
|
95
|
+
if (cleanupInterval) {
|
|
96
|
+
clearInterval(cleanupInterval);
|
|
97
|
+
cleanupInterval = null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Auto-start for backward compatibility (will be replaced by proxy.ts lifecycle)
|
|
102
|
+
startCleanupInterval();
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic provider translator.
|
|
3
|
+
* Handles Gemini ↔ Anthropic request/response mapping and streaming SSE events.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { log } from '../../logger';
|
|
7
|
+
import {
|
|
8
|
+
fixParamTypes,
|
|
9
|
+
translateToolCallToNative,
|
|
10
|
+
formatTranslatedResponse,
|
|
11
|
+
normalizeToolArgs,
|
|
12
|
+
ToolCallArgs,
|
|
13
|
+
} from './utils';
|
|
14
|
+
import {
|
|
15
|
+
modelToolCallIds,
|
|
16
|
+
activeStreamContexts,
|
|
17
|
+
translatedToolCalls,
|
|
18
|
+
stateTimestamps,
|
|
19
|
+
touchStateTimestamp,
|
|
20
|
+
StreamContext,
|
|
21
|
+
} from '../shared';
|
|
22
|
+
import { detectModelCapabilitiesByName } from '../modelUtils';
|
|
23
|
+
|
|
24
|
+
// ─── Types ────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
interface GeminiTool {
|
|
27
|
+
functionDeclarations?: GeminiFunctionDeclaration[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface GeminiFunctionDeclaration {
|
|
31
|
+
name: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
parameters?: GeminiParameters;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface GeminiParameters {
|
|
37
|
+
type: string;
|
|
38
|
+
properties?: Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface AnthropicTool {
|
|
42
|
+
name: string;
|
|
43
|
+
description: string;
|
|
44
|
+
input_schema: Record<string, unknown>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface GeminiContent {
|
|
48
|
+
role?: string;
|
|
49
|
+
parts?: GeminiPart[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface GeminiPart {
|
|
53
|
+
text?: string;
|
|
54
|
+
thought?: boolean;
|
|
55
|
+
functionCall?: GeminiFunctionCall;
|
|
56
|
+
functionResponse?: GeminiFunctionResponse;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface GeminiFunctionCall {
|
|
60
|
+
name: string;
|
|
61
|
+
args: Record<string, unknown>;
|
|
62
|
+
id?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface GeminiFunctionResponse {
|
|
66
|
+
name: string;
|
|
67
|
+
response: unknown;
|
|
68
|
+
id?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface GeminiRequestBody {
|
|
72
|
+
systemInstruction?: { parts: GeminiPart[] };
|
|
73
|
+
contents?: GeminiContent[];
|
|
74
|
+
tools?: GeminiTool[];
|
|
75
|
+
generationConfig?: {
|
|
76
|
+
temperature?: number;
|
|
77
|
+
maxOutputTokens?: number;
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface AnthropicContentBlock {
|
|
82
|
+
type: 'text' | 'tool_use' | 'tool_result' | 'thinking';
|
|
83
|
+
text?: string;
|
|
84
|
+
thinking?: string;
|
|
85
|
+
id?: string;
|
|
86
|
+
name?: string;
|
|
87
|
+
input?: Record<string, unknown>;
|
|
88
|
+
tool_use_id?: string;
|
|
89
|
+
content?: string | AnthropicContentBlock[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type AnthropicMessageRole = 'user' | 'assistant';
|
|
93
|
+
|
|
94
|
+
interface AnthropicMessage {
|
|
95
|
+
role: AnthropicMessageRole;
|
|
96
|
+
content: string | AnthropicContentBlock[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface AnthropicRequestBody {
|
|
100
|
+
model: string;
|
|
101
|
+
messages: AnthropicMessage[];
|
|
102
|
+
system?: string;
|
|
103
|
+
max_tokens: number;
|
|
104
|
+
temperature?: number;
|
|
105
|
+
tools?: AnthropicTool[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface AnthropicResponse {
|
|
109
|
+
content?: AnthropicContentBlock[];
|
|
110
|
+
usage?: {
|
|
111
|
+
input_tokens: number;
|
|
112
|
+
output_tokens: number;
|
|
113
|
+
};
|
|
114
|
+
stop_reason?: string;
|
|
115
|
+
type?: string;
|
|
116
|
+
message?: { id: string };
|
|
117
|
+
index?: number;
|
|
118
|
+
content_block?: AnthropicContentBlock;
|
|
119
|
+
delta?: {
|
|
120
|
+
type?: string;
|
|
121
|
+
text?: string;
|
|
122
|
+
thinking?: string;
|
|
123
|
+
partial_json?: string;
|
|
124
|
+
stop_reason?: string;
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface GeminiGenerateContentResponse {
|
|
129
|
+
candidates: GeminiCandidate[];
|
|
130
|
+
usageMetadata?: GeminiUsageMetadata;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface GeminiCandidate {
|
|
134
|
+
content: {
|
|
135
|
+
parts: GeminiPart[];
|
|
136
|
+
role: string;
|
|
137
|
+
};
|
|
138
|
+
finishReason: string;
|
|
139
|
+
index: number;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
interface GeminiUsageMetadata {
|
|
143
|
+
promptTokenCount: number;
|
|
144
|
+
candidatesTokenCount: number;
|
|
145
|
+
totalTokenCount: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── REQUEST: Gemini → Anthropic ──────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
function mapGeminiToolsToAnthropic(geminiTools: GeminiTool[]): AnthropicTool[] {
|
|
151
|
+
if (!geminiTools || !Array.isArray(geminiTools)) return [];
|
|
152
|
+
const anthropicTools: AnthropicTool[] = [];
|
|
153
|
+
for (const toolGroup of geminiTools) {
|
|
154
|
+
if (toolGroup.functionDeclarations && Array.isArray(toolGroup.functionDeclarations)) {
|
|
155
|
+
for (const func of toolGroup.functionDeclarations) {
|
|
156
|
+
const params = func.parameters
|
|
157
|
+
? (JSON.parse(JSON.stringify(func.parameters)) as Record<string, unknown>)
|
|
158
|
+
: { type: 'OBJECT', properties: {} };
|
|
159
|
+
if (params.type && typeof params.type === 'string') {
|
|
160
|
+
(params as Record<string, string>).type = (params.type as string).toLowerCase();
|
|
161
|
+
}
|
|
162
|
+
if (params.properties) {
|
|
163
|
+
fixParamTypes(params.properties as Record<string, unknown>);
|
|
164
|
+
}
|
|
165
|
+
anthropicTools.push({
|
|
166
|
+
name: func.name,
|
|
167
|
+
description: func.description || '',
|
|
168
|
+
input_schema: params,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return anthropicTools;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function mapGeminiToAnthropic(geminiBody: GeminiRequestBody, modelName: string): AnthropicRequestBody {
|
|
177
|
+
const messages: AnthropicMessage[] = [];
|
|
178
|
+
let system: string | undefined = undefined;
|
|
179
|
+
|
|
180
|
+
if (geminiBody.systemInstruction && geminiBody.systemInstruction.parts) {
|
|
181
|
+
system = geminiBody.systemInstruction.parts.map((p) => p.text || '').join('');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (geminiBody.contents) {
|
|
185
|
+
for (const item of geminiBody.contents) {
|
|
186
|
+
if (item.parts) {
|
|
187
|
+
const hasFunctionCall = item.parts.some((p) => p.functionCall);
|
|
188
|
+
const hasFunctionResponse = item.parts.some((p) => p.functionResponse);
|
|
189
|
+
|
|
190
|
+
if (hasFunctionCall && item.role === 'model') {
|
|
191
|
+
const contentBlocks: AnthropicContentBlock[] = [];
|
|
192
|
+
for (const p of item.parts) {
|
|
193
|
+
if (p.text) contentBlocks.push({ type: 'text', text: p.text });
|
|
194
|
+
if (p.functionCall) {
|
|
195
|
+
const callId = p.functionCall.id || 'call_' + Math.random().toString(36).slice(2, 10);
|
|
196
|
+
let originalName = p.functionCall.name;
|
|
197
|
+
let originalArgs = p.functionCall.args;
|
|
198
|
+
const translatedInfo = translatedToolCalls.get(callId);
|
|
199
|
+
if (translatedInfo) {
|
|
200
|
+
originalName = translatedInfo.originalName;
|
|
201
|
+
originalArgs = { CommandLine: translatedInfo.cmd, Cwd: translatedInfo.cwd };
|
|
202
|
+
}
|
|
203
|
+
contentBlocks.push({
|
|
204
|
+
type: 'tool_use',
|
|
205
|
+
id: callId,
|
|
206
|
+
name: originalName,
|
|
207
|
+
input:
|
|
208
|
+
typeof originalArgs === 'string'
|
|
209
|
+
? (JSON.parse(originalArgs) as Record<string, unknown>)
|
|
210
|
+
: (originalArgs as Record<string, unknown>),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
messages.push({ role: 'assistant', content: contentBlocks });
|
|
215
|
+
} else if (hasFunctionResponse) {
|
|
216
|
+
const contentBlocks: AnthropicContentBlock[] = [];
|
|
217
|
+
for (const p of item.parts) {
|
|
218
|
+
if (p.functionResponse) {
|
|
219
|
+
const funcName = p.functionResponse.name || '';
|
|
220
|
+
const modelTCIds = modelToolCallIds.get(modelName) || {};
|
|
221
|
+
const toolCallId = p.functionResponse.id || modelTCIds[funcName] || 'call_' + funcName;
|
|
222
|
+
const responseData = p.functionResponse.response;
|
|
223
|
+
let contentStr = '';
|
|
224
|
+
const translatedInfo = translatedToolCalls.get(toolCallId);
|
|
225
|
+
if (translatedInfo) {
|
|
226
|
+
contentStr = formatTranslatedResponse(translatedInfo, responseData);
|
|
227
|
+
} else {
|
|
228
|
+
contentStr = typeof responseData === 'string' ? responseData : JSON.stringify(responseData || {});
|
|
229
|
+
}
|
|
230
|
+
contentBlocks.push({
|
|
231
|
+
type: 'tool_result',
|
|
232
|
+
tool_use_id: toolCallId,
|
|
233
|
+
content: contentStr,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
messages.push({ role: 'user', content: contentBlocks });
|
|
238
|
+
} else {
|
|
239
|
+
const roleStr = item.role === 'model' ? 'assistant' : item.role || 'user';
|
|
240
|
+
let content = '';
|
|
241
|
+
if (item.parts) content = item.parts.map((p) => p.text || '').join('');
|
|
242
|
+
if (roleStr === 'system') {
|
|
243
|
+
system = (system || '') + '\n' + content;
|
|
244
|
+
} else {
|
|
245
|
+
messages.push({ role: roleStr as AnthropicMessageRole, content });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const result: AnthropicRequestBody = {
|
|
253
|
+
model: modelName,
|
|
254
|
+
messages,
|
|
255
|
+
system,
|
|
256
|
+
max_tokens: geminiBody.generationConfig?.maxOutputTokens ?? 16000,
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// Claude thinking models don't support temperature (centralized detection)
|
|
260
|
+
const { isThinkingModel } = detectModelCapabilitiesByName(modelName);
|
|
261
|
+
if (!isThinkingModel) {
|
|
262
|
+
const temp = geminiBody.generationConfig?.temperature;
|
|
263
|
+
if (temp !== undefined && temp !== null) result.temperature = temp;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (geminiBody.tools && Array.isArray(geminiBody.tools)) {
|
|
267
|
+
const anthTools = mapGeminiToolsToAnthropic(geminiBody.tools);
|
|
268
|
+
if (anthTools.length > 0) result.tools = anthTools;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ─── RESPONSE: Anthropic → Gemini ─────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
export function mapAnthropicToGemini(anthRes: AnthropicResponse, modelName: string): GeminiGenerateContentResponse {
|
|
277
|
+
const contentBlocks = anthRes.content || [];
|
|
278
|
+
const parts: GeminiPart[] = [];
|
|
279
|
+
const functionCalls: GeminiPart[] = [];
|
|
280
|
+
|
|
281
|
+
for (const block of contentBlocks) {
|
|
282
|
+
if (block.type === 'text' && block.text) {
|
|
283
|
+
parts.push({ text: block.text });
|
|
284
|
+
} else if (block.type === 'thinking' && block.thinking) {
|
|
285
|
+
parts.push({ text: block.thinking, thought: true });
|
|
286
|
+
} else if (block.type === 'tool_use') {
|
|
287
|
+
const modelTCIds = modelToolCallIds.get(modelName) || {};
|
|
288
|
+
modelTCIds[block.name || ''] = block.id || '';
|
|
289
|
+
modelToolCallIds.set(modelName, modelTCIds);
|
|
290
|
+
touchStateTimestamp(stateTimestamps.toolCallIds, modelName);
|
|
291
|
+
|
|
292
|
+
const normalizedInput = normalizeToolArgs(block.name || '', block.input || {});
|
|
293
|
+
const translated = translateToolCallToNative(block.name || '', normalizedInput);
|
|
294
|
+
if (translated.name !== block.name) {
|
|
295
|
+
translated.args = normalizeToolArgs(translated.name, translated.args) as Record<string, unknown>;
|
|
296
|
+
translatedToolCalls.set(block.id || '', {
|
|
297
|
+
originalName: block.name || '',
|
|
298
|
+
translatedName: translated.name,
|
|
299
|
+
cmd: (normalizedInput.CommandLine as string) || '',
|
|
300
|
+
cwd: (normalizedInput.Cwd as string) || '',
|
|
301
|
+
});
|
|
302
|
+
touchStateTimestamp(stateTimestamps.translatedCalls, block.id || '');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
functionCalls.push({
|
|
306
|
+
functionCall: { name: translated.name, args: translated.args as Record<string, unknown>, id: block.id },
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (functionCalls.length > 0) {
|
|
312
|
+
return {
|
|
313
|
+
candidates: [
|
|
314
|
+
{ content: { parts: [...parts, ...functionCalls], role: 'model' }, finishReason: 'TOOL_CALL', index: 0 },
|
|
315
|
+
],
|
|
316
|
+
usageMetadata: {
|
|
317
|
+
promptTokenCount: anthRes.usage?.input_tokens || 0,
|
|
318
|
+
candidatesTokenCount: anthRes.usage?.output_tokens || 0,
|
|
319
|
+
totalTokenCount: (anthRes.usage?.input_tokens || 0) + (anthRes.usage?.output_tokens || 0),
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const finishReason =
|
|
325
|
+
anthRes.stop_reason === 'end_turn' ? 'STOP' : anthRes.stop_reason === 'max_tokens' ? 'MAX_TOKENS' : 'OTHER';
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
candidates: [{ content: { parts, role: 'model' }, finishReason, index: 0 }],
|
|
329
|
+
usageMetadata: {
|
|
330
|
+
promptTokenCount: anthRes.usage?.input_tokens || 0,
|
|
331
|
+
candidatesTokenCount: anthRes.usage?.output_tokens || 0,
|
|
332
|
+
totalTokenCount: (anthRes.usage?.input_tokens || 0) + (anthRes.usage?.output_tokens || 0),
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ─── STREAM CHUNK: Anthropic SSE → Gemini ─────────────────────────────────
|
|
338
|
+
|
|
339
|
+
export function mapAnthropicChunkToGemini(chunk: AnthropicResponse, modelName: string): GeminiCandidate | null {
|
|
340
|
+
const type = chunk.type;
|
|
341
|
+
const streamId = chunk.message?.id || 'anthropic_stream';
|
|
342
|
+
|
|
343
|
+
if (!activeStreamContexts.has(streamId)) {
|
|
344
|
+
activeStreamContexts.set(streamId, { accumulatedText: '', accumulatedReasoning: '', toolCalls: {} });
|
|
345
|
+
touchStateTimestamp(stateTimestamps.streamCtx, streamId);
|
|
346
|
+
}
|
|
347
|
+
const context = activeStreamContexts.get(streamId)!;
|
|
348
|
+
|
|
349
|
+
if (type === 'content_block_start') {
|
|
350
|
+
const block = chunk.content_block;
|
|
351
|
+
const idx = chunk.index ?? 0;
|
|
352
|
+
if (block?.type === 'tool_use') {
|
|
353
|
+
context.toolCalls[idx] = { id: block.id || '', name: block.name || '', arguments: '' };
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (type === 'content_block_delta') {
|
|
358
|
+
const delta = chunk.delta;
|
|
359
|
+
const idx = chunk.index ?? 0;
|
|
360
|
+
if (delta?.type === 'text_delta') {
|
|
361
|
+
const text = delta.text || '';
|
|
362
|
+
context.accumulatedText += text;
|
|
363
|
+
return { content: { parts: [{ text }], role: 'model' }, finishReason: 'OTHER', index: 0 };
|
|
364
|
+
} else if (delta?.type === 'thinking_delta') {
|
|
365
|
+
const thinkingText = delta.thinking || '';
|
|
366
|
+
context.accumulatedReasoning += thinkingText;
|
|
367
|
+
return {
|
|
368
|
+
content: { parts: [{ text: thinkingText, thought: true }], role: 'model' },
|
|
369
|
+
finishReason: 'OTHER',
|
|
370
|
+
index: 0,
|
|
371
|
+
};
|
|
372
|
+
} else if (delta?.type === 'input_delta') {
|
|
373
|
+
if (context.toolCalls[idx]) {
|
|
374
|
+
context.toolCalls[idx].arguments += delta.partial_json || '';
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (type === 'message_delta') {
|
|
380
|
+
const delta = chunk.delta;
|
|
381
|
+
if (delta?.stop_reason === 'tool_use') {
|
|
382
|
+
const parts: GeminiPart[] = Object.values(context.toolCalls).map((tc) => {
|
|
383
|
+
let args: ToolCallArgs = {};
|
|
384
|
+
try {
|
|
385
|
+
args = JSON.parse(tc.arguments);
|
|
386
|
+
} catch (e) {
|
|
387
|
+
log.debug('[Anthropic] Stream tool args parse fallback:', (e as Error).message);
|
|
388
|
+
args = {};
|
|
389
|
+
}
|
|
390
|
+
args = normalizeToolArgs(tc.name, args) as ToolCallArgs;
|
|
391
|
+
const modelTCIds = modelToolCallIds.get(modelName) || {};
|
|
392
|
+
modelTCIds[tc.name] = tc.id;
|
|
393
|
+
modelToolCallIds.set(modelName, modelTCIds);
|
|
394
|
+
touchStateTimestamp(stateTimestamps.toolCallIds, modelName);
|
|
395
|
+
const translated = translateToolCallToNative(tc.name, args);
|
|
396
|
+
if (translated.name !== tc.name) {
|
|
397
|
+
translatedToolCalls.set(tc.id, {
|
|
398
|
+
originalName: tc.name,
|
|
399
|
+
translatedName: translated.name,
|
|
400
|
+
cmd: args.CommandLine || '',
|
|
401
|
+
cwd: args.Cwd || '',
|
|
402
|
+
});
|
|
403
|
+
touchStateTimestamp(stateTimestamps.translatedCalls, tc.id);
|
|
404
|
+
}
|
|
405
|
+
return { functionCall: { name: translated.name, args: translated.args as Record<string, unknown>, id: tc.id } };
|
|
406
|
+
});
|
|
407
|
+
activeStreamContexts.delete(streamId);
|
|
408
|
+
return { content: { parts, role: 'model' }, finishReason: 'TOOL_CALL', index: 0 };
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (type === 'message_stop') {
|
|
413
|
+
activeStreamContexts.delete(streamId);
|
|
414
|
+
return { content: { parts: [], role: 'model' }, finishReason: 'STOP', index: 0 };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export { mapGeminiToolsToAnthropic };
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google AI Studio Translator.
|
|
3
|
+
*
|
|
4
|
+
* Google AI Studio speaks Gemini format natively, so request/response
|
|
5
|
+
* translation is a passthrough. The main addition is SSE streaming chunk
|
|
6
|
+
* parsing and proper endpoint URL handling.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { log } from '../../logger';
|
|
10
|
+
|
|
11
|
+
// ─── Types ────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
interface GeminiPart {
|
|
14
|
+
text?: string;
|
|
15
|
+
functionCall?: { name: string; args: Record<string, unknown> };
|
|
16
|
+
functionResponse?: { name: string; response: Record<string, unknown> };
|
|
17
|
+
thought?: boolean;
|
|
18
|
+
inlineData?: { mimeType: string; data: string };
|
|
19
|
+
fileData?: { mimeType: string; fileUri: string };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface GeminiContent {
|
|
23
|
+
parts?: GeminiPart[];
|
|
24
|
+
role?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface GeminiCandidate {
|
|
28
|
+
content?: GeminiContent;
|
|
29
|
+
finishReason?: string;
|
|
30
|
+
index?: number;
|
|
31
|
+
safetyRatings?: unknown[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface GeminiStreamChunk {
|
|
35
|
+
candidates?: GeminiCandidate[];
|
|
36
|
+
usageMetadata?: unknown;
|
|
37
|
+
modelVersion?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface GeminiRequestBody {
|
|
41
|
+
model?: string;
|
|
42
|
+
modelId?: string;
|
|
43
|
+
contents?: GeminiContent[];
|
|
44
|
+
systemInstruction?: { parts: { text?: string }[] };
|
|
45
|
+
tools?: unknown[];
|
|
46
|
+
generationConfig?: {
|
|
47
|
+
temperature?: number;
|
|
48
|
+
maxOutputTokens?: number;
|
|
49
|
+
topP?: number;
|
|
50
|
+
topK?: number;
|
|
51
|
+
stopSequences?: string[];
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── Request Translation (Passthrough) ────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Google AI Studio uses the same Gemini format — just pass through.
|
|
59
|
+
* The caller handles URL routing (streamGenerateContent vs generateContent).
|
|
60
|
+
*/
|
|
61
|
+
export function mapGeminiToGoogle(geminiBody: GeminiRequestBody, modelName: string): GeminiRequestBody {
|
|
62
|
+
// Ensure the external model name is set
|
|
63
|
+
const body: GeminiRequestBody = { ...geminiBody };
|
|
64
|
+
if (modelName && !body.model) {
|
|
65
|
+
body.model = modelName;
|
|
66
|
+
}
|
|
67
|
+
return body;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ─── Response Translation (Passthrough) ───────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Google AI Studio returns Gemini-format responses directly.
|
|
74
|
+
* Just pass through — the proxy wraps it in the Cloud Code envelope.
|
|
75
|
+
*/
|
|
76
|
+
export function mapGoogleToGemini(googleRes: unknown, _modelName: string): unknown {
|
|
77
|
+
// Google AI Studio response is already in Gemini format
|
|
78
|
+
// Wrapped by caller in { response, traceId, metadata }
|
|
79
|
+
return googleRes;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── Streaming Chunk Translation ──────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Parse a Google AI Studio SSE streaming chunk into a Gemini candidate.
|
|
86
|
+
*
|
|
87
|
+
* Google AI Studio streams JSON chunks like:
|
|
88
|
+
* {"candidates":[{"content":{"parts":[{"text":"Hello"}],"role":"model"},...}]}
|
|
89
|
+
*
|
|
90
|
+
* Each chunk contains complete candidate objects (not deltas).
|
|
91
|
+
*/
|
|
92
|
+
export function mapGoogleChunkToGemini(chunk: unknown, _modelName: string): GeminiCandidate | null {
|
|
93
|
+
if (!chunk || typeof chunk !== 'object') return null;
|
|
94
|
+
|
|
95
|
+
const data = chunk as GeminiStreamChunk;
|
|
96
|
+
|
|
97
|
+
// Extract first candidate
|
|
98
|
+
if (!data.candidates || data.candidates.length === 0) return null;
|
|
99
|
+
|
|
100
|
+
const candidate = data.candidates[0];
|
|
101
|
+
|
|
102
|
+
// Check if there's actual content to emit
|
|
103
|
+
const parts = candidate.content?.parts;
|
|
104
|
+
if (!parts || parts.length === 0) {
|
|
105
|
+
// Might be a final chunk with just finishReason
|
|
106
|
+
if (candidate.finishReason) {
|
|
107
|
+
return {
|
|
108
|
+
content: { parts: [], role: 'model' },
|
|
109
|
+
finishReason: candidate.finishReason,
|
|
110
|
+
index: candidate.index ?? 0,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
content: candidate.content,
|
|
118
|
+
finishReason: candidate.finishReason || 'OTHER',
|
|
119
|
+
index: candidate.index ?? 0,
|
|
120
|
+
safetyRatings: candidate.safetyRatings,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── URL Helpers ──────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Constructs the correct Google AI Studio endpoint URL based on streaming mode.
|
|
128
|
+
*
|
|
129
|
+
* Google AI Studio uses different endpoints:
|
|
130
|
+
* - Non-streaming: :generateContent
|
|
131
|
+
* - Streaming: :streamGenerateContent
|
|
132
|
+
*
|
|
133
|
+
* If the user's URL already contains one of these endpoints, it's kept as-is.
|
|
134
|
+
*/
|
|
135
|
+
export function getGoogleApiUrl(baseUrl: string, modelName: string, isStream: boolean): string {
|
|
136
|
+
let url = baseUrl;
|
|
137
|
+
|
|
138
|
+
// If the URL doesn't already specify a method, append one
|
|
139
|
+
if (!url.includes(':generateContent') && !url.includes(':streamGenerateContent')) {
|
|
140
|
+
// Strip trailing slash if present
|
|
141
|
+
url = url.replace(/\/$/, '');
|
|
142
|
+
|
|
143
|
+
// Check if the URL ends with the model path (e.g. /models/gemini-1.5-pro)
|
|
144
|
+
const modelPathPattern = /\/models\/([^\/]+)$/;
|
|
145
|
+
const modelMatch = modelPathPattern.exec(url);
|
|
146
|
+
|
|
147
|
+
if (modelMatch) {
|
|
148
|
+
// URL like .../v1beta/models/gemini-1.5-pro → append :method
|
|
149
|
+
const method = isStream ? ':streamGenerateContent' : ':generateContent';
|
|
150
|
+
url += method;
|
|
151
|
+
} else if (modelName) {
|
|
152
|
+
// Append full path with model name
|
|
153
|
+
const method = isStream ? ':streamGenerateContent' : ':generateContent';
|
|
154
|
+
url += `models/${modelName}${method}`;
|
|
155
|
+
} else {
|
|
156
|
+
// Fallback: assume the URL is already complete
|
|
157
|
+
log.warn('[GoogleTranslator] Could not determine model name for URL construction');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return url;
|
|
162
|
+
}
|