@strand-js/google 0.1.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/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # @strand-js/google
2
+
3
+ Google Gemini provider adapter for [Strand](https://github.com/strand-js/strand) — AI state management for React.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @strand-js/google
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### Express / Fastify / Hono
14
+
15
+ ```ts
16
+ import { createStrandHandler } from '@strand-js/google'
17
+
18
+ app.post('/api/strand', createStrandHandler({
19
+ apiKey: process.env.GOOGLE_API_KEY,
20
+ model: 'gemini-2.0-flash',
21
+ system: 'You are a helpful assistant.',
22
+ }))
23
+ ```
24
+
25
+ ### Next.js App Router
26
+
27
+ ```ts
28
+ // app/api/strand/route.ts
29
+ import { createStrandRoute } from '@strand-js/google'
30
+
31
+ export const POST = createStrandRoute({
32
+ apiKey: process.env.GOOGLE_API_KEY,
33
+ model: 'gemini-2.0-flash',
34
+ })
35
+ ```
36
+
37
+ ## Get a Google API key
38
+
39
+ 1. Go to [aistudio.google.com](https://aistudio.google.com)
40
+ 2. Click **Get API Key**
41
+ 3. Create a new key or use an existing project
42
+
43
+ ## Supported models
44
+
45
+ | Model | Notes |
46
+ |---|---|
47
+ | `gemini-2.0-flash` | Fastest, recommended for most use cases |
48
+ | `gemini-2.5-pro` | Most capable |
49
+ | `gemini-1.5-pro` | Previous generation |
50
+ | `gemini-1.5-flash` | Previous generation, fast |
51
+
52
+ ## Switching from Anthropic
53
+
54
+ Only the server changes — the React hooks are identical:
55
+
56
+ ```diff
57
+ - import { createStrandHandler } from '@strand-js/anthropic'
58
+ + import { createStrandHandler } from '@strand-js/google'
59
+
60
+ createStrandHandler({
61
+ - apiKey: process.env.ANTHROPIC_API_KEY,
62
+ - model: 'claude-sonnet-4-6',
63
+ + apiKey: process.env.GOOGLE_API_KEY,
64
+ + model: 'gemini-2.0-flash',
65
+ })
66
+ ```
67
+
68
+ ## Production security
69
+
70
+ ```ts
71
+ createStrandHandler({
72
+ apiKey: process.env.GOOGLE_API_KEY,
73
+ model: 'gemini-2.0-flash',
74
+ authorize: async (request) => {
75
+ const user = await verifyToken(request.headers.get('authorization'))
76
+ if (!user) throw new Error('Unauthorized')
77
+ },
78
+ rateLimit: { windowMs: 60_000, maxRequests: 20 },
79
+ maxMessages: 50,
80
+ maxMessageLength: 10_000,
81
+ })
82
+ ```
83
+
84
+ ## Config reference
85
+
86
+ | Option | Type | Description |
87
+ |---|---|---|
88
+ | `apiKey` | `string` | Your Google AI API key |
89
+ | `model` | `string` | Model ID, e.g. `'gemini-2.0-flash'` |
90
+ | `system` | `string \| (req) => string` | System prompt |
91
+ | `tools` | `ToolDefinition[]` | Tools available to the model |
92
+ | `onToolCall` | `async (name, args, ctx) => result` | Server-side tool execution |
93
+ | `authorize` | `async (req) => void` | Throw to reject with 401 |
94
+ | `rateLimit` | `{ windowMs, maxRequests }` | Built-in IP rate limiting |
95
+ | `maxMessages` | `number` | Max messages per request (default: 100) |
96
+ | `maxMessageLength` | `number` | Max chars per message (default: 50,000) |
97
+ | `maxSteps` | `number` | Max tool call rounds (default: 10) |
98
+
99
+ [Full documentation →](https://github.com/strand-js/strand)
@@ -0,0 +1,30 @@
1
+ import { ToolDefinition, RateLimitConfig, Session } from '@strand-js/core';
2
+
3
+ interface StrandHandlerConfig {
4
+ apiKey: string;
5
+ model: string;
6
+ system?: string | ((request: Request) => string | Promise<string>);
7
+ tools?: ToolDefinition[];
8
+ onToolCall?: (name: string, args: Record<string, unknown>, ctx: {
9
+ request: Request;
10
+ }) => Promise<unknown>;
11
+ authorize?: (request: Request) => Promise<unknown> | unknown;
12
+ rateLimit?: RateLimitConfig;
13
+ maxMessages?: number;
14
+ maxMessageLength?: number;
15
+ maxSteps?: number;
16
+ onFinish?: (session: Session) => void;
17
+ }
18
+ type AnyReq = Record<string, unknown>;
19
+ type AnyRes = {
20
+ setHeader(name: string, value: string): void;
21
+ status(code: number): AnyRes;
22
+ json(body: unknown): void;
23
+ write(chunk: string): void;
24
+ end(): void;
25
+ };
26
+ declare function createStrandHandler(config: StrandHandlerConfig): (req: AnyReq, res: AnyRes) => Promise<void>;
27
+
28
+ declare function createStrandRoute(config: StrandHandlerConfig): (req: Request) => Promise<Response>;
29
+
30
+ export { type StrandHandlerConfig, createStrandHandler, createStrandRoute };
@@ -0,0 +1,30 @@
1
+ import { ToolDefinition, RateLimitConfig, Session } from '@strand-js/core';
2
+
3
+ interface StrandHandlerConfig {
4
+ apiKey: string;
5
+ model: string;
6
+ system?: string | ((request: Request) => string | Promise<string>);
7
+ tools?: ToolDefinition[];
8
+ onToolCall?: (name: string, args: Record<string, unknown>, ctx: {
9
+ request: Request;
10
+ }) => Promise<unknown>;
11
+ authorize?: (request: Request) => Promise<unknown> | unknown;
12
+ rateLimit?: RateLimitConfig;
13
+ maxMessages?: number;
14
+ maxMessageLength?: number;
15
+ maxSteps?: number;
16
+ onFinish?: (session: Session) => void;
17
+ }
18
+ type AnyReq = Record<string, unknown>;
19
+ type AnyRes = {
20
+ setHeader(name: string, value: string): void;
21
+ status(code: number): AnyRes;
22
+ json(body: unknown): void;
23
+ write(chunk: string): void;
24
+ end(): void;
25
+ };
26
+ declare function createStrandHandler(config: StrandHandlerConfig): (req: AnyReq, res: AnyRes) => Promise<void>;
27
+
28
+ declare function createStrandRoute(config: StrandHandlerConfig): (req: Request) => Promise<Response>;
29
+
30
+ export { type StrandHandlerConfig, createStrandHandler, createStrandRoute };
package/dist/index.js ADDED
@@ -0,0 +1,339 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ createStrandHandler: () => createStrandHandler,
24
+ createStrandRoute: () => createStrandRoute
25
+ });
26
+ module.exports = __toCommonJS(index_exports);
27
+
28
+ // src/handler.ts
29
+ var import_generative_ai = require("@google/generative-ai");
30
+ var import_core2 = require("@strand-js/core");
31
+
32
+ // src/format.ts
33
+ var import_core = require("@strand-js/core");
34
+ function toolToGoogleTool(tool) {
35
+ const schema = (0, import_core.toolToJsonSchema)(tool);
36
+ return {
37
+ functionDeclarations: [
38
+ {
39
+ name: tool.name,
40
+ description: tool.description,
41
+ parameters: schema
42
+ }
43
+ ]
44
+ };
45
+ }
46
+ function messagesToGoogleMessages(messages) {
47
+ const result = [];
48
+ for (const msg of messages) {
49
+ if (!msg.content.trim() && !msg.toolCalls?.length) continue;
50
+ const role = msg.role === "assistant" ? "model" : "user";
51
+ const parts = [];
52
+ if (msg.content.trim()) {
53
+ parts.push({ text: msg.content });
54
+ }
55
+ for (const tc of msg.toolCalls ?? []) {
56
+ parts.push({
57
+ functionCall: { name: tc.name, args: tc.input }
58
+ });
59
+ }
60
+ const last = result.at(-1);
61
+ if (last && last.role === role) {
62
+ last.parts.push(...parts);
63
+ } else {
64
+ result.push({ role, parts });
65
+ }
66
+ }
67
+ return result;
68
+ }
69
+
70
+ // src/handler.ts
71
+ function emit(res, eventType, data) {
72
+ res.write(`event: ${eventType}
73
+ data: ${JSON.stringify(data)}
74
+
75
+ `);
76
+ }
77
+ function normalizeRequest(req) {
78
+ const rawHeaders = req.headers ?? {};
79
+ return {
80
+ ...req,
81
+ headers: {
82
+ get: (name) => {
83
+ const val = rawHeaders[name.toLowerCase()];
84
+ return Array.isArray(val) ? val[0] ?? null : val ?? null;
85
+ }
86
+ }
87
+ };
88
+ }
89
+ function createStrandHandler(config) {
90
+ const genAI = new import_generative_ai.GoogleGenerativeAI(config.apiKey);
91
+ const googleTools = (config.tools ?? []).map(toolToGoogleTool);
92
+ const maxSteps = config.maxSteps ?? 10;
93
+ const rateLimiter = config.rateLimit ? new import_core2.RateLimiter(config.rateLimit) : null;
94
+ return async (req, res) => {
95
+ const normalizedReq = normalizeRequest(req);
96
+ const body = req.body;
97
+ if (rateLimiter) {
98
+ const ip = req.ip ?? "unknown";
99
+ const limited = rateLimiter.check(ip);
100
+ if (limited) {
101
+ res.status(429).json({ error: "Too many requests", retryAfter: limited.retryAfter });
102
+ return;
103
+ }
104
+ }
105
+ const validation = (0, import_core2.validateMessages)(body?.messages, {
106
+ maxMessages: config.maxMessages,
107
+ maxMessageLength: config.maxMessageLength
108
+ });
109
+ if (!validation.ok) {
110
+ res.status(validation.status).json({ error: validation.error });
111
+ return;
112
+ }
113
+ if (config.authorize) {
114
+ try {
115
+ await config.authorize(normalizedReq);
116
+ } catch (err) {
117
+ const message = err instanceof Error ? err.message : "Unauthorized";
118
+ res.status(401).json({ error: message });
119
+ return;
120
+ }
121
+ }
122
+ res.setHeader("Content-Type", "text/event-stream");
123
+ res.setHeader("Cache-Control", "no-cache");
124
+ res.setHeader("Connection", "keep-alive");
125
+ emit(res, "strand:start", { sessionId: (0, import_core2.generateId)(), requestId: (0, import_core2.generateId)() });
126
+ try {
127
+ const messages = body.messages;
128
+ const system = typeof config.system === "function" ? await config.system(normalizedReq) : config.system ?? "";
129
+ const model = genAI.getGenerativeModel({
130
+ model: config.model,
131
+ ...system ? { systemInstruction: system } : {},
132
+ ...googleTools.length > 0 ? { tools: googleTools } : {}
133
+ });
134
+ const allMessages = messages.map((m) => ({
135
+ id: (0, import_core2.generateId)(),
136
+ role: m.role,
137
+ content: m.content,
138
+ createdAt: /* @__PURE__ */ new Date()
139
+ }));
140
+ const history = messagesToGoogleMessages(allMessages.slice(0, -1));
141
+ const lastMessage = allMessages.at(-1)?.content ?? "";
142
+ const chat = model.startChat({ history });
143
+ let totalInput = 0;
144
+ let totalOutput = 0;
145
+ for (let step = 0; step < maxSteps; step++) {
146
+ const result = await chat.sendMessageStream(lastMessage);
147
+ let pendingFunctionCall = null;
148
+ for await (const chunk of result.stream) {
149
+ const candidate = chunk.candidates?.[0];
150
+ if (!candidate) continue;
151
+ for (const part of candidate.content?.parts ?? []) {
152
+ if ("text" in part && part.text) {
153
+ emit(res, "strand:text-delta", { delta: part.text });
154
+ }
155
+ if ("functionCall" in part && part.functionCall) {
156
+ const fc = part.functionCall;
157
+ pendingFunctionCall = {
158
+ name: fc.name,
159
+ args: fc.args
160
+ };
161
+ const toolCallId2 = (0, import_core2.generateId)();
162
+ emit(res, "strand:tool-start", { toolCallId: toolCallId2, toolName: fc.name });
163
+ emit(res, "strand:tool-input-done", { toolCallId: toolCallId2, input: fc.args });
164
+ }
165
+ }
166
+ if (chunk.usageMetadata) {
167
+ totalInput = chunk.usageMetadata.promptTokenCount ?? totalInput;
168
+ totalOutput = chunk.usageMetadata.candidatesTokenCount ?? totalOutput;
169
+ }
170
+ }
171
+ const response = await result.response;
172
+ const finishReason = response.candidates?.[0]?.finishReason;
173
+ if (!pendingFunctionCall || finishReason === "STOP") {
174
+ emit(res, "strand:done", {
175
+ usage: { input: totalInput, output: totalOutput, total: totalInput + totalOutput }
176
+ });
177
+ res.end();
178
+ return;
179
+ }
180
+ const toolCallId = (0, import_core2.generateId)();
181
+ try {
182
+ const toolResult = await config.onToolCall?.(
183
+ pendingFunctionCall.name,
184
+ pendingFunctionCall.args,
185
+ { request: normalizedReq }
186
+ );
187
+ emit(res, "strand:tool-result", { toolCallId, result: toolResult });
188
+ await chat.sendMessageStream([{
189
+ functionResponse: {
190
+ name: pendingFunctionCall.name,
191
+ response: { result: toolResult }
192
+ }
193
+ }]);
194
+ continue;
195
+ } catch (err) {
196
+ const message = err instanceof Error ? err.message : "Tool execution failed";
197
+ emit(res, "strand:tool-error", { toolCallId, error: message });
198
+ break;
199
+ }
200
+ }
201
+ emit(res, "strand:done", {
202
+ usage: { input: totalInput, output: totalOutput, total: totalInput + totalOutput }
203
+ });
204
+ res.end();
205
+ } catch (err) {
206
+ const message = err instanceof Error ? err.message : "Internal server error";
207
+ emit(res, "strand:error", { code: "server_error", message });
208
+ res.end();
209
+ }
210
+ };
211
+ }
212
+
213
+ // src/route.ts
214
+ var import_generative_ai2 = require("@google/generative-ai");
215
+ var import_core3 = require("@strand-js/core");
216
+ function sseChunk(eventType, data) {
217
+ return new TextEncoder().encode(`event: ${eventType}
218
+ data: ${JSON.stringify(data)}
219
+
220
+ `);
221
+ }
222
+ function createStrandRoute(config) {
223
+ const genAI = new import_generative_ai2.GoogleGenerativeAI(config.apiKey);
224
+ const googleTools = (config.tools ?? []).map(toolToGoogleTool);
225
+ const maxSteps = config.maxSteps ?? 10;
226
+ const rateLimiter = config.rateLimit ? new import_core3.RateLimiter(config.rateLimit) : null;
227
+ return async (req) => {
228
+ if (rateLimiter) {
229
+ const ip = req.headers.get("x-forwarded-for") ?? req.headers.get("x-real-ip") ?? "unknown";
230
+ const limited = rateLimiter.check(ip);
231
+ if (limited) {
232
+ return new Response(JSON.stringify({ error: "Too many requests", retryAfter: limited.retryAfter }), {
233
+ status: 429,
234
+ headers: { "Content-Type": "application/json", "Retry-After": String(limited.retryAfter) }
235
+ });
236
+ }
237
+ }
238
+ const body = await req.json();
239
+ const validation = (0, import_core3.validateMessages)(body?.messages, {
240
+ maxMessages: config.maxMessages,
241
+ maxMessageLength: config.maxMessageLength
242
+ });
243
+ if (!validation.ok) {
244
+ return new Response(JSON.stringify({ error: validation.error }), {
245
+ status: validation.status,
246
+ headers: { "Content-Type": "application/json" }
247
+ });
248
+ }
249
+ if (config.authorize) {
250
+ try {
251
+ await config.authorize(req);
252
+ } catch (err) {
253
+ const message = err instanceof Error ? err.message : "Unauthorized";
254
+ return new Response(JSON.stringify({ error: message }), {
255
+ status: 401,
256
+ headers: { "Content-Type": "application/json" }
257
+ });
258
+ }
259
+ }
260
+ const messages = body.messages;
261
+ const stream = new ReadableStream({
262
+ async start(controller) {
263
+ const emit2 = (eventType, data) => controller.enqueue(sseChunk(eventType, data));
264
+ emit2("strand:start", { sessionId: (0, import_core3.generateId)(), requestId: (0, import_core3.generateId)() });
265
+ try {
266
+ const system = typeof config.system === "function" ? await config.system(req) : config.system ?? "";
267
+ const model = genAI.getGenerativeModel({
268
+ model: config.model,
269
+ ...system ? { systemInstruction: system } : {},
270
+ ...googleTools.length > 0 ? { tools: googleTools } : {}
271
+ });
272
+ const allMessages = messages.map((m) => ({
273
+ id: (0, import_core3.generateId)(),
274
+ role: m.role,
275
+ content: m.content,
276
+ createdAt: /* @__PURE__ */ new Date()
277
+ }));
278
+ const history = messagesToGoogleMessages(allMessages.slice(0, -1));
279
+ const lastMessage = allMessages.at(-1)?.content ?? "";
280
+ const chat = model.startChat({ history });
281
+ let totalInput = 0;
282
+ let totalOutput = 0;
283
+ for (let step = 0; step < maxSteps; step++) {
284
+ const result = await chat.sendMessageStream(lastMessage);
285
+ let pendingFunctionCall = null;
286
+ for await (const chunk of result.stream) {
287
+ const candidate = chunk.candidates?.[0];
288
+ if (!candidate) continue;
289
+ for (const part of candidate.content?.parts ?? []) {
290
+ if ("text" in part && part.text) emit2("strand:text-delta", { delta: part.text });
291
+ if ("functionCall" in part && part.functionCall) {
292
+ const fc = part.functionCall;
293
+ pendingFunctionCall = { name: fc.name, args: fc.args };
294
+ const toolCallId2 = (0, import_core3.generateId)();
295
+ emit2("strand:tool-start", { toolCallId: toolCallId2, toolName: fc.name });
296
+ emit2("strand:tool-input-done", { toolCallId: toolCallId2, input: fc.args });
297
+ }
298
+ }
299
+ if (chunk.usageMetadata) {
300
+ totalInput = chunk.usageMetadata.promptTokenCount ?? totalInput;
301
+ totalOutput = chunk.usageMetadata.candidatesTokenCount ?? totalOutput;
302
+ }
303
+ }
304
+ const response = await result.response;
305
+ const finishReason = response.candidates?.[0]?.finishReason;
306
+ if (!pendingFunctionCall || finishReason === "STOP") {
307
+ emit2("strand:done", { usage: { input: totalInput, output: totalOutput, total: totalInput + totalOutput } });
308
+ break;
309
+ }
310
+ const toolCallId = (0, import_core3.generateId)();
311
+ try {
312
+ const toolResult = await config.onToolCall?.(pendingFunctionCall.name, pendingFunctionCall.args, { request: req });
313
+ emit2("strand:tool-result", { toolCallId, result: toolResult });
314
+ await chat.sendMessageStream([{ functionResponse: { name: pendingFunctionCall.name, response: { result: toolResult } } }]);
315
+ continue;
316
+ } catch (err) {
317
+ const message = err instanceof Error ? err.message : "Tool execution failed";
318
+ emit2("strand:tool-error", { toolCallId, error: message });
319
+ break;
320
+ }
321
+ }
322
+ } catch (err) {
323
+ const message = err instanceof Error ? err.message : "Internal server error";
324
+ emit2("strand:error", { code: "server_error", message });
325
+ } finally {
326
+ controller.close();
327
+ }
328
+ }
329
+ });
330
+ return new Response(stream, {
331
+ headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive" }
332
+ });
333
+ };
334
+ }
335
+ // Annotate the CommonJS export names for ESM import in node:
336
+ 0 && (module.exports = {
337
+ createStrandHandler,
338
+ createStrandRoute
339
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,311 @@
1
+ // src/handler.ts
2
+ import { GoogleGenerativeAI } from "@google/generative-ai";
3
+ import { generateId, validateMessages, RateLimiter } from "@strand-js/core";
4
+
5
+ // src/format.ts
6
+ import { toolToJsonSchema } from "@strand-js/core";
7
+ function toolToGoogleTool(tool) {
8
+ const schema = toolToJsonSchema(tool);
9
+ return {
10
+ functionDeclarations: [
11
+ {
12
+ name: tool.name,
13
+ description: tool.description,
14
+ parameters: schema
15
+ }
16
+ ]
17
+ };
18
+ }
19
+ function messagesToGoogleMessages(messages) {
20
+ const result = [];
21
+ for (const msg of messages) {
22
+ if (!msg.content.trim() && !msg.toolCalls?.length) continue;
23
+ const role = msg.role === "assistant" ? "model" : "user";
24
+ const parts = [];
25
+ if (msg.content.trim()) {
26
+ parts.push({ text: msg.content });
27
+ }
28
+ for (const tc of msg.toolCalls ?? []) {
29
+ parts.push({
30
+ functionCall: { name: tc.name, args: tc.input }
31
+ });
32
+ }
33
+ const last = result.at(-1);
34
+ if (last && last.role === role) {
35
+ last.parts.push(...parts);
36
+ } else {
37
+ result.push({ role, parts });
38
+ }
39
+ }
40
+ return result;
41
+ }
42
+
43
+ // src/handler.ts
44
+ function emit(res, eventType, data) {
45
+ res.write(`event: ${eventType}
46
+ data: ${JSON.stringify(data)}
47
+
48
+ `);
49
+ }
50
+ function normalizeRequest(req) {
51
+ const rawHeaders = req.headers ?? {};
52
+ return {
53
+ ...req,
54
+ headers: {
55
+ get: (name) => {
56
+ const val = rawHeaders[name.toLowerCase()];
57
+ return Array.isArray(val) ? val[0] ?? null : val ?? null;
58
+ }
59
+ }
60
+ };
61
+ }
62
+ function createStrandHandler(config) {
63
+ const genAI = new GoogleGenerativeAI(config.apiKey);
64
+ const googleTools = (config.tools ?? []).map(toolToGoogleTool);
65
+ const maxSteps = config.maxSteps ?? 10;
66
+ const rateLimiter = config.rateLimit ? new RateLimiter(config.rateLimit) : null;
67
+ return async (req, res) => {
68
+ const normalizedReq = normalizeRequest(req);
69
+ const body = req.body;
70
+ if (rateLimiter) {
71
+ const ip = req.ip ?? "unknown";
72
+ const limited = rateLimiter.check(ip);
73
+ if (limited) {
74
+ res.status(429).json({ error: "Too many requests", retryAfter: limited.retryAfter });
75
+ return;
76
+ }
77
+ }
78
+ const validation = validateMessages(body?.messages, {
79
+ maxMessages: config.maxMessages,
80
+ maxMessageLength: config.maxMessageLength
81
+ });
82
+ if (!validation.ok) {
83
+ res.status(validation.status).json({ error: validation.error });
84
+ return;
85
+ }
86
+ if (config.authorize) {
87
+ try {
88
+ await config.authorize(normalizedReq);
89
+ } catch (err) {
90
+ const message = err instanceof Error ? err.message : "Unauthorized";
91
+ res.status(401).json({ error: message });
92
+ return;
93
+ }
94
+ }
95
+ res.setHeader("Content-Type", "text/event-stream");
96
+ res.setHeader("Cache-Control", "no-cache");
97
+ res.setHeader("Connection", "keep-alive");
98
+ emit(res, "strand:start", { sessionId: generateId(), requestId: generateId() });
99
+ try {
100
+ const messages = body.messages;
101
+ const system = typeof config.system === "function" ? await config.system(normalizedReq) : config.system ?? "";
102
+ const model = genAI.getGenerativeModel({
103
+ model: config.model,
104
+ ...system ? { systemInstruction: system } : {},
105
+ ...googleTools.length > 0 ? { tools: googleTools } : {}
106
+ });
107
+ const allMessages = messages.map((m) => ({
108
+ id: generateId(),
109
+ role: m.role,
110
+ content: m.content,
111
+ createdAt: /* @__PURE__ */ new Date()
112
+ }));
113
+ const history = messagesToGoogleMessages(allMessages.slice(0, -1));
114
+ const lastMessage = allMessages.at(-1)?.content ?? "";
115
+ const chat = model.startChat({ history });
116
+ let totalInput = 0;
117
+ let totalOutput = 0;
118
+ for (let step = 0; step < maxSteps; step++) {
119
+ const result = await chat.sendMessageStream(lastMessage);
120
+ let pendingFunctionCall = null;
121
+ for await (const chunk of result.stream) {
122
+ const candidate = chunk.candidates?.[0];
123
+ if (!candidate) continue;
124
+ for (const part of candidate.content?.parts ?? []) {
125
+ if ("text" in part && part.text) {
126
+ emit(res, "strand:text-delta", { delta: part.text });
127
+ }
128
+ if ("functionCall" in part && part.functionCall) {
129
+ const fc = part.functionCall;
130
+ pendingFunctionCall = {
131
+ name: fc.name,
132
+ args: fc.args
133
+ };
134
+ const toolCallId2 = generateId();
135
+ emit(res, "strand:tool-start", { toolCallId: toolCallId2, toolName: fc.name });
136
+ emit(res, "strand:tool-input-done", { toolCallId: toolCallId2, input: fc.args });
137
+ }
138
+ }
139
+ if (chunk.usageMetadata) {
140
+ totalInput = chunk.usageMetadata.promptTokenCount ?? totalInput;
141
+ totalOutput = chunk.usageMetadata.candidatesTokenCount ?? totalOutput;
142
+ }
143
+ }
144
+ const response = await result.response;
145
+ const finishReason = response.candidates?.[0]?.finishReason;
146
+ if (!pendingFunctionCall || finishReason === "STOP") {
147
+ emit(res, "strand:done", {
148
+ usage: { input: totalInput, output: totalOutput, total: totalInput + totalOutput }
149
+ });
150
+ res.end();
151
+ return;
152
+ }
153
+ const toolCallId = generateId();
154
+ try {
155
+ const toolResult = await config.onToolCall?.(
156
+ pendingFunctionCall.name,
157
+ pendingFunctionCall.args,
158
+ { request: normalizedReq }
159
+ );
160
+ emit(res, "strand:tool-result", { toolCallId, result: toolResult });
161
+ await chat.sendMessageStream([{
162
+ functionResponse: {
163
+ name: pendingFunctionCall.name,
164
+ response: { result: toolResult }
165
+ }
166
+ }]);
167
+ continue;
168
+ } catch (err) {
169
+ const message = err instanceof Error ? err.message : "Tool execution failed";
170
+ emit(res, "strand:tool-error", { toolCallId, error: message });
171
+ break;
172
+ }
173
+ }
174
+ emit(res, "strand:done", {
175
+ usage: { input: totalInput, output: totalOutput, total: totalInput + totalOutput }
176
+ });
177
+ res.end();
178
+ } catch (err) {
179
+ const message = err instanceof Error ? err.message : "Internal server error";
180
+ emit(res, "strand:error", { code: "server_error", message });
181
+ res.end();
182
+ }
183
+ };
184
+ }
185
+
186
+ // src/route.ts
187
+ import { GoogleGenerativeAI as GoogleGenerativeAI2 } from "@google/generative-ai";
188
+ import { generateId as generateId2, validateMessages as validateMessages2, RateLimiter as RateLimiter2 } from "@strand-js/core";
189
+ function sseChunk(eventType, data) {
190
+ return new TextEncoder().encode(`event: ${eventType}
191
+ data: ${JSON.stringify(data)}
192
+
193
+ `);
194
+ }
195
+ function createStrandRoute(config) {
196
+ const genAI = new GoogleGenerativeAI2(config.apiKey);
197
+ const googleTools = (config.tools ?? []).map(toolToGoogleTool);
198
+ const maxSteps = config.maxSteps ?? 10;
199
+ const rateLimiter = config.rateLimit ? new RateLimiter2(config.rateLimit) : null;
200
+ return async (req) => {
201
+ if (rateLimiter) {
202
+ const ip = req.headers.get("x-forwarded-for") ?? req.headers.get("x-real-ip") ?? "unknown";
203
+ const limited = rateLimiter.check(ip);
204
+ if (limited) {
205
+ return new Response(JSON.stringify({ error: "Too many requests", retryAfter: limited.retryAfter }), {
206
+ status: 429,
207
+ headers: { "Content-Type": "application/json", "Retry-After": String(limited.retryAfter) }
208
+ });
209
+ }
210
+ }
211
+ const body = await req.json();
212
+ const validation = validateMessages2(body?.messages, {
213
+ maxMessages: config.maxMessages,
214
+ maxMessageLength: config.maxMessageLength
215
+ });
216
+ if (!validation.ok) {
217
+ return new Response(JSON.stringify({ error: validation.error }), {
218
+ status: validation.status,
219
+ headers: { "Content-Type": "application/json" }
220
+ });
221
+ }
222
+ if (config.authorize) {
223
+ try {
224
+ await config.authorize(req);
225
+ } catch (err) {
226
+ const message = err instanceof Error ? err.message : "Unauthorized";
227
+ return new Response(JSON.stringify({ error: message }), {
228
+ status: 401,
229
+ headers: { "Content-Type": "application/json" }
230
+ });
231
+ }
232
+ }
233
+ const messages = body.messages;
234
+ const stream = new ReadableStream({
235
+ async start(controller) {
236
+ const emit2 = (eventType, data) => controller.enqueue(sseChunk(eventType, data));
237
+ emit2("strand:start", { sessionId: generateId2(), requestId: generateId2() });
238
+ try {
239
+ const system = typeof config.system === "function" ? await config.system(req) : config.system ?? "";
240
+ const model = genAI.getGenerativeModel({
241
+ model: config.model,
242
+ ...system ? { systemInstruction: system } : {},
243
+ ...googleTools.length > 0 ? { tools: googleTools } : {}
244
+ });
245
+ const allMessages = messages.map((m) => ({
246
+ id: generateId2(),
247
+ role: m.role,
248
+ content: m.content,
249
+ createdAt: /* @__PURE__ */ new Date()
250
+ }));
251
+ const history = messagesToGoogleMessages(allMessages.slice(0, -1));
252
+ const lastMessage = allMessages.at(-1)?.content ?? "";
253
+ const chat = model.startChat({ history });
254
+ let totalInput = 0;
255
+ let totalOutput = 0;
256
+ for (let step = 0; step < maxSteps; step++) {
257
+ const result = await chat.sendMessageStream(lastMessage);
258
+ let pendingFunctionCall = null;
259
+ for await (const chunk of result.stream) {
260
+ const candidate = chunk.candidates?.[0];
261
+ if (!candidate) continue;
262
+ for (const part of candidate.content?.parts ?? []) {
263
+ if ("text" in part && part.text) emit2("strand:text-delta", { delta: part.text });
264
+ if ("functionCall" in part && part.functionCall) {
265
+ const fc = part.functionCall;
266
+ pendingFunctionCall = { name: fc.name, args: fc.args };
267
+ const toolCallId2 = generateId2();
268
+ emit2("strand:tool-start", { toolCallId: toolCallId2, toolName: fc.name });
269
+ emit2("strand:tool-input-done", { toolCallId: toolCallId2, input: fc.args });
270
+ }
271
+ }
272
+ if (chunk.usageMetadata) {
273
+ totalInput = chunk.usageMetadata.promptTokenCount ?? totalInput;
274
+ totalOutput = chunk.usageMetadata.candidatesTokenCount ?? totalOutput;
275
+ }
276
+ }
277
+ const response = await result.response;
278
+ const finishReason = response.candidates?.[0]?.finishReason;
279
+ if (!pendingFunctionCall || finishReason === "STOP") {
280
+ emit2("strand:done", { usage: { input: totalInput, output: totalOutput, total: totalInput + totalOutput } });
281
+ break;
282
+ }
283
+ const toolCallId = generateId2();
284
+ try {
285
+ const toolResult = await config.onToolCall?.(pendingFunctionCall.name, pendingFunctionCall.args, { request: req });
286
+ emit2("strand:tool-result", { toolCallId, result: toolResult });
287
+ await chat.sendMessageStream([{ functionResponse: { name: pendingFunctionCall.name, response: { result: toolResult } } }]);
288
+ continue;
289
+ } catch (err) {
290
+ const message = err instanceof Error ? err.message : "Tool execution failed";
291
+ emit2("strand:tool-error", { toolCallId, error: message });
292
+ break;
293
+ }
294
+ }
295
+ } catch (err) {
296
+ const message = err instanceof Error ? err.message : "Internal server error";
297
+ emit2("strand:error", { code: "server_error", message });
298
+ } finally {
299
+ controller.close();
300
+ }
301
+ }
302
+ });
303
+ return new Response(stream, {
304
+ headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive" }
305
+ });
306
+ };
307
+ }
308
+ export {
309
+ createStrandHandler,
310
+ createStrandRoute
311
+ };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@strand-js/google",
3
+ "version": "0.1.0",
4
+ "description": "Google Gemini provider adapter for Strand",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
19
+ "dependencies": {
20
+ "@google/generative-ai": "^0.24.0",
21
+ "@strand-js/core": "0.1.7"
22
+ },
23
+ "devDependencies": {
24
+ "tsup": "^8.3.5",
25
+ "typescript": "^5.7.2",
26
+ "vitest": "^2.1.8",
27
+ "zod": "^3.23.8"
28
+ },
29
+ "license": "MIT",
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "scripts": {
34
+ "build": "tsup src/index.ts --format esm,cjs --dts --clean",
35
+ "dev": "tsup src/index.ts --format esm,cjs --dts --watch",
36
+ "typecheck": "tsc --noEmit",
37
+ "test": "vitest run",
38
+ "lint": "eslint src"
39
+ }
40
+ }