toolnexus 0.3.1 → 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 +62 -3
- 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/src/client.ts
CHANGED
|
@@ -26,8 +26,21 @@ export interface ClientOptions {
|
|
|
26
26
|
/** Conversation provider for `ask(prompt, { id })`. Default: in-memory (process lifetime).
|
|
27
27
|
* Supply a file/db store to persist conversations across processes. */
|
|
28
28
|
store?: ConversationStore
|
|
29
|
+
/** Observability sink — receives semantic metric events ({event:"llm"|"tool"|"run"…}) as the
|
|
30
|
+
* loop runs (§8 Observability). Forward to statsd/logs/OTel/anything. No cost when unset. */
|
|
31
|
+
onMetric?: (ev: MetricEvent) => void
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Semantic observability events (§8). NOT counter/histogram primitives — readable records the
|
|
36
|
+
* host can forward anywhere. The same events also feed the built-in Prometheus registry
|
|
37
|
+
* (`client.metrics()`).
|
|
38
|
+
*/
|
|
39
|
+
export type MetricEvent =
|
|
40
|
+
| { event: "llm"; model: string; status: "ok" | "error"; ms: number; promptTokens: number; completionTokens: number }
|
|
41
|
+
| { event: "tool"; tool: string; source: string; isError: boolean; ms: number }
|
|
42
|
+
| { event: "run"; model: string; turns: number; toolCalls: number; totalTokens: number; ms: number; error?: string }
|
|
43
|
+
|
|
31
44
|
/**
|
|
32
45
|
* Where `ask()` conversations are remembered — two methods. Ship the in-memory
|
|
33
46
|
* default; implement this for a file/db/redis provider to persist across processes.
|
|
@@ -127,6 +140,13 @@ function addUsage(acc: Usage, raw: any, style: ClientStyle): void {
|
|
|
127
140
|
}
|
|
128
141
|
}
|
|
129
142
|
|
|
143
|
+
/** Per-call token counts from one raw usage payload (not summed). */
|
|
144
|
+
function perCall(raw: any, style: ClientStyle): { prompt: number; completion: number } {
|
|
145
|
+
if (!raw) return { prompt: 0, completion: 0 }
|
|
146
|
+
if (style === "anthropic") return { prompt: raw.input_tokens ?? 0, completion: raw.output_tokens ?? 0 }
|
|
147
|
+
return { prompt: raw.prompt_tokens ?? 0, completion: raw.completion_tokens ?? 0 }
|
|
148
|
+
}
|
|
149
|
+
|
|
130
150
|
function resolveKey(opts: ClientOptions): string {
|
|
131
151
|
if (opts.apiKey) return opts.apiKey
|
|
132
152
|
const env = process.env
|
|
@@ -139,13 +159,139 @@ function resolveKey(opts: ClientOptions): string {
|
|
|
139
159
|
return key
|
|
140
160
|
}
|
|
141
161
|
|
|
162
|
+
// ---- Prometheus metrics registry (zero-dependency; §8 Observability) ----
|
|
163
|
+
|
|
164
|
+
/** FIXED across all ports for byte-parity of `..._duration_seconds` histograms. Seconds. */
|
|
165
|
+
const DURATION_BUCKETS = [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60]
|
|
166
|
+
|
|
167
|
+
interface Hist {
|
|
168
|
+
counts: number[]
|
|
169
|
+
sum: number
|
|
170
|
+
count: number
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Escape a Prometheus label value: backslash, double-quote, newline. */
|
|
174
|
+
function escapeLabel(v: string): string {
|
|
175
|
+
return v.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Render an ordered list of label pairs to `k="v",k="v"` (order is load-bearing for parity). */
|
|
179
|
+
function labelStr(pairs: [string, string][]): string {
|
|
180
|
+
return pairs.map(([k, v]) => `${k}="${escapeLabel(v)}"`).join(",")
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function inc(m: Map<string, number>, key: string, by = 1): void {
|
|
184
|
+
m.set(key, (m.get(key) ?? 0) + by)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function observe(m: Map<string, Hist>, key: string, seconds: number): void {
|
|
188
|
+
let h = m.get(key)
|
|
189
|
+
if (!h) {
|
|
190
|
+
h = { counts: new Array(DURATION_BUCKETS.length).fill(0), sum: 0, count: 0 }
|
|
191
|
+
m.set(key, h)
|
|
192
|
+
}
|
|
193
|
+
h.sum += seconds
|
|
194
|
+
h.count++
|
|
195
|
+
for (let i = 0; i < DURATION_BUCKETS.length; i++) if (seconds <= DURATION_BUCKETS[i]) h.counts[i]++
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* In-memory cumulative registry that turns the same semantic events `onMetric` sees into the
|
|
200
|
+
* Prometheus counters/histograms of §8, and renders the text exposition format by hand (no dep).
|
|
201
|
+
*/
|
|
202
|
+
class MetricsRegistry {
|
|
203
|
+
private readonly llmRequests = new Map<string, number>() // labels: model,status
|
|
204
|
+
private readonly llmTokens = new Map<string, number>() // labels: type
|
|
205
|
+
private readonly llmDuration = new Map<string, Hist>() // labels: model
|
|
206
|
+
private readonly toolCalls = new Map<string, number>() // labels: tool,source,is_error
|
|
207
|
+
private readonly toolDuration = new Map<string, Hist>() // labels: tool
|
|
208
|
+
private readonly runErrors = new Map<string, number>() // labels: model
|
|
209
|
+
|
|
210
|
+
record(ev: MetricEvent): void {
|
|
211
|
+
if (ev.event === "llm") {
|
|
212
|
+
inc(this.llmRequests, labelStr([["model", ev.model], ["status", ev.status]]))
|
|
213
|
+
if (ev.status === "ok") {
|
|
214
|
+
inc(this.llmTokens, labelStr([["type", "prompt"]]), ev.promptTokens)
|
|
215
|
+
inc(this.llmTokens, labelStr([["type", "completion"]]), ev.completionTokens)
|
|
216
|
+
}
|
|
217
|
+
observe(this.llmDuration, labelStr([["model", ev.model]]), ev.ms / 1000)
|
|
218
|
+
} else if (ev.event === "tool") {
|
|
219
|
+
inc(this.toolCalls, labelStr([["tool", ev.tool], ["source", ev.source], ["is_error", String(ev.isError)]]))
|
|
220
|
+
observe(this.toolDuration, labelStr([["tool", ev.tool]]), ev.ms / 1000)
|
|
221
|
+
} else if (ev.event === "run") {
|
|
222
|
+
if (ev.error) inc(this.runErrors, labelStr([["model", ev.model]]))
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
render(): string {
|
|
227
|
+
const out: string[] = []
|
|
228
|
+
this.renderCounter(out, "toolnexus_llm_requests_total", "Total LLM requests.", this.llmRequests)
|
|
229
|
+
this.renderCounter(out, "toolnexus_llm_tokens_total", "Total tokens, by type.", this.llmTokens)
|
|
230
|
+
this.renderHistogram(out, "toolnexus_llm_request_duration_seconds", "LLM request duration in seconds.", this.llmDuration)
|
|
231
|
+
this.renderCounter(out, "toolnexus_tool_calls_total", "Total tool calls.", this.toolCalls)
|
|
232
|
+
this.renderHistogram(out, "toolnexus_tool_duration_seconds", "Tool execution duration in seconds.", this.toolDuration)
|
|
233
|
+
this.renderCounter(out, "toolnexus_run_errors_total", "Total run errors.", this.runErrors)
|
|
234
|
+
return out.join("\n") + "\n"
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private renderCounter(out: string[], name: string, help: string, m: Map<string, number>): void {
|
|
238
|
+
out.push(`# HELP ${name} ${help}`)
|
|
239
|
+
out.push(`# TYPE ${name} counter`)
|
|
240
|
+
for (const key of [...m.keys()].sort()) {
|
|
241
|
+
out.push(key ? `${name}{${key}} ${m.get(key)}` : `${name} ${m.get(key)}`)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private renderHistogram(out: string[], name: string, help: string, m: Map<string, Hist>): void {
|
|
246
|
+
out.push(`# HELP ${name} ${help}`)
|
|
247
|
+
out.push(`# TYPE ${name} histogram`)
|
|
248
|
+
for (const key of [...m.keys()].sort()) {
|
|
249
|
+
const h = m.get(key)!
|
|
250
|
+
for (let i = 0; i < DURATION_BUCKETS.length; i++) {
|
|
251
|
+
out.push(`${name}_bucket{${withLe(key, String(DURATION_BUCKETS[i]))}} ${h.counts[i]}`)
|
|
252
|
+
}
|
|
253
|
+
out.push(`${name}_bucket{${withLe(key, "+Inf")}} ${h.count}`)
|
|
254
|
+
out.push(key ? `${name}_sum{${key}} ${h.sum}` : `${name}_sum ${h.sum}`)
|
|
255
|
+
out.push(key ? `${name}_count{${key}} ${h.count}` : `${name}_count ${h.count}`)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function withLe(base: string, le: string): string {
|
|
261
|
+
return base ? `${base},le="${le}"` : `le="${le}"`
|
|
262
|
+
}
|
|
263
|
+
|
|
142
264
|
export class Client {
|
|
143
265
|
/** Conversation provider for `ask()` — from opts.store, else in-memory. */
|
|
144
266
|
private readonly store: ConversationStore
|
|
267
|
+
/** Cumulative Prometheus registry fed by the same events `onMetric` sees. */
|
|
268
|
+
private readonly registry = new MetricsRegistry()
|
|
145
269
|
constructor(private readonly opts: ClientOptions) {
|
|
146
270
|
this.store = opts.store ?? new InMemoryConversationStore()
|
|
147
271
|
}
|
|
148
272
|
|
|
273
|
+
/** Prometheus text exposition of cumulative metrics (§8). Always valid, empty-but-valid before activity. */
|
|
274
|
+
metrics(): string {
|
|
275
|
+
return this.registry.render()
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Feed a semantic metric event to the built-in registry and the optional onMetric sink. */
|
|
279
|
+
private emit(ev: MetricEvent): void {
|
|
280
|
+
this.registry.record(ev)
|
|
281
|
+
this.opts.onMetric?.(ev)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Emit the terminal `run` metric event and build the RunResult. */
|
|
285
|
+
private endRun(runStart: number, text: string, messages: any[], toolCalls: ToolCallRecord[], turns: number, usage: Usage): RunResult {
|
|
286
|
+
this.emit({ event: "run", model: this.opts.model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart })
|
|
287
|
+
return this.result(text, messages, toolCalls, turns, usage)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Emit a `run` error metric event (once, on a thrown run). */
|
|
291
|
+
private emitRunError(runStart: number, toolCalls: ToolCallRecord[], turns: number, usage: Usage, e: unknown): void {
|
|
292
|
+
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) })
|
|
293
|
+
}
|
|
294
|
+
|
|
149
295
|
async run(prompt: string, ctx: { toolkit: Toolkit; signal?: AbortSignal; history?: any[] }): Promise<RunResult> {
|
|
150
296
|
return this.opts.style === "anthropic"
|
|
151
297
|
? this.runAnthropic(prompt, ctx.toolkit, ctx.signal, ctx.history)
|
|
@@ -158,7 +304,17 @@ export class Client {
|
|
|
158
304
|
* the answer — so the next `ask` with the same `id` continues it. Without an
|
|
159
305
|
* `id` it is a stateless one-shot (identical to `run`).
|
|
160
306
|
*/
|
|
161
|
-
async ask(prompt: string, ctx: { toolkit: Toolkit; id?: string; signal?: AbortSignal }): Promise<RunResult> {
|
|
307
|
+
async ask(prompt: string, ctx: { toolkit: Toolkit; id?: string; on_text?: (delta: string) => void; signal?: AbortSignal }): Promise<RunResult> {
|
|
308
|
+
// Block-style streaming: run the streaming loop, forward text deltas, still return the
|
|
309
|
+
// final RunResult. Memory (id load/save) is handled by stream() itself, so no duplication.
|
|
310
|
+
if (ctx.on_text) {
|
|
311
|
+
let result: RunResult | undefined
|
|
312
|
+
for await (const ev of this.stream(prompt, { toolkit: ctx.toolkit, id: ctx.id, signal: ctx.signal })) {
|
|
313
|
+
if (ev.type === "text") ctx.on_text(ev.delta)
|
|
314
|
+
else if (ev.type === "done") result = ev.result
|
|
315
|
+
}
|
|
316
|
+
return result!
|
|
317
|
+
}
|
|
162
318
|
if (!ctx.id) return this.run(prompt, { toolkit: ctx.toolkit, signal: ctx.signal })
|
|
163
319
|
const history = (await this.store.get(ctx.id)) ?? []
|
|
164
320
|
const result = await this.run(prompt, { toolkit: ctx.toolkit, signal: ctx.signal, history })
|
|
@@ -171,11 +327,20 @@ export class Client {
|
|
|
171
327
|
return new Conversation(this, ctx.toolkit, ctx.signal)
|
|
172
328
|
}
|
|
173
329
|
|
|
174
|
-
/**
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
330
|
+
/**
|
|
331
|
+
* Streaming variant: async-iterate live events (text deltas, tool calls/results, usage, done).
|
|
332
|
+
* With an `id`, it is stateful (like `ask`): the thread's transcript is loaded as history before
|
|
333
|
+
* streaming, and saved back to the ConversationStore on the terminal `done` event. No `id` ⇒ stateless.
|
|
334
|
+
*/
|
|
335
|
+
async *stream(prompt: string, ctx: { toolkit: Toolkit; id?: string; signal?: AbortSignal }): AsyncGenerator<StreamEvent, void, unknown> {
|
|
336
|
+
const history = ctx.id ? (await this.store.get(ctx.id)) ?? [] : undefined
|
|
337
|
+
const gen = this.opts.style === "anthropic"
|
|
338
|
+
? this.streamAnthropic(prompt, ctx.toolkit, ctx.signal, history)
|
|
339
|
+
: this.streamOpenAI(prompt, ctx.toolkit, ctx.signal, history)
|
|
340
|
+
for await (const ev of gen) {
|
|
341
|
+
if (ev.type === "done" && ctx.id) await this.store.save(ctx.id, ev.result.messages)
|
|
342
|
+
yield ev
|
|
343
|
+
}
|
|
179
344
|
}
|
|
180
345
|
|
|
181
346
|
private system(toolkit: Toolkit): string {
|
|
@@ -220,10 +385,16 @@ export class Client {
|
|
|
220
385
|
/** Run one tool through beforeTool/afterTool hooks (mutate args, short-circuit, transform result). */
|
|
221
386
|
private async runTool(toolkit: Toolkit, name: string, args: Record<string, unknown>, id: string | undefined, turn: number) {
|
|
222
387
|
const h = this.opts.hooks
|
|
388
|
+
const source = toolkit.get(name)?.source ?? "custom"
|
|
389
|
+
const t0 = Date.now()
|
|
223
390
|
let a = args
|
|
224
391
|
if (h?.beforeTool) {
|
|
225
392
|
const ov = await h.beforeTool({ name, args: a, id, turn })
|
|
226
|
-
if (ov?.result)
|
|
393
|
+
if (ov?.result) {
|
|
394
|
+
// short-circuit (deny/cache/dry-run) — still a tool call for metrics purposes
|
|
395
|
+
this.emit({ event: "tool", tool: name, source, isError: ov.result.isError, ms: Date.now() - t0 })
|
|
396
|
+
return { args: a, result: ov.result }
|
|
397
|
+
}
|
|
227
398
|
if (ov?.args) a = ov.args
|
|
228
399
|
}
|
|
229
400
|
let result = await toolkit.execute(name, a)
|
|
@@ -231,13 +402,31 @@ export class Client {
|
|
|
231
402
|
const ov = await h.afterTool({ name, args: a, result, id, turn })
|
|
232
403
|
if (ov?.result) result = ov.result
|
|
233
404
|
}
|
|
405
|
+
this.emit({ event: "tool", tool: name, source, isError: result.isError, ms: Date.now() - t0 })
|
|
234
406
|
return { args: a, result }
|
|
235
407
|
}
|
|
236
408
|
|
|
409
|
+
/** One non-streaming LLM call, with an `llm` metric event (ok/error + per-call tokens + ms). */
|
|
410
|
+
private async llmCallJson(url: string, init: RequestInit, signal: AbortSignal, style: ClientStyle): Promise<any> {
|
|
411
|
+
const t0 = Date.now()
|
|
412
|
+
try {
|
|
413
|
+
const res = await this.llmFetch(url, init, signal)
|
|
414
|
+
if (!res.ok) throw new Error(`LLM ${res.status}: ${await res.text()}`)
|
|
415
|
+
const data: any = await res.json()
|
|
416
|
+
const tok = perCall(data.usage, style)
|
|
417
|
+
this.emit({ event: "llm", model: this.opts.model, status: "ok", ms: Date.now() - t0, promptTokens: tok.prompt, completionTokens: tok.completion })
|
|
418
|
+
return data
|
|
419
|
+
} catch (e) {
|
|
420
|
+
this.emit({ event: "llm", model: this.opts.model, status: "error", ms: Date.now() - t0, promptTokens: 0, completionTokens: 0 })
|
|
421
|
+
throw e
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
237
425
|
// ---- OpenAI-style: POST {baseUrl}/chat/completions ----
|
|
238
426
|
private async runOpenAI(prompt: string, toolkit: Toolkit, external?: AbortSignal, history?: any[]): Promise<RunResult> {
|
|
239
427
|
const key = resolveKey(this.opts)
|
|
240
428
|
const signal = this.makeSignal(external)
|
|
429
|
+
const runStart = Date.now()
|
|
241
430
|
let messages: any[] = history && history.length ? [...history] : []
|
|
242
431
|
if (!messages.length) {
|
|
243
432
|
const system = this.system(toolkit)
|
|
@@ -249,37 +438,40 @@ export class Client {
|
|
|
249
438
|
const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
|
|
250
439
|
let turns = 0
|
|
251
440
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
441
|
+
try {
|
|
442
|
+
for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
|
|
443
|
+
turns++
|
|
444
|
+
if (this.opts.hooks?.beforeLLM) {
|
|
445
|
+
const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn })
|
|
446
|
+
if (ov?.messages) messages = ov.messages
|
|
447
|
+
if (ov?.tools) tools = ov.tools
|
|
448
|
+
}
|
|
449
|
+
const data: any = await this.llmCallJson(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
|
|
450
|
+
method: "POST",
|
|
451
|
+
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
|
|
452
|
+
body: JSON.stringify({ model: this.opts.model, messages, tools, tool_choice: "auto" }),
|
|
453
|
+
}, signal, "openai")
|
|
454
|
+
addUsage(usage, data.usage, "openai")
|
|
455
|
+
if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn })
|
|
456
|
+
const msg = data.choices[0].message
|
|
457
|
+
messages.push(msg)
|
|
458
|
+
const calls = msg.tool_calls ?? []
|
|
459
|
+
if (calls.length === 0) return this.endRun(runStart, msg.content ?? "", messages, toolCalls, turns, usage)
|
|
460
|
+
// execute all tool calls in this turn concurrently (true parallel tool calling)
|
|
461
|
+
const results = await Promise.all(
|
|
462
|
+
calls.map(async (call: any) => {
|
|
463
|
+
const { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn)
|
|
464
|
+
toolCalls.push({ name: call.function.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
|
|
465
|
+
return { role: "tool", tool_call_id: call.id, content: result.output }
|
|
466
|
+
}),
|
|
467
|
+
)
|
|
468
|
+
messages.push(...results)
|
|
258
469
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
}, signal)
|
|
264
|
-
if (!res.ok) throw new Error(`LLM ${res.status}: ${await res.text()}`)
|
|
265
|
-
const data: any = await res.json()
|
|
266
|
-
addUsage(usage, data.usage, "openai")
|
|
267
|
-
if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn })
|
|
268
|
-
const msg = data.choices[0].message
|
|
269
|
-
messages.push(msg)
|
|
270
|
-
const calls = msg.tool_calls ?? []
|
|
271
|
-
if (calls.length === 0) return this.result(msg.content ?? "", messages, toolCalls, turns, usage)
|
|
272
|
-
// execute all tool calls in this turn concurrently (true parallel tool calling)
|
|
273
|
-
const results = await Promise.all(
|
|
274
|
-
calls.map(async (call: any) => {
|
|
275
|
-
const { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn)
|
|
276
|
-
toolCalls.push({ name: call.function.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
|
|
277
|
-
return { role: "tool", tool_call_id: call.id, content: result.output }
|
|
278
|
-
}),
|
|
279
|
-
)
|
|
280
|
-
messages.push(...results)
|
|
470
|
+
return this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage)
|
|
471
|
+
} catch (e) {
|
|
472
|
+
this.emitRunError(runStart, toolCalls, turns, usage, e)
|
|
473
|
+
throw e
|
|
281
474
|
}
|
|
282
|
-
return this.result(lastText(messages), messages, toolCalls, turns, usage)
|
|
283
475
|
}
|
|
284
476
|
|
|
285
477
|
private result(text: string, messages: any[], toolCalls: ToolCallRecord[], turns: number, usage: Usage): RunResult {
|
|
@@ -293,184 +485,216 @@ export class Client {
|
|
|
293
485
|
const base = this.opts.baseUrl.replace(/\/$/, "")
|
|
294
486
|
const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`
|
|
295
487
|
const system = this.system(toolkit)
|
|
488
|
+
const runStart = Date.now()
|
|
296
489
|
let messages: any[] = history && history.length ? [...history, { role: "user", content: prompt }] : [{ role: "user", content: prompt }]
|
|
297
490
|
let tools = toolkit.toAnthropic()
|
|
298
491
|
const toolCalls: ToolCallRecord[] = []
|
|
299
492
|
const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
|
|
300
493
|
let turns = 0
|
|
301
494
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
495
|
+
try {
|
|
496
|
+
for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
|
|
497
|
+
turns++
|
|
498
|
+
if (this.opts.hooks?.beforeLLM) {
|
|
499
|
+
const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn })
|
|
500
|
+
if (ov?.messages) messages = ov.messages
|
|
501
|
+
if (ov?.tools) tools = ov.tools
|
|
502
|
+
}
|
|
503
|
+
const data: any = await this.llmCallJson(endpoint, {
|
|
504
|
+
method: "POST",
|
|
505
|
+
headers: {
|
|
506
|
+
"x-api-key": key,
|
|
507
|
+
"anthropic-version": "2023-06-01",
|
|
508
|
+
"Content-Type": "application/json",
|
|
509
|
+
...this.opts.headers,
|
|
510
|
+
},
|
|
511
|
+
body: JSON.stringify({ model: this.opts.model, max_tokens: 4096, system, messages, tools }),
|
|
512
|
+
}, signal, "anthropic")
|
|
513
|
+
addUsage(usage, data.usage, "anthropic")
|
|
514
|
+
if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn })
|
|
515
|
+
messages.push({ role: "assistant", content: data.content })
|
|
516
|
+
const uses = (data.content ?? []).filter((b: any) => b.type === "tool_use")
|
|
517
|
+
if (uses.length === 0) {
|
|
518
|
+
const text = (data.content ?? []).filter((b: any) => b.type === "text").map((b: any) => b.text).join("")
|
|
519
|
+
return this.endRun(runStart, text, messages, toolCalls, turns, usage)
|
|
520
|
+
}
|
|
521
|
+
// execute all tool_use blocks in this turn concurrently (true parallel tool calling)
|
|
522
|
+
const results = await Promise.all(
|
|
523
|
+
uses.map(async (use: any) => {
|
|
524
|
+
const { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn)
|
|
525
|
+
toolCalls.push({ name: use.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
|
|
526
|
+
return { type: "tool_result", tool_use_id: use.id, content: result.output, is_error: result.isError }
|
|
527
|
+
}),
|
|
528
|
+
)
|
|
529
|
+
messages.push({ role: "user", content: results })
|
|
328
530
|
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
toolCalls.push({ name: use.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
|
|
334
|
-
return { type: "tool_result", tool_use_id: use.id, content: result.output, is_error: result.isError }
|
|
335
|
-
}),
|
|
336
|
-
)
|
|
337
|
-
messages.push({ role: "user", content: results })
|
|
531
|
+
return this.endRun(runStart, "", messages, toolCalls, turns, usage)
|
|
532
|
+
} catch (e) {
|
|
533
|
+
this.emitRunError(runStart, toolCalls, turns, usage, e)
|
|
534
|
+
throw e
|
|
338
535
|
}
|
|
339
|
-
return this.result("", messages, toolCalls, turns, usage)
|
|
340
536
|
}
|
|
341
537
|
|
|
342
538
|
// ---- Streaming: OpenAI-style ----
|
|
343
|
-
private async *streamOpenAI(prompt: string, toolkit: Toolkit, external?: AbortSignal): AsyncGenerator<StreamEvent, void, unknown> {
|
|
539
|
+
private async *streamOpenAI(prompt: string, toolkit: Toolkit, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
|
|
344
540
|
const key = resolveKey(this.opts)
|
|
345
541
|
const signal = this.makeSignal(external)
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
if (
|
|
542
|
+
const runStart = Date.now()
|
|
543
|
+
let messages: any[] = history && history.length ? [...history] : []
|
|
544
|
+
if (!messages.length) {
|
|
545
|
+
const system = this.system(toolkit)
|
|
546
|
+
if (system) messages.push({ role: "system", content: system })
|
|
547
|
+
}
|
|
349
548
|
messages.push({ role: "user", content: prompt })
|
|
350
549
|
let tools = toolkit.toOpenAI()
|
|
351
550
|
const toolCalls: ToolCallRecord[] = []
|
|
352
551
|
const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
|
|
353
552
|
let turns = 0
|
|
354
553
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
554
|
+
try {
|
|
555
|
+
for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
|
|
556
|
+
turns++
|
|
557
|
+
if (this.opts.hooks?.beforeLLM) {
|
|
558
|
+
const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn })
|
|
559
|
+
if (ov?.messages) messages = ov.messages
|
|
560
|
+
if (ov?.tools) tools = ov.tools
|
|
561
|
+
}
|
|
562
|
+
const t0 = Date.now()
|
|
563
|
+
const beforeP = usage.promptTokens, beforeC = usage.completionTokens
|
|
564
|
+
let content = ""
|
|
565
|
+
const acc = new Map<number, { id: string; name: string; args: string }>()
|
|
566
|
+
try {
|
|
567
|
+
const res = await this.llmFetch(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
|
|
568
|
+
method: "POST",
|
|
569
|
+
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
|
|
570
|
+
body: JSON.stringify({ model: this.opts.model, messages, tools, tool_choice: "auto", stream: true, stream_options: { include_usage: true } }),
|
|
571
|
+
}, signal)
|
|
572
|
+
if (!res.ok || !res.body) throw new Error(`LLM ${res.status}: ${await res.text()}`)
|
|
573
|
+
for await (const line of sseLines(res.body)) {
|
|
574
|
+
if (!line.startsWith("data:")) continue
|
|
575
|
+
const payload = line.slice(5).trim()
|
|
576
|
+
if (payload === "[DONE]") break
|
|
577
|
+
const j = safeParse(payload)
|
|
578
|
+
if (j?.usage) addUsage(usage, j.usage, "openai")
|
|
579
|
+
const choice = j?.choices?.[0]
|
|
580
|
+
const d = choice?.delta
|
|
581
|
+
if (d?.content) { content += d.content; yield { type: "text", delta: d.content } }
|
|
582
|
+
for (const tc of d?.tool_calls ?? []) {
|
|
583
|
+
const slot = acc.get(tc.index) ?? { id: "", name: "", args: "" }
|
|
584
|
+
if (tc.id) slot.id = tc.id
|
|
585
|
+
if (tc.function?.name) slot.name += tc.function.name
|
|
586
|
+
if (tc.function?.arguments) slot.args += tc.function.arguments
|
|
587
|
+
acc.set(tc.index, slot)
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
} catch (e) {
|
|
591
|
+
this.emit({ event: "llm", model: this.opts.model, status: "error", ms: Date.now() - t0, promptTokens: 0, completionTokens: 0 })
|
|
592
|
+
throw e
|
|
593
|
+
}
|
|
594
|
+
this.emit({ event: "llm", model: this.opts.model, status: "ok", ms: Date.now() - t0, promptTokens: usage.promptTokens - beforeP, completionTokens: usage.completionTokens - beforeC })
|
|
595
|
+
if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: { streamed: true, usage }, model: this.opts.model, turn })
|
|
596
|
+
|
|
597
|
+
const calls = [...acc.values()]
|
|
598
|
+
if (calls.length === 0) {
|
|
599
|
+
messages.push({ role: "assistant", content })
|
|
600
|
+
yield { type: "usage", usage }
|
|
601
|
+
yield { type: "done", result: this.endRun(runStart, content, messages, toolCalls, turns, usage) }
|
|
602
|
+
return
|
|
603
|
+
}
|
|
604
|
+
messages.push({ role: "assistant", content: content || null, tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.args } })) })
|
|
605
|
+
for (const c of calls) yield { type: "tool_call", id: c.id, name: c.name, args: safeJson(c.args) }
|
|
606
|
+
const settled = await Promise.all(calls.map((c) => this.runTool(toolkit, c.name, safeJson(c.args), c.id, turn)))
|
|
607
|
+
for (let i = 0; i < calls.length; i++) {
|
|
608
|
+
const c = calls[i], { args, result } = settled[i]
|
|
609
|
+
toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
|
|
610
|
+
messages.push({ role: "tool", tool_call_id: c.id, content: result.output })
|
|
611
|
+
yield { type: "tool_result", id: c.id, name: c.name, output: result.output, isError: result.isError }
|
|
386
612
|
}
|
|
387
613
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
messages.push({ role: "assistant", content })
|
|
393
|
-
yield { type: "usage", usage }
|
|
394
|
-
yield { type: "done", result: this.result(content, messages, toolCalls, turns, usage) }
|
|
395
|
-
return
|
|
396
|
-
}
|
|
397
|
-
messages.push({ role: "assistant", content: content || null, tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.args } })) })
|
|
398
|
-
for (const c of calls) yield { type: "tool_call", id: c.id, name: c.name, args: safeJson(c.args) }
|
|
399
|
-
const settled = await Promise.all(calls.map((c) => this.runTool(toolkit, c.name, safeJson(c.args), c.id, turn)))
|
|
400
|
-
for (let i = 0; i < calls.length; i++) {
|
|
401
|
-
const c = calls[i], { args, result } = settled[i]
|
|
402
|
-
toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
|
|
403
|
-
messages.push({ role: "tool", tool_call_id: c.id, content: result.output })
|
|
404
|
-
yield { type: "tool_result", id: c.id, name: c.name, output: result.output, isError: result.isError }
|
|
405
|
-
}
|
|
614
|
+
yield { type: "done", result: this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage) }
|
|
615
|
+
} catch (e) {
|
|
616
|
+
this.emitRunError(runStart, toolCalls, turns, usage, e)
|
|
617
|
+
throw e
|
|
406
618
|
}
|
|
407
|
-
yield { type: "done", result: this.result(lastText(messages), messages, toolCalls, turns, usage) }
|
|
408
619
|
}
|
|
409
620
|
|
|
410
621
|
// ---- Streaming: Anthropic-style ----
|
|
411
|
-
private async *streamAnthropic(prompt: string, toolkit: Toolkit, external?: AbortSignal): AsyncGenerator<StreamEvent, void, unknown> {
|
|
622
|
+
private async *streamAnthropic(prompt: string, toolkit: Toolkit, external?: AbortSignal, history?: any[]): AsyncGenerator<StreamEvent, void, unknown> {
|
|
412
623
|
const key = resolveKey(this.opts)
|
|
413
624
|
const signal = this.makeSignal(external)
|
|
625
|
+
const runStart = Date.now()
|
|
414
626
|
const base = this.opts.baseUrl.replace(/\/$/, "")
|
|
415
627
|
const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`
|
|
416
628
|
const system = this.system(toolkit)
|
|
417
|
-
let messages: any[] = [{ role: "user", content: prompt }]
|
|
629
|
+
let messages: any[] = history && history.length ? [...history, { role: "user", content: prompt }] : [{ role: "user", content: prompt }]
|
|
418
630
|
let tools = toolkit.toAnthropic()
|
|
419
631
|
const toolCalls: ToolCallRecord[] = []
|
|
420
632
|
const usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
|
|
421
633
|
let turns = 0
|
|
422
634
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
635
|
+
try {
|
|
636
|
+
for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
|
|
637
|
+
turns++
|
|
638
|
+
if (this.opts.hooks?.beforeLLM) {
|
|
639
|
+
const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn })
|
|
640
|
+
if (ov?.messages) messages = ov.messages
|
|
641
|
+
if (ov?.tools) tools = ov.tools
|
|
642
|
+
}
|
|
643
|
+
const t0 = Date.now()
|
|
644
|
+
const beforeP = usage.promptTokens, beforeC = usage.completionTokens
|
|
645
|
+
const blocks = new Map<number, { type: string; text?: string; id?: string; name?: string; json?: string }>()
|
|
646
|
+
let stopReason = ""
|
|
647
|
+
try {
|
|
648
|
+
const res = await this.llmFetch(endpoint, {
|
|
649
|
+
method: "POST",
|
|
650
|
+
headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "Content-Type": "application/json", ...this.opts.headers },
|
|
651
|
+
body: JSON.stringify({ model: this.opts.model, max_tokens: 4096, system, messages, tools, stream: true }),
|
|
652
|
+
}, signal)
|
|
653
|
+
if (!res.ok || !res.body) throw new Error(`LLM ${res.status}: ${await res.text()}`)
|
|
654
|
+
for await (const line of sseLines(res.body)) {
|
|
655
|
+
if (!line.startsWith("data:")) continue
|
|
656
|
+
const j = safeParse(line.slice(5).trim())
|
|
657
|
+
if (!j) continue
|
|
658
|
+
if (j.type === "message_start") addUsage(usage, j.message?.usage, "anthropic")
|
|
659
|
+
else if (j.type === "content_block_start") blocks.set(j.index, { type: j.content_block.type, id: j.content_block.id, name: j.content_block.name, text: "", json: "" })
|
|
660
|
+
else if (j.type === "content_block_delta") {
|
|
661
|
+
const b = blocks.get(j.index)!
|
|
662
|
+
if (j.delta.type === "text_delta") { b.text += j.delta.text; yield { type: "text", delta: j.delta.text } }
|
|
663
|
+
else if (j.delta.type === "input_json_delta") b.json += j.delta.partial_json
|
|
664
|
+
} else if (j.type === "message_delta") { stopReason = j.delta?.stop_reason ?? stopReason; addUsage(usage, j.usage, "anthropic") }
|
|
665
|
+
}
|
|
666
|
+
} catch (e) {
|
|
667
|
+
this.emit({ event: "llm", model: this.opts.model, status: "error", ms: Date.now() - t0, promptTokens: 0, completionTokens: 0 })
|
|
668
|
+
throw e
|
|
669
|
+
}
|
|
670
|
+
this.emit({ event: "llm", model: this.opts.model, status: "ok", ms: Date.now() - t0, promptTokens: usage.promptTokens - beforeP, completionTokens: usage.completionTokens - beforeC })
|
|
671
|
+
if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: { streamed: true, usage }, model: this.opts.model, turn })
|
|
672
|
+
|
|
673
|
+
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 })
|
|
674
|
+
messages.push({ role: "assistant", content })
|
|
675
|
+
const uses = content.filter((b: any) => b.type === "tool_use")
|
|
676
|
+
if (stopReason !== "tool_use" || uses.length === 0) {
|
|
677
|
+
const text = content.filter((b: any) => b.type === "text").map((b: any) => b.text).join("")
|
|
678
|
+
yield { type: "usage", usage }
|
|
679
|
+
yield { type: "done", result: this.endRun(runStart, text, messages, toolCalls, turns, usage) }
|
|
680
|
+
return
|
|
681
|
+
}
|
|
682
|
+
for (const u of uses as any[]) yield { type: "tool_call", id: u.id, name: u.name, args: u.input }
|
|
683
|
+
const settled = await Promise.all((uses as any[]).map((u) => this.runTool(toolkit, u.name, u.input ?? {}, u.id, turn)))
|
|
684
|
+
const results: any[] = []
|
|
685
|
+
for (let i = 0; i < uses.length; i++) {
|
|
686
|
+
const u = uses[i] as any, { args, result } = settled[i]
|
|
687
|
+
toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
|
|
688
|
+
results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError })
|
|
689
|
+
yield { type: "tool_result", id: u.id, name: u.name, output: result.output, isError: result.isError }
|
|
690
|
+
}
|
|
691
|
+
messages.push({ role: "user", content: results })
|
|
470
692
|
}
|
|
471
|
-
|
|
693
|
+
yield { type: "done", result: this.endRun(runStart, "", messages, toolCalls, turns, usage) }
|
|
694
|
+
} catch (e) {
|
|
695
|
+
this.emitRunError(runStart, toolCalls, turns, usage, e)
|
|
696
|
+
throw e
|
|
472
697
|
}
|
|
473
|
-
yield { type: "done", result: this.result("", messages, toolCalls, turns, usage) }
|
|
474
698
|
}
|
|
475
699
|
}
|
|
476
700
|
|