@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 +99 -0
- package/dist/index.d.mts +30 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +339 -0
- package/dist/index.mjs +311 -0
- package/package.json +40 -0
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)
|
package/dist/index.d.mts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|