aigetwey 1.0.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.
Files changed (216) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/LICENSE +21 -0
  3. package/README.md +302 -0
  4. package/assets/logo.svg +8 -0
  5. package/assets/screenshot.png +0 -0
  6. package/assets/wordmark.svg +9 -0
  7. package/config.example.yaml +56 -0
  8. package/dashboard/.env.example +12 -0
  9. package/dashboard/next-env.d.ts +6 -0
  10. package/dashboard/next.config.ts +12 -0
  11. package/dashboard/package-lock.json +1771 -0
  12. package/dashboard/package.json +29 -0
  13. package/dashboard/postcss.config.mjs +5 -0
  14. package/dashboard/src/app/(console)/combos/page.tsx +10 -0
  15. package/dashboard/src/app/(console)/config/page.tsx +5 -0
  16. package/dashboard/src/app/(console)/console/page.tsx +92 -0
  17. package/dashboard/src/app/(console)/endpoint/page.tsx +5 -0
  18. package/dashboard/src/app/(console)/layout.tsx +17 -0
  19. package/dashboard/src/app/(console)/page.tsx +8 -0
  20. package/dashboard/src/app/(console)/providers/[id]/page.tsx +6 -0
  21. package/dashboard/src/app/(console)/providers/page.tsx +5 -0
  22. package/dashboard/src/app/(console)/quota/page.tsx +5 -0
  23. package/dashboard/src/app/(console)/tools/[id]/page.tsx +6 -0
  24. package/dashboard/src/app/(console)/tools/page.tsx +5 -0
  25. package/dashboard/src/app/(console)/usage/page.tsx +24 -0
  26. package/dashboard/src/app/api/cli-detect/[tool]/route.ts +253 -0
  27. package/dashboard/src/app/api/gw/[...path]/route.ts +89 -0
  28. package/dashboard/src/app/api/login/route.ts +30 -0
  29. package/dashboard/src/app/api/logout/route.ts +9 -0
  30. package/dashboard/src/app/api/password/route.ts +34 -0
  31. package/dashboard/src/app/globals.css +340 -0
  32. package/dashboard/src/app/icon.svg +8 -0
  33. package/dashboard/src/app/layout.tsx +28 -0
  34. package/dashboard/src/app/login/page.tsx +60 -0
  35. package/dashboard/src/components/AreaChart.tsx +115 -0
  36. package/dashboard/src/components/Badge.tsx +32 -0
  37. package/dashboard/src/components/Button.tsx +60 -0
  38. package/dashboard/src/components/CapacityBadges.tsx +40 -0
  39. package/dashboard/src/components/Checkbox.tsx +40 -0
  40. package/dashboard/src/components/CliToolConfig.tsx +63 -0
  41. package/dashboard/src/components/ConfigEditor.tsx +199 -0
  42. package/dashboard/src/components/ConfirmModal.tsx +36 -0
  43. package/dashboard/src/components/CooldownTimer.tsx +42 -0
  44. package/dashboard/src/components/EndpointView.tsx +439 -0
  45. package/dashboard/src/components/Icon.tsx +25 -0
  46. package/dashboard/src/components/KeyReveal.tsx +78 -0
  47. package/dashboard/src/components/Lamp.tsx +8 -0
  48. package/dashboard/src/components/LogTable.tsx +223 -0
  49. package/dashboard/src/components/LogoutButton.tsx +20 -0
  50. package/dashboard/src/components/ModelPicker.tsx +121 -0
  51. package/dashboard/src/components/ModelSelectModal.tsx +126 -0
  52. package/dashboard/src/components/PasswordEditor.tsx +86 -0
  53. package/dashboard/src/components/PricingEditor.tsx +171 -0
  54. package/dashboard/src/components/ProviderDetail.tsx +566 -0
  55. package/dashboard/src/components/ProviderManager.tsx +311 -0
  56. package/dashboard/src/components/QuotaView.tsx +78 -0
  57. package/dashboard/src/components/Rail.tsx +82 -0
  58. package/dashboard/src/components/RichCard.tsx +46 -0
  59. package/dashboard/src/components/RoutingView.tsx +329 -0
  60. package/dashboard/src/components/ThemeProvider.tsx +36 -0
  61. package/dashboard/src/components/ToastProvider.tsx +58 -0
  62. package/dashboard/src/components/ToolDetail.tsx +475 -0
  63. package/dashboard/src/components/TopBar.tsx +128 -0
  64. package/dashboard/src/components/UsageView.tsx +151 -0
  65. package/dashboard/src/components/ui.tsx +54 -0
  66. package/dashboard/src/lib/capabilities.ts +318 -0
  67. package/dashboard/src/lib/cliTools.ts +120 -0
  68. package/dashboard/src/lib/client.ts +190 -0
  69. package/dashboard/src/lib/gateway.ts +269 -0
  70. package/dashboard/src/lib/session.ts +71 -0
  71. package/dashboard/src/middleware.ts +37 -0
  72. package/dashboard/tsconfig.json +21 -0
  73. package/dist/adapters/anthropic.js +289 -0
  74. package/dist/adapters/anthropic.js.map +1 -0
  75. package/dist/adapters/gemini.js +268 -0
  76. package/dist/adapters/gemini.js.map +1 -0
  77. package/dist/adapters/index.js +8 -0
  78. package/dist/adapters/index.js.map +1 -0
  79. package/dist/adapters/openai.js +13 -0
  80. package/dist/adapters/openai.js.map +1 -0
  81. package/dist/cli/tray/autostart.js +152 -0
  82. package/dist/cli/tray/autostart.js.map +1 -0
  83. package/dist/cli/tray/icon.js +4 -0
  84. package/dist/cli/tray/icon.js.map +1 -0
  85. package/dist/cli/tray/tray.js +141 -0
  86. package/dist/cli/tray/tray.js.map +1 -0
  87. package/dist/cli/tray/trayRuntime.js +91 -0
  88. package/dist/cli/tray/trayRuntime.js.map +1 -0
  89. package/dist/cli.js +361 -0
  90. package/dist/cli.js.map +1 -0
  91. package/dist/config.js +728 -0
  92. package/dist/config.js.map +1 -0
  93. package/dist/core/authStore.js +78 -0
  94. package/dist/core/authStore.js.map +1 -0
  95. package/dist/core/canonical.js +9 -0
  96. package/dist/core/canonical.js.map +1 -0
  97. package/dist/core/console-buffer.js +25 -0
  98. package/dist/core/console-buffer.js.map +1 -0
  99. package/dist/core/fallback.js +62 -0
  100. package/dist/core/fallback.js.map +1 -0
  101. package/dist/core/handler.js +174 -0
  102. package/dist/core/handler.js.map +1 -0
  103. package/dist/core/keypool.js +105 -0
  104. package/dist/core/keypool.js.map +1 -0
  105. package/dist/core/quota.js +165 -0
  106. package/dist/core/quota.js.map +1 -0
  107. package/dist/core/state.js +52 -0
  108. package/dist/core/state.js.map +1 -0
  109. package/dist/db.js +193 -0
  110. package/dist/db.js.map +1 -0
  111. package/dist/headroom/compress.js +44 -0
  112. package/dist/headroom/compress.js.map +1 -0
  113. package/dist/headroom/detect.js +108 -0
  114. package/dist/headroom/detect.js.map +1 -0
  115. package/dist/headroom/process.js +158 -0
  116. package/dist/headroom/process.js.map +1 -0
  117. package/dist/inject/caveman.js +30 -0
  118. package/dist/inject/caveman.js.map +1 -0
  119. package/dist/inject/index.js +24 -0
  120. package/dist/inject/index.js.map +1 -0
  121. package/dist/inject/ponytail.js +19 -0
  122. package/dist/inject/ponytail.js.map +1 -0
  123. package/dist/middleware/auth.js +66 -0
  124. package/dist/middleware/auth.js.map +1 -0
  125. package/dist/providers/capabilities.js +246 -0
  126. package/dist/providers/capabilities.js.map +1 -0
  127. package/dist/providers/free.js +43 -0
  128. package/dist/providers/free.js.map +1 -0
  129. package/dist/providers/pricing.js +224 -0
  130. package/dist/providers/pricing.js.map +1 -0
  131. package/dist/providers/vertex.js +97 -0
  132. package/dist/providers/vertex.js.map +1 -0
  133. package/dist/routes/admin.js +622 -0
  134. package/dist/routes/admin.js.map +1 -0
  135. package/dist/routes/health.js +4 -0
  136. package/dist/routes/health.js.map +1 -0
  137. package/dist/routes/index.js +12 -0
  138. package/dist/routes/index.js.map +1 -0
  139. package/dist/routes/v1.js +75 -0
  140. package/dist/routes/v1.js.map +1 -0
  141. package/dist/rtk/detect.js +50 -0
  142. package/dist/rtk/detect.js.map +1 -0
  143. package/dist/rtk/filters.js +85 -0
  144. package/dist/rtk/filters.js.map +1 -0
  145. package/dist/rtk/index.js +39 -0
  146. package/dist/rtk/index.js.map +1 -0
  147. package/dist/server.js +100 -0
  148. package/dist/server.js.map +1 -0
  149. package/dist/stream/anthropic-stream.js +239 -0
  150. package/dist/stream/anthropic-stream.js.map +1 -0
  151. package/dist/stream/chunk.js +7 -0
  152. package/dist/stream/chunk.js.map +1 -0
  153. package/dist/stream/gemini-stream.js +135 -0
  154. package/dist/stream/gemini-stream.js.map +1 -0
  155. package/dist/stream/index.js +12 -0
  156. package/dist/stream/index.js.map +1 -0
  157. package/dist/stream/openai-stream.js +34 -0
  158. package/dist/stream/openai-stream.js.map +1 -0
  159. package/dist/stream/sse.js +64 -0
  160. package/dist/stream/sse.js.map +1 -0
  161. package/dist/translator/thinking.js +70 -0
  162. package/dist/translator/thinking.js.map +1 -0
  163. package/dist/translator/thinkingUnified.js +322 -0
  164. package/dist/translator/thinkingUnified.js.map +1 -0
  165. package/dist/upstream/client.js +120 -0
  166. package/dist/upstream/client.js.map +1 -0
  167. package/package.json +76 -0
  168. package/run.sh +27 -0
  169. package/src/adapters/anthropic.ts +377 -0
  170. package/src/adapters/gemini.ts +341 -0
  171. package/src/adapters/index.ts +17 -0
  172. package/src/adapters/openai.ts +22 -0
  173. package/src/cli/tray/autostart.ts +133 -0
  174. package/src/cli/tray/icon.ts +4 -0
  175. package/src/cli/tray/tray.ts +156 -0
  176. package/src/cli/tray/trayRuntime.ts +90 -0
  177. package/src/cli.ts +379 -0
  178. package/src/config.ts +777 -0
  179. package/src/core/authStore.ts +86 -0
  180. package/src/core/canonical.ts +93 -0
  181. package/src/core/console-buffer.ts +39 -0
  182. package/src/core/fallback.ts +116 -0
  183. package/src/core/handler.ts +236 -0
  184. package/src/core/keypool.ts +152 -0
  185. package/src/core/quota.ts +214 -0
  186. package/src/core/state.ts +65 -0
  187. package/src/db.ts +280 -0
  188. package/src/headroom/compress.ts +78 -0
  189. package/src/headroom/detect.ts +119 -0
  190. package/src/headroom/process.ts +166 -0
  191. package/src/inject/caveman.ts +35 -0
  192. package/src/inject/index.ts +46 -0
  193. package/src/inject/ponytail.ts +31 -0
  194. package/src/middleware/auth.ts +76 -0
  195. package/src/providers/capabilities.ts +297 -0
  196. package/src/providers/free.ts +53 -0
  197. package/src/providers/pricing.ts +261 -0
  198. package/src/providers/vertex.ts +117 -0
  199. package/src/routes/admin.ts +716 -0
  200. package/src/routes/health.ts +5 -0
  201. package/src/routes/index.ts +24 -0
  202. package/src/routes/v1.ts +87 -0
  203. package/src/rtk/detect.ts +55 -0
  204. package/src/rtk/filters.ts +94 -0
  205. package/src/rtk/index.ts +58 -0
  206. package/src/server.ts +108 -0
  207. package/src/stream/anthropic-stream.ts +310 -0
  208. package/src/stream/chunk.ts +46 -0
  209. package/src/stream/gemini-stream.ts +158 -0
  210. package/src/stream/index.ts +23 -0
  211. package/src/stream/openai-stream.ts +41 -0
  212. package/src/stream/sse.ts +72 -0
  213. package/src/translator/thinking.ts +64 -0
  214. package/src/translator/thinkingUnified.ts +319 -0
  215. package/src/upstream/client.ts +155 -0
  216. package/tsconfig.json +20 -0
