@strand-js/openai 0.1.0 → 0.1.2
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 +53 -0
- package/dist/index.d.mts +30 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +321 -0
- package/dist/index.mjs +283 -0
- package/package.json +12 -11
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# @strand-js/openai
|
|
2
|
+
|
|
3
|
+
OpenAI 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/openai
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### Express / Fastify / Hono
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { createStrandHandler } from '@strand-js/openai'
|
|
17
|
+
|
|
18
|
+
app.post('/api/strand', createStrandHandler({
|
|
19
|
+
apiKey: process.env.OPENAI_API_KEY,
|
|
20
|
+
model: 'gpt-4o',
|
|
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/openai'
|
|
30
|
+
|
|
31
|
+
export const POST = createStrandRoute({
|
|
32
|
+
apiKey: process.env.OPENAI_API_KEY,
|
|
33
|
+
model: 'gpt-4o',
|
|
34
|
+
})
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Switching from Anthropic
|
|
38
|
+
|
|
39
|
+
Only the server changes — the React hooks are identical:
|
|
40
|
+
|
|
41
|
+
```diff
|
|
42
|
+
- import { createStrandHandler } from '@strand-js/anthropic'
|
|
43
|
+
+ import { createStrandHandler } from '@strand-js/openai'
|
|
44
|
+
|
|
45
|
+
createStrandHandler({
|
|
46
|
+
- apiKey: process.env.ANTHROPIC_API_KEY,
|
|
47
|
+
- model: 'claude-sonnet-4-6',
|
|
48
|
+
+ apiKey: process.env.OPENAI_API_KEY,
|
|
49
|
+
+ model: 'gpt-4o',
|
|
50
|
+
})
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
[Full documentation →](https://github.com/strand-js/strand)
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ToolDefinition, 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
|
+
maxMessages?: number;
|
|
13
|
+
maxMessageLength?: number;
|
|
14
|
+
maxSteps?: number;
|
|
15
|
+
onRequest?: (req: Request) => void;
|
|
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, 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
|
+
maxMessages?: number;
|
|
13
|
+
maxMessageLength?: number;
|
|
14
|
+
maxSteps?: number;
|
|
15
|
+
onRequest?: (req: Request) => void;
|
|
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,321 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
createStrandHandler: () => createStrandHandler,
|
|
34
|
+
createStrandRoute: () => createStrandRoute
|
|
35
|
+
});
|
|
36
|
+
module.exports = __toCommonJS(index_exports);
|
|
37
|
+
|
|
38
|
+
// src/handler.ts
|
|
39
|
+
var import_openai = __toESM(require("openai"));
|
|
40
|
+
var import_core2 = require("@strand-js/core");
|
|
41
|
+
|
|
42
|
+
// src/format.ts
|
|
43
|
+
var import_core = require("@strand-js/core");
|
|
44
|
+
function toolToOpenAITool(tool) {
|
|
45
|
+
const schema = (0, import_core.toolToJsonSchema)(tool);
|
|
46
|
+
return {
|
|
47
|
+
type: "function",
|
|
48
|
+
function: {
|
|
49
|
+
name: tool.name,
|
|
50
|
+
description: tool.description,
|
|
51
|
+
parameters: schema
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// src/handler.ts
|
|
57
|
+
function emit(res, eventType, data) {
|
|
58
|
+
res.write(`event: ${eventType}
|
|
59
|
+
data: ${JSON.stringify(data)}
|
|
60
|
+
|
|
61
|
+
`);
|
|
62
|
+
}
|
|
63
|
+
function createStrandHandler(config) {
|
|
64
|
+
const client = new import_openai.default({ apiKey: config.apiKey });
|
|
65
|
+
const openAITools = (config.tools ?? []).map(toolToOpenAITool);
|
|
66
|
+
const maxSteps = config.maxSteps ?? 10;
|
|
67
|
+
return async (req, res) => {
|
|
68
|
+
const body = req.body;
|
|
69
|
+
const validation = (0, import_core2.validateMessages)(body?.messages, {
|
|
70
|
+
maxMessages: config.maxMessages,
|
|
71
|
+
maxMessageLength: config.maxMessageLength
|
|
72
|
+
});
|
|
73
|
+
if (!validation.ok) {
|
|
74
|
+
res.status(validation.status).json({ error: validation.error });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (config.authorize) {
|
|
78
|
+
try {
|
|
79
|
+
await config.authorize(req);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
const message = err instanceof Error ? err.message : "Unauthorized";
|
|
82
|
+
res.status(401).json({ error: message });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
87
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
88
|
+
res.setHeader("Connection", "keep-alive");
|
|
89
|
+
emit(res, "strand:start", { sessionId: (0, import_core2.generateId)(), requestId: (0, import_core2.generateId)() });
|
|
90
|
+
try {
|
|
91
|
+
const messages = body.messages;
|
|
92
|
+
const system = typeof config.system === "function" ? await config.system(req) : config.system ?? "";
|
|
93
|
+
const conversation = [
|
|
94
|
+
...system ? [{ role: "system", content: system }] : [],
|
|
95
|
+
...messages.map((m) => ({ role: m.role, content: m.content }))
|
|
96
|
+
];
|
|
97
|
+
let totalInput = 0;
|
|
98
|
+
let totalOutput = 0;
|
|
99
|
+
for (let step = 0; step < maxSteps; step++) {
|
|
100
|
+
const stream = await client.chat.completions.create({
|
|
101
|
+
model: config.model,
|
|
102
|
+
messages: conversation,
|
|
103
|
+
...openAITools.length > 0 ? { tools: openAITools } : {},
|
|
104
|
+
stream: true,
|
|
105
|
+
stream_options: { include_usage: true }
|
|
106
|
+
});
|
|
107
|
+
let finishReason = null;
|
|
108
|
+
const toolCallAccumulator = /* @__PURE__ */ new Map();
|
|
109
|
+
for await (const chunk of stream) {
|
|
110
|
+
const choice = chunk.choices[0];
|
|
111
|
+
if (!choice) {
|
|
112
|
+
if (chunk.usage) {
|
|
113
|
+
totalInput += chunk.usage.prompt_tokens;
|
|
114
|
+
totalOutput += chunk.usage.completion_tokens;
|
|
115
|
+
}
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const delta = choice.delta;
|
|
119
|
+
if (delta.content) emit(res, "strand:text-delta", { delta: delta.content });
|
|
120
|
+
if (delta.tool_calls) {
|
|
121
|
+
for (const tc of delta.tool_calls) {
|
|
122
|
+
if (tc.id) {
|
|
123
|
+
toolCallAccumulator.set(tc.index, { id: tc.id, name: tc.function?.name ?? "", argsJson: "" });
|
|
124
|
+
emit(res, "strand:tool-start", { toolCallId: tc.id, toolName: tc.function?.name ?? "" });
|
|
125
|
+
}
|
|
126
|
+
if (tc.function?.arguments) {
|
|
127
|
+
const acc = toolCallAccumulator.get(tc.index);
|
|
128
|
+
if (acc) {
|
|
129
|
+
acc.argsJson += tc.function.arguments;
|
|
130
|
+
emit(res, "strand:tool-input-delta", { toolCallId: acc.id, delta: tc.function.arguments });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (choice.finish_reason) finishReason = choice.finish_reason;
|
|
136
|
+
}
|
|
137
|
+
if (finishReason === "stop") {
|
|
138
|
+
emit(res, "strand:done", { usage: { input: totalInput, output: totalOutput, total: totalInput + totalOutput } });
|
|
139
|
+
res.end();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (finishReason === "tool_calls" && toolCallAccumulator.size > 0) {
|
|
143
|
+
const completedTools = Array.from(toolCallAccumulator.values()).map((acc) => ({
|
|
144
|
+
id: acc.id,
|
|
145
|
+
name: acc.name,
|
|
146
|
+
input: JSON.parse(acc.argsJson || "{}")
|
|
147
|
+
}));
|
|
148
|
+
conversation.push({
|
|
149
|
+
role: "assistant",
|
|
150
|
+
content: null,
|
|
151
|
+
tool_calls: completedTools.map((tc) => ({ id: tc.id, type: "function", function: { name: tc.name, arguments: JSON.stringify(tc.input) } }))
|
|
152
|
+
});
|
|
153
|
+
const toolResults = await Promise.all(
|
|
154
|
+
completedTools.map(async (block) => {
|
|
155
|
+
emit(res, "strand:tool-input-done", { toolCallId: block.id, input: block.input });
|
|
156
|
+
try {
|
|
157
|
+
const result = await config.onToolCall?.(block.name, block.input, { request: req });
|
|
158
|
+
emit(res, "strand:tool-result", { toolCallId: block.id, result });
|
|
159
|
+
return { role: "tool", tool_call_id: block.id, content: JSON.stringify(result ?? null) };
|
|
160
|
+
} catch (err) {
|
|
161
|
+
const message = err instanceof Error ? err.message : "Tool execution failed";
|
|
162
|
+
emit(res, "strand:tool-error", { toolCallId: block.id, error: message });
|
|
163
|
+
return { role: "tool", tool_call_id: block.id, content: message };
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
);
|
|
167
|
+
conversation.push(...toolResults);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
emit(res, "strand:done", { usage: { input: totalInput, output: totalOutput, total: totalInput + totalOutput } });
|
|
173
|
+
res.end();
|
|
174
|
+
} catch (err) {
|
|
175
|
+
const message = err instanceof Error ? err.message : "Internal server error";
|
|
176
|
+
emit(res, "strand:error", { code: "server_error", message });
|
|
177
|
+
res.end();
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// src/route.ts
|
|
183
|
+
var import_openai2 = __toESM(require("openai"));
|
|
184
|
+
var import_core3 = require("@strand-js/core");
|
|
185
|
+
function sseChunk(eventType, data) {
|
|
186
|
+
return new TextEncoder().encode(`event: ${eventType}
|
|
187
|
+
data: ${JSON.stringify(data)}
|
|
188
|
+
|
|
189
|
+
`);
|
|
190
|
+
}
|
|
191
|
+
function createStrandRoute(config) {
|
|
192
|
+
const client = new import_openai2.default({ apiKey: config.apiKey });
|
|
193
|
+
const openAITools = (config.tools ?? []).map(toolToOpenAITool);
|
|
194
|
+
const maxSteps = config.maxSteps ?? 10;
|
|
195
|
+
return async (req) => {
|
|
196
|
+
const body = await req.json();
|
|
197
|
+
const validation = (0, import_core3.validateMessages)(body?.messages, {
|
|
198
|
+
maxMessages: config.maxMessages,
|
|
199
|
+
maxMessageLength: config.maxMessageLength
|
|
200
|
+
});
|
|
201
|
+
if (!validation.ok) {
|
|
202
|
+
return new Response(JSON.stringify({ error: validation.error }), {
|
|
203
|
+
status: validation.status,
|
|
204
|
+
headers: { "Content-Type": "application/json" }
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
if (config.authorize) {
|
|
208
|
+
try {
|
|
209
|
+
await config.authorize(req);
|
|
210
|
+
} catch (err) {
|
|
211
|
+
const message = err instanceof Error ? err.message : "Unauthorized";
|
|
212
|
+
return new Response(JSON.stringify({ error: message }), {
|
|
213
|
+
status: 401,
|
|
214
|
+
headers: { "Content-Type": "application/json" }
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const messages = body.messages;
|
|
219
|
+
const stream = new ReadableStream({
|
|
220
|
+
async start(controller) {
|
|
221
|
+
const emit2 = (eventType, data) => controller.enqueue(sseChunk(eventType, data));
|
|
222
|
+
emit2("strand:start", { sessionId: (0, import_core3.generateId)(), requestId: (0, import_core3.generateId)() });
|
|
223
|
+
try {
|
|
224
|
+
const system = typeof config.system === "function" ? await config.system(req) : config.system ?? "";
|
|
225
|
+
const conversation = [
|
|
226
|
+
...system ? [{ role: "system", content: system }] : [],
|
|
227
|
+
...messages.map((m) => ({ role: m.role, content: m.content }))
|
|
228
|
+
];
|
|
229
|
+
let totalInput = 0;
|
|
230
|
+
let totalOutput = 0;
|
|
231
|
+
for (let step = 0; step < maxSteps; step++) {
|
|
232
|
+
const openAIStream = await client.chat.completions.create({
|
|
233
|
+
model: config.model,
|
|
234
|
+
messages: conversation,
|
|
235
|
+
...openAITools.length > 0 ? { tools: openAITools } : {},
|
|
236
|
+
stream: true,
|
|
237
|
+
stream_options: { include_usage: true }
|
|
238
|
+
});
|
|
239
|
+
let finishReason = null;
|
|
240
|
+
const toolCallAccumulator = /* @__PURE__ */ new Map();
|
|
241
|
+
for await (const chunk of openAIStream) {
|
|
242
|
+
const choice = chunk.choices[0];
|
|
243
|
+
if (!choice) {
|
|
244
|
+
if (chunk.usage) {
|
|
245
|
+
totalInput += chunk.usage.prompt_tokens;
|
|
246
|
+
totalOutput += chunk.usage.completion_tokens;
|
|
247
|
+
}
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const delta = choice.delta;
|
|
251
|
+
if (delta.content) emit2("strand:text-delta", { delta: delta.content });
|
|
252
|
+
if (delta.tool_calls) {
|
|
253
|
+
for (const tc of delta.tool_calls) {
|
|
254
|
+
if (tc.id) {
|
|
255
|
+
toolCallAccumulator.set(tc.index, { id: tc.id, name: tc.function?.name ?? "", argsJson: "" });
|
|
256
|
+
emit2("strand:tool-start", { toolCallId: tc.id, toolName: tc.function?.name ?? "" });
|
|
257
|
+
}
|
|
258
|
+
if (tc.function?.arguments) {
|
|
259
|
+
const acc = toolCallAccumulator.get(tc.index);
|
|
260
|
+
if (acc) {
|
|
261
|
+
acc.argsJson += tc.function.arguments;
|
|
262
|
+
emit2("strand:tool-input-delta", { toolCallId: acc.id, delta: tc.function.arguments });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (choice.finish_reason) finishReason = choice.finish_reason;
|
|
268
|
+
}
|
|
269
|
+
if (finishReason === "stop") {
|
|
270
|
+
emit2("strand:done", { usage: { input: totalInput, output: totalOutput, total: totalInput + totalOutput } });
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
if (finishReason === "tool_calls" && toolCallAccumulator.size > 0) {
|
|
274
|
+
const completedTools = Array.from(toolCallAccumulator.values()).map((acc) => ({
|
|
275
|
+
id: acc.id,
|
|
276
|
+
name: acc.name,
|
|
277
|
+
input: JSON.parse(acc.argsJson || "{}")
|
|
278
|
+
}));
|
|
279
|
+
conversation.push({
|
|
280
|
+
role: "assistant",
|
|
281
|
+
content: null,
|
|
282
|
+
tool_calls: completedTools.map((tc) => ({ id: tc.id, type: "function", function: { name: tc.name, arguments: JSON.stringify(tc.input) } }))
|
|
283
|
+
});
|
|
284
|
+
const results = await Promise.all(
|
|
285
|
+
completedTools.map(async (block) => {
|
|
286
|
+
emit2("strand:tool-input-done", { toolCallId: block.id, input: block.input });
|
|
287
|
+
try {
|
|
288
|
+
const result = await config.onToolCall?.(block.name, block.input, { request: req });
|
|
289
|
+
emit2("strand:tool-result", { toolCallId: block.id, result });
|
|
290
|
+
return { role: "tool", tool_call_id: block.id, content: JSON.stringify(result ?? null) };
|
|
291
|
+
} catch (err) {
|
|
292
|
+
const message = err instanceof Error ? err.message : "Tool execution failed";
|
|
293
|
+
emit2("strand:tool-error", { toolCallId: block.id, error: message });
|
|
294
|
+
return { role: "tool", tool_call_id: block.id, content: message };
|
|
295
|
+
}
|
|
296
|
+
})
|
|
297
|
+
);
|
|
298
|
+
conversation.push(...results);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
emit2("strand:done", { usage: { input: totalInput, output: totalOutput, total: totalInput + totalOutput } });
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
} catch (err) {
|
|
305
|
+
const message = err instanceof Error ? err.message : "Internal server error";
|
|
306
|
+
emit2("strand:error", { code: "server_error", message });
|
|
307
|
+
} finally {
|
|
308
|
+
controller.close();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
return new Response(stream, {
|
|
313
|
+
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive" }
|
|
314
|
+
});
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
318
|
+
0 && (module.exports = {
|
|
319
|
+
createStrandHandler,
|
|
320
|
+
createStrandRoute
|
|
321
|
+
});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// src/handler.ts
|
|
2
|
+
import OpenAI from "openai";
|
|
3
|
+
import { generateId, validateMessages } from "@strand-js/core";
|
|
4
|
+
|
|
5
|
+
// src/format.ts
|
|
6
|
+
import { toolToJsonSchema } from "@strand-js/core";
|
|
7
|
+
function toolToOpenAITool(tool) {
|
|
8
|
+
const schema = toolToJsonSchema(tool);
|
|
9
|
+
return {
|
|
10
|
+
type: "function",
|
|
11
|
+
function: {
|
|
12
|
+
name: tool.name,
|
|
13
|
+
description: tool.description,
|
|
14
|
+
parameters: schema
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// src/handler.ts
|
|
20
|
+
function emit(res, eventType, data) {
|
|
21
|
+
res.write(`event: ${eventType}
|
|
22
|
+
data: ${JSON.stringify(data)}
|
|
23
|
+
|
|
24
|
+
`);
|
|
25
|
+
}
|
|
26
|
+
function createStrandHandler(config) {
|
|
27
|
+
const client = new OpenAI({ apiKey: config.apiKey });
|
|
28
|
+
const openAITools = (config.tools ?? []).map(toolToOpenAITool);
|
|
29
|
+
const maxSteps = config.maxSteps ?? 10;
|
|
30
|
+
return async (req, res) => {
|
|
31
|
+
const body = req.body;
|
|
32
|
+
const validation = validateMessages(body?.messages, {
|
|
33
|
+
maxMessages: config.maxMessages,
|
|
34
|
+
maxMessageLength: config.maxMessageLength
|
|
35
|
+
});
|
|
36
|
+
if (!validation.ok) {
|
|
37
|
+
res.status(validation.status).json({ error: validation.error });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (config.authorize) {
|
|
41
|
+
try {
|
|
42
|
+
await config.authorize(req);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
const message = err instanceof Error ? err.message : "Unauthorized";
|
|
45
|
+
res.status(401).json({ error: message });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
50
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
51
|
+
res.setHeader("Connection", "keep-alive");
|
|
52
|
+
emit(res, "strand:start", { sessionId: generateId(), requestId: generateId() });
|
|
53
|
+
try {
|
|
54
|
+
const messages = body.messages;
|
|
55
|
+
const system = typeof config.system === "function" ? await config.system(req) : config.system ?? "";
|
|
56
|
+
const conversation = [
|
|
57
|
+
...system ? [{ role: "system", content: system }] : [],
|
|
58
|
+
...messages.map((m) => ({ role: m.role, content: m.content }))
|
|
59
|
+
];
|
|
60
|
+
let totalInput = 0;
|
|
61
|
+
let totalOutput = 0;
|
|
62
|
+
for (let step = 0; step < maxSteps; step++) {
|
|
63
|
+
const stream = await client.chat.completions.create({
|
|
64
|
+
model: config.model,
|
|
65
|
+
messages: conversation,
|
|
66
|
+
...openAITools.length > 0 ? { tools: openAITools } : {},
|
|
67
|
+
stream: true,
|
|
68
|
+
stream_options: { include_usage: true }
|
|
69
|
+
});
|
|
70
|
+
let finishReason = null;
|
|
71
|
+
const toolCallAccumulator = /* @__PURE__ */ new Map();
|
|
72
|
+
for await (const chunk of stream) {
|
|
73
|
+
const choice = chunk.choices[0];
|
|
74
|
+
if (!choice) {
|
|
75
|
+
if (chunk.usage) {
|
|
76
|
+
totalInput += chunk.usage.prompt_tokens;
|
|
77
|
+
totalOutput += chunk.usage.completion_tokens;
|
|
78
|
+
}
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const delta = choice.delta;
|
|
82
|
+
if (delta.content) emit(res, "strand:text-delta", { delta: delta.content });
|
|
83
|
+
if (delta.tool_calls) {
|
|
84
|
+
for (const tc of delta.tool_calls) {
|
|
85
|
+
if (tc.id) {
|
|
86
|
+
toolCallAccumulator.set(tc.index, { id: tc.id, name: tc.function?.name ?? "", argsJson: "" });
|
|
87
|
+
emit(res, "strand:tool-start", { toolCallId: tc.id, toolName: tc.function?.name ?? "" });
|
|
88
|
+
}
|
|
89
|
+
if (tc.function?.arguments) {
|
|
90
|
+
const acc = toolCallAccumulator.get(tc.index);
|
|
91
|
+
if (acc) {
|
|
92
|
+
acc.argsJson += tc.function.arguments;
|
|
93
|
+
emit(res, "strand:tool-input-delta", { toolCallId: acc.id, delta: tc.function.arguments });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (choice.finish_reason) finishReason = choice.finish_reason;
|
|
99
|
+
}
|
|
100
|
+
if (finishReason === "stop") {
|
|
101
|
+
emit(res, "strand:done", { usage: { input: totalInput, output: totalOutput, total: totalInput + totalOutput } });
|
|
102
|
+
res.end();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (finishReason === "tool_calls" && toolCallAccumulator.size > 0) {
|
|
106
|
+
const completedTools = Array.from(toolCallAccumulator.values()).map((acc) => ({
|
|
107
|
+
id: acc.id,
|
|
108
|
+
name: acc.name,
|
|
109
|
+
input: JSON.parse(acc.argsJson || "{}")
|
|
110
|
+
}));
|
|
111
|
+
conversation.push({
|
|
112
|
+
role: "assistant",
|
|
113
|
+
content: null,
|
|
114
|
+
tool_calls: completedTools.map((tc) => ({ id: tc.id, type: "function", function: { name: tc.name, arguments: JSON.stringify(tc.input) } }))
|
|
115
|
+
});
|
|
116
|
+
const toolResults = await Promise.all(
|
|
117
|
+
completedTools.map(async (block) => {
|
|
118
|
+
emit(res, "strand:tool-input-done", { toolCallId: block.id, input: block.input });
|
|
119
|
+
try {
|
|
120
|
+
const result = await config.onToolCall?.(block.name, block.input, { request: req });
|
|
121
|
+
emit(res, "strand:tool-result", { toolCallId: block.id, result });
|
|
122
|
+
return { role: "tool", tool_call_id: block.id, content: JSON.stringify(result ?? null) };
|
|
123
|
+
} catch (err) {
|
|
124
|
+
const message = err instanceof Error ? err.message : "Tool execution failed";
|
|
125
|
+
emit(res, "strand:tool-error", { toolCallId: block.id, error: message });
|
|
126
|
+
return { role: "tool", tool_call_id: block.id, content: message };
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
);
|
|
130
|
+
conversation.push(...toolResults);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
emit(res, "strand:done", { usage: { input: totalInput, output: totalOutput, total: totalInput + totalOutput } });
|
|
136
|
+
res.end();
|
|
137
|
+
} catch (err) {
|
|
138
|
+
const message = err instanceof Error ? err.message : "Internal server error";
|
|
139
|
+
emit(res, "strand:error", { code: "server_error", message });
|
|
140
|
+
res.end();
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// src/route.ts
|
|
146
|
+
import OpenAI2 from "openai";
|
|
147
|
+
import { generateId as generateId2, validateMessages as validateMessages2 } from "@strand-js/core";
|
|
148
|
+
function sseChunk(eventType, data) {
|
|
149
|
+
return new TextEncoder().encode(`event: ${eventType}
|
|
150
|
+
data: ${JSON.stringify(data)}
|
|
151
|
+
|
|
152
|
+
`);
|
|
153
|
+
}
|
|
154
|
+
function createStrandRoute(config) {
|
|
155
|
+
const client = new OpenAI2({ apiKey: config.apiKey });
|
|
156
|
+
const openAITools = (config.tools ?? []).map(toolToOpenAITool);
|
|
157
|
+
const maxSteps = config.maxSteps ?? 10;
|
|
158
|
+
return async (req) => {
|
|
159
|
+
const body = await req.json();
|
|
160
|
+
const validation = validateMessages2(body?.messages, {
|
|
161
|
+
maxMessages: config.maxMessages,
|
|
162
|
+
maxMessageLength: config.maxMessageLength
|
|
163
|
+
});
|
|
164
|
+
if (!validation.ok) {
|
|
165
|
+
return new Response(JSON.stringify({ error: validation.error }), {
|
|
166
|
+
status: validation.status,
|
|
167
|
+
headers: { "Content-Type": "application/json" }
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (config.authorize) {
|
|
171
|
+
try {
|
|
172
|
+
await config.authorize(req);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
const message = err instanceof Error ? err.message : "Unauthorized";
|
|
175
|
+
return new Response(JSON.stringify({ error: message }), {
|
|
176
|
+
status: 401,
|
|
177
|
+
headers: { "Content-Type": "application/json" }
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const messages = body.messages;
|
|
182
|
+
const stream = new ReadableStream({
|
|
183
|
+
async start(controller) {
|
|
184
|
+
const emit2 = (eventType, data) => controller.enqueue(sseChunk(eventType, data));
|
|
185
|
+
emit2("strand:start", { sessionId: generateId2(), requestId: generateId2() });
|
|
186
|
+
try {
|
|
187
|
+
const system = typeof config.system === "function" ? await config.system(req) : config.system ?? "";
|
|
188
|
+
const conversation = [
|
|
189
|
+
...system ? [{ role: "system", content: system }] : [],
|
|
190
|
+
...messages.map((m) => ({ role: m.role, content: m.content }))
|
|
191
|
+
];
|
|
192
|
+
let totalInput = 0;
|
|
193
|
+
let totalOutput = 0;
|
|
194
|
+
for (let step = 0; step < maxSteps; step++) {
|
|
195
|
+
const openAIStream = await client.chat.completions.create({
|
|
196
|
+
model: config.model,
|
|
197
|
+
messages: conversation,
|
|
198
|
+
...openAITools.length > 0 ? { tools: openAITools } : {},
|
|
199
|
+
stream: true,
|
|
200
|
+
stream_options: { include_usage: true }
|
|
201
|
+
});
|
|
202
|
+
let finishReason = null;
|
|
203
|
+
const toolCallAccumulator = /* @__PURE__ */ new Map();
|
|
204
|
+
for await (const chunk of openAIStream) {
|
|
205
|
+
const choice = chunk.choices[0];
|
|
206
|
+
if (!choice) {
|
|
207
|
+
if (chunk.usage) {
|
|
208
|
+
totalInput += chunk.usage.prompt_tokens;
|
|
209
|
+
totalOutput += chunk.usage.completion_tokens;
|
|
210
|
+
}
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const delta = choice.delta;
|
|
214
|
+
if (delta.content) emit2("strand:text-delta", { delta: delta.content });
|
|
215
|
+
if (delta.tool_calls) {
|
|
216
|
+
for (const tc of delta.tool_calls) {
|
|
217
|
+
if (tc.id) {
|
|
218
|
+
toolCallAccumulator.set(tc.index, { id: tc.id, name: tc.function?.name ?? "", argsJson: "" });
|
|
219
|
+
emit2("strand:tool-start", { toolCallId: tc.id, toolName: tc.function?.name ?? "" });
|
|
220
|
+
}
|
|
221
|
+
if (tc.function?.arguments) {
|
|
222
|
+
const acc = toolCallAccumulator.get(tc.index);
|
|
223
|
+
if (acc) {
|
|
224
|
+
acc.argsJson += tc.function.arguments;
|
|
225
|
+
emit2("strand:tool-input-delta", { toolCallId: acc.id, delta: tc.function.arguments });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (choice.finish_reason) finishReason = choice.finish_reason;
|
|
231
|
+
}
|
|
232
|
+
if (finishReason === "stop") {
|
|
233
|
+
emit2("strand:done", { usage: { input: totalInput, output: totalOutput, total: totalInput + totalOutput } });
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
if (finishReason === "tool_calls" && toolCallAccumulator.size > 0) {
|
|
237
|
+
const completedTools = Array.from(toolCallAccumulator.values()).map((acc) => ({
|
|
238
|
+
id: acc.id,
|
|
239
|
+
name: acc.name,
|
|
240
|
+
input: JSON.parse(acc.argsJson || "{}")
|
|
241
|
+
}));
|
|
242
|
+
conversation.push({
|
|
243
|
+
role: "assistant",
|
|
244
|
+
content: null,
|
|
245
|
+
tool_calls: completedTools.map((tc) => ({ id: tc.id, type: "function", function: { name: tc.name, arguments: JSON.stringify(tc.input) } }))
|
|
246
|
+
});
|
|
247
|
+
const results = await Promise.all(
|
|
248
|
+
completedTools.map(async (block) => {
|
|
249
|
+
emit2("strand:tool-input-done", { toolCallId: block.id, input: block.input });
|
|
250
|
+
try {
|
|
251
|
+
const result = await config.onToolCall?.(block.name, block.input, { request: req });
|
|
252
|
+
emit2("strand:tool-result", { toolCallId: block.id, result });
|
|
253
|
+
return { role: "tool", tool_call_id: block.id, content: JSON.stringify(result ?? null) };
|
|
254
|
+
} catch (err) {
|
|
255
|
+
const message = err instanceof Error ? err.message : "Tool execution failed";
|
|
256
|
+
emit2("strand:tool-error", { toolCallId: block.id, error: message });
|
|
257
|
+
return { role: "tool", tool_call_id: block.id, content: message };
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
);
|
|
261
|
+
conversation.push(...results);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
emit2("strand:done", { usage: { input: totalInput, output: totalOutput, total: totalInput + totalOutput } });
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
} catch (err) {
|
|
268
|
+
const message = err instanceof Error ? err.message : "Internal server error";
|
|
269
|
+
emit2("strand:error", { code: "server_error", message });
|
|
270
|
+
} finally {
|
|
271
|
+
controller.close();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
return new Response(stream, {
|
|
276
|
+
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive" }
|
|
277
|
+
});
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
export {
|
|
281
|
+
createStrandHandler,
|
|
282
|
+
createStrandRoute
|
|
283
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strand-js/openai",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"license": "MIT",
|
|
4
5
|
"description": "OpenAI provider adapter for Strand",
|
|
5
6
|
"main": "./dist/index.js",
|
|
6
7
|
"module": "./dist/index.mjs",
|
|
@@ -15,9 +16,16 @@
|
|
|
15
16
|
"files": [
|
|
16
17
|
"dist"
|
|
17
18
|
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
|
21
|
+
"dev": "tsup src/index.ts --format esm,cjs --dts --watch",
|
|
22
|
+
"typecheck": "tsc --noEmit",
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"lint": "eslint src"
|
|
25
|
+
},
|
|
18
26
|
"dependencies": {
|
|
19
|
-
"
|
|
20
|
-
"
|
|
27
|
+
"@strand-js/core": "workspace:*",
|
|
28
|
+
"openai": "^4.77.0"
|
|
21
29
|
},
|
|
22
30
|
"devDependencies": {
|
|
23
31
|
"@types/express": "^5.0.0",
|
|
@@ -28,12 +36,5 @@
|
|
|
28
36
|
},
|
|
29
37
|
"publishConfig": {
|
|
30
38
|
"access": "public"
|
|
31
|
-
},
|
|
32
|
-
"scripts": {
|
|
33
|
-
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
|
34
|
-
"dev": "tsup src/index.ts --format esm,cjs --dts --watch",
|
|
35
|
-
"typecheck": "tsc --noEmit",
|
|
36
|
-
"test": "vitest run",
|
|
37
|
-
"lint": "eslint src"
|
|
38
39
|
}
|
|
39
|
-
}
|
|
40
|
+
}
|