@arcote.tech/arc-ai-gemini 0.4.6

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/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@arcote.tech/arc-ai-gemini",
3
+ "type": "module",
4
+ "version": "0.4.6",
5
+ "private": false,
6
+ "description": "Gemini (Google) adapter for Arc AI framework",
7
+ "main": "./src/index.ts",
8
+ "types": "./src/index.ts",
9
+ "scripts": {
10
+ "type-check": "tsc --noEmit"
11
+ },
12
+ "peerDependencies": {
13
+ "@arcote.tech/arc-ai": "^0.4.6",
14
+ "typescript": "^5.0.0"
15
+ },
16
+ "devDependencies": {
17
+ "@types/bun": "latest"
18
+ }
19
+ }
package/src/index.ts ADDED
@@ -0,0 +1,263 @@
1
+ import type {
2
+ LLMProvider,
3
+ CompletionRequest,
4
+ CompletionResult,
5
+ StreamChunk,
6
+ ToolCall,
7
+ TokenUsage,
8
+ FinishReason,
9
+ } from "@arcote.tech/arc-ai";
10
+
11
+ // ─── Config ──────────────────────────────────────────────────────
12
+
13
+ export interface GeminiConfig {
14
+ apiKey: string;
15
+ }
16
+
17
+ // ─── Adapter ─────────────────────────────────────────────────────
18
+
19
+ export function gemini(config: GeminiConfig): LLMProvider {
20
+ const baseUrl = "https://generativelanguage.googleapis.com/v1beta";
21
+
22
+ function generateToolCallId(): string {
23
+ return `tc_${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`;
24
+ }
25
+
26
+ function translateTools(
27
+ tools: CompletionRequest["tools"],
28
+ ): unknown[] | undefined {
29
+ if (!tools || tools.length === 0) return undefined;
30
+ return [
31
+ {
32
+ functionDeclarations: tools.map((t) => ({
33
+ name: t.name,
34
+ description: t.description,
35
+ parameters: t.parameters,
36
+ })),
37
+ },
38
+ ];
39
+ }
40
+
41
+ function buildContents(messages: CompletionRequest["messages"]) {
42
+ const systemMessages = messages.filter((m) => m.role === "system");
43
+ const nonSystemMessages = messages.filter((m) => m.role !== "system");
44
+
45
+ const systemInstruction = systemMessages.length
46
+ ? { parts: [{ text: systemMessages.map((m) => m.content).join("\n\n") }] }
47
+ : undefined;
48
+
49
+ const contents = nonSystemMessages.map((m) => {
50
+ if (m.role === "tool") {
51
+ return {
52
+ role: "user",
53
+ parts: [
54
+ {
55
+ functionResponse: {
56
+ name: m.name ?? "unknown",
57
+ response: { result: m.content },
58
+ },
59
+ },
60
+ ],
61
+ };
62
+ }
63
+
64
+ return {
65
+ role: m.role === "assistant" ? "model" : "user",
66
+ parts: [{ text: m.content }],
67
+ };
68
+ });
69
+
70
+ return { systemInstruction, contents };
71
+ }
72
+
73
+ function parseUsage(raw: any): TokenUsage {
74
+ const meta = raw.usageMetadata ?? {};
75
+ return {
76
+ inputTokens: meta.promptTokenCount ?? 0,
77
+ outputTokens: meta.candidatesTokenCount ?? 0,
78
+ totalTokens: meta.totalTokenCount ?? 0,
79
+ cachedTokens: meta.cachedContentTokenCount ?? 0,
80
+ reasoningTokens: 0,
81
+ };
82
+ }
83
+
84
+ async function complete(request: CompletionRequest): Promise<CompletionResult> {
85
+ const { systemInstruction, contents } = buildContents(request.messages);
86
+
87
+ const body: Record<string, unknown> = {
88
+ contents,
89
+ generationConfig: {
90
+ temperature: request.temperature,
91
+ maxOutputTokens: request.maxTokens,
92
+ },
93
+ };
94
+
95
+ if (systemInstruction) body.systemInstruction = systemInstruction;
96
+
97
+ const tools = translateTools(request.tools);
98
+ if (tools) body.tools = tools;
99
+
100
+ if (request.webSearch) {
101
+ body.tools = [
102
+ ...(tools ?? []),
103
+ { googleSearch: {} },
104
+ ];
105
+ }
106
+
107
+ const response = await fetch(
108
+ `${baseUrl}/models/${request.model}:generateContent?key=${config.apiKey}`,
109
+ {
110
+ method: "POST",
111
+ headers: { "Content-Type": "application/json" },
112
+ body: JSON.stringify(body),
113
+ },
114
+ );
115
+
116
+ if (!response.ok) {
117
+ const error = await response.text();
118
+ throw new Error(`Gemini API error ${response.status}: ${error}`);
119
+ }
120
+
121
+ const data = await response.json() as any;
122
+ const candidate = data.candidates?.[0];
123
+ const parts = candidate?.content?.parts ?? [];
124
+
125
+ let content = "";
126
+ const toolCalls: ToolCall[] = [];
127
+
128
+ for (const part of parts) {
129
+ if (part.text) {
130
+ content += part.text;
131
+ } else if (part.functionCall) {
132
+ toolCalls.push({
133
+ id: generateToolCallId(),
134
+ name: part.functionCall.name,
135
+ arguments: part.functionCall.args ?? {},
136
+ });
137
+ }
138
+ }
139
+
140
+ const finishReason: FinishReason =
141
+ toolCalls.length > 0 ? "tool_call" : "stop";
142
+
143
+ return {
144
+ content,
145
+ toolCalls,
146
+ usage: parseUsage(data),
147
+ finishReason,
148
+ };
149
+ }
150
+
151
+ async function streamComplete(
152
+ request: CompletionRequest,
153
+ onChunk: (chunk: StreamChunk) => void,
154
+ ): Promise<CompletionResult> {
155
+ const { systemInstruction, contents } = buildContents(request.messages);
156
+
157
+ const body: Record<string, unknown> = {
158
+ contents,
159
+ generationConfig: {
160
+ temperature: request.temperature,
161
+ maxOutputTokens: request.maxTokens,
162
+ },
163
+ };
164
+
165
+ if (systemInstruction) body.systemInstruction = systemInstruction;
166
+
167
+ const tools = translateTools(request.tools);
168
+ if (tools) body.tools = tools;
169
+
170
+ if (request.webSearch) {
171
+ body.tools = [
172
+ ...(tools ?? []),
173
+ { googleSearch: {} },
174
+ ];
175
+ }
176
+
177
+ const response = await fetch(
178
+ `${baseUrl}/models/${request.model}:streamGenerateContent?alt=sse&key=${config.apiKey}`,
179
+ {
180
+ method: "POST",
181
+ headers: { "Content-Type": "application/json" },
182
+ body: JSON.stringify(body),
183
+ },
184
+ );
185
+
186
+ if (!response.ok) {
187
+ const error = await response.text();
188
+ throw new Error(`Gemini API error ${response.status}: ${error}`);
189
+ }
190
+
191
+ let content = "";
192
+ let usage: TokenUsage = {
193
+ inputTokens: 0,
194
+ outputTokens: 0,
195
+ totalTokens: 0,
196
+ cachedTokens: 0,
197
+ reasoningTokens: 0,
198
+ };
199
+ const toolCalls: ToolCall[] = [];
200
+
201
+ const reader = response.body!.getReader();
202
+ const decoder = new TextDecoder();
203
+ let buffer = "";
204
+
205
+ while (true) {
206
+ const { done, value } = await reader.read();
207
+ if (done) break;
208
+
209
+ buffer += decoder.decode(value, { stream: true });
210
+ const lines = buffer.split("\n");
211
+ buffer = lines.pop()!;
212
+
213
+ for (const line of lines) {
214
+ if (!line.startsWith("data: ")) continue;
215
+ const data = line.slice(6).trim();
216
+
217
+ try {
218
+ const parsed = JSON.parse(data);
219
+ const parts = parsed.candidates?.[0]?.content?.parts ?? [];
220
+
221
+ for (const part of parts) {
222
+ if (part.text) {
223
+ content += part.text;
224
+ onChunk({ type: "content_delta", content: part.text });
225
+ } else if (part.functionCall) {
226
+ const tc: ToolCall = {
227
+ id: generateToolCallId(),
228
+ name: part.functionCall.name,
229
+ arguments: part.functionCall.args ?? {},
230
+ };
231
+ toolCalls.push(tc);
232
+ onChunk({ type: "tool_call_start", toolCall: tc });
233
+ }
234
+ }
235
+
236
+ // Gemini sends usage in every chunk
237
+ if (parsed.usageMetadata) {
238
+ usage = parseUsage(parsed);
239
+ }
240
+ } catch {
241
+ // Skip malformed JSON
242
+ }
243
+ }
244
+ }
245
+
246
+ const finishReason: FinishReason =
247
+ toolCalls.length > 0 ? "tool_call" : "stop";
248
+
249
+ return {
250
+ content,
251
+ toolCalls,
252
+ usage,
253
+ finishReason,
254
+ };
255
+ }
256
+
257
+ return {
258
+ name: "gemini",
259
+ models: ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash"],
260
+ complete,
261
+ streamComplete,
262
+ };
263
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../../../../tsconfig.json",
3
+ "include": ["src/**/*"]
4
+ }