toolnexus 0.3.0 → 0.4.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 +201 -63
- package/dist/client.d.ts +49 -1
- package/dist/client.js +410 -206
- package/package.json +1 -1
- package/src/client.ts +401 -177
package/dist/client.js
CHANGED
|
@@ -30,6 +30,14 @@ function addUsage(acc, raw, style) {
|
|
|
30
30
|
acc.totalTokens += raw.total_tokens ?? (raw.prompt_tokens ?? 0) + (raw.completion_tokens ?? 0);
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
+
/** Per-call token counts from one raw usage payload (not summed). */
|
|
34
|
+
function perCall(raw, style) {
|
|
35
|
+
if (!raw)
|
|
36
|
+
return { prompt: 0, completion: 0 };
|
|
37
|
+
if (style === "anthropic")
|
|
38
|
+
return { prompt: raw.input_tokens ?? 0, completion: raw.output_tokens ?? 0 };
|
|
39
|
+
return { prompt: raw.prompt_tokens ?? 0, completion: raw.completion_tokens ?? 0 };
|
|
40
|
+
}
|
|
33
41
|
function resolveKey(opts) {
|
|
34
42
|
if (opts.apiKey)
|
|
35
43
|
return opts.apiKey;
|
|
@@ -42,14 +50,123 @@ function resolveKey(opts) {
|
|
|
42
50
|
throw new Error("No API key: set apiKey or OPENROUTER_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY");
|
|
43
51
|
return key;
|
|
44
52
|
}
|
|
53
|
+
// ---- Prometheus metrics registry (zero-dependency; §8 Observability) ----
|
|
54
|
+
/** FIXED across all ports for byte-parity of `..._duration_seconds` histograms. Seconds. */
|
|
55
|
+
const DURATION_BUCKETS = [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60];
|
|
56
|
+
/** Escape a Prometheus label value: backslash, double-quote, newline. */
|
|
57
|
+
function escapeLabel(v) {
|
|
58
|
+
return v.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
|
|
59
|
+
}
|
|
60
|
+
/** Render an ordered list of label pairs to `k="v",k="v"` (order is load-bearing for parity). */
|
|
61
|
+
function labelStr(pairs) {
|
|
62
|
+
return pairs.map(([k, v]) => `${k}="${escapeLabel(v)}"`).join(",");
|
|
63
|
+
}
|
|
64
|
+
function inc(m, key, by = 1) {
|
|
65
|
+
m.set(key, (m.get(key) ?? 0) + by);
|
|
66
|
+
}
|
|
67
|
+
function observe(m, key, seconds) {
|
|
68
|
+
let h = m.get(key);
|
|
69
|
+
if (!h) {
|
|
70
|
+
h = { counts: new Array(DURATION_BUCKETS.length).fill(0), sum: 0, count: 0 };
|
|
71
|
+
m.set(key, h);
|
|
72
|
+
}
|
|
73
|
+
h.sum += seconds;
|
|
74
|
+
h.count++;
|
|
75
|
+
for (let i = 0; i < DURATION_BUCKETS.length; i++)
|
|
76
|
+
if (seconds <= DURATION_BUCKETS[i])
|
|
77
|
+
h.counts[i]++;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* In-memory cumulative registry that turns the same semantic events `onMetric` sees into the
|
|
81
|
+
* Prometheus counters/histograms of §8, and renders the text exposition format by hand (no dep).
|
|
82
|
+
*/
|
|
83
|
+
class MetricsRegistry {
|
|
84
|
+
llmRequests = new Map(); // labels: model,status
|
|
85
|
+
llmTokens = new Map(); // labels: type
|
|
86
|
+
llmDuration = new Map(); // labels: model
|
|
87
|
+
toolCalls = new Map(); // labels: tool,source,is_error
|
|
88
|
+
toolDuration = new Map(); // labels: tool
|
|
89
|
+
runErrors = new Map(); // labels: model
|
|
90
|
+
record(ev) {
|
|
91
|
+
if (ev.event === "llm") {
|
|
92
|
+
inc(this.llmRequests, labelStr([["model", ev.model], ["status", ev.status]]));
|
|
93
|
+
if (ev.status === "ok") {
|
|
94
|
+
inc(this.llmTokens, labelStr([["type", "prompt"]]), ev.promptTokens);
|
|
95
|
+
inc(this.llmTokens, labelStr([["type", "completion"]]), ev.completionTokens);
|
|
96
|
+
}
|
|
97
|
+
observe(this.llmDuration, labelStr([["model", ev.model]]), ev.ms / 1000);
|
|
98
|
+
}
|
|
99
|
+
else if (ev.event === "tool") {
|
|
100
|
+
inc(this.toolCalls, labelStr([["tool", ev.tool], ["source", ev.source], ["is_error", String(ev.isError)]]));
|
|
101
|
+
observe(this.toolDuration, labelStr([["tool", ev.tool]]), ev.ms / 1000);
|
|
102
|
+
}
|
|
103
|
+
else if (ev.event === "run") {
|
|
104
|
+
if (ev.error)
|
|
105
|
+
inc(this.runErrors, labelStr([["model", ev.model]]));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
render() {
|
|
109
|
+
const out = [];
|
|
110
|
+
this.renderCounter(out, "toolnexus_llm_requests_total", "Total LLM requests.", this.llmRequests);
|
|
111
|
+
this.renderCounter(out, "toolnexus_llm_tokens_total", "Total tokens, by type.", this.llmTokens);
|
|
112
|
+
this.renderHistogram(out, "toolnexus_llm_request_duration_seconds", "LLM request duration in seconds.", this.llmDuration);
|
|
113
|
+
this.renderCounter(out, "toolnexus_tool_calls_total", "Total tool calls.", this.toolCalls);
|
|
114
|
+
this.renderHistogram(out, "toolnexus_tool_duration_seconds", "Tool execution duration in seconds.", this.toolDuration);
|
|
115
|
+
this.renderCounter(out, "toolnexus_run_errors_total", "Total run errors.", this.runErrors);
|
|
116
|
+
return out.join("\n") + "\n";
|
|
117
|
+
}
|
|
118
|
+
renderCounter(out, name, help, m) {
|
|
119
|
+
out.push(`# HELP ${name} ${help}`);
|
|
120
|
+
out.push(`# TYPE ${name} counter`);
|
|
121
|
+
for (const key of [...m.keys()].sort()) {
|
|
122
|
+
out.push(key ? `${name}{${key}} ${m.get(key)}` : `${name} ${m.get(key)}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
renderHistogram(out, name, help, m) {
|
|
126
|
+
out.push(`# HELP ${name} ${help}`);
|
|
127
|
+
out.push(`# TYPE ${name} histogram`);
|
|
128
|
+
for (const key of [...m.keys()].sort()) {
|
|
129
|
+
const h = m.get(key);
|
|
130
|
+
for (let i = 0; i < DURATION_BUCKETS.length; i++) {
|
|
131
|
+
out.push(`${name}_bucket{${withLe(key, String(DURATION_BUCKETS[i]))}} ${h.counts[i]}`);
|
|
132
|
+
}
|
|
133
|
+
out.push(`${name}_bucket{${withLe(key, "+Inf")}} ${h.count}`);
|
|
134
|
+
out.push(key ? `${name}_sum{${key}} ${h.sum}` : `${name}_sum ${h.sum}`);
|
|
135
|
+
out.push(key ? `${name}_count{${key}} ${h.count}` : `${name}_count ${h.count}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function withLe(base, le) {
|
|
140
|
+
return base ? `${base},le="${le}"` : `le="${le}"`;
|
|
141
|
+
}
|
|
45
142
|
export class Client {
|
|
46
143
|
opts;
|
|
47
144
|
/** Conversation provider for `ask()` — from opts.store, else in-memory. */
|
|
48
145
|
store;
|
|
146
|
+
/** Cumulative Prometheus registry fed by the same events `onMetric` sees. */
|
|
147
|
+
registry = new MetricsRegistry();
|
|
49
148
|
constructor(opts) {
|
|
50
149
|
this.opts = opts;
|
|
51
150
|
this.store = opts.store ?? new InMemoryConversationStore();
|
|
52
151
|
}
|
|
152
|
+
/** Prometheus text exposition of cumulative metrics (§8). Always valid, empty-but-valid before activity. */
|
|
153
|
+
metrics() {
|
|
154
|
+
return this.registry.render();
|
|
155
|
+
}
|
|
156
|
+
/** Feed a semantic metric event to the built-in registry and the optional onMetric sink. */
|
|
157
|
+
emit(ev) {
|
|
158
|
+
this.registry.record(ev);
|
|
159
|
+
this.opts.onMetric?.(ev);
|
|
160
|
+
}
|
|
161
|
+
/** Emit the terminal `run` metric event and build the RunResult. */
|
|
162
|
+
endRun(runStart, text, messages, toolCalls, turns, usage) {
|
|
163
|
+
this.emit({ event: "run", model: this.opts.model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart });
|
|
164
|
+
return this.result(text, messages, toolCalls, turns, usage);
|
|
165
|
+
}
|
|
166
|
+
/** Emit a `run` error metric event (once, on a thrown run). */
|
|
167
|
+
emitRunError(runStart, toolCalls, turns, usage, e) {
|
|
168
|
+
this.emit({ event: "run", model: this.opts.model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart, error: e instanceof Error ? e.message : String(e) });
|
|
169
|
+
}
|
|
53
170
|
async run(prompt, ctx) {
|
|
54
171
|
return this.opts.style === "anthropic"
|
|
55
172
|
? this.runAnthropic(prompt, ctx.toolkit, ctx.signal, ctx.history)
|
|
@@ -62,6 +179,18 @@ export class Client {
|
|
|
62
179
|
* `id` it is a stateless one-shot (identical to `run`).
|
|
63
180
|
*/
|
|
64
181
|
async ask(prompt, ctx) {
|
|
182
|
+
// Block-style streaming: run the streaming loop, forward text deltas, still return the
|
|
183
|
+
// final RunResult. Memory (id load/save) is handled by stream() itself, so no duplication.
|
|
184
|
+
if (ctx.on_text) {
|
|
185
|
+
let result;
|
|
186
|
+
for await (const ev of this.stream(prompt, { toolkit: ctx.toolkit, id: ctx.id, signal: ctx.signal })) {
|
|
187
|
+
if (ev.type === "text")
|
|
188
|
+
ctx.on_text(ev.delta);
|
|
189
|
+
else if (ev.type === "done")
|
|
190
|
+
result = ev.result;
|
|
191
|
+
}
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
65
194
|
if (!ctx.id)
|
|
66
195
|
return this.run(prompt, { toolkit: ctx.toolkit, signal: ctx.signal });
|
|
67
196
|
const history = (await this.store.get(ctx.id)) ?? [];
|
|
@@ -73,11 +202,21 @@ export class Client {
|
|
|
73
202
|
conversation(ctx) {
|
|
74
203
|
return new Conversation(this, ctx.toolkit, ctx.signal);
|
|
75
204
|
}
|
|
76
|
-
/**
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
205
|
+
/**
|
|
206
|
+
* Streaming variant: async-iterate live events (text deltas, tool calls/results, usage, done).
|
|
207
|
+
* With an `id`, it is stateful (like `ask`): the thread's transcript is loaded as history before
|
|
208
|
+
* streaming, and saved back to the ConversationStore on the terminal `done` event. No `id` ⇒ stateless.
|
|
209
|
+
*/
|
|
210
|
+
async *stream(prompt, ctx) {
|
|
211
|
+
const history = ctx.id ? (await this.store.get(ctx.id)) ?? [] : undefined;
|
|
212
|
+
const gen = this.opts.style === "anthropic"
|
|
213
|
+
? this.streamAnthropic(prompt, ctx.toolkit, ctx.signal, history)
|
|
214
|
+
: this.streamOpenAI(prompt, ctx.toolkit, ctx.signal, history);
|
|
215
|
+
for await (const ev of gen) {
|
|
216
|
+
if (ev.type === "done" && ctx.id)
|
|
217
|
+
await this.store.save(ctx.id, ev.result.messages);
|
|
218
|
+
yield ev;
|
|
219
|
+
}
|
|
81
220
|
}
|
|
82
221
|
system(toolkit) {
|
|
83
222
|
return [this.opts.systemPrompt ?? "", toolkit.skillsPrompt()].filter(Boolean).join("\n\n");
|
|
@@ -124,11 +263,16 @@ export class Client {
|
|
|
124
263
|
/** Run one tool through beforeTool/afterTool hooks (mutate args, short-circuit, transform result). */
|
|
125
264
|
async runTool(toolkit, name, args, id, turn) {
|
|
126
265
|
const h = this.opts.hooks;
|
|
266
|
+
const source = toolkit.get(name)?.source ?? "custom";
|
|
267
|
+
const t0 = Date.now();
|
|
127
268
|
let a = args;
|
|
128
269
|
if (h?.beforeTool) {
|
|
129
270
|
const ov = await h.beforeTool({ name, args: a, id, turn });
|
|
130
|
-
if (ov?.result)
|
|
131
|
-
|
|
271
|
+
if (ov?.result) {
|
|
272
|
+
// short-circuit (deny/cache/dry-run) — still a tool call for metrics purposes
|
|
273
|
+
this.emit({ event: "tool", tool: name, source, isError: ov.result.isError, ms: Date.now() - t0 });
|
|
274
|
+
return { args: a, result: ov.result };
|
|
275
|
+
}
|
|
132
276
|
if (ov?.args)
|
|
133
277
|
a = ov.args;
|
|
134
278
|
}
|
|
@@ -138,12 +282,31 @@ export class Client {
|
|
|
138
282
|
if (ov?.result)
|
|
139
283
|
result = ov.result;
|
|
140
284
|
}
|
|
285
|
+
this.emit({ event: "tool", tool: name, source, isError: result.isError, ms: Date.now() - t0 });
|
|
141
286
|
return { args: a, result };
|
|
142
287
|
}
|
|
288
|
+
/** One non-streaming LLM call, with an `llm` metric event (ok/error + per-call tokens + ms). */
|
|
289
|
+
async llmCallJson(url, init, signal, style) {
|
|
290
|
+
const t0 = Date.now();
|
|
291
|
+
try {
|
|
292
|
+
const res = await this.llmFetch(url, init, signal);
|
|
293
|
+
if (!res.ok)
|
|
294
|
+
throw new Error(`LLM ${res.status}: ${await res.text()}`);
|
|
295
|
+
const data = await res.json();
|
|
296
|
+
const tok = perCall(data.usage, style);
|
|
297
|
+
this.emit({ event: "llm", model: this.opts.model, status: "ok", ms: Date.now() - t0, promptTokens: tok.prompt, completionTokens: tok.completion });
|
|
298
|
+
return data;
|
|
299
|
+
}
|
|
300
|
+
catch (e) {
|
|
301
|
+
this.emit({ event: "llm", model: this.opts.model, status: "error", ms: Date.now() - t0, promptTokens: 0, completionTokens: 0 });
|
|
302
|
+
throw e;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
143
305
|
// ---- OpenAI-style: POST {baseUrl}/chat/completions ----
|
|
144
306
|
async runOpenAI(prompt, toolkit, external, history) {
|
|
145
307
|
const key = resolveKey(this.opts);
|
|
146
308
|
const signal = this.makeSignal(external);
|
|
309
|
+
const runStart = Date.now();
|
|
147
310
|
let messages = history && history.length ? [...history] : [];
|
|
148
311
|
if (!messages.length) {
|
|
149
312
|
const system = this.system(toolkit);
|
|
@@ -155,40 +318,43 @@ export class Client {
|
|
|
155
318
|
const toolCalls = [];
|
|
156
319
|
const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
157
320
|
let turns = 0;
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
321
|
+
try {
|
|
322
|
+
for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
|
|
323
|
+
turns++;
|
|
324
|
+
if (this.opts.hooks?.beforeLLM) {
|
|
325
|
+
const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn });
|
|
326
|
+
if (ov?.messages)
|
|
327
|
+
messages = ov.messages;
|
|
328
|
+
if (ov?.tools)
|
|
329
|
+
tools = ov.tools;
|
|
330
|
+
}
|
|
331
|
+
const data = await this.llmCallJson(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
|
|
332
|
+
method: "POST",
|
|
333
|
+
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
|
|
334
|
+
body: JSON.stringify({ model: this.opts.model, messages, tools, tool_choice: "auto" }),
|
|
335
|
+
}, signal, "openai");
|
|
336
|
+
addUsage(usage, data.usage, "openai");
|
|
337
|
+
if (this.opts.hooks?.afterLLM)
|
|
338
|
+
await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn });
|
|
339
|
+
const msg = data.choices[0].message;
|
|
340
|
+
messages.push(msg);
|
|
341
|
+
const calls = msg.tool_calls ?? [];
|
|
342
|
+
if (calls.length === 0)
|
|
343
|
+
return this.endRun(runStart, msg.content ?? "", messages, toolCalls, turns, usage);
|
|
344
|
+
// execute all tool calls in this turn concurrently (true parallel tool calling)
|
|
345
|
+
const results = await Promise.all(calls.map(async (call) => {
|
|
346
|
+
const { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn);
|
|
347
|
+
toolCalls.push({ name: call.function.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
|
|
348
|
+
return { role: "tool", tool_call_id: call.id, content: result.output };
|
|
349
|
+
}));
|
|
350
|
+
messages.push(...results);
|
|
166
351
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
if (!res.ok)
|
|
173
|
-
throw new Error(`LLM ${res.status}: ${await res.text()}`);
|
|
174
|
-
const data = await res.json();
|
|
175
|
-
addUsage(usage, data.usage, "openai");
|
|
176
|
-
if (this.opts.hooks?.afterLLM)
|
|
177
|
-
await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn });
|
|
178
|
-
const msg = data.choices[0].message;
|
|
179
|
-
messages.push(msg);
|
|
180
|
-
const calls = msg.tool_calls ?? [];
|
|
181
|
-
if (calls.length === 0)
|
|
182
|
-
return this.result(msg.content ?? "", messages, toolCalls, turns, usage);
|
|
183
|
-
// execute all tool calls in this turn concurrently (true parallel tool calling)
|
|
184
|
-
const results = await Promise.all(calls.map(async (call) => {
|
|
185
|
-
const { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn);
|
|
186
|
-
toolCalls.push({ name: call.function.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
|
|
187
|
-
return { role: "tool", tool_call_id: call.id, content: result.output };
|
|
188
|
-
}));
|
|
189
|
-
messages.push(...results);
|
|
352
|
+
return this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage);
|
|
353
|
+
}
|
|
354
|
+
catch (e) {
|
|
355
|
+
this.emitRunError(runStart, toolCalls, turns, usage, e);
|
|
356
|
+
throw e;
|
|
190
357
|
}
|
|
191
|
-
return this.result(lastText(messages), messages, toolCalls, turns, usage);
|
|
192
358
|
}
|
|
193
359
|
result(text, messages, toolCalls, turns, usage) {
|
|
194
360
|
return { text, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model };
|
|
@@ -200,209 +366,247 @@ export class Client {
|
|
|
200
366
|
const base = this.opts.baseUrl.replace(/\/$/, "");
|
|
201
367
|
const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`;
|
|
202
368
|
const system = this.system(toolkit);
|
|
369
|
+
const runStart = Date.now();
|
|
203
370
|
let messages = history && history.length ? [...history, { role: "user", content: prompt }] : [{ role: "user", content: prompt }];
|
|
204
371
|
let tools = toolkit.toAnthropic();
|
|
205
372
|
const toolCalls = [];
|
|
206
373
|
const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
207
374
|
let turns = 0;
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
375
|
+
try {
|
|
376
|
+
for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
|
|
377
|
+
turns++;
|
|
378
|
+
if (this.opts.hooks?.beforeLLM) {
|
|
379
|
+
const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn });
|
|
380
|
+
if (ov?.messages)
|
|
381
|
+
messages = ov.messages;
|
|
382
|
+
if (ov?.tools)
|
|
383
|
+
tools = ov.tools;
|
|
384
|
+
}
|
|
385
|
+
const data = await this.llmCallJson(endpoint, {
|
|
386
|
+
method: "POST",
|
|
387
|
+
headers: {
|
|
388
|
+
"x-api-key": key,
|
|
389
|
+
"anthropic-version": "2023-06-01",
|
|
390
|
+
"Content-Type": "application/json",
|
|
391
|
+
...this.opts.headers,
|
|
392
|
+
},
|
|
393
|
+
body: JSON.stringify({ model: this.opts.model, max_tokens: 4096, system, messages, tools }),
|
|
394
|
+
}, signal, "anthropic");
|
|
395
|
+
addUsage(usage, data.usage, "anthropic");
|
|
396
|
+
if (this.opts.hooks?.afterLLM)
|
|
397
|
+
await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn });
|
|
398
|
+
messages.push({ role: "assistant", content: data.content });
|
|
399
|
+
const uses = (data.content ?? []).filter((b) => b.type === "tool_use");
|
|
400
|
+
if (uses.length === 0) {
|
|
401
|
+
const text = (data.content ?? []).filter((b) => b.type === "text").map((b) => b.text).join("");
|
|
402
|
+
return this.endRun(runStart, text, messages, toolCalls, turns, usage);
|
|
403
|
+
}
|
|
404
|
+
// execute all tool_use blocks in this turn concurrently (true parallel tool calling)
|
|
405
|
+
const results = await Promise.all(uses.map(async (use) => {
|
|
406
|
+
const { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn);
|
|
407
|
+
toolCalls.push({ name: use.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
|
|
408
|
+
return { type: "tool_result", tool_use_id: use.id, content: result.output, is_error: result.isError };
|
|
409
|
+
}));
|
|
410
|
+
messages.push({ role: "user", content: results });
|
|
238
411
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}));
|
|
245
|
-
messages.push({ role: "user", content: results });
|
|
412
|
+
return this.endRun(runStart, "", messages, toolCalls, turns, usage);
|
|
413
|
+
}
|
|
414
|
+
catch (e) {
|
|
415
|
+
this.emitRunError(runStart, toolCalls, turns, usage, e);
|
|
416
|
+
throw e;
|
|
246
417
|
}
|
|
247
|
-
return this.result("", messages, toolCalls, turns, usage);
|
|
248
418
|
}
|
|
249
419
|
// ---- Streaming: OpenAI-style ----
|
|
250
|
-
async *streamOpenAI(prompt, toolkit, external) {
|
|
420
|
+
async *streamOpenAI(prompt, toolkit, external, history) {
|
|
251
421
|
const key = resolveKey(this.opts);
|
|
252
422
|
const signal = this.makeSignal(external);
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
if (
|
|
256
|
-
|
|
423
|
+
const runStart = Date.now();
|
|
424
|
+
let messages = history && history.length ? [...history] : [];
|
|
425
|
+
if (!messages.length) {
|
|
426
|
+
const system = this.system(toolkit);
|
|
427
|
+
if (system)
|
|
428
|
+
messages.push({ role: "system", content: system });
|
|
429
|
+
}
|
|
257
430
|
messages.push({ role: "user", content: prompt });
|
|
258
431
|
let tools = toolkit.toOpenAI();
|
|
259
432
|
const toolCalls = [];
|
|
260
433
|
const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
261
434
|
let turns = 0;
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const res = await this.llmFetch(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
|
|
272
|
-
method: "POST",
|
|
273
|
-
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
|
|
274
|
-
body: JSON.stringify({ model: this.opts.model, messages, tools, tool_choice: "auto", stream: true, stream_options: { include_usage: true } }),
|
|
275
|
-
}, signal);
|
|
276
|
-
if (!res.ok || !res.body)
|
|
277
|
-
throw new Error(`LLM ${res.status}: ${await res.text()}`);
|
|
278
|
-
let content = "";
|
|
279
|
-
const acc = new Map();
|
|
280
|
-
for await (const line of sseLines(res.body)) {
|
|
281
|
-
if (!line.startsWith("data:"))
|
|
282
|
-
continue;
|
|
283
|
-
const payload = line.slice(5).trim();
|
|
284
|
-
if (payload === "[DONE]")
|
|
285
|
-
break;
|
|
286
|
-
const j = safeParse(payload);
|
|
287
|
-
if (j?.usage)
|
|
288
|
-
addUsage(usage, j.usage, "openai");
|
|
289
|
-
const choice = j?.choices?.[0];
|
|
290
|
-
const d = choice?.delta;
|
|
291
|
-
if (d?.content) {
|
|
292
|
-
content += d.content;
|
|
293
|
-
yield { type: "text", delta: d.content };
|
|
435
|
+
try {
|
|
436
|
+
for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
|
|
437
|
+
turns++;
|
|
438
|
+
if (this.opts.hooks?.beforeLLM) {
|
|
439
|
+
const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn });
|
|
440
|
+
if (ov?.messages)
|
|
441
|
+
messages = ov.messages;
|
|
442
|
+
if (ov?.tools)
|
|
443
|
+
tools = ov.tools;
|
|
294
444
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
445
|
+
const t0 = Date.now();
|
|
446
|
+
const beforeP = usage.promptTokens, beforeC = usage.completionTokens;
|
|
447
|
+
let content = "";
|
|
448
|
+
const acc = new Map();
|
|
449
|
+
try {
|
|
450
|
+
const res = await this.llmFetch(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
|
|
451
|
+
method: "POST",
|
|
452
|
+
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
|
|
453
|
+
body: JSON.stringify({ model: this.opts.model, messages, tools, tool_choice: "auto", stream: true, stream_options: { include_usage: true } }),
|
|
454
|
+
}, signal);
|
|
455
|
+
if (!res.ok || !res.body)
|
|
456
|
+
throw new Error(`LLM ${res.status}: ${await res.text()}`);
|
|
457
|
+
for await (const line of sseLines(res.body)) {
|
|
458
|
+
if (!line.startsWith("data:"))
|
|
459
|
+
continue;
|
|
460
|
+
const payload = line.slice(5).trim();
|
|
461
|
+
if (payload === "[DONE]")
|
|
462
|
+
break;
|
|
463
|
+
const j = safeParse(payload);
|
|
464
|
+
if (j?.usage)
|
|
465
|
+
addUsage(usage, j.usage, "openai");
|
|
466
|
+
const choice = j?.choices?.[0];
|
|
467
|
+
const d = choice?.delta;
|
|
468
|
+
if (d?.content) {
|
|
469
|
+
content += d.content;
|
|
470
|
+
yield { type: "text", delta: d.content };
|
|
471
|
+
}
|
|
472
|
+
for (const tc of d?.tool_calls ?? []) {
|
|
473
|
+
const slot = acc.get(tc.index) ?? { id: "", name: "", args: "" };
|
|
474
|
+
if (tc.id)
|
|
475
|
+
slot.id = tc.id;
|
|
476
|
+
if (tc.function?.name)
|
|
477
|
+
slot.name += tc.function.name;
|
|
478
|
+
if (tc.function?.arguments)
|
|
479
|
+
slot.args += tc.function.arguments;
|
|
480
|
+
acc.set(tc.index, slot);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
catch (e) {
|
|
485
|
+
this.emit({ event: "llm", model: this.opts.model, status: "error", ms: Date.now() - t0, promptTokens: 0, completionTokens: 0 });
|
|
486
|
+
throw e;
|
|
487
|
+
}
|
|
488
|
+
this.emit({ event: "llm", model: this.opts.model, status: "ok", ms: Date.now() - t0, promptTokens: usage.promptTokens - beforeP, completionTokens: usage.completionTokens - beforeC });
|
|
489
|
+
if (this.opts.hooks?.afterLLM)
|
|
490
|
+
await this.opts.hooks.afterLLM({ response: { streamed: true, usage }, model: this.opts.model, turn });
|
|
491
|
+
const calls = [...acc.values()];
|
|
492
|
+
if (calls.length === 0) {
|
|
493
|
+
messages.push({ role: "assistant", content });
|
|
494
|
+
yield { type: "usage", usage };
|
|
495
|
+
yield { type: "done", result: this.endRun(runStart, content, messages, toolCalls, turns, usage) };
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
messages.push({ role: "assistant", content: content || null, tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.args } })) });
|
|
499
|
+
for (const c of calls)
|
|
500
|
+
yield { type: "tool_call", id: c.id, name: c.name, args: safeJson(c.args) };
|
|
501
|
+
const settled = await Promise.all(calls.map((c) => this.runTool(toolkit, c.name, safeJson(c.args), c.id, turn)));
|
|
502
|
+
for (let i = 0; i < calls.length; i++) {
|
|
503
|
+
const c = calls[i], { args, result } = settled[i];
|
|
504
|
+
toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
|
|
505
|
+
messages.push({ role: "tool", tool_call_id: c.id, content: result.output });
|
|
506
|
+
yield { type: "tool_result", id: c.id, name: c.name, output: result.output, isError: result.isError };
|
|
304
507
|
}
|
|
305
508
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
yield { type: "usage", usage };
|
|
312
|
-
yield { type: "done", result: this.result(content, messages, toolCalls, turns, usage) };
|
|
313
|
-
return;
|
|
314
|
-
}
|
|
315
|
-
messages.push({ role: "assistant", content: content || null, tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.args } })) });
|
|
316
|
-
for (const c of calls)
|
|
317
|
-
yield { type: "tool_call", id: c.id, name: c.name, args: safeJson(c.args) };
|
|
318
|
-
const settled = await Promise.all(calls.map((c) => this.runTool(toolkit, c.name, safeJson(c.args), c.id, turn)));
|
|
319
|
-
for (let i = 0; i < calls.length; i++) {
|
|
320
|
-
const c = calls[i], { args, result } = settled[i];
|
|
321
|
-
toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
|
|
322
|
-
messages.push({ role: "tool", tool_call_id: c.id, content: result.output });
|
|
323
|
-
yield { type: "tool_result", id: c.id, name: c.name, output: result.output, isError: result.isError };
|
|
324
|
-
}
|
|
509
|
+
yield { type: "done", result: this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage) };
|
|
510
|
+
}
|
|
511
|
+
catch (e) {
|
|
512
|
+
this.emitRunError(runStart, toolCalls, turns, usage, e);
|
|
513
|
+
throw e;
|
|
325
514
|
}
|
|
326
|
-
yield { type: "done", result: this.result(lastText(messages), messages, toolCalls, turns, usage) };
|
|
327
515
|
}
|
|
328
516
|
// ---- Streaming: Anthropic-style ----
|
|
329
|
-
async *streamAnthropic(prompt, toolkit, external) {
|
|
517
|
+
async *streamAnthropic(prompt, toolkit, external, history) {
|
|
330
518
|
const key = resolveKey(this.opts);
|
|
331
519
|
const signal = this.makeSignal(external);
|
|
520
|
+
const runStart = Date.now();
|
|
332
521
|
const base = this.opts.baseUrl.replace(/\/$/, "");
|
|
333
522
|
const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`;
|
|
334
523
|
const system = this.system(toolkit);
|
|
335
|
-
let messages = [{ role: "user", content: prompt }];
|
|
524
|
+
let messages = history && history.length ? [...history, { role: "user", content: prompt }] : [{ role: "user", content: prompt }];
|
|
336
525
|
let tools = toolkit.toAnthropic();
|
|
337
526
|
const toolCalls = [];
|
|
338
527
|
const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
339
528
|
let turns = 0;
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
529
|
+
try {
|
|
530
|
+
for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
|
|
531
|
+
turns++;
|
|
532
|
+
if (this.opts.hooks?.beforeLLM) {
|
|
533
|
+
const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn });
|
|
534
|
+
if (ov?.messages)
|
|
535
|
+
messages = ov.messages;
|
|
536
|
+
if (ov?.tools)
|
|
537
|
+
tools = ov.tools;
|
|
538
|
+
}
|
|
539
|
+
const t0 = Date.now();
|
|
540
|
+
const beforeP = usage.promptTokens, beforeC = usage.completionTokens;
|
|
541
|
+
const blocks = new Map();
|
|
542
|
+
let stopReason = "";
|
|
543
|
+
try {
|
|
544
|
+
const res = await this.llmFetch(endpoint, {
|
|
545
|
+
method: "POST",
|
|
546
|
+
headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "Content-Type": "application/json", ...this.opts.headers },
|
|
547
|
+
body: JSON.stringify({ model: this.opts.model, max_tokens: 4096, system, messages, tools, stream: true }),
|
|
548
|
+
}, signal);
|
|
549
|
+
if (!res.ok || !res.body)
|
|
550
|
+
throw new Error(`LLM ${res.status}: ${await res.text()}`);
|
|
551
|
+
for await (const line of sseLines(res.body)) {
|
|
552
|
+
if (!line.startsWith("data:"))
|
|
553
|
+
continue;
|
|
554
|
+
const j = safeParse(line.slice(5).trim());
|
|
555
|
+
if (!j)
|
|
556
|
+
continue;
|
|
557
|
+
if (j.type === "message_start")
|
|
558
|
+
addUsage(usage, j.message?.usage, "anthropic");
|
|
559
|
+
else if (j.type === "content_block_start")
|
|
560
|
+
blocks.set(j.index, { type: j.content_block.type, id: j.content_block.id, name: j.content_block.name, text: "", json: "" });
|
|
561
|
+
else if (j.type === "content_block_delta") {
|
|
562
|
+
const b = blocks.get(j.index);
|
|
563
|
+
if (j.delta.type === "text_delta") {
|
|
564
|
+
b.text += j.delta.text;
|
|
565
|
+
yield { type: "text", delta: j.delta.text };
|
|
566
|
+
}
|
|
567
|
+
else if (j.delta.type === "input_json_delta")
|
|
568
|
+
b.json += j.delta.partial_json;
|
|
569
|
+
}
|
|
570
|
+
else if (j.type === "message_delta") {
|
|
571
|
+
stopReason = j.delta?.stop_reason ?? stopReason;
|
|
572
|
+
addUsage(usage, j.usage, "anthropic");
|
|
573
|
+
}
|
|
373
574
|
}
|
|
374
|
-
else if (j.delta.type === "input_json_delta")
|
|
375
|
-
b.json += j.delta.partial_json;
|
|
376
575
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
576
|
+
catch (e) {
|
|
577
|
+
this.emit({ event: "llm", model: this.opts.model, status: "error", ms: Date.now() - t0, promptTokens: 0, completionTokens: 0 });
|
|
578
|
+
throw e;
|
|
380
579
|
}
|
|
580
|
+
this.emit({ event: "llm", model: this.opts.model, status: "ok", ms: Date.now() - t0, promptTokens: usage.promptTokens - beforeP, completionTokens: usage.completionTokens - beforeC });
|
|
581
|
+
if (this.opts.hooks?.afterLLM)
|
|
582
|
+
await this.opts.hooks.afterLLM({ response: { streamed: true, usage }, model: this.opts.model, turn });
|
|
583
|
+
const content = [...blocks.values()].map((b) => b.type === "tool_use" ? { type: "tool_use", id: b.id, name: b.name, input: safeJson(b.json || "{}") } : { type: "text", text: b.text });
|
|
584
|
+
messages.push({ role: "assistant", content });
|
|
585
|
+
const uses = content.filter((b) => b.type === "tool_use");
|
|
586
|
+
if (stopReason !== "tool_use" || uses.length === 0) {
|
|
587
|
+
const text = content.filter((b) => b.type === "text").map((b) => b.text).join("");
|
|
588
|
+
yield { type: "usage", usage };
|
|
589
|
+
yield { type: "done", result: this.endRun(runStart, text, messages, toolCalls, turns, usage) };
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
for (const u of uses)
|
|
593
|
+
yield { type: "tool_call", id: u.id, name: u.name, args: u.input };
|
|
594
|
+
const settled = await Promise.all(uses.map((u) => this.runTool(toolkit, u.name, u.input ?? {}, u.id, turn)));
|
|
595
|
+
const results = [];
|
|
596
|
+
for (let i = 0; i < uses.length; i++) {
|
|
597
|
+
const u = uses[i], { args, result } = settled[i];
|
|
598
|
+
toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
|
|
599
|
+
results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError });
|
|
600
|
+
yield { type: "tool_result", id: u.id, name: u.name, output: result.output, isError: result.isError };
|
|
601
|
+
}
|
|
602
|
+
messages.push({ role: "user", content: results });
|
|
381
603
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
if (stopReason !== "tool_use" || uses.length === 0) {
|
|
388
|
-
const text = content.filter((b) => b.type === "text").map((b) => b.text).join("");
|
|
389
|
-
yield { type: "usage", usage };
|
|
390
|
-
yield { type: "done", result: this.result(text, messages, toolCalls, turns, usage) };
|
|
391
|
-
return;
|
|
392
|
-
}
|
|
393
|
-
for (const u of uses)
|
|
394
|
-
yield { type: "tool_call", id: u.id, name: u.name, args: u.input };
|
|
395
|
-
const settled = await Promise.all(uses.map((u) => this.runTool(toolkit, u.name, u.input ?? {}, u.id, turn)));
|
|
396
|
-
const results = [];
|
|
397
|
-
for (let i = 0; i < uses.length; i++) {
|
|
398
|
-
const u = uses[i], { args, result } = settled[i];
|
|
399
|
-
toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
|
|
400
|
-
results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError });
|
|
401
|
-
yield { type: "tool_result", id: u.id, name: u.name, output: result.output, isError: result.isError };
|
|
402
|
-
}
|
|
403
|
-
messages.push({ role: "user", content: results });
|
|
604
|
+
yield { type: "done", result: this.endRun(runStart, "", messages, toolCalls, turns, usage) };
|
|
605
|
+
}
|
|
606
|
+
catch (e) {
|
|
607
|
+
this.emitRunError(runStart, toolCalls, turns, usage, e);
|
|
608
|
+
throw e;
|
|
404
609
|
}
|
|
405
|
-
yield { type: "done", result: this.result("", messages, toolCalls, turns, usage) };
|
|
406
610
|
}
|
|
407
611
|
}
|
|
408
612
|
export function createClient(opts) {
|