toolnexus 0.19.0 → 0.21.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.d.ts CHANGED
@@ -207,20 +207,24 @@ export declare class InMemoryConversationStore implements ConversationStore {
207
207
  * Lifecycle hooks around the agent loop. Each may observe, and (where noted)
208
208
  * mutate or short-circuit. All may be async.
209
209
  */
210
+ /** The `beforeLLM` hook event. */
211
+ export interface BeforeLLMEvent {
212
+ messages: any[];
213
+ tools: any[];
214
+ model: string;
215
+ turn: number;
216
+ }
217
+ /** What a `beforeLLM` hook may return. `model` (non-empty) applies to that turn only. */
218
+ export interface LLMOverride {
219
+ messages?: any[];
220
+ tools?: any[];
221
+ model?: string | null;
222
+ }
210
223
  export interface Hooks {
211
- /** Before each model call. Return { messages?, tools? } to replace them. */
212
- beforeLLM?(ev: {
213
- messages: any[];
214
- tools: any[];
215
- model: string;
216
- turn: number;
217
- }): void | {
218
- messages?: any[];
219
- tools?: any[];
220
- } | Promise<void | {
221
- messages?: any[];
222
- tools?: any[];
223
- }>;
224
+ /** Before each model call. Return { messages?, tools? } to replace them, and { model } to send a
225
+ * different model for THIS turn only (request body + afterLLM event); absent/null/empty keeps the
226
+ * configured model verbatim (SPEC §8, e.g. a ModelRouterClassifier). */
227
+ beforeLLM?(ev: BeforeLLMEvent): void | LLMOverride | Promise<void | LLMOverride>;
224
228
  /** After each model call (observe: logging, cost, tracing). */
225
229
  afterLLM?(ev: {
226
230
  response: any;
@@ -412,7 +416,17 @@ export declare class Client {
412
416
  /** §0.10 system message. The toolkit is OPTIONAL — with none there is no skills prompt and
413
417
  * the system message is the system prompt alone. This deref was issue #86 (ADR 0023). */
414
418
  private system;
415
- /** Build a run-scoped abort signal from the optional run-level timeout + an external signal. */
419
+ /** Run-scoped timeout timers, cleared by `releaseSignal` when the run settles. */
420
+ private readonly runTimers;
421
+ /** Drop a run's timeout timer. Idempotent, and safe on a signal that never had one. */
422
+ private releaseSignal;
423
+ /** Build a run-scoped abort signal from the optional run-level timeout + an external signal.
424
+ *
425
+ * The timer is deliberately NOT `unref()`d. An unref'd timer cannot keep the event loop
426
+ * alive, so a run whose only pending work is a request that never settles — an injected
427
+ * `fetch`, an in-process transport, a stalled stream — drains the loop and the timeout never
428
+ * fires at all, which is the one case `timeoutMs` exists for. It is RELEASED instead: the
429
+ * caller clears it however the run leaves, so a finished run holds nothing open. */
416
430
  private makeSignal;
417
431
  /** fetch with retry + exponential backoff on the default retryable set (429/500/502/503/504/529)
418
432
  * plus network errors and anything `retryableStatuses` added, honoring Retry-After; aborts via signal.
@@ -425,6 +439,10 @@ export declare class Client {
425
439
  /** One non-streaming LLM call, with an `llm` metric event (ok/error + per-call tokens + ms). */
426
440
  private llmCallJson;
427
441
  private runOpenAI;
442
+ private runAnthropic;
443
+ private streamOpenAI;
444
+ private streamAnthropic;
445
+ private runOpenAIWithSignal;
428
446
  private result;
429
447
  /** §10: a run halted because a tool suspended and no `waitFor` was configured. */
430
448
  private pendingRun;
@@ -434,9 +452,9 @@ export declare class Client {
434
452
  * no `waitFor` is configured (the run should stop and surface the request).
435
453
  */
436
454
  private resolvePending;
437
- private runAnthropic;
438
- private streamOpenAI;
439
- private streamAnthropic;
455
+ private runAnthropicWithSignal;
456
+ private streamOpenAIWithSignal;
457
+ private streamAnthropicWithSignal;
440
458
  }
441
459
  export declare function createClient(opts: ClientOptions): Client;
442
460
  /** One tool call an in-process model asks for. Flat on purpose: the nested
package/dist/client.js CHANGED
@@ -254,8 +254,8 @@ export class Client {
254
254
  }
255
255
  /** §8 Gap 5. OpenAI-style body; omits tools/tool_choice when the tool list is empty. Key order
256
256
  * matches the pre-change body so a no-options call is byte-identical. */
257
- openaiBody(messages, tools, stream) {
258
- const b = { model: this.opts.model, messages: toOpenAIWire(messages, this.wireOpts()) };
257
+ openaiBody(messages, tools, stream, model = this.opts.model) {
258
+ const b = { model, messages: toOpenAIWire(messages, this.wireOpts()) };
259
259
  if (tools.length) {
260
260
  b.tools = tools;
261
261
  b.tool_choice = "auto";
@@ -267,8 +267,8 @@ export class Client {
267
267
  return this.finalizeBody(b);
268
268
  }
269
269
  /** §8 Gap 5. Anthropic-style body; omits tools when the tool list is empty. */
270
- anthropicBody(system, messages, tools, stream) {
271
- const b = { model: this.opts.model, max_tokens: 4096, system, messages: toAnthropicWire(messages, this.wireOpts()) };
270
+ anthropicBody(system, messages, tools, stream, model = this.opts.model) {
271
+ const b = { model, max_tokens: 4096, system, messages: toAnthropicWire(messages, this.wireOpts()) };
272
272
  if (tools.length)
273
273
  b.tools = tools;
274
274
  if (stream)
@@ -290,13 +290,13 @@ export class Client {
290
290
  }
291
291
  /** Emit the terminal `run` metric event and build the RunResult. `exhausted` marks a
292
292
  * maxTurns loop exit — with no final text that is a LOUD "incomplete", never "done" (§7D). */
293
- endRun(runStart, text, messages, toolCalls, turns, usage, exhausted = false) {
294
- this.emit({ event: "run", model: this.opts.model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart });
295
- return this.result(text, messages, toolCalls, turns, usage, exhausted);
293
+ endRun(runStart, model, text, messages, toolCalls, turns, usage, exhausted = false) {
294
+ this.emit({ event: "run", model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart });
295
+ return this.result(model, text, messages, toolCalls, turns, usage, exhausted);
296
296
  }
297
297
  /** Emit a `run` error metric event (once, on a thrown run). */
298
- emitRunError(runStart, toolCalls, turns, usage, e) {
299
- 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) });
298
+ emitRunError(runStart, model, toolCalls, turns, usage, e) {
299
+ this.emit({ event: "run", model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart, error: e instanceof Error ? e.message : String(e) });
300
300
  }
301
301
  /**
302
302
  * Run the loop. `ctx` — and `ctx.toolkit` inside it — are OPTIONAL: an absent toolkit is a
@@ -334,14 +334,17 @@ export class Client {
334
334
  if (sys && !hasSystemMessage(messages))
335
335
  messages = [{ role: "system", content: sys }, ...messages];
336
336
  let tools = declared;
337
+ let turnModel = this.opts.model;
337
338
  if (this.opts.hooks?.beforeLLM) {
338
339
  const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn: 0 });
339
340
  if (ov?.messages)
340
341
  messages = ov.messages;
341
342
  if (ov?.tools)
342
343
  tools = ov.tools;
344
+ if (ov?.model)
345
+ turnModel = ov.model;
343
346
  }
344
- const body = { model: this.opts.model, messages };
347
+ const body = { model: turnModel, messages };
345
348
  if (tools.length)
346
349
  body.tools = tools;
347
350
  if (req.toolChoice !== undefined)
@@ -349,14 +352,14 @@ export class Client {
349
352
  if (req.maxTokens)
350
353
  body.max_tokens = req.maxTokens;
351
354
  const signal = this.makeSignal(req.signal);
352
- {
355
+ try {
353
356
  const data = await this.llmCallJson(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
354
357
  method: "POST",
355
358
  headers: { "content-type": "application/json", authorization: `Bearer ${key}`, ...this.opts.headers },
356
359
  body: JSON.stringify(this.finalizeBody(body)),
357
- }, signal, "openai");
360
+ }, signal, "openai", turnModel);
358
361
  if (this.opts.hooks?.afterLLM)
359
- await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn: 0 });
362
+ await this.opts.hooks.afterLLM({ response: data, model: turnModel, turn: 0 });
360
363
  const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
361
364
  addUsage(usage, data.usage, "openai");
362
365
  const choice = data.choices?.[0];
@@ -370,10 +373,13 @@ export class Client {
370
373
  toolCalls,
371
374
  finishReason: choice?.finish_reason ?? finishReasonFor(toolCalls.length > 0),
372
375
  usage,
373
- model: this.opts.model,
376
+ model: turnModel,
374
377
  raw: data,
375
378
  };
376
379
  }
380
+ finally {
381
+ this.releaseSignal(signal);
382
+ }
377
383
  }
378
384
  /** Anthropic-style upstream: the real translation. */
379
385
  async translateAnthropic(req) {
@@ -387,15 +393,18 @@ export class Client {
387
393
  ...(req.toolkit ? req.toolkit.toAnthropic() : []),
388
394
  ...openAIToolsToAnthropic(req.tools),
389
395
  ];
396
+ let turnModel = this.opts.model;
390
397
  if (this.opts.hooks?.beforeLLM) {
391
398
  const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn: 0 });
392
399
  if (ov?.messages)
393
400
  messages = ov.messages;
394
401
  if (ov?.tools)
395
402
  tools = ov.tools;
403
+ if (ov?.model)
404
+ turnModel = ov.model;
396
405
  }
397
406
  const body = {
398
- model: this.opts.model,
407
+ model: turnModel,
399
408
  max_tokens: req.maxTokens && req.maxTokens > 0 ? req.maxTokens : 4096,
400
409
  messages,
401
410
  };
@@ -407,7 +416,7 @@ export class Client {
407
416
  if (tc)
408
417
  body.tool_choice = tc;
409
418
  const signal = this.makeSignal(req.signal);
410
- {
419
+ try {
411
420
  const data = await this.llmCallJson(endpoint, {
412
421
  method: "POST",
413
422
  headers: {
@@ -417,9 +426,9 @@ export class Client {
417
426
  ...this.opts.headers,
418
427
  },
419
428
  body: JSON.stringify(this.finalizeBody(body)),
420
- }, signal, "anthropic");
429
+ }, signal, "anthropic", turnModel);
421
430
  if (this.opts.hooks?.afterLLM)
422
- await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn: 0 });
431
+ await this.opts.hooks.afterLLM({ response: data, model: turnModel, turn: 0 });
423
432
  const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
424
433
  addUsage(usage, data.usage, "anthropic");
425
434
  const text = [];
@@ -440,10 +449,13 @@ export class Client {
440
449
  toolCalls,
441
450
  finishReason: finishReasonFor(toolCalls.length > 0, data.stop_reason),
442
451
  usage,
443
- model: this.opts.model,
452
+ model: turnModel,
444
453
  raw: data,
445
454
  };
446
455
  }
456
+ finally {
457
+ this.releaseSignal(signal);
458
+ }
447
459
  }
448
460
  /**
449
461
  * Renders a translate result's tool calls as an OpenAI `tool_calls` array, ready to put
@@ -508,12 +520,28 @@ export class Client {
508
520
  system(toolkit) {
509
521
  return [this.opts.systemPrompt ?? "", toolkit?.skillsPrompt() ?? ""].filter(Boolean).join("\n\n");
510
522
  }
511
- /** Build a run-scoped abort signal from the optional run-level timeout + an external signal. */
523
+ /** Run-scoped timeout timers, cleared by `releaseSignal` when the run settles. */
524
+ runTimers = new WeakMap();
525
+ /** Drop a run's timeout timer. Idempotent, and safe on a signal that never had one. */
526
+ releaseSignal(signal) {
527
+ const t = this.runTimers.get(signal);
528
+ if (t !== undefined) {
529
+ clearTimeout(t);
530
+ this.runTimers.delete(signal);
531
+ }
532
+ }
533
+ /** Build a run-scoped abort signal from the optional run-level timeout + an external signal.
534
+ *
535
+ * The timer is deliberately NOT `unref()`d. An unref'd timer cannot keep the event loop
536
+ * alive, so a run whose only pending work is a request that never settles — an injected
537
+ * `fetch`, an in-process transport, a stalled stream — drains the loop and the timeout never
538
+ * fires at all, which is the one case `timeoutMs` exists for. It is RELEASED instead: the
539
+ * caller clears it however the run leaves, so a finished run holds nothing open. */
512
540
  makeSignal(external) {
513
541
  const ctrl = new AbortController();
514
542
  if (this.opts.timeoutMs) {
515
543
  const t = setTimeout(() => ctrl.abort(new Error(`run timeout after ${this.opts.timeoutMs}ms`)), this.opts.timeoutMs);
516
- t.unref?.();
544
+ this.runTimers.set(ctrl.signal, t);
517
545
  }
518
546
  if (external) {
519
547
  if (external.aborted)
@@ -596,7 +624,7 @@ export class Client {
596
624
  this.emit({ event: "tool", tool: name, source, isError: req ? false : result.isError, ms: Date.now() - t0, ...(req ? { pending: true } : {}) });
597
625
  }
598
626
  /** One non-streaming LLM call, with an `llm` metric event (ok/error + per-call tokens + ms). */
599
- async llmCallJson(url, init, signal, style) {
627
+ async llmCallJson(url, init, signal, style, model) {
600
628
  const t0 = Date.now();
601
629
  try {
602
630
  const res = await this.llmFetch(url, init, signal);
@@ -604,18 +632,55 @@ export class Client {
604
632
  throw await llmHttpError(res);
605
633
  const data = await res.json();
606
634
  const tok = perCall(data.usage, style);
607
- this.emit({ event: "llm", model: this.opts.model, status: "ok", ms: Date.now() - t0, promptTokens: tok.prompt, completionTokens: tok.completion });
635
+ this.emit({ event: "llm", model, status: "ok", ms: Date.now() - t0, promptTokens: tok.prompt, completionTokens: tok.completion });
608
636
  return data;
609
637
  }
610
638
  catch (e) {
611
- this.emit({ event: "llm", model: this.opts.model, status: "error", ms: Date.now() - t0, promptTokens: 0, completionTokens: 0 });
639
+ this.emit({ event: "llm", model, status: "error", ms: Date.now() - t0, promptTokens: 0, completionTokens: 0 });
612
640
  throw e;
613
641
  }
614
642
  }
615
- // ---- OpenAI-style: POST {baseUrl}/chat/completions ----
643
+ // The run-scoped timeout timer is owned by these four wrappers, so it is cleared however the
644
+ // run leaves: return, throw, or a consumer abandoning the stream half-way.
616
645
  async runOpenAI(prompt, toolkit, external, history) {
617
- const key = resolveKey(this.opts);
618
646
  const signal = this.makeSignal(external);
647
+ try {
648
+ return await this.runOpenAIWithSignal(signal, prompt, toolkit, history);
649
+ }
650
+ finally {
651
+ this.releaseSignal(signal);
652
+ }
653
+ }
654
+ async runAnthropic(prompt, toolkit, external, history) {
655
+ const signal = this.makeSignal(external);
656
+ try {
657
+ return await this.runAnthropicWithSignal(signal, prompt, toolkit, history);
658
+ }
659
+ finally {
660
+ this.releaseSignal(signal);
661
+ }
662
+ }
663
+ async *streamOpenAI(prompt, toolkit, external, history) {
664
+ const signal = this.makeSignal(external);
665
+ try {
666
+ yield* this.streamOpenAIWithSignal(signal, prompt, toolkit, history);
667
+ }
668
+ finally {
669
+ this.releaseSignal(signal);
670
+ }
671
+ }
672
+ async *streamAnthropic(prompt, toolkit, external, history) {
673
+ const signal = this.makeSignal(external);
674
+ try {
675
+ yield* this.streamAnthropicWithSignal(signal, prompt, toolkit, history);
676
+ }
677
+ finally {
678
+ this.releaseSignal(signal);
679
+ }
680
+ }
681
+ // ---- OpenAI-style: POST {baseUrl}/chat/completions ----
682
+ async runOpenAIWithSignal(signal, prompt, toolkit, history) {
683
+ const key = resolveKey(this.opts);
619
684
  const runStart = Date.now();
620
685
  let messages = history && history.length ? [...history] : [];
621
686
  if (!messages.length) {
@@ -628,29 +693,35 @@ export class Client {
628
693
  const toolCalls = [];
629
694
  const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
630
695
  let turns = 0;
696
+ // The model of the last call this run made (a beforeLLM override when one applied) — §8.
697
+ let lastModel = this.opts.model;
631
698
  try {
632
699
  for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
633
700
  turns++;
701
+ let turnModel = this.opts.model;
634
702
  if (this.opts.hooks?.beforeLLM) {
635
703
  const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn });
636
704
  if (ov?.messages)
637
705
  messages = ov.messages;
638
706
  if (ov?.tools)
639
707
  tools = ov.tools;
708
+ if (ov?.model)
709
+ turnModel = ov.model;
640
710
  }
711
+ lastModel = turnModel;
641
712
  const data = await this.llmCallJson(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
642
713
  method: "POST",
643
714
  headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
644
- body: JSON.stringify(this.openaiBody(messages, tools, false)),
645
- }, signal, "openai");
715
+ body: JSON.stringify(this.openaiBody(messages, tools, false, turnModel)),
716
+ }, signal, "openai", turnModel);
646
717
  addUsage(usage, data.usage, "openai");
647
718
  if (this.opts.hooks?.afterLLM)
648
- await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn });
719
+ await this.opts.hooks.afterLLM({ response: data, model: turnModel, turn });
649
720
  const msg = data.choices[0].message;
650
721
  messages.push(msg);
651
722
  const calls = msg.tool_calls ?? [];
652
723
  if (calls.length === 0)
653
- return this.endRun(runStart, msg.content ?? "", messages, toolCalls, turns, usage);
724
+ return this.endRun(runStart, lastModel, msg.content ?? "", messages, toolCalls, turns, usage);
654
725
  // execute all tool calls in this turn concurrently (true parallel tool calling)
655
726
  const settled = await Promise.all(calls.map(async (call) => {
656
727
  let { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn);
@@ -670,27 +741,27 @@ export class Client {
670
741
  toolCalls.push({ name: s.call.function.name, args: s.args, output: s.result.output, isError: s.result.isError, metadata: s.result.metadata });
671
742
  messages.push(toolMessage(s.call.id, s.call.function.name, s.result));
672
743
  if (s.halted)
673
- return this.pendingRun(runStart, s.halted, messages, toolCalls, turns, usage);
744
+ return this.pendingRun(runStart, lastModel, s.halted, messages, toolCalls, turns, usage);
674
745
  }
675
746
  }
676
- return this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage, true);
747
+ return this.endRun(runStart, lastModel, lastText(messages), messages, toolCalls, turns, usage, true);
677
748
  }
678
749
  catch (e) {
679
- this.emitRunError(runStart, toolCalls, turns, usage, e);
750
+ this.emitRunError(runStart, lastModel, toolCalls, turns, usage, e);
680
751
  throw e;
681
752
  }
682
753
  }
683
- result(text, messages, toolCalls, turns, usage, exhausted = false) {
754
+ result(model, text, messages, toolCalls, turns, usage, exhausted = false) {
684
755
  const incomplete = exhausted && text === "";
685
- const r = { text, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model, status: incomplete ? "incomplete" : "done" };
756
+ const r = { text, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model, status: incomplete ? "incomplete" : "done" };
686
757
  if (incomplete)
687
758
  r.limit = "maxTurns";
688
759
  return r;
689
760
  }
690
761
  /** §10: a run halted because a tool suspended and no `waitFor` was configured. */
691
- pendingRun(runStart, request, messages, toolCalls, turns, usage) {
692
- this.emit({ event: "run", model: this.opts.model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart });
693
- return { text: request.prompt, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model, status: "pending", pending: request };
762
+ pendingRun(runStart, model, request, messages, toolCalls, turns, usage) {
763
+ this.emit({ event: "run", model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart });
764
+ return { text: request.prompt, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model, status: "pending", pending: request };
694
765
  }
695
766
  /**
696
767
  * §10: resolve a suspended tool result. Calls `waitFor(request)`, and on `ok` re-executes the
@@ -718,9 +789,8 @@ export class Client {
718
789
  return { result };
719
790
  }
720
791
  // ---- Anthropic-style: POST {baseUrl}/messages ----
721
- async runAnthropic(prompt, toolkit, external, history) {
792
+ async runAnthropicWithSignal(signal, prompt, toolkit, history) {
722
793
  const key = resolveKey(this.opts);
723
- const signal = this.makeSignal(external);
724
794
  const base = this.opts.baseUrl.replace(/\/$/, "");
725
795
  const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`;
726
796
  const system = this.system(toolkit);
@@ -730,16 +800,22 @@ export class Client {
730
800
  const toolCalls = [];
731
801
  const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
732
802
  let turns = 0;
803
+ // The model of the last call this run made (a beforeLLM override when one applied) — §8.
804
+ let lastModel = this.opts.model;
733
805
  try {
734
806
  for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
735
807
  turns++;
808
+ let turnModel = this.opts.model;
736
809
  if (this.opts.hooks?.beforeLLM) {
737
810
  const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn });
738
811
  if (ov?.messages)
739
812
  messages = ov.messages;
740
813
  if (ov?.tools)
741
814
  tools = ov.tools;
815
+ if (ov?.model)
816
+ turnModel = ov.model;
742
817
  }
818
+ lastModel = turnModel;
743
819
  const data = await this.llmCallJson(endpoint, {
744
820
  method: "POST",
745
821
  headers: {
@@ -748,16 +824,16 @@ export class Client {
748
824
  "Content-Type": "application/json",
749
825
  ...this.opts.headers,
750
826
  },
751
- body: JSON.stringify(this.anthropicBody(system, messages, tools, false)),
752
- }, signal, "anthropic");
827
+ body: JSON.stringify(this.anthropicBody(system, messages, tools, false, turnModel)),
828
+ }, signal, "anthropic", turnModel);
753
829
  addUsage(usage, data.usage, "anthropic");
754
830
  if (this.opts.hooks?.afterLLM)
755
- await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn });
831
+ await this.opts.hooks.afterLLM({ response: data, model: turnModel, turn });
756
832
  messages.push({ role: "assistant", content: data.content });
757
833
  const uses = (data.content ?? []).filter((b) => b.type === "tool_use");
758
834
  if (uses.length === 0) {
759
835
  const text = (data.content ?? []).filter((b) => b.type === "text").map((b) => b.text).join("");
760
- return this.endRun(runStart, text, messages, toolCalls, turns, usage);
836
+ return this.endRun(runStart, lastModel, text, messages, toolCalls, turns, usage);
761
837
  }
762
838
  // execute all tool_use blocks in this turn concurrently (true parallel tool calling)
763
839
  const settled = await Promise.all(uses.map(async (use) => {
@@ -786,19 +862,18 @@ export class Client {
786
862
  }
787
863
  messages.push({ role: "user", content });
788
864
  if (halted)
789
- return this.pendingRun(runStart, halted, messages, toolCalls, turns, usage);
865
+ return this.pendingRun(runStart, lastModel, halted, messages, toolCalls, turns, usage);
790
866
  }
791
- return this.endRun(runStart, "", messages, toolCalls, turns, usage, true);
867
+ return this.endRun(runStart, lastModel, "", messages, toolCalls, turns, usage, true);
792
868
  }
793
869
  catch (e) {
794
- this.emitRunError(runStart, toolCalls, turns, usage, e);
870
+ this.emitRunError(runStart, lastModel, toolCalls, turns, usage, e);
795
871
  throw e;
796
872
  }
797
873
  }
798
874
  // ---- Streaming: OpenAI-style ----
799
- async *streamOpenAI(prompt, toolkit, external, history) {
875
+ async *streamOpenAIWithSignal(signal, prompt, toolkit, history) {
800
876
  const key = resolveKey(this.opts);
801
- const signal = this.makeSignal(external);
802
877
  const runStart = Date.now();
803
878
  let messages = history && history.length ? [...history] : [];
804
879
  if (!messages.length) {
@@ -811,16 +886,22 @@ export class Client {
811
886
  const toolCalls = [];
812
887
  const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
813
888
  let turns = 0;
889
+ // The model of the last call this run made (a beforeLLM override when one applied) — §8.
890
+ let lastModel = this.opts.model;
814
891
  try {
815
892
  for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
816
893
  turns++;
894
+ let turnModel = this.opts.model;
817
895
  if (this.opts.hooks?.beforeLLM) {
818
896
  const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn });
819
897
  if (ov?.messages)
820
898
  messages = ov.messages;
821
899
  if (ov?.tools)
822
900
  tools = ov.tools;
901
+ if (ov?.model)
902
+ turnModel = ov.model;
823
903
  }
904
+ lastModel = turnModel;
824
905
  const t0 = Date.now();
825
906
  const beforeP = usage.promptTokens, beforeC = usage.completionTokens;
826
907
  let content = "";
@@ -829,7 +910,7 @@ export class Client {
829
910
  const res = await this.llmFetch(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
830
911
  method: "POST",
831
912
  headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
832
- body: JSON.stringify(this.openaiBody(messages, tools, true)),
913
+ body: JSON.stringify(this.openaiBody(messages, tools, true, turnModel)),
833
914
  }, signal);
834
915
  if (!res.ok || !res.body)
835
916
  throw await llmHttpError(res);
@@ -861,17 +942,17 @@ export class Client {
861
942
  }
862
943
  }
863
944
  catch (e) {
864
- this.emit({ event: "llm", model: this.opts.model, status: "error", ms: Date.now() - t0, promptTokens: 0, completionTokens: 0 });
945
+ this.emit({ event: "llm", model: turnModel, status: "error", ms: Date.now() - t0, promptTokens: 0, completionTokens: 0 });
865
946
  throw e;
866
947
  }
867
- this.emit({ event: "llm", model: this.opts.model, status: "ok", ms: Date.now() - t0, promptTokens: usage.promptTokens - beforeP, completionTokens: usage.completionTokens - beforeC });
948
+ this.emit({ event: "llm", model: turnModel, status: "ok", ms: Date.now() - t0, promptTokens: usage.promptTokens - beforeP, completionTokens: usage.completionTokens - beforeC });
868
949
  if (this.opts.hooks?.afterLLM)
869
- await this.opts.hooks.afterLLM({ response: { streamed: true, usage }, model: this.opts.model, turn });
950
+ await this.opts.hooks.afterLLM({ response: { streamed: true, usage }, model: turnModel, turn });
870
951
  const calls = [...acc.values()];
871
952
  if (calls.length === 0) {
872
953
  messages.push({ role: "assistant", content });
873
954
  yield { type: "usage", usage };
874
- yield { type: "done", result: this.endRun(runStart, content, messages, toolCalls, turns, usage) };
955
+ yield { type: "done", result: this.endRun(runStart, lastModel, content, messages, toolCalls, turns, usage) };
875
956
  return;
876
957
  }
877
958
  messages.push({ role: "assistant", content: content || null, tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.args } })) });
@@ -889,7 +970,7 @@ export class Client {
889
970
  if (r.halted) {
890
971
  toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
891
972
  messages.push(toolMessage(c.id, c.name, result));
892
- yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) };
973
+ yield { type: "done", result: this.pendingRun(runStart, lastModel, r.halted, messages, toolCalls, turns, usage) };
893
974
  return;
894
975
  }
895
976
  }
@@ -898,17 +979,16 @@ export class Client {
898
979
  yield { type: "tool_result", id: c.id, name: c.name, output: result.output, isError: result.isError };
899
980
  }
900
981
  }
901
- yield { type: "done", result: this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage, true) };
982
+ yield { type: "done", result: this.endRun(runStart, lastModel, lastText(messages), messages, toolCalls, turns, usage, true) };
902
983
  }
903
984
  catch (e) {
904
- this.emitRunError(runStart, toolCalls, turns, usage, e);
985
+ this.emitRunError(runStart, lastModel, toolCalls, turns, usage, e);
905
986
  throw e;
906
987
  }
907
988
  }
908
989
  // ---- Streaming: Anthropic-style ----
909
- async *streamAnthropic(prompt, toolkit, external, history) {
990
+ async *streamAnthropicWithSignal(signal, prompt, toolkit, history) {
910
991
  const key = resolveKey(this.opts);
911
- const signal = this.makeSignal(external);
912
992
  const runStart = Date.now();
913
993
  const base = this.opts.baseUrl.replace(/\/$/, "");
914
994
  const endpoint = base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`;
@@ -918,16 +998,22 @@ export class Client {
918
998
  const toolCalls = [];
919
999
  const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
920
1000
  let turns = 0;
1001
+ // The model of the last call this run made (a beforeLLM override when one applied) — §8.
1002
+ let lastModel = this.opts.model;
921
1003
  try {
922
1004
  for (let turn = 0; turn < (this.opts.maxTurns ?? 10); turn++) {
923
1005
  turns++;
1006
+ let turnModel = this.opts.model;
924
1007
  if (this.opts.hooks?.beforeLLM) {
925
1008
  const ov = await this.opts.hooks.beforeLLM({ messages, tools, model: this.opts.model, turn });
926
1009
  if (ov?.messages)
927
1010
  messages = ov.messages;
928
1011
  if (ov?.tools)
929
1012
  tools = ov.tools;
1013
+ if (ov?.model)
1014
+ turnModel = ov.model;
930
1015
  }
1016
+ lastModel = turnModel;
931
1017
  const t0 = Date.now();
932
1018
  const beforeP = usage.promptTokens, beforeC = usage.completionTokens;
933
1019
  const blocks = new Map();
@@ -936,7 +1022,7 @@ export class Client {
936
1022
  const res = await this.llmFetch(endpoint, {
937
1023
  method: "POST",
938
1024
  headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "Content-Type": "application/json", ...this.opts.headers },
939
- body: JSON.stringify(this.anthropicBody(system, messages, tools, true)),
1025
+ body: JSON.stringify(this.anthropicBody(system, messages, tools, true, turnModel)),
940
1026
  }, signal);
941
1027
  if (!res.ok || !res.body)
942
1028
  throw await llmHttpError(res);
@@ -966,19 +1052,19 @@ export class Client {
966
1052
  }
967
1053
  }
968
1054
  catch (e) {
969
- this.emit({ event: "llm", model: this.opts.model, status: "error", ms: Date.now() - t0, promptTokens: 0, completionTokens: 0 });
1055
+ this.emit({ event: "llm", model: turnModel, status: "error", ms: Date.now() - t0, promptTokens: 0, completionTokens: 0 });
970
1056
  throw e;
971
1057
  }
972
- this.emit({ event: "llm", model: this.opts.model, status: "ok", ms: Date.now() - t0, promptTokens: usage.promptTokens - beforeP, completionTokens: usage.completionTokens - beforeC });
1058
+ this.emit({ event: "llm", model: turnModel, status: "ok", ms: Date.now() - t0, promptTokens: usage.promptTokens - beforeP, completionTokens: usage.completionTokens - beforeC });
973
1059
  if (this.opts.hooks?.afterLLM)
974
- await this.opts.hooks.afterLLM({ response: { streamed: true, usage }, model: this.opts.model, turn });
1060
+ await this.opts.hooks.afterLLM({ response: { streamed: true, usage }, model: turnModel, turn });
975
1061
  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 });
976
1062
  messages.push({ role: "assistant", content });
977
1063
  const uses = content.filter((b) => b.type === "tool_use");
978
1064
  if (stopReason !== "tool_use" || uses.length === 0) {
979
1065
  const text = content.filter((b) => b.type === "text").map((b) => b.text).join("");
980
1066
  yield { type: "usage", usage };
981
- yield { type: "done", result: this.endRun(runStart, text, messages, toolCalls, turns, usage) };
1067
+ yield { type: "done", result: this.endRun(runStart, lastModel, text, messages, toolCalls, turns, usage) };
982
1068
  return;
983
1069
  }
984
1070
  for (const u of uses)
@@ -997,7 +1083,7 @@ export class Client {
997
1083
  toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
998
1084
  results.push(toolResultBlock(u.id, result));
999
1085
  messages.push({ role: "user", content: results });
1000
- yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) };
1086
+ yield { type: "done", result: this.pendingRun(runStart, lastModel, r.halted, messages, toolCalls, turns, usage) };
1001
1087
  return;
1002
1088
  }
1003
1089
  }
@@ -1007,10 +1093,10 @@ export class Client {
1007
1093
  }
1008
1094
  messages.push({ role: "user", content: results });
1009
1095
  }
1010
- yield { type: "done", result: this.endRun(runStart, "", messages, toolCalls, turns, usage, true) };
1096
+ yield { type: "done", result: this.endRun(runStart, lastModel, "", messages, toolCalls, turns, usage, true) };
1011
1097
  }
1012
1098
  catch (e) {
1013
- this.emitRunError(runStart, toolCalls, turns, usage, e);
1099
+ this.emitRunError(runStart, lastModel, toolCalls, turns, usage, e);
1014
1100
  throw e;
1015
1101
  }
1016
1102
  }
package/dist/index.d.ts CHANGED
@@ -16,3 +16,6 @@ export * from "./toolkit.js";
16
16
  export * from "./client.js";
17
17
  export * from "./classifier.js";
18
18
  export * as agents from "./agents/index.js";
19
+ export * as judge from "./judge.js";
20
+ export * from "./batteries.js";
21
+ export { State, context, questionMap, ask, gate, decide, applyGate, applyPolicy, answersOf, staticClassifier, Tape, DEFAULT_BANDS, type NamedQuestion, type Band, type Bands, type Answers, type Rule, type GateOutcome, type Policy, } from "./judge.js";
package/dist/index.js CHANGED
@@ -16,3 +16,9 @@ export * from "./toolkit.js";
16
16
  export * from "./client.js";
17
17
  export * from "./classifier.js";
18
18
  export * as agents from "./agents/index.js";
19
+ // Simple judgments (§8B, add-judge-adapters). `judge.noul/choice/score` are the NAMED builders
20
+ // (the bare `noul/choice/score` above stay the §8B wire builders); the rest is also top-level.
21
+ export * as judge from "./judge.js";
22
+ // Judge batteries (§8B "Batteries", add-judge-batteries): eight `*Classifier` values + latestUserText.
23
+ export * from "./batteries.js";
24
+ export { State, context, questionMap, ask, gate, decide, applyGate, applyPolicy, answersOf, staticClassifier, Tape, DEFAULT_BANDS, } from "./judge.js";