clauderipple 0.7.0 → 0.8.1
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/CHANGELOG.md +77 -0
- package/README.ko.md +18 -2
- package/README.md +27 -5
- package/dist/app/package.json +1 -1
- package/dist/cli/src/claude-auth.js +35 -1
- package/dist/cli/src/codex.js +23 -0
- package/dist/cli/src/hooks/agent-title.js +58 -29
- package/dist/cli/src/index.js +46 -1
- package/dist/cli/src/picker.js +19 -11
- package/dist/cli/src/runtime.js +3 -0
- package/dist/cli/src/settings.js +49 -0
- package/dist/mod/.claude-plugin/plugin.json +1 -0
- package/dist/mod/hooks/hooks.json +1 -0
- package/dist/mod/hooks/register.tsx +309 -0
- package/dist/mod/hooks/worker.ts +49 -0
- package/dist/mod/tsconfig.json +3 -0
- package/dist/mod/types/index.d.ts +30 -0
- package/dist/router/src/admin.js +411 -32
- package/dist/router/src/agents.js +24 -8
- package/dist/router/src/catalog.js +80 -0
- package/dist/router/src/compat.js +27 -1
- package/dist/router/src/config.js +31 -0
- package/dist/router/src/index.js +12 -1
- package/dist/router/src/ingress/server.js +3 -2
- package/dist/router/src/picker.js +33 -2
- package/dist/router/src/presets.js +2 -1
- package/dist/router/src/providers/chatgpt/index.js +77 -18
- package/dist/router/src/providers/chatgpt/translate.js +34 -6
- package/dist/router/src/providers/google/accounts.js +345 -0
- package/dist/router/src/providers/google/antigravity.js +392 -0
- package/dist/router/src/providers/google/index.js +617 -0
- package/dist/router/src/providers/google/login.js +249 -0
- package/dist/router/src/providers/google/thoughtSignatures.js +47 -0
- package/dist/router/src/providers/google/translate.js +539 -0
- package/dist/router/src/providers/google/transport.js +118 -0
- package/dist/router/src/providers/openai/translate.js +3 -3
- package/dist/router/src/proxy.js +116 -9
- package/dist/router/src/requestlog.js +2 -0
- package/dist/router/src/routing.js +22 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +420 -45
- package/dist/ui/i18n.js +76 -4
- package/dist/ui/index.html +5 -0
- package/dist/ui/style.css +4 -0
- package/docs/ARCHITECTURE.md +307 -4
- package/package.json +1 -1
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
// Anthropic Messages ⇄ Google Gemini `generateContent`. Pure translation and streaming mapping
|
|
2
|
+
// for the AI Studio wire; the transport (endpoint, auth, envelope) is separated in transport.ts so
|
|
3
|
+
// another Google backend can be added without touching any of this.
|
|
4
|
+
//
|
|
5
|
+
// Prompt-cache rule (the same one every translated provider follows): the generated body must be a
|
|
6
|
+
// byte-stable prefix of the next turn's, so nothing here uses a timestamp or a random value, key
|
|
7
|
+
// order is fixed, and replayed thinking is dropped from the history.
|
|
8
|
+
//
|
|
9
|
+
// Gemini-3 thought signatures are the one piece of state that cannot live in the body alone: a
|
|
10
|
+
// functionCall part must carry back the `thoughtSignature` the model attached, so the request path
|
|
11
|
+
// reads it from a map keyed by our own tool_use id (see thoughtSignatures.ts). A missing signature
|
|
12
|
+
// is answered with the documented validator-bypass sentinel — see `SIGNATURE_SENTINEL`.
|
|
13
|
+
import crypto from "node:crypto";
|
|
14
|
+
import { clampEffort } from "../../compat.js";
|
|
15
|
+
import { estimateTokens, normalizeSchema, serverToolNames, systemWithDroppedToolNote, toolNameForResponses } from "../chatgpt/translate.js";
|
|
16
|
+
import { identityPrefix, instructionsSuffix } from "../../identity.js";
|
|
17
|
+
/**
|
|
18
|
+
* The value Google's own thought-signatures guide documents for a fake signature: it "instructs the
|
|
19
|
+
* API to skip validation for that turn", for exactly our case — functionCall parts "that were not
|
|
20
|
+
* generated by the API and therefore don't have an associated thought signature" (migrating a
|
|
21
|
+
* history in). It is a last resort and costs reasoning continuity, so it is used only where a real
|
|
22
|
+
* signature is unavailable, and never on a Gemini 2.5 turn (which never asked for one).
|
|
23
|
+
*
|
|
24
|
+
* Source: https://cloud.google.com/vertex-ai/generative-ai/docs/thought-signatures (read
|
|
25
|
+
* 2026-10-01: "should be a last resort as it will negatively impact model performance").
|
|
26
|
+
*/
|
|
27
|
+
export const SIGNATURE_SENTINEL = "skip_thought_signature_validator";
|
|
28
|
+
// ---- text helpers ------------------------------------------------------------------
|
|
29
|
+
function textOf(content) {
|
|
30
|
+
if (typeof content === "string")
|
|
31
|
+
return content;
|
|
32
|
+
if (!Array.isArray(content))
|
|
33
|
+
return "";
|
|
34
|
+
return content
|
|
35
|
+
.filter((block) => block.type === "text")
|
|
36
|
+
.map((block) => String(block.text ?? ""))
|
|
37
|
+
.filter((text) => text.length > 0)
|
|
38
|
+
.join("\n");
|
|
39
|
+
}
|
|
40
|
+
function inlineImages(content) {
|
|
41
|
+
if (!Array.isArray(content))
|
|
42
|
+
return [];
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const block of content) {
|
|
45
|
+
if (block.type !== "image")
|
|
46
|
+
continue;
|
|
47
|
+
const source = block.source;
|
|
48
|
+
if (source?.type === "base64" && typeof source.data === "string") {
|
|
49
|
+
out.push({ mimeType: typeof source.media_type === "string" ? source.media_type : "image/png", data: source.data });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
// ---- effort → thinkingConfig -------------------------------------------------------
|
|
55
|
+
// Google exposes reasoning depth two different ways depending on the model family:
|
|
56
|
+
//
|
|
57
|
+
// - Gemini 3 (`gemini-3-*`, `gemini-3.<n>-*`) takes a *level* on
|
|
58
|
+
// `generationConfig.thinkingConfig.thinkingLevel`. The documented allowed values are
|
|
59
|
+
// `minimal`, `low`, `medium`, `high` (Google Cloud "Get started with Gemini 3" / "Gemini 3
|
|
60
|
+
// Flash": Flash supports minimal…high, Pro supports low/high). The Generative Language
|
|
61
|
+
// Discovery document adds a second spelling (`thinkingLevel: "OFF" | "BASIC" | "DEEP"`,
|
|
62
|
+
// ai.google.dev/api/generate-content), which does not agree with the guide — so this is
|
|
63
|
+
// (assumption)-grade and the lower-case guide spelling is used. Our seven-step ladder is
|
|
64
|
+
// clamped onto those four: `max`/`xhigh`→`high`, `none`→`minimal`.
|
|
65
|
+
// - Gemini 2.5 takes a token *budget* on `generationConfig.thinkingConfig.thinkingBudget`
|
|
66
|
+
// (an integer; `0` disables thinking). The per-generation ranges are (assumption): flash
|
|
67
|
+
// 0..24576, pro 128..32768 — the numbers below stay well inside both.
|
|
68
|
+
//
|
|
69
|
+
// A model that matches neither family gets no thinkingConfig at all: inventing a field for an
|
|
70
|
+
// unknown model is the one failure shape worse than sending no reasoning hint.
|
|
71
|
+
const GEMINI3_LEVELS = ["minimal", "low", "medium", "high"];
|
|
72
|
+
/** The Gemini-3 thinking level our effort maps to, or undefined when the effort is not one we know. */
|
|
73
|
+
function gemini3Level(effort) {
|
|
74
|
+
switch (effort) {
|
|
75
|
+
case "none": return "minimal";
|
|
76
|
+
case "minimal": return "minimal";
|
|
77
|
+
case "low": return "low";
|
|
78
|
+
case "medium": return "medium";
|
|
79
|
+
case "high": return "high";
|
|
80
|
+
// The top of our ladder has no separate step on this wire; clamp to the top the wire has.
|
|
81
|
+
case "xhigh": return "high";
|
|
82
|
+
case "max": return "high";
|
|
83
|
+
case "ultra": return "high";
|
|
84
|
+
default: return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** A Gemini-2.5 thinking budget for our effort, or undefined when the effort is not one we know. */
|
|
88
|
+
function gemini25Budget(effort) {
|
|
89
|
+
switch (effort) {
|
|
90
|
+
case "none": return 0;
|
|
91
|
+
case "minimal": return 1024;
|
|
92
|
+
case "low": return 1024;
|
|
93
|
+
case "medium": return 8192;
|
|
94
|
+
case "high": return 16384;
|
|
95
|
+
case "xhigh": return 24576;
|
|
96
|
+
case "max": return 24576;
|
|
97
|
+
case "ultra": return 24576;
|
|
98
|
+
default: return undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function thinkingFamily(model) {
|
|
102
|
+
if (/^gemini-3[-.]/.test(model))
|
|
103
|
+
return "gemini3";
|
|
104
|
+
if (/^gemini-2\.5/.test(model))
|
|
105
|
+
return "gemini25";
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The reasoning effort a request will actually put on the wire, named for the identity line. A
|
|
110
|
+
* provider whose caps declare a ladder clamps to it first, so the model is told the level it sees.
|
|
111
|
+
*/
|
|
112
|
+
function namedEffort(opts) {
|
|
113
|
+
if (!opts.effort)
|
|
114
|
+
return undefined;
|
|
115
|
+
const levels = opts.caps?.effortLevels;
|
|
116
|
+
return levels && levels.length > 0 ? clampEffort(opts.effort, levels) : opts.effort;
|
|
117
|
+
}
|
|
118
|
+
function thinkingConfigFor(model, effort) {
|
|
119
|
+
if (!effort)
|
|
120
|
+
return undefined;
|
|
121
|
+
const family = thinkingFamily(model);
|
|
122
|
+
if (family === "gemini3") {
|
|
123
|
+
const level = gemini3Level(effort);
|
|
124
|
+
return level ? { thinkingLevel: level } : undefined;
|
|
125
|
+
}
|
|
126
|
+
if (family === "gemini25") {
|
|
127
|
+
const budget = gemini25Budget(effort);
|
|
128
|
+
return budget === undefined ? undefined : { thinkingBudget: budget };
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
// ---- messages → contents -----------------------------------------------------------
|
|
133
|
+
/** The system text this provider should see: what it is, the caller's prompt, the configured addendum. */
|
|
134
|
+
function systemForGoogle(sys, opts) {
|
|
135
|
+
return [identityPrefix({ model: opts.model, effort: namedEffort(opts), identity: opts.identity }), sys, instructionsSuffix({ model: opts.model, instructionsAppend: opts.instructionsAppend })]
|
|
136
|
+
.filter(Boolean)
|
|
137
|
+
.join("\n\n");
|
|
138
|
+
}
|
|
139
|
+
/** Build `contents` from the Anthropic history, repairing the tool_call/tool_result adjacency Gemini needs. */
|
|
140
|
+
function toContents(req, opts) {
|
|
141
|
+
const contents = [];
|
|
142
|
+
const messages = req.messages;
|
|
143
|
+
for (let i = 0; i < messages.length; i++) {
|
|
144
|
+
const message = messages[i];
|
|
145
|
+
const blocks = Array.isArray(message.content) ? message.content : null;
|
|
146
|
+
if (message.role === "assistant") {
|
|
147
|
+
const parts = [];
|
|
148
|
+
const calls = [];
|
|
149
|
+
// Gemini signs only the first functionCall of a step, so a parallel call with no signature of
|
|
150
|
+
// its own is normal, not missing. The sentinel is for a turn that has no real signature at all
|
|
151
|
+
// (a restart, another model's history), and then only on its first call, where one belongs:
|
|
152
|
+
// sent on every unsigned call it would skip validation — and cost reasoning — on healthy turns.
|
|
153
|
+
const signedTurn = (blocks ?? []).some((block) => block.type === "tool_use" && opts.thoughtSignatures?.get(block.id) !== undefined);
|
|
154
|
+
let sentinelPlaced = false;
|
|
155
|
+
for (const block of blocks ?? []) {
|
|
156
|
+
if (block.type === "text") {
|
|
157
|
+
const text = String(block.text ?? "");
|
|
158
|
+
if (text)
|
|
159
|
+
parts.push({ text });
|
|
160
|
+
}
|
|
161
|
+
else if (block.type === "tool_use") {
|
|
162
|
+
const call = block;
|
|
163
|
+
const wireName = toolNameForResponses(call.name);
|
|
164
|
+
calls.push({ id: call.id, wireName });
|
|
165
|
+
const part = { functionCall: { name: wireName, args: asArgs(call.input), id: call.id } };
|
|
166
|
+
const signature = opts.thoughtSignatures?.get(call.id);
|
|
167
|
+
if (signature)
|
|
168
|
+
part.thoughtSignature = signature;
|
|
169
|
+
else if (opts.signatureSentinel && !signedTurn && !sentinelPlaced && thinkingFamily(opts.model) === "gemini3") {
|
|
170
|
+
part.thoughtSignature = SIGNATURE_SENTINEL;
|
|
171
|
+
sentinelPlaced = true;
|
|
172
|
+
}
|
|
173
|
+
parts.push(part);
|
|
174
|
+
}
|
|
175
|
+
// thinking / redacted_thinking / anything else: dropped on purpose (cache stability)
|
|
176
|
+
}
|
|
177
|
+
// A turn Gemini cannot represent (thinking-only, for instance) would serialize as `parts: []`,
|
|
178
|
+
// which Gemini rejects; it is skipped rather than sent empty.
|
|
179
|
+
if (parts.length > 0)
|
|
180
|
+
contents.push({ role: "model", parts });
|
|
181
|
+
if (calls.length > 0) {
|
|
182
|
+
// Gemini requires every functionCall in a turn to be answered by an adjacent functionResponse
|
|
183
|
+
// batch. Gather the tool_result blocks from the user turn(s) that follow, then skip them.
|
|
184
|
+
const required = new Map(calls.map((call) => [call.id, call]));
|
|
185
|
+
const results = new Map();
|
|
186
|
+
const orphanResults = [];
|
|
187
|
+
let j = i + 1;
|
|
188
|
+
while (j < messages.length) {
|
|
189
|
+
const next = messages[j];
|
|
190
|
+
const nextContent = next.content;
|
|
191
|
+
if (next.role === "assistant" || !Array.isArray(nextContent))
|
|
192
|
+
break;
|
|
193
|
+
const resultBlocks = nextContent.filter((b) => b.type === "tool_result");
|
|
194
|
+
if (resultBlocks.length === 0)
|
|
195
|
+
break;
|
|
196
|
+
for (const block of resultBlocks) {
|
|
197
|
+
const id = block.tool_use_id;
|
|
198
|
+
if (typeof id === "string" && required.has(id) && !results.has(id))
|
|
199
|
+
results.set(id, block);
|
|
200
|
+
else
|
|
201
|
+
orphanResults.push(block);
|
|
202
|
+
}
|
|
203
|
+
j++;
|
|
204
|
+
}
|
|
205
|
+
const responseParts = [];
|
|
206
|
+
for (const call of calls)
|
|
207
|
+
responseParts.push(functionResponsePart(call, results.get(call.id)));
|
|
208
|
+
for (const orphan of orphanResults)
|
|
209
|
+
responseParts.push(...orphanResultParts(orphan));
|
|
210
|
+
contents.push({ role: "user", parts: responseParts });
|
|
211
|
+
i = j - 1;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
// user
|
|
217
|
+
if (typeof message.content === "string") {
|
|
218
|
+
if (message.content)
|
|
219
|
+
contents.push({ role: "user", parts: [{ text: message.content }] });
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const parts = [];
|
|
223
|
+
for (const block of blocks ?? []) {
|
|
224
|
+
if (block.type === "text") {
|
|
225
|
+
const text = String(block.text ?? "");
|
|
226
|
+
if (text)
|
|
227
|
+
parts.push({ text });
|
|
228
|
+
}
|
|
229
|
+
else if (block.type === "image") {
|
|
230
|
+
const image = inlineImages([block])[0];
|
|
231
|
+
// Gemini takes base64 bytes through inlineData; a remote URL carries no bytes we hold, so a
|
|
232
|
+
// short marker stands in rather than inlining the URL as a huge text blob.
|
|
233
|
+
if (image)
|
|
234
|
+
parts.push({ inlineData: { mimeType: image.mimeType, data: image.data } });
|
|
235
|
+
else
|
|
236
|
+
parts.push({ text: `[image: ${String(block.source?.url ?? "")}]` });
|
|
237
|
+
}
|
|
238
|
+
else if (block.type === "tool_result") {
|
|
239
|
+
// A result with no adjacent call cannot be a functionResponse; it is sent as user text.
|
|
240
|
+
parts.push(...orphanResultParts(block));
|
|
241
|
+
}
|
|
242
|
+
// thinking etc. dropped
|
|
243
|
+
}
|
|
244
|
+
if (parts.length > 0)
|
|
245
|
+
contents.push({ role: "user", parts });
|
|
246
|
+
}
|
|
247
|
+
// Gemini rejects a request that opens on a functionCall turn ("function call turn comes
|
|
248
|
+
// immediately after a user turn or after a function response turn", HTTP 400). Compaction can
|
|
249
|
+
// truncate a long history so it opens on an assistant tool call.
|
|
250
|
+
const first = contents[0];
|
|
251
|
+
if (first?.role === "model" && first.parts.some((part) => part.functionCall !== undefined)) {
|
|
252
|
+
contents.unshift({ role: "user", parts: [{ text: "(continue)" }] });
|
|
253
|
+
}
|
|
254
|
+
// Gemini also rejects an assistant-tail history ("Requests ending with a model turn are not
|
|
255
|
+
// supported", HTTP 400). A compaction, an interrupt or a replayed turn can leave one.
|
|
256
|
+
const last = contents[contents.length - 1];
|
|
257
|
+
if (!last || last.role === "model")
|
|
258
|
+
contents.push({ role: "user", parts: [{ text: "(continue)" }] });
|
|
259
|
+
return contents;
|
|
260
|
+
}
|
|
261
|
+
/** A tool's input as Gemini's `functionCall.args` wants it: a JSON object, never a string. */
|
|
262
|
+
function asArgs(input) {
|
|
263
|
+
if (input && typeof input === "object" && !Array.isArray(input))
|
|
264
|
+
return input;
|
|
265
|
+
if (typeof input === "string") {
|
|
266
|
+
try {
|
|
267
|
+
const parsed = JSON.parse(input);
|
|
268
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
269
|
+
return parsed;
|
|
270
|
+
}
|
|
271
|
+
catch { /* fall through to the empty object */ }
|
|
272
|
+
}
|
|
273
|
+
return {};
|
|
274
|
+
}
|
|
275
|
+
const MISSING_TOOL_RESULT = "[missing tool_result for this tool_use in history]";
|
|
276
|
+
const EMPTY_TOOL_OUTPUT = "(empty tool output)";
|
|
277
|
+
const EMPTY_TEXT = "(empty)";
|
|
278
|
+
function functionResponsePart(call, result) {
|
|
279
|
+
if (!result) {
|
|
280
|
+
// Repair the boundary without inventing success: say a result is missing rather than claiming one.
|
|
281
|
+
return { functionResponse: { name: call.wireName, response: { result: MISSING_TOOL_RESULT }, id: call.id } };
|
|
282
|
+
}
|
|
283
|
+
const block = result;
|
|
284
|
+
let output = textOf(block.content);
|
|
285
|
+
if (block.is_error && !output)
|
|
286
|
+
output = "Tool execution failed";
|
|
287
|
+
if (!output)
|
|
288
|
+
output = EMPTY_TOOL_OUTPUT;
|
|
289
|
+
return { functionResponse: { name: call.wireName, response: { result: output }, id: call.id } };
|
|
290
|
+
}
|
|
291
|
+
function orphanResultParts(block) {
|
|
292
|
+
const b = block;
|
|
293
|
+
let output = textOf(b.content);
|
|
294
|
+
if (b.is_error && !output)
|
|
295
|
+
output = "Tool execution failed";
|
|
296
|
+
if (!output)
|
|
297
|
+
output = EMPTY_TOOL_OUTPUT;
|
|
298
|
+
const parts = [{ text: `[Tool result]\n${output}` }];
|
|
299
|
+
for (const image of inlineImages(b.content))
|
|
300
|
+
parts.push({ inlineData: { mimeType: image.mimeType, data: image.data } });
|
|
301
|
+
return parts;
|
|
302
|
+
}
|
|
303
|
+
// ---- tools -------------------------------------------------------------------------
|
|
304
|
+
// Anthropic's server-side tools (`web_search` and friends) are run by Anthropic, not by the model
|
|
305
|
+
// holding them. Declaring one to this provider offers a tool that cannot execute: the model calls
|
|
306
|
+
// it, nothing answers, and the turn returns empty with no error. The same rule every translated
|
|
307
|
+
// provider follows.
|
|
308
|
+
function functionTools(req) {
|
|
309
|
+
const dropped = serverToolNames(req.tools);
|
|
310
|
+
return (req.tools ?? [])
|
|
311
|
+
.filter((tool) => typeof tool.name === "string" && !dropped.has(tool.name))
|
|
312
|
+
.map((tool) => ({
|
|
313
|
+
name: toolNameForResponses(tool.name),
|
|
314
|
+
description: tool.description ?? "",
|
|
315
|
+
parametersJsonSchema: normalizeSchema(tool.input_schema),
|
|
316
|
+
}));
|
|
317
|
+
}
|
|
318
|
+
/** Whether a `tool_choice` that named a dropped tool would force the model onto something undeclared. */
|
|
319
|
+
function choiceSurvives(req) {
|
|
320
|
+
const choice = req.tool_choice;
|
|
321
|
+
return choice?.type === "tool" && !!choice.name && !serverToolNames(req.tools).has(choice.name);
|
|
322
|
+
}
|
|
323
|
+
function mapToolChoice(req, tools) {
|
|
324
|
+
// `mode: "ANY"` with no declarations is a guaranteed 400, so a tool-less request sends no config.
|
|
325
|
+
if (tools.length === 0)
|
|
326
|
+
return undefined;
|
|
327
|
+
const choice = req.tool_choice;
|
|
328
|
+
if (!choice)
|
|
329
|
+
return undefined;
|
|
330
|
+
if (choice.type === "auto")
|
|
331
|
+
return { functionCallingConfig: { mode: "AUTO" } };
|
|
332
|
+
if (choice.type === "any")
|
|
333
|
+
return { functionCallingConfig: { mode: "ANY" } };
|
|
334
|
+
if (choice.type === "none")
|
|
335
|
+
return { functionCallingConfig: { mode: "NONE" } };
|
|
336
|
+
return choiceSurvives(req)
|
|
337
|
+
? { functionCallingConfig: { mode: "ANY", allowedFunctionNames: [toolNameForResponses(choice.name)] } }
|
|
338
|
+
: undefined;
|
|
339
|
+
}
|
|
340
|
+
export function toGeminiRequest(req, opts) {
|
|
341
|
+
const out = { contents: toContents(req, opts) };
|
|
342
|
+
const sys = systemForGoogle(systemWithDroppedToolNote(req), opts);
|
|
343
|
+
if (sys)
|
|
344
|
+
out.systemInstruction = { parts: [{ text: sys }] };
|
|
345
|
+
const tools = functionTools(req);
|
|
346
|
+
if (tools.length > 0) {
|
|
347
|
+
out.tools = [{ functionDeclarations: tools }];
|
|
348
|
+
const toolConfig = mapToolChoice(req, tools);
|
|
349
|
+
if (toolConfig)
|
|
350
|
+
out.toolConfig = toolConfig;
|
|
351
|
+
}
|
|
352
|
+
const generationConfig = {};
|
|
353
|
+
if (typeof req.max_tokens === "number")
|
|
354
|
+
generationConfig.maxOutputTokens = req.max_tokens;
|
|
355
|
+
if (typeof req.temperature === "number")
|
|
356
|
+
generationConfig.temperature = req.temperature;
|
|
357
|
+
if (Array.isArray(req.stop_sequences) && req.stop_sequences.every((value) => typeof value === "string")) {
|
|
358
|
+
generationConfig.stopSequences = req.stop_sequences;
|
|
359
|
+
}
|
|
360
|
+
const thinkingConfig = thinkingConfigFor(opts.model, namedEffort(opts));
|
|
361
|
+
if (thinkingConfig)
|
|
362
|
+
generationConfig.thinkingConfig = thinkingConfig;
|
|
363
|
+
if (Object.keys(generationConfig).length > 0)
|
|
364
|
+
out.generationConfig = generationConfig;
|
|
365
|
+
return out;
|
|
366
|
+
}
|
|
367
|
+
export { estimateTokens };
|
|
368
|
+
/** Maps Gemini `streamGenerateContent` SSE chunks to Anthropic Messages SSE. */
|
|
369
|
+
export class GoogleStreamMapper {
|
|
370
|
+
started = false;
|
|
371
|
+
finished = false;
|
|
372
|
+
blockIndex = -1;
|
|
373
|
+
open = null;
|
|
374
|
+
sawTool = false;
|
|
375
|
+
finishReason;
|
|
376
|
+
sawCompletion = false;
|
|
377
|
+
toolCounter = 0;
|
|
378
|
+
toolNames;
|
|
379
|
+
onFunctionCall;
|
|
380
|
+
messageId = `msg_${crypto.randomBytes(12).toString("hex")}`;
|
|
381
|
+
content = [];
|
|
382
|
+
usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 };
|
|
383
|
+
stopReason = "end_turn";
|
|
384
|
+
failure;
|
|
385
|
+
model;
|
|
386
|
+
startInput;
|
|
387
|
+
constructor(model, startInput = 0, toolNames = new Map(), onFunctionCall) {
|
|
388
|
+
this.model = model;
|
|
389
|
+
this.startInput = startInput;
|
|
390
|
+
this.toolNames = toolNames;
|
|
391
|
+
this.onFunctionCall = onFunctionCall;
|
|
392
|
+
}
|
|
393
|
+
get isFinished() { return this.finished; }
|
|
394
|
+
/** Whether Gemini said the answer was over (a `finishReason` was seen). */
|
|
395
|
+
get completed() { return this.sawCompletion; }
|
|
396
|
+
start() {
|
|
397
|
+
if (this.started)
|
|
398
|
+
return [];
|
|
399
|
+
this.started = true;
|
|
400
|
+
return [{ event: "message_start", data: { type: "message_start", message: { id: this.messageId, type: "message", role: "assistant", model: this.model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: this.startInput, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 } } } }];
|
|
401
|
+
}
|
|
402
|
+
closeBlock() {
|
|
403
|
+
if (!this.open)
|
|
404
|
+
return [];
|
|
405
|
+
const index = this.open.index;
|
|
406
|
+
this.open = null;
|
|
407
|
+
return [{ event: "content_block_stop", data: { type: "content_block_stop", index } }];
|
|
408
|
+
}
|
|
409
|
+
openBlock(kind, contentBlock, toolIndex) {
|
|
410
|
+
const out = this.closeBlock();
|
|
411
|
+
const index = ++this.blockIndex;
|
|
412
|
+
this.open = { kind, index, ...(toolIndex === undefined ? {} : { toolIndex }) };
|
|
413
|
+
out.push({ event: "content_block_start", data: { type: "content_block_start", index, content_block: contentBlock } });
|
|
414
|
+
return out;
|
|
415
|
+
}
|
|
416
|
+
setUsage(value) {
|
|
417
|
+
if (!value || typeof value !== "object")
|
|
418
|
+
return;
|
|
419
|
+
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? Math.max(0, v) : 0);
|
|
420
|
+
const prompt = num(value.promptTokenCount);
|
|
421
|
+
const cached = num(value.cachedContentTokenCount);
|
|
422
|
+
const candidates = num(value.candidatesTokenCount);
|
|
423
|
+
const thoughts = num(value.thoughtsTokenCount);
|
|
424
|
+
this.usage = {
|
|
425
|
+
input_tokens: Math.max(0, prompt - cached),
|
|
426
|
+
output_tokens: candidates + thoughts,
|
|
427
|
+
cache_read_input_tokens: cached,
|
|
428
|
+
cache_creation_input_tokens: 0,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
/** The vendor echoes the name it was given; Claude Code only recognises the original. */
|
|
432
|
+
restore(name) {
|
|
433
|
+
return this.toolNames.get(name) ?? name;
|
|
434
|
+
}
|
|
435
|
+
feedParts(parts) {
|
|
436
|
+
const out = [];
|
|
437
|
+
for (const part of parts) {
|
|
438
|
+
if (typeof part.text === "string" && part.text) {
|
|
439
|
+
if (part.thought === true) {
|
|
440
|
+
if (!this.open || this.open.kind !== "thinking") {
|
|
441
|
+
this.content.push({ type: "thinking", thinking: "" });
|
|
442
|
+
out.push(...this.openBlock("thinking", { type: "thinking", thinking: "" }));
|
|
443
|
+
}
|
|
444
|
+
const last = this.content[this.content.length - 1];
|
|
445
|
+
if (last?.type === "thinking")
|
|
446
|
+
last.thinking += part.text;
|
|
447
|
+
out.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.open.index, delta: { type: "thinking_delta", thinking: part.text } } });
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
if (!this.open || this.open.kind !== "text") {
|
|
451
|
+
this.content.push({ type: "text", text: "" });
|
|
452
|
+
out.push(...this.openBlock("text", { type: "text", text: "" }));
|
|
453
|
+
}
|
|
454
|
+
const last = this.content[this.content.length - 1];
|
|
455
|
+
if (last?.type === "text")
|
|
456
|
+
last.text += part.text;
|
|
457
|
+
out.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.open.index, delta: { type: "text_delta", text: part.text } } });
|
|
458
|
+
}
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
if (part.functionCall && typeof part.functionCall.name === "string") {
|
|
462
|
+
this.sawTool = true;
|
|
463
|
+
const index = this.toolCounter++;
|
|
464
|
+
const name = this.restore(part.functionCall.name);
|
|
465
|
+
const id = `toolu_${crypto.randomBytes(12).toString("hex")}`;
|
|
466
|
+
const args = typeof part.functionCall.args === "object" && part.functionCall.args !== null ? JSON.stringify(part.functionCall.args) : "{}";
|
|
467
|
+
const tool = { type: "tool_use", id, name, input: {}, args, index };
|
|
468
|
+
this.content.push(tool);
|
|
469
|
+
// A whole functionCall arrives in one part, so it opens, takes its arguments in a single
|
|
470
|
+
// delta, and closes — there is nothing to stream.
|
|
471
|
+
out.push(...this.openBlock("tool", { type: "tool_use", id, name, input: {} }, index));
|
|
472
|
+
out.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.open.index, delta: { type: "input_json_delta", partial_json: args } } });
|
|
473
|
+
out.push(...this.closeBlock());
|
|
474
|
+
// Let the adapter remember the signature against the id it just minted, so the next turn can
|
|
475
|
+
// send it back with the replayed functionCall.
|
|
476
|
+
this.onFunctionCall?.({ id, name, signature: typeof part.thoughtSignature === "string" && part.thoughtSignature ? part.thoughtSignature : undefined });
|
|
477
|
+
}
|
|
478
|
+
// inlineData (image output) is not handled in this stage; it is ignored rather than guessed at.
|
|
479
|
+
}
|
|
480
|
+
// A part's own text may have been empty but a later part non-empty; the caller decides on close.
|
|
481
|
+
return out;
|
|
482
|
+
}
|
|
483
|
+
feed(chunk) {
|
|
484
|
+
if (this.finished)
|
|
485
|
+
return [];
|
|
486
|
+
const out = [...this.start()];
|
|
487
|
+
if (chunk.error && typeof chunk.error === "object") {
|
|
488
|
+
const message = typeof chunk.error.message === "string" ? chunk.error.message : "upstream error";
|
|
489
|
+
const status = typeof chunk.error.status === "string" ? chunk.error.status : undefined;
|
|
490
|
+
return [...out, ...this.fail(message, status)];
|
|
491
|
+
}
|
|
492
|
+
if (chunk.usageMetadata)
|
|
493
|
+
this.setUsage(chunk.usageMetadata);
|
|
494
|
+
const candidates = Array.isArray(chunk.candidates) ? chunk.candidates : [];
|
|
495
|
+
for (const candidate of candidates) {
|
|
496
|
+
if (typeof candidate?.finishReason === "string" && candidate.finishReason) {
|
|
497
|
+
this.finishReason = candidate.finishReason;
|
|
498
|
+
this.sawCompletion = true;
|
|
499
|
+
}
|
|
500
|
+
const parts = candidate?.content?.parts;
|
|
501
|
+
if (Array.isArray(parts))
|
|
502
|
+
out.push(...this.feedParts(parts));
|
|
503
|
+
}
|
|
504
|
+
return out;
|
|
505
|
+
}
|
|
506
|
+
finish() {
|
|
507
|
+
if (this.finished)
|
|
508
|
+
return [];
|
|
509
|
+
this.finished = true;
|
|
510
|
+
this.stopReason = this.sawTool ? "tool_use" : this.finishReason === "MAX_TOKENS" ? "max_tokens" : "end_turn";
|
|
511
|
+
return [...this.start(), ...this.closeBlock(), { event: "message_delta", data: { type: "message_delta", delta: { stop_reason: this.stopReason, stop_sequence: null }, usage: this.usage } }, { event: "message_stop", data: { type: "message_stop" } }];
|
|
512
|
+
}
|
|
513
|
+
fail(message, status) {
|
|
514
|
+
if (this.finished)
|
|
515
|
+
return [];
|
|
516
|
+
this.finished = true;
|
|
517
|
+
// `overloaded_error` is the one mid-stream error Claude Code retries on its own; `api_error` is
|
|
518
|
+
// final. Gemini's RESOURCE_EXHAUSTED becomes a rate limit the client backs off from.
|
|
519
|
+
const type = status === "RESOURCE_EXHAUSTED" ? "rate_limit_error" : status === "UNAVAILABLE" || status === "INTERNAL" || status === "DEADLINE_EXCEEDED" ? "overloaded_error" : "api_error";
|
|
520
|
+
this.failure = { type, message };
|
|
521
|
+
return [...this.start(), ...this.closeBlock(), { event: "error", data: { type: "error", error: { type, message } } }];
|
|
522
|
+
}
|
|
523
|
+
message() {
|
|
524
|
+
const content = this.content.map((block) => {
|
|
525
|
+
if (block.type !== "tool_use")
|
|
526
|
+
return block;
|
|
527
|
+
let input = {};
|
|
528
|
+
try {
|
|
529
|
+
input = block.args ? JSON.parse(block.args) : {};
|
|
530
|
+
}
|
|
531
|
+
catch { /* malformed vendor arguments become an empty tool input */ }
|
|
532
|
+
return { type: "tool_use", id: block.id, name: block.name, input };
|
|
533
|
+
});
|
|
534
|
+
return { id: this.messageId, type: "message", role: "assistant", model: this.model, content, stop_reason: this.stopReason, stop_sequence: null, usage: this.usage };
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
export function formatSse(event) {
|
|
538
|
+
return `event: ${event.event}\ndata: ${JSON.stringify(event.data)}\n\n`;
|
|
539
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// The Google transport: endpoint, authentication, envelope and response unwrapping, kept apart from
|
|
2
|
+
// the translation so the two Google backends share one `translate.ts`.
|
|
3
|
+
//
|
|
4
|
+
// api-key the AI Studio Generative Language API, called with a key on `x-goog-api-key`.
|
|
5
|
+
// antigravity Cloud Code Assist / Antigravity: an OAuth bearer, the Gemini body wrapped in a
|
|
6
|
+
// `{ project, model, request: {…} }` envelope on `v1internal:`, and the answer nested
|
|
7
|
+
// back under `response`. Account selection, refresh and retry live in index.ts; this
|
|
8
|
+
// file only builds the bytes and unwraps the frames.
|
|
9
|
+
//
|
|
10
|
+
// The antigravity wire is taken from the reference implementation opencodex as a behavioral spec
|
|
11
|
+
// (src/adapters/google.ts:1056-1160, src/adapters/google-antigravity-wire.ts) and is **not measured
|
|
12
|
+
// live** — this environment has no Antigravity subscription. See docs/ARCHITECTURE.md §4d.
|
|
13
|
+
import { antigravityRequestId, antigravitySessionId, antigravityUserAgent, GOOGLE_OAUTH, resolveAntigravityWireModel } from "./antigravity.js";
|
|
14
|
+
const DEFAULT_BASE = "https://generativelanguage.googleapis.com";
|
|
15
|
+
/** The AI Studio key: the provider's own, or the environment names Google's own SDKs read. Never logged. */
|
|
16
|
+
export function resolveApiKey(configured) {
|
|
17
|
+
const key = configured?.trim();
|
|
18
|
+
if (key)
|
|
19
|
+
return key;
|
|
20
|
+
const env = process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY;
|
|
21
|
+
return env && env.trim() ? env.trim() : undefined;
|
|
22
|
+
}
|
|
23
|
+
export function googleBaseUrl(configured) {
|
|
24
|
+
return (configured?.trim() || DEFAULT_BASE).replace(/\/+$/, "");
|
|
25
|
+
}
|
|
26
|
+
/** The Cloud Code Assist base; the daily host, overridable by the provider's `url`. */
|
|
27
|
+
export function antigravityBaseUrl(configured) {
|
|
28
|
+
return (configured?.trim() || GOOGLE_OAUTH.dailyApi).replace(/\/+$/, "");
|
|
29
|
+
}
|
|
30
|
+
export function createTransport(mode, opts) {
|
|
31
|
+
if (mode === "antigravity") {
|
|
32
|
+
const base = antigravityBaseUrl(opts.url);
|
|
33
|
+
return {
|
|
34
|
+
kind: "antigravity",
|
|
35
|
+
build(req, o) {
|
|
36
|
+
const auth = o.antigravity;
|
|
37
|
+
if (!auth)
|
|
38
|
+
throw new Error("ClaudeRipple: the Google Antigravity mode needs a signed-in account; run `clauderipple google-login`");
|
|
39
|
+
const resolved = resolveAntigravityWireModel(o.model, o.effort);
|
|
40
|
+
const request = { ...req, sessionId: antigravitySessionId(auth.conversationKey) };
|
|
41
|
+
// The transport owns `thinkingConfig` on this wire. The translation guesses a level from the
|
|
42
|
+
// base id, but CCA's wire id already encodes the tier for some models (gemini-3.8-flash-low);
|
|
43
|
+
// sending a level beside one states the effort twice and the run tier becomes unknowable. So
|
|
44
|
+
// whatever the translation set is replaced by exactly what the resolved wire id wants — a
|
|
45
|
+
// level, or nothing.
|
|
46
|
+
const generationConfig = { ...(request.generationConfig ?? {}) };
|
|
47
|
+
if (resolved.thinkingLevel)
|
|
48
|
+
generationConfig.thinkingConfig = { thinkingLevel: resolved.thinkingLevel };
|
|
49
|
+
else
|
|
50
|
+
delete generationConfig.thinkingConfig;
|
|
51
|
+
if (Object.keys(generationConfig).length > 0)
|
|
52
|
+
request.generationConfig = generationConfig;
|
|
53
|
+
else
|
|
54
|
+
delete request.generationConfig;
|
|
55
|
+
// Claude models on CCA force validated function calling (the real client always sets it). A
|
|
56
|
+
// client that asked for no tools is honoured by dropping the declarations, the wire shape of a
|
|
57
|
+
// tool-less turn. Source: opencodex src/adapters/google.ts:1109-1120.
|
|
58
|
+
if (/^claude-/.test(resolved.wire) && request.tools) {
|
|
59
|
+
if (request.toolConfig?.functionCallingConfig?.mode === "NONE") {
|
|
60
|
+
delete request.tools;
|
|
61
|
+
delete request.toolConfig;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
request.toolConfig = { functionCallingConfig: { ...(request.toolConfig?.functionCallingConfig ?? {}), mode: "VALIDATED" } };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const method = o.stream ? "streamGenerateContent" : "generateContent";
|
|
68
|
+
const query = o.stream ? "?alt=sse" : "";
|
|
69
|
+
const envelope = {
|
|
70
|
+
model: resolved.wire,
|
|
71
|
+
// A protocol constant, distinct from the HTTP User-Agent header below. Source: opencodex
|
|
72
|
+
// src/adapters/google.ts:1149-1155.
|
|
73
|
+
userAgent: "antigravity",
|
|
74
|
+
requestType: "agent",
|
|
75
|
+
project: auth.projectId,
|
|
76
|
+
requestId: antigravityRequestId(),
|
|
77
|
+
request,
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
url: `${base}/${GOOGLE_OAUTH.apiVersion}:${method}${query}`,
|
|
81
|
+
headers: {
|
|
82
|
+
"content-type": "application/json",
|
|
83
|
+
accept: o.stream ? "text/event-stream" : "application/json",
|
|
84
|
+
// The IDE client family is required to unlock newer agent models. Never a giveaway literal.
|
|
85
|
+
"user-agent": antigravityUserAgent(),
|
|
86
|
+
authorization: `Bearer ${auth.accessToken}`,
|
|
87
|
+
},
|
|
88
|
+
body: JSON.stringify(envelope),
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
unwrap(raw) {
|
|
92
|
+
const wrapped = raw.response;
|
|
93
|
+
// A top-level `{ error: … }` frame has no `response`; it is returned as it is so the mapper
|
|
94
|
+
// still sees the error.
|
|
95
|
+
return wrapped && typeof wrapped === "object" && !Array.isArray(wrapped) ? wrapped : raw;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const base = googleBaseUrl(opts.url);
|
|
100
|
+
const apiKey = resolveApiKey(opts.apiKey);
|
|
101
|
+
return {
|
|
102
|
+
kind: "api-key",
|
|
103
|
+
build(req, o) {
|
|
104
|
+
if (!apiKey)
|
|
105
|
+
throw new Error("ClaudeRipple: this Google provider has no API key; set apiKey or GEMINI_API_KEY");
|
|
106
|
+
const method = o.stream ? "streamGenerateContent" : "generateContent";
|
|
107
|
+
const query = o.stream ? "?alt=sse" : "";
|
|
108
|
+
return {
|
|
109
|
+
url: `${base}/v1beta/models/${encodeURIComponent(o.model)}:${method}${query}`,
|
|
110
|
+
headers: { "content-type": "application/json", accept: o.stream ? "text/event-stream" : "application/json", "x-goog-api-key": apiKey },
|
|
111
|
+
body: JSON.stringify(req),
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
unwrap(raw) {
|
|
115
|
+
return raw;
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|