@@ -0,0 +1,377 @@
1
+ /**
2
+ * Anthropic Messages API <-> canonical (OpenAI) translation, non-streaming.
3
+ *
4
+ * - requestToCanonical: client speaks Anthropic -> canonical (ingress)
5
+ * - requestFromCanonical: canonical -> Anthropic provider (egress)
6
+ * - responseToCanonical: Anthropic provider reply -> canonical
7
+ * - responseFromCanonical: canonical -> Anthropic client reply
8
+ */
9
+ import type {
10
+ CanonicalContentPart,
11
+ CanonicalMessage,
12
+ CanonicalRequest,
13
+ CanonicalResponse,
14
+ CanonicalToolCall,
15
+ CanonicalToolDef,
16
+ FinishReason,
17
+ } from "../core/canonical.js";
18
+
19
+ const TOOL_ID_RE = /^[a-zA-Z0-9_-]+$/;
20
+
21
+ /** Anthropic requires tool ids match ^[a-zA-Z0-9_-]+$; coerce if needed. */
22
+ function sanitizeToolId(id: string, fallback: string): string {
23
+ if (id && TOOL_ID_RE.test(id)) return id;
24
+ const cleaned = (id || "").replace(/[^a-zA-Z0-9_-]/g, "");
25
+ return cleaned || fallback;
26
+ }
27
+
28
+ interface AnthropicTextBlock {
29
+ type: "text";
30
+ text: string;
31
+ }
32
+ interface AnthropicImageBlock {
33
+ type: "image";
34
+ source: { type: "base64"; media_type: string; data: string };
35
+ }
36
+ interface AnthropicToolUseBlock {
37
+ type: "tool_use";
38
+ id: string;
39
+ name: string;
40
+ input: unknown;
41
+ }
42
+ interface AnthropicToolResultBlock {
43
+ type: "tool_result";
44
+ tool_use_id: string;
45
+ content: string | Array<{ type: string; text?: string }>;
46
+ is_error?: boolean;
47
+ }
48
+ type AnthropicBlock =
49
+ | AnthropicTextBlock
50
+ | AnthropicImageBlock
51
+ | AnthropicToolUseBlock
52
+ | AnthropicToolResultBlock;
53
+
54
+ interface AnthropicMessage {
55
+ role: "user" | "assistant";
56
+ content: string | AnthropicBlock[];
57
+ }
58
+
59
+ interface AnthropicRequest {
60
+ model: string;
61
+ messages: AnthropicMessage[];
62
+ system?: string | AnthropicTextBlock[];
63
+ max_tokens: number;
64
+ stream?: boolean;
65
+ temperature?: number;
66
+ top_p?: number;
67
+ stop_sequences?: string[];
68
+ tools?: Array<{ name: string; description?: string; input_schema: Record<string, unknown> }>;
69
+ tool_choice?: unknown;
70
+ }
71
+
72
+ // ---------- ingress: Anthropic request -> canonical ----------
73
+
74
+ function systemToMessage(system: AnthropicRequest["system"]): CanonicalMessage | null {
75
+ if (!system) return null;
76
+ const text = typeof system === "string" ? system : system.map((b) => b.text).join("\n");
77
+ if (!text) return null;
78
+ return { role: "system", content: text };
79
+ }
80
+
81
+ function anthropicContentToCanonical(content: string | AnthropicBlock[]): {
82
+ parts: string | CanonicalContentPart[] | null;
83
+ toolCalls: CanonicalToolCall[];
84
+ toolResults: Array<{ id: string; content: string }>;
85
+ } {
86
+ if (typeof content === "string") {
87
+ return { parts: content, toolCalls: [], toolResults: [] };
88
+ }
89
+
90
+ const parts: CanonicalContentPart[] = [];
91
+ const toolCalls: CanonicalToolCall[] = [];
92
+ const toolResults: Array<{ id: string; content: string }> = [];
93
+
94
+ for (const block of content) {
95
+ switch (block.type) {
96
+ case "text":
97
+ parts.push({ type: "text", text: block.text });
98
+ break;
99
+ case "image":
100
+ parts.push({
101
+ type: "image_url",
102
+ image_url: { url: `data:${block.source.media_type};base64,${block.source.data}` },
103
+ });
104
+ break;
105
+ case "tool_use":
106
+ toolCalls.push({
107
+ id: block.id,
108
+ type: "function",
109
+ function: { name: block.name, arguments: JSON.stringify(block.input ?? {}) },
110
+ });
111
+ break;
112
+ case "tool_result": {
113
+ const text =
114
+ typeof block.content === "string"
115
+ ? block.content
116
+ : block.content.map((c) => (c.type === "text" ? (c.text ?? "") : "")).join("");
117
+ toolResults.push({ id: block.tool_use_id, content: text });
118
+ break;
119
+ }
120
+ }
121
+ }
122
+
123
+ const textOnly =
124
+ parts.length > 0 && parts.every((p) => p.type === "text")
125
+ ? parts.map((p) => (p as CanonicalTextPartLike).text).join("")
126
+ : parts.length > 0
127
+ ? parts
128
+ : null;
129
+
130
+ return { parts: textOnly, toolCalls, toolResults };
131
+ }
132
+
133
+ type CanonicalTextPartLike = { text: string };
134
+
135
+ export function requestToCanonical(body: unknown): CanonicalRequest {
136
+ const req = body as AnthropicRequest;
137
+ const messages: CanonicalMessage[] = [];
138
+
139
+ const sys = systemToMessage(req.system);
140
+ if (sys) messages.push(sys);
141
+
142
+ for (const m of req.messages) {
143
+ const { parts, toolCalls, toolResults } = anthropicContentToCanonical(m.content);
144
+
145
+ // tool_result blocks become separate role="tool" messages
146
+ for (const tr of toolResults) {
147
+ messages.push({ role: "tool", tool_call_id: tr.id, content: tr.content });
148
+ }
149
+
150
+ if (m.role === "assistant") {
151
+ const msg: CanonicalMessage = { role: "assistant", content: parts };
152
+ if (toolCalls.length > 0) msg.tool_calls = toolCalls;
153
+ if (parts !== null || toolCalls.length > 0) messages.push(msg);
154
+ } else if (parts !== null) {
155
+ messages.push({ role: "user", content: parts });
156
+ }
157
+ }
158
+
159
+ const tools: CanonicalToolDef[] | undefined = req.tools?.map((t) => ({
160
+ type: "function",
161
+ function: { name: t.name, description: t.description, parameters: t.input_schema },
162
+ }));
163
+
164
+ const canonical: CanonicalRequest = {
165
+ model: req.model,
166
+ messages,
167
+ stream: req.stream,
168
+ max_tokens: req.max_tokens,
169
+ };
170
+ if (req.temperature !== undefined) canonical.temperature = req.temperature;
171
+ if (req.top_p !== undefined) canonical.top_p = req.top_p;
172
+ if (req.stop_sequences) canonical.stop = req.stop_sequences;
173
+ if (tools) canonical.tools = tools;
174
+ if (req.tool_choice !== undefined) canonical.tool_choice = req.tool_choice;
175
+
176
+ return canonical;
177
+ }
178
+
179
+ // ---------- egress: canonical -> Anthropic request ----------
180
+
181
+ function canonicalContentToBlocks(content: CanonicalMessage["content"]): AnthropicBlock[] {
182
+ if (content === null) return [];
183
+ if (typeof content === "string") return content ? [{ type: "text", text: content }] : [];
184
+ return content.map((p): AnthropicBlock => {
185
+ if (p.type === "text") return { type: "text", text: p.text };
186
+ const m = /^data:([^;]+);base64,(.*)$/.exec(p.image_url.url);
187
+ if (m) return { type: "image", source: { type: "base64", media_type: m[1]!, data: m[2]! } };
188
+ return { type: "text", text: "" };
189
+ });
190
+ }
191
+
192
+ export function requestFromCanonical(req: CanonicalRequest): unknown {
193
+ const systemParts: string[] = [];
194
+ const messages: AnthropicMessage[] = [];
195
+
196
+ for (const m of req.messages) {
197
+ if (m.role === "system") {
198
+ if (typeof m.content === "string") systemParts.push(m.content);
199
+ continue;
200
+ }
201
+ if (m.role === "tool") {
202
+ messages.push({
203
+ role: "user",
204
+ content: [
205
+ {
206
+ type: "tool_result",
207
+ tool_use_id: m.tool_call_id ?? "",
208
+ content: typeof m.content === "string" ? m.content : JSON.stringify(m.content),
209
+ },
210
+ ],
211
+ });
212
+ continue;
213
+ }
214
+
215
+ const blocks = canonicalContentToBlocks(m.content);
216
+ if (m.role === "assistant" && m.tool_calls) {
217
+ for (let i = 0; i < m.tool_calls.length; i++) {
218
+ const tc = m.tool_calls[i]!;
219
+ let input: unknown = {};
220
+ try {
221
+ input = tc.function.arguments ? JSON.parse(tc.function.arguments) : {};
222
+ } catch {
223
+ input = {};
224
+ }
225
+ blocks.push({ type: "tool_use", id: sanitizeToolId(tc.id, `call_${i}`), name: tc.function.name, input });
226
+ }
227
+ }
228
+ if (blocks.length > 0) {
229
+ messages.push({ role: m.role as "user" | "assistant", content: blocks });
230
+ }
231
+ }
232
+
233
+ const out: AnthropicRequest = {
234
+ model: req.model,
235
+ messages,
236
+ max_tokens: req.max_tokens ?? 4096,
237
+ };
238
+ if (systemParts.length > 0) out.system = systemParts.join("\n");
239
+ if (req.temperature !== undefined) out.temperature = req.temperature;
240
+ if (req.top_p !== undefined) out.top_p = req.top_p;
241
+ if (req.stop !== undefined) out.stop_sequences = Array.isArray(req.stop) ? req.stop : [req.stop];
242
+ if (req.stream !== undefined) out.stream = req.stream;
243
+ if (req.tools) {
244
+ out.tools = req.tools.map((t) => ({
245
+ name: t.function.name,
246
+ description: t.function.description,
247
+ input_schema: t.function.parameters ?? { type: "object", properties: {} },
248
+ }));
249
+ }
250
+ if (req.tool_choice !== undefined) out.tool_choice = req.tool_choice;
251
+
252
+ return out;
253
+ }
254
+
255
+ // ---------- response: Anthropic reply -> canonical ----------
256
+
257
+ interface AnthropicResponse {
258
+ id: string;
259
+ model: string;
260
+ role: "assistant";
261
+ content: Array<AnthropicTextBlock | AnthropicToolUseBlock>;
262
+ stop_reason: string | null;
263
+ usage?: {
264
+ input_tokens: number;
265
+ output_tokens: number;
266
+ cache_read_input_tokens?: number;
267
+ cache_creation_input_tokens?: number;
268
+ };
269
+ }
270
+
271
+ function mapStopReason(reason: string | null): FinishReason {
272
+ switch (reason) {
273
+ case "end_turn":
274
+ case "stop_sequence":
275
+ return "stop";
276
+ case "max_tokens":
277
+ return "length";
278
+ case "tool_use":
279
+ return "tool_calls";
280
+ default:
281
+ return null;
282
+ }
283
+ }
284
+
285
+ export function responseToCanonical(resp: unknown): CanonicalResponse {
286
+ const r = resp as AnthropicResponse;
287
+ let text = "";
288
+ const toolCalls: CanonicalToolCall[] = [];
289
+
290
+ for (const block of r.content ?? []) {
291
+ if (block.type === "text") text += block.text;
292
+ else if (block.type === "tool_use") {
293
+ toolCalls.push({
294
+ id: block.id,
295
+ type: "function",
296
+ function: { name: block.name, arguments: JSON.stringify(block.input ?? {}) },
297
+ });
298
+ }
299
+ }
300
+
301
+ const message: CanonicalMessage = { role: "assistant", content: text || null };
302
+ if (toolCalls.length > 0) message.tool_calls = toolCalls;
303
+
304
+ return {
305
+ id: r.id,
306
+ model: r.model,
307
+ created: 0,
308
+ choices: [{ index: 0, message, finish_reason: mapStopReason(r.stop_reason) }],
309
+ usage: r.usage
310
+ ? {
311
+ prompt_tokens: r.usage.input_tokens,
312
+ completion_tokens: r.usage.output_tokens,
313
+ total_tokens: r.usage.input_tokens + r.usage.output_tokens,
314
+ cached_tokens: r.usage.cache_read_input_tokens,
315
+ cache_creation_tokens: r.usage.cache_creation_input_tokens,
316
+ }
317
+ : undefined,
318
+ };
319
+ }
320
+
321
+ // ---------- response: canonical -> Anthropic reply ----------
322
+
323
+ function reverseStopReason(reason: FinishReason): string | null {
324
+ switch (reason) {
325
+ case "stop":
326
+ return "end_turn";
327
+ case "length":
328
+ return "max_tokens";
329
+ case "tool_calls":
330
+ return "tool_use";
331
+ default:
332
+ return null;
333
+ }
334
+ }
335
+
336
+ export function responseFromCanonical(resp: CanonicalResponse): unknown {
337
+ const choice = resp.choices[0];
338
+ const msg = choice?.message;
339
+ const content: Array<AnthropicTextBlock | AnthropicToolUseBlock> = [];
340
+
341
+ if (msg) {
342
+ if (typeof msg.content === "string" && msg.content) {
343
+ content.push({ type: "text", text: msg.content });
344
+ } else if (Array.isArray(msg.content)) {
345
+ for (const p of msg.content) if (p.type === "text") content.push({ type: "text", text: p.text });
346
+ }
347
+ if (msg.tool_calls) {
348
+ for (let i = 0; i < msg.tool_calls.length; i++) {
349
+ const tc = msg.tool_calls[i]!;
350
+ let input: unknown = {};
351
+ try {
352
+ input = tc.function.arguments ? JSON.parse(tc.function.arguments) : {};
353
+ } catch {
354
+ input = {};
355
+ }
356
+ content.push({ type: "tool_use", id: sanitizeToolId(tc.id, `call_${i}`), name: tc.function.name, input });
357
+ }
358
+ }
359
+ }
360
+
361
+ return {
362
+ id: resp.id,
363
+ type: "message",
364
+ role: "assistant",
365
+ model: resp.model,
366
+ content,
367
+ stop_reason: reverseStopReason(choice?.finish_reason ?? null),
368
+ stop_sequence: null,
369
+ usage: {
370
+ input_tokens: resp.usage?.prompt_tokens ?? 0,
371
+ output_tokens: resp.usage?.completion_tokens ?? 0,
372
+ ...(resp.usage?.cached_tokens !== undefined
373
+ ? { cache_read_input_tokens: resp.usage.cached_tokens }
374
+ : {}),
375
+ },
376
+ };
377
+ }
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Google Gemini (generateContent) <-> canonical (OpenAI) translation, non-streaming.
3
+ *
4
+ * Gemini differs structurally from both pivots:
5
+ * - messages live in `contents: [{ role, parts: [...] }]`, role is user|model
6
+ * - the system prompt is a separate `systemInstruction`, not a message
7
+ * - tool calls are `parts: [{ functionCall: { name, args } }]`
8
+ * - tool results are `parts: [{ functionResponse: { name, response } }]`
9
+ * - tuning lives under `generationConfig`; usage under `usageMetadata`
10
+ */
11
+ import type {
12
+ CanonicalContentPart,
13
+ CanonicalMessage,
14
+ CanonicalRequest,
15
+ CanonicalResponse,
16
+ CanonicalToolCall,
17
+ FinishReason,
18
+ } from "../core/canonical.js";
19
+
20
+ interface GeminiTextPart {
21
+ text: string;
22
+ }
23
+ interface GeminiInlineDataPart {
24
+ inlineData: { mimeType: string; data: string };
25
+ }
26
+ interface GeminiFunctionCallPart {
27
+ functionCall: { name: string; args: Record<string, unknown> };
28
+ }
29
+ interface GeminiFunctionResponsePart {
30
+ functionResponse: { name: string; response: Record<string, unknown> };
31
+ }
32
+ type GeminiPart =
33
+ | GeminiTextPart
34
+ | GeminiInlineDataPart
35
+ | GeminiFunctionCallPart
36
+ | GeminiFunctionResponsePart;
37
+
38
+ interface GeminiContent {
39
+ role: "user" | "model";
40
+ parts: GeminiPart[];
41
+ }
42
+
43
+ interface GeminiRequest {
44
+ contents: GeminiContent[];
45
+ systemInstruction?: { parts: GeminiTextPart[] };
46
+ generationConfig?: {
47
+ maxOutputTokens?: number;
48
+ temperature?: number;
49
+ topP?: number;
50
+ stopSequences?: string[];
51
+ };
52
+ tools?: Array<{
53
+ functionDeclarations: Array<{
54
+ name: string;
55
+ description?: string;
56
+ parameters?: Record<string, unknown>;
57
+ }>;
58
+ }>;
59
+ }
60
+
61
+ interface GeminiResponse {
62
+ candidates?: Array<{
63
+ content?: { role?: string; parts?: GeminiPart[] };
64
+ finishReason?: string;
65
+ }>;
66
+ usageMetadata?: {
67
+ promptTokenCount?: number;
68
+ candidatesTokenCount?: number;
69
+ cachedContentTokenCount?: number;
70
+ };
71
+ modelVersion?: string;
72
+ }
73
+
74
+ const isText = (p: GeminiPart): p is GeminiTextPart => typeof (p as GeminiTextPart).text === "string";
75
+ const isCall = (p: GeminiPart): p is GeminiFunctionCallPart => !!(p as GeminiFunctionCallPart).functionCall;
76
+ const isResp = (p: GeminiPart): p is GeminiFunctionResponsePart =>
77
+ !!(p as GeminiFunctionResponsePart).functionResponse;
78
+
79
+ function dataUrlToInline(url: string): GeminiInlineDataPart | null {
80
+ const m = /^data:([^;]+);base64,(.*)$/.exec(url);
81
+ if (!m) return null;
82
+ return { inlineData: { mimeType: m[1]!, data: m[2]! } };
83
+ }
84
+
85
+ function mapFinish(reason: string | undefined): FinishReason {
86
+ switch (reason) {
87
+ case "STOP":
88
+ return "stop";
89
+ case "MAX_TOKENS":
90
+ return "length";
91
+ case "SAFETY":
92
+ case "RECITATION":
93
+ return "content_filter";
94
+ default:
95
+ return reason ? "stop" : null;
96
+ }
97
+ }
98
+
99
+ // ---------- ingress: Gemini request -> canonical ----------
100
+
101
+ export function requestToCanonical(body: unknown): CanonicalRequest {
102
+ const req = body as GeminiRequest & { model?: string };
103
+ const messages: CanonicalMessage[] = [];
104
+
105
+ const sysText = req.systemInstruction?.parts?.map((p) => p.text).join("\n");
106
+ if (sysText) messages.push({ role: "system", content: sysText });
107
+
108
+ for (const c of req.contents ?? []) {
109
+ const textParts: CanonicalContentPart[] = [];
110
+ const toolCalls: CanonicalToolCall[] = [];
111
+ let toolResult: { name: string; content: string } | null = null;
112
+
113
+ for (const p of c.parts ?? []) {
114
+ if (isText(p)) textParts.push({ type: "text", text: p.text });
115
+ else if (isCall(p))
116
+ toolCalls.push({
117
+ id: `call_${p.functionCall.name}_${toolCalls.length}`,
118
+ type: "function",
119
+ function: { name: p.functionCall.name, arguments: JSON.stringify(p.functionCall.args ?? {}) },
120
+ });
121
+ else if (isResp(p))
122
+ toolResult = {
123
+ name: p.functionResponse.name,
124
+ content: JSON.stringify(p.functionResponse.response ?? {}),
125
+ };
126
+ else if ((p as GeminiInlineDataPart).inlineData) {
127
+ const d = (p as GeminiInlineDataPart).inlineData;
128
+ textParts.push({ type: "image_url", image_url: { url: `data:${d.mimeType};base64,${d.data}` } });
129
+ }
130
+ }
131
+
132
+ if (toolResult) {
133
+ messages.push({
134
+ role: "tool",
135
+ tool_call_id: toolResult.name,
136
+ name: toolResult.name,
137
+ content: toolResult.content,
138
+ });
139
+ continue;
140
+ }
141
+
142
+ const role = c.role === "model" ? "assistant" : "user";
143
+ const textOnly =
144
+ textParts.length > 0 && textParts.every((p) => p.type === "text")
145
+ ? textParts.map((p) => (p as { text: string }).text).join("")
146
+ : textParts.length > 0
147
+ ? textParts
148
+ : null;
149
+ const msg: CanonicalMessage = { role, content: textOnly };
150
+ if (toolCalls.length > 0) msg.tool_calls = toolCalls;
151
+ if (textOnly !== null || toolCalls.length > 0) messages.push(msg);
152
+ }
153
+
154
+ const out: CanonicalRequest = { model: req.model ?? "", messages };
155
+ if (req.generationConfig) {
156
+ const g = req.generationConfig;
157
+ if (g.maxOutputTokens !== undefined) out.max_tokens = g.maxOutputTokens;
158
+ if (g.temperature !== undefined) out.temperature = g.temperature;
159
+ if (g.topP !== undefined) out.top_p = g.topP;
160
+ if (g.stopSequences) out.stop = g.stopSequences;
161
+ }
162
+ const decls = req.tools?.flatMap((t) => t.functionDeclarations ?? []);
163
+ if (decls && decls.length > 0) {
164
+ out.tools = decls.map((d) => ({
165
+ type: "function",
166
+ function: { name: d.name, description: d.description, parameters: d.parameters },
167
+ }));
168
+ }
169
+ return out;
170
+ }
171
+
172
+ // ---------- egress: canonical -> Gemini request ----------
173
+
174
+ export function requestFromCanonical(req: CanonicalRequest): unknown {
175
+ const contents: GeminiContent[] = [];
176
+ const systemParts: GeminiTextPart[] = [];
177
+ // map tool_call_id -> function name, to label functionResponse
178
+ const idToName = new Map<string, string>();
179
+ for (const m of req.messages) {
180
+ for (const tc of m.tool_calls ?? []) idToName.set(tc.id, tc.function.name);
181
+ }
182
+
183
+ for (const m of req.messages) {
184
+ if (m.role === "system") {
185
+ if (typeof m.content === "string" && m.content) systemParts.push({ text: m.content });
186
+ continue;
187
+ }
188
+
189
+ if (m.role === "tool") {
190
+ const name = m.name ?? (m.tool_call_id ? idToName.get(m.tool_call_id) : undefined) ?? "tool";
191
+ let response: Record<string, unknown>;
192
+ try {
193
+ const parsed = typeof m.content === "string" ? JSON.parse(m.content) : m.content;
194
+ response =
195
+ parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : { result: parsed };
196
+ } catch {
197
+ response = { result: typeof m.content === "string" ? m.content : "" };
198
+ }
199
+ contents.push({ role: "user", parts: [{ functionResponse: { name, response } }] });
200
+ continue;
201
+ }
202
+
203
+ const parts: GeminiPart[] = [];
204
+ if (typeof m.content === "string") {
205
+ if (m.content) parts.push({ text: m.content });
206
+ } else if (Array.isArray(m.content)) {
207
+ for (const p of m.content) {
208
+ if (p.type === "text") parts.push({ text: p.text });
209
+ else {
210
+ const inline = dataUrlToInline(p.image_url.url);
211
+ if (inline) parts.push(inline);
212
+ }
213
+ }
214
+ }
215
+ for (const tc of m.tool_calls ?? []) {
216
+ let args: Record<string, unknown> = {};
217
+ try {
218
+ args = tc.function.arguments ? JSON.parse(tc.function.arguments) : {};
219
+ } catch {
220
+ args = {};
221
+ }
222
+ parts.push({ functionCall: { name: tc.function.name, args } });
223
+ }
224
+ if (parts.length > 0) {
225
+ contents.push({ role: m.role === "assistant" ? "model" : "user", parts });
226
+ }
227
+ }
228
+
229
+ const out: GeminiRequest = { contents };
230
+ if (systemParts.length > 0) out.systemInstruction = { parts: systemParts };
231
+
232
+ const gen: NonNullable<GeminiRequest["generationConfig"]> = {};
233
+ if (req.max_tokens !== undefined) gen.maxOutputTokens = req.max_tokens;
234
+ if (req.temperature !== undefined) gen.temperature = req.temperature;
235
+ if (req.top_p !== undefined) gen.topP = req.top_p;
236
+ if (req.stop !== undefined) gen.stopSequences = Array.isArray(req.stop) ? req.stop : [req.stop];
237
+ if (Object.keys(gen).length > 0) out.generationConfig = gen;
238
+
239
+ if (req.tools && req.tools.length > 0) {
240
+ out.tools = [
241
+ {
242
+ functionDeclarations: req.tools.map((t) => ({
243
+ name: t.function.name,
244
+ description: t.function.description,
245
+ parameters: t.function.parameters,
246
+ })),
247
+ },
248
+ ];
249
+ }
250
+ return out;
251
+ }
252
+
253
+ // ---------- response: Gemini reply -> canonical ----------
254
+
255
+ export function responseToCanonical(resp: unknown): CanonicalResponse {
256
+ const r = resp as GeminiResponse;
257
+ const cand = r.candidates?.[0];
258
+ let text = "";
259
+ const toolCalls: CanonicalToolCall[] = [];
260
+
261
+ for (const p of cand?.content?.parts ?? []) {
262
+ if (isText(p)) text += p.text;
263
+ else if (isCall(p))
264
+ toolCalls.push({
265
+ id: `call_${p.functionCall.name}_${toolCalls.length}`,
266
+ type: "function",
267
+ function: { name: p.functionCall.name, arguments: JSON.stringify(p.functionCall.args ?? {}) },
268
+ });
269
+ }
270
+
271
+ const message: CanonicalMessage = { role: "assistant", content: text || null };
272
+ if (toolCalls.length > 0) message.tool_calls = toolCalls;
273
+
274
+ const u = r.usageMetadata;
275
+ return {
276
+ id: "gemini",
277
+ model: r.modelVersion ?? "",
278
+ created: 0,
279
+ choices: [
280
+ {
281
+ index: 0,
282
+ message,
283
+ finish_reason: toolCalls.length > 0 ? "tool_calls" : mapFinish(cand?.finishReason),
284
+ },
285
+ ],
286
+ usage: u
287
+ ? {
288
+ prompt_tokens: u.promptTokenCount ?? 0,
289
+ completion_tokens: u.candidatesTokenCount ?? 0,
290
+ total_tokens: (u.promptTokenCount ?? 0) + (u.candidatesTokenCount ?? 0),
291
+ cached_tokens: u.cachedContentTokenCount,
292
+ }
293
+ : undefined,
294
+ };
295
+ }
296
+
297
+ // ---------- response: canonical -> Gemini reply ----------
298
+
299
+ function reverseFinish(reason: FinishReason): string {
300
+ switch (reason) {
301
+ case "length":
302
+ return "MAX_TOKENS";
303
+ case "content_filter":
304
+ return "SAFETY";
305
+ default:
306
+ return "STOP";
307
+ }
308
+ }
309
+
310
+ export function responseFromCanonical(resp: CanonicalResponse): unknown {
311
+ const choice = resp.choices[0];
312
+ const msg = choice?.message;
313
+ const parts: GeminiPart[] = [];
314
+
315
+ if (msg) {
316
+ if (typeof msg.content === "string" && msg.content) parts.push({ text: msg.content });
317
+ else if (Array.isArray(msg.content))
318
+ for (const p of msg.content) if (p.type === "text") parts.push({ text: p.text });
319
+ for (const tc of msg.tool_calls ?? []) {
320
+ let args: Record<string, unknown> = {};
321
+ try {
322
+ args = tc.function.arguments ? JSON.parse(tc.function.arguments) : {};
323
+ } catch {
324
+ args = {};
325
+ }
326
+ parts.push({ functionCall: { name: tc.function.name, args } });
327
+ }
328
+ }
329
+
330
+ return {
331
+ candidates: [
332
+ { content: { role: "model", parts }, finishReason: reverseFinish(choice?.finish_reason ?? null), index: 0 },
333
+ ],
334
+ usageMetadata: {
335
+ promptTokenCount: resp.usage?.prompt_tokens ?? 0,
336
+ candidatesTokenCount: resp.usage?.completion_tokens ?? 0,
337
+ totalTokenCount: resp.usage?.total_tokens ?? 0,
338
+ },
339
+ modelVersion: resp.model,
340
+ };
341
+ }