@strand-js/anthropic 0.1.4 → 0.1.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/README.md +60 -3
- package/dist/index.d.mts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +40 -3
- package/dist/index.mjs +42 -5
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -54,17 +54,74 @@ createStrandHandler({
|
|
|
54
54
|
})
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
## Production security checklist
|
|
58
|
+
|
|
59
|
+
Before going live, configure these options:
|
|
58
60
|
|
|
59
61
|
```ts
|
|
60
62
|
createStrandHandler({
|
|
61
63
|
apiKey: process.env.ANTHROPIC_API_KEY,
|
|
62
64
|
model: 'claude-sonnet-4-6',
|
|
65
|
+
|
|
66
|
+
// 1. REQUIRED: authenticate every request
|
|
67
|
+
// Without this, anyone can hit your endpoint and burn your API credits.
|
|
63
68
|
authorize: async (request) => {
|
|
64
|
-
const
|
|
65
|
-
|
|
69
|
+
const token = request.headers.get('authorization')?.replace('Bearer ', '')
|
|
70
|
+
const user = await verifyToken(token)
|
|
71
|
+
if (!user) throw new Error('Unauthorized') // returns 401, no LLM call is made
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
// 2. RECOMMENDED: built-in rate limiting by IP
|
|
75
|
+
// Prevents a single user from spamming your endpoint.
|
|
76
|
+
rateLimit: {
|
|
77
|
+
windowMs: 60_000, // 1 minute window
|
|
78
|
+
maxRequests: 20, // max 20 requests per IP per minute
|
|
66
79
|
},
|
|
80
|
+
|
|
81
|
+
// 3. RECOMMENDED: limit message size
|
|
82
|
+
// Prevents oversized payloads from reaching the LLM.
|
|
83
|
+
maxMessages: 50,
|
|
84
|
+
maxMessageLength: 10_000,
|
|
67
85
|
})
|
|
68
86
|
```
|
|
69
87
|
|
|
88
|
+
**Also configure on your server (outside Strand):**
|
|
89
|
+
- **CORS** — restrict which origins can call your endpoint
|
|
90
|
+
- **HTTPS** — never run in production over HTTP
|
|
91
|
+
|
|
92
|
+
## What `authorize` is
|
|
93
|
+
|
|
94
|
+
`authorize` is a function you provide that runs before any LLM call. Strand calls it with the incoming request. If it throws, the request is rejected with a 401 and no API credits are used. If it resolves, the request continues.
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
// JWT example
|
|
98
|
+
authorize: async (request) => {
|
|
99
|
+
const token = request.headers.get('authorization')?.replace('Bearer ', '')
|
|
100
|
+
const user = await verifyJWT(token)
|
|
101
|
+
if (!user) throw new Error('Unauthorized')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Simple shared secret example
|
|
105
|
+
authorize: async (request) => {
|
|
106
|
+
if (request.headers.get('x-api-key') !== process.env.MY_SECRET)
|
|
107
|
+
throw new Error('Unauthorized')
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Config reference
|
|
112
|
+
|
|
113
|
+
| Option | Type | Description |
|
|
114
|
+
|---|---|---|
|
|
115
|
+
| `apiKey` | `string` | Your Anthropic API key |
|
|
116
|
+
| `model` | `string` | Model ID, e.g. `'claude-sonnet-4-6'` |
|
|
117
|
+
| `system` | `string \| (req) => string` | System prompt (static or dynamic) |
|
|
118
|
+
| `tools` | `ToolDefinition[]` | Tools available to the model |
|
|
119
|
+
| `onToolCall` | `async (name, args, ctx) => result` | Server-side tool execution |
|
|
120
|
+
| `authorize` | `async (req) => void` | Throw to reject with 401 |
|
|
121
|
+
| `rateLimit` | `{ windowMs, maxRequests }` | Built-in IP rate limiting |
|
|
122
|
+
| `maxMessages` | `number` | Max messages per request (default: 100) |
|
|
123
|
+
| `maxMessageLength` | `number` | Max chars per message (default: 50,000) |
|
|
124
|
+
| `maxSteps` | `number` | Max tool call rounds (default: 10) |
|
|
125
|
+
| `onFinish` | `(session) => void` | Called after response completes |
|
|
126
|
+
|
|
70
127
|
[Full documentation →](https://github.com/strand-js/strand)
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ToolDefinition, Session } from '@strand-js/core';
|
|
1
|
+
import { ToolDefinition, RateLimitConfig, Session } from '@strand-js/core';
|
|
2
2
|
|
|
3
3
|
interface StrandHandlerConfig {
|
|
4
4
|
apiKey: string;
|
|
@@ -9,6 +9,7 @@ interface StrandHandlerConfig {
|
|
|
9
9
|
request: Request;
|
|
10
10
|
}) => Promise<unknown>;
|
|
11
11
|
authorize?: (request: Request) => Promise<unknown> | unknown;
|
|
12
|
+
rateLimit?: RateLimitConfig;
|
|
12
13
|
maxMessages?: number;
|
|
13
14
|
maxMessageLength?: number;
|
|
14
15
|
maxSteps?: number;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ToolDefinition, Session } from '@strand-js/core';
|
|
1
|
+
import { ToolDefinition, RateLimitConfig, Session } from '@strand-js/core';
|
|
2
2
|
|
|
3
3
|
interface StrandHandlerConfig {
|
|
4
4
|
apiKey: string;
|
|
@@ -9,6 +9,7 @@ interface StrandHandlerConfig {
|
|
|
9
9
|
request: Request;
|
|
10
10
|
}) => Promise<unknown>;
|
|
11
11
|
authorize?: (request: Request) => Promise<unknown> | unknown;
|
|
12
|
+
rateLimit?: RateLimitConfig;
|
|
12
13
|
maxMessages?: number;
|
|
13
14
|
maxMessageLength?: number;
|
|
14
15
|
maxSteps?: number;
|
package/dist/index.js
CHANGED
|
@@ -57,12 +57,38 @@ data: ${JSON.stringify(data)}
|
|
|
57
57
|
|
|
58
58
|
`);
|
|
59
59
|
}
|
|
60
|
+
function normalizeRequest(req) {
|
|
61
|
+
const rawHeaders = req.headers ?? {};
|
|
62
|
+
const normalized = {
|
|
63
|
+
...req,
|
|
64
|
+
headers: {
|
|
65
|
+
get: (name) => {
|
|
66
|
+
const val = rawHeaders[name.toLowerCase()];
|
|
67
|
+
return Array.isArray(val) ? val[0] ?? null : val ?? null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
return normalized;
|
|
72
|
+
}
|
|
60
73
|
function createStrandHandler(config) {
|
|
61
74
|
const client = new import_sdk.default({ apiKey: config.apiKey });
|
|
62
75
|
const anthropicTools = (config.tools ?? []).map(toolToAnthropicTool);
|
|
63
76
|
const maxSteps = config.maxSteps ?? 10;
|
|
77
|
+
const rateLimiter = config.rateLimit ? new import_core2.RateLimiter(config.rateLimit) : null;
|
|
64
78
|
return async (req, res) => {
|
|
79
|
+
const normalizedReq = normalizeRequest(req);
|
|
65
80
|
const body = req.body;
|
|
81
|
+
if (rateLimiter) {
|
|
82
|
+
const ip = req.ip ?? "unknown";
|
|
83
|
+
const limited = rateLimiter.check(ip);
|
|
84
|
+
if (limited) {
|
|
85
|
+
res.status(429).json({
|
|
86
|
+
error: "Too many requests",
|
|
87
|
+
retryAfter: limited.retryAfter
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
66
92
|
const validation = (0, import_core2.validateMessages)(body?.messages, {
|
|
67
93
|
maxMessages: config.maxMessages,
|
|
68
94
|
maxMessageLength: config.maxMessageLength
|
|
@@ -73,7 +99,7 @@ function createStrandHandler(config) {
|
|
|
73
99
|
}
|
|
74
100
|
if (config.authorize) {
|
|
75
101
|
try {
|
|
76
|
-
await config.authorize(
|
|
102
|
+
await config.authorize(normalizedReq);
|
|
77
103
|
} catch (err) {
|
|
78
104
|
const message = err instanceof Error ? err.message : "Unauthorized";
|
|
79
105
|
res.status(401).json({ error: message });
|
|
@@ -86,7 +112,7 @@ function createStrandHandler(config) {
|
|
|
86
112
|
emit(res, "strand:start", { sessionId: (0, import_core2.generateId)(), requestId: (0, import_core2.generateId)() });
|
|
87
113
|
try {
|
|
88
114
|
const messages = body.messages;
|
|
89
|
-
const system = typeof config.system === "function" ? await config.system(
|
|
115
|
+
const system = typeof config.system === "function" ? await config.system(normalizedReq) : config.system ?? "";
|
|
90
116
|
const conversation = messages.map((m) => ({
|
|
91
117
|
role: m.role,
|
|
92
118
|
content: m.content
|
|
@@ -152,7 +178,7 @@ function createStrandHandler(config) {
|
|
|
152
178
|
completedTools.map(async (block) => {
|
|
153
179
|
emit(res, "strand:tool-input-done", { toolCallId: block.id, input: block.input });
|
|
154
180
|
try {
|
|
155
|
-
const result = await config.onToolCall?.(block.name, block.input, { request:
|
|
181
|
+
const result = await config.onToolCall?.(block.name, block.input, { request: normalizedReq });
|
|
156
182
|
emit(res, "strand:tool-result", { toolCallId: block.id, result });
|
|
157
183
|
return { type: "tool_result", tool_use_id: block.id, content: JSON.stringify(result ?? null) };
|
|
158
184
|
} catch (err) {
|
|
@@ -190,7 +216,18 @@ function createStrandRoute(config) {
|
|
|
190
216
|
const client = new import_sdk2.default({ apiKey: config.apiKey });
|
|
191
217
|
const anthropicTools = (config.tools ?? []).map(toolToAnthropicTool);
|
|
192
218
|
const maxSteps = config.maxSteps ?? 10;
|
|
219
|
+
const rateLimiter = config.rateLimit ? new import_core3.RateLimiter(config.rateLimit) : null;
|
|
193
220
|
return async (req) => {
|
|
221
|
+
if (rateLimiter) {
|
|
222
|
+
const ip = req.headers.get("x-forwarded-for") ?? req.headers.get("x-real-ip") ?? "unknown";
|
|
223
|
+
const limited = rateLimiter.check(ip);
|
|
224
|
+
if (limited) {
|
|
225
|
+
return new Response(JSON.stringify({ error: "Too many requests", retryAfter: limited.retryAfter }), {
|
|
226
|
+
status: 429,
|
|
227
|
+
headers: { "Content-Type": "application/json", "Retry-After": String(limited.retryAfter) }
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
194
231
|
const body = await req.json();
|
|
195
232
|
const validation = (0, import_core3.validateMessages)(body?.messages, {
|
|
196
233
|
maxMessages: config.maxMessages,
|
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/handler.ts
|
|
2
2
|
import Anthropic from "@anthropic-ai/sdk";
|
|
3
|
-
import { generateId, validateMessages } from "@strand-js/core";
|
|
3
|
+
import { generateId, validateMessages, RateLimiter } from "@strand-js/core";
|
|
4
4
|
|
|
5
5
|
// src/format.ts
|
|
6
6
|
import { toolToJsonSchema } from "@strand-js/core";
|
|
@@ -20,12 +20,38 @@ data: ${JSON.stringify(data)}
|
|
|
20
20
|
|
|
21
21
|
`);
|
|
22
22
|
}
|
|
23
|
+
function normalizeRequest(req) {
|
|
24
|
+
const rawHeaders = req.headers ?? {};
|
|
25
|
+
const normalized = {
|
|
26
|
+
...req,
|
|
27
|
+
headers: {
|
|
28
|
+
get: (name) => {
|
|
29
|
+
const val = rawHeaders[name.toLowerCase()];
|
|
30
|
+
return Array.isArray(val) ? val[0] ?? null : val ?? null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
return normalized;
|
|
35
|
+
}
|
|
23
36
|
function createStrandHandler(config) {
|
|
24
37
|
const client = new Anthropic({ apiKey: config.apiKey });
|
|
25
38
|
const anthropicTools = (config.tools ?? []).map(toolToAnthropicTool);
|
|
26
39
|
const maxSteps = config.maxSteps ?? 10;
|
|
40
|
+
const rateLimiter = config.rateLimit ? new RateLimiter(config.rateLimit) : null;
|
|
27
41
|
return async (req, res) => {
|
|
42
|
+
const normalizedReq = normalizeRequest(req);
|
|
28
43
|
const body = req.body;
|
|
44
|
+
if (rateLimiter) {
|
|
45
|
+
const ip = req.ip ?? "unknown";
|
|
46
|
+
const limited = rateLimiter.check(ip);
|
|
47
|
+
if (limited) {
|
|
48
|
+
res.status(429).json({
|
|
49
|
+
error: "Too many requests",
|
|
50
|
+
retryAfter: limited.retryAfter
|
|
51
|
+
});
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
29
55
|
const validation = validateMessages(body?.messages, {
|
|
30
56
|
maxMessages: config.maxMessages,
|
|
31
57
|
maxMessageLength: config.maxMessageLength
|
|
@@ -36,7 +62,7 @@ function createStrandHandler(config) {
|
|
|
36
62
|
}
|
|
37
63
|
if (config.authorize) {
|
|
38
64
|
try {
|
|
39
|
-
await config.authorize(
|
|
65
|
+
await config.authorize(normalizedReq);
|
|
40
66
|
} catch (err) {
|
|
41
67
|
const message = err instanceof Error ? err.message : "Unauthorized";
|
|
42
68
|
res.status(401).json({ error: message });
|
|
@@ -49,7 +75,7 @@ function createStrandHandler(config) {
|
|
|
49
75
|
emit(res, "strand:start", { sessionId: generateId(), requestId: generateId() });
|
|
50
76
|
try {
|
|
51
77
|
const messages = body.messages;
|
|
52
|
-
const system = typeof config.system === "function" ? await config.system(
|
|
78
|
+
const system = typeof config.system === "function" ? await config.system(normalizedReq) : config.system ?? "";
|
|
53
79
|
const conversation = messages.map((m) => ({
|
|
54
80
|
role: m.role,
|
|
55
81
|
content: m.content
|
|
@@ -115,7 +141,7 @@ function createStrandHandler(config) {
|
|
|
115
141
|
completedTools.map(async (block) => {
|
|
116
142
|
emit(res, "strand:tool-input-done", { toolCallId: block.id, input: block.input });
|
|
117
143
|
try {
|
|
118
|
-
const result = await config.onToolCall?.(block.name, block.input, { request:
|
|
144
|
+
const result = await config.onToolCall?.(block.name, block.input, { request: normalizedReq });
|
|
119
145
|
emit(res, "strand:tool-result", { toolCallId: block.id, result });
|
|
120
146
|
return { type: "tool_result", tool_use_id: block.id, content: JSON.stringify(result ?? null) };
|
|
121
147
|
} catch (err) {
|
|
@@ -142,7 +168,7 @@ function createStrandHandler(config) {
|
|
|
142
168
|
|
|
143
169
|
// src/route.ts
|
|
144
170
|
import Anthropic2 from "@anthropic-ai/sdk";
|
|
145
|
-
import { generateId as generateId2, validateMessages as validateMessages2 } from "@strand-js/core";
|
|
171
|
+
import { generateId as generateId2, validateMessages as validateMessages2, RateLimiter as RateLimiter2 } from "@strand-js/core";
|
|
146
172
|
function sseChunk(eventType, data) {
|
|
147
173
|
return new TextEncoder().encode(`event: ${eventType}
|
|
148
174
|
data: ${JSON.stringify(data)}
|
|
@@ -153,7 +179,18 @@ function createStrandRoute(config) {
|
|
|
153
179
|
const client = new Anthropic2({ apiKey: config.apiKey });
|
|
154
180
|
const anthropicTools = (config.tools ?? []).map(toolToAnthropicTool);
|
|
155
181
|
const maxSteps = config.maxSteps ?? 10;
|
|
182
|
+
const rateLimiter = config.rateLimit ? new RateLimiter2(config.rateLimit) : null;
|
|
156
183
|
return async (req) => {
|
|
184
|
+
if (rateLimiter) {
|
|
185
|
+
const ip = req.headers.get("x-forwarded-for") ?? req.headers.get("x-real-ip") ?? "unknown";
|
|
186
|
+
const limited = rateLimiter.check(ip);
|
|
187
|
+
if (limited) {
|
|
188
|
+
return new Response(JSON.stringify({ error: "Too many requests", retryAfter: limited.retryAfter }), {
|
|
189
|
+
status: 429,
|
|
190
|
+
headers: { "Content-Type": "application/json", "Retry-After": String(limited.retryAfter) }
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
157
194
|
const body = await req.json();
|
|
158
195
|
const validation = validateMessages2(body?.messages, {
|
|
159
196
|
maxMessages: config.maxMessages,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strand-js/anthropic",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Anthropic provider adapter for Strand",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@anthropic-ai/sdk": "^0.40.0",
|
|
21
|
-
"@strand-js/core": "0.1.
|
|
21
|
+
"@strand-js/core": "0.1.6"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"@types/express": "^5.0.0",
|