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/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
- /** Streaming variant: async-iterate live events (text deltas, tool calls/results, usage, done). */
77
- stream(prompt, ctx) {
78
- return this.opts.style === "anthropic"
79
- ? this.streamAnthropic(prompt, ctx.toolkit, ctx.signal)
80
- : this.streamOpenAI(prompt, ctx.toolkit, ctx.signal);
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
- return { args: a, result: ov.result }; // short-circuit (deny/cache/dry-run)
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
- for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
159
- turns++;
160
- if (this.opts.hooks?.beforeLLM) {
161
- const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn });
162
- if (ov?.messages)
163
- messages = ov.messages;
164
- if (ov?.tools)
165
- tools = ov.tools;
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
- const res = await this.llmFetch(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
168
- method: "POST",
169
- headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
170
- body: JSON.stringify({ model: this.opts.model, messages, tools, tool_choice: "auto" }),
171
- }, signal);
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
- for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
209
- turns++;
210
- if (this.opts.hooks?.beforeLLM) {
211
- const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn });
212
- if (ov?.messages)
213
- messages = ov.messages;
214
- if (ov?.tools)
215
- tools = ov.tools;
216
- }
217
- const res = await this.llmFetch(endpoint, {
218
- method: "POST",
219
- headers: {
220
- "x-api-key": key,
221
- "anthropic-version": "2023-06-01",
222
- "Content-Type": "application/json",
223
- ...this.opts.headers,
224
- },
225
- body: JSON.stringify({ model: this.opts.model, max_tokens: 4096, system, messages, tools }),
226
- }, signal);
227
- if (!res.ok)
228
- throw new Error(`LLM ${res.status}: ${await res.text()}`);
229
- const data = await res.json();
230
- addUsage(usage, data.usage, "anthropic");
231
- if (this.opts.hooks?.afterLLM)
232
- await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn });
233
- messages.push({ role: "assistant", content: data.content });
234
- const uses = (data.content ?? []).filter((b) => b.type === "tool_use");
235
- if (uses.length === 0) {
236
- const text = (data.content ?? []).filter((b) => b.type === "text").map((b) => b.text).join("");
237
- return this.result(text, messages, toolCalls, turns, usage);
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
- // execute all tool_use blocks in this turn concurrently (true parallel tool calling)
240
- const results = await Promise.all(uses.map(async (use) => {
241
- const { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn);
242
- toolCalls.push({ name: use.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
243
- return { type: "tool_result", tool_use_id: use.id, content: result.output, is_error: result.isError };
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
- let messages = [];
254
- const system = this.system(toolkit);
255
- if (system)
256
- messages.push({ role: "system", content: system });
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
- for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
263
- turns++;
264
- if (this.opts.hooks?.beforeLLM) {
265
- const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn });
266
- if (ov?.messages)
267
- messages = ov.messages;
268
- if (ov?.tools)
269
- tools = ov.tools;
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
- for (const tc of d?.tool_calls ?? []) {
296
- const slot = acc.get(tc.index) ?? { id: "", name: "", args: "" };
297
- if (tc.id)
298
- slot.id = tc.id;
299
- if (tc.function?.name)
300
- slot.name += tc.function.name;
301
- if (tc.function?.arguments)
302
- slot.args += tc.function.arguments;
303
- acc.set(tc.index, slot);
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
- if (this.opts.hooks?.afterLLM)
307
- await this.opts.hooks.afterLLM({ response: { streamed: true, usage }, model: this.opts.model, turn });
308
- const calls = [...acc.values()];
309
- if (calls.length === 0) {
310
- messages.push({ role: "assistant", content });
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
- for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
341
- turns++;
342
- if (this.opts.hooks?.beforeLLM) {
343
- const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn });
344
- if (ov?.messages)
345
- messages = ov.messages;
346
- if (ov?.tools)
347
- tools = ov.tools;
348
- }
349
- const res = await this.llmFetch(endpoint, {
350
- method: "POST",
351
- headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "Content-Type": "application/json", ...this.opts.headers },
352
- body: JSON.stringify({ model: this.opts.model, max_tokens: 4096, system, messages, tools, stream: true }),
353
- }, signal);
354
- if (!res.ok || !res.body)
355
- throw new Error(`LLM ${res.status}: ${await res.text()}`);
356
- const blocks = new Map();
357
- let stopReason = "";
358
- for await (const line of sseLines(res.body)) {
359
- if (!line.startsWith("data:"))
360
- continue;
361
- const j = safeParse(line.slice(5).trim());
362
- if (!j)
363
- continue;
364
- if (j.type === "message_start")
365
- addUsage(usage, j.message?.usage, "anthropic");
366
- else if (j.type === "content_block_start")
367
- blocks.set(j.index, { type: j.content_block.type, id: j.content_block.id, name: j.content_block.name, text: "", json: "" });
368
- else if (j.type === "content_block_delta") {
369
- const b = blocks.get(j.index);
370
- if (j.delta.type === "text_delta") {
371
- b.text += j.delta.text;
372
- yield { type: "text", delta: j.delta.text };
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
- else if (j.type === "message_delta") {
378
- stopReason = j.delta?.stop_reason ?? stopReason;
379
- addUsage(usage, j.usage, "anthropic");
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
- if (this.opts.hooks?.afterLLM)
383
- await this.opts.hooks.afterLLM({ response: { streamed: true, usage }, model: this.opts.model, turn });
384
- 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 });
385
- messages.push({ role: "assistant", content });
386
- const uses = content.filter((b) => b.type === "tool_use");
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) {