toolnexus 0.15.0 → 0.17.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
@@ -1,4 +1,5 @@
1
1
  import { pendingOf } from "./types.js";
2
+ import { toOpenAIWire, toAnthropicWire, checkPromptParts } from "./wire.js";
2
3
  import { finishReasonFor, argsString, openAIMessagesToAnthropic, openAIToolsToAnthropic, openAIToolChoiceToAnthropic, hasSystemMessage, } from "./translate.js";
3
4
  /** Default conversation provider — keeps transcripts in memory for the client's lifetime. */
4
5
  export class InMemoryConversationStore {
@@ -207,7 +208,7 @@ export class Client {
207
208
  /** §8 Gap 5. OpenAI-style body; omits tools/tool_choice when the tool list is empty. Key order
208
209
  * matches the pre-change body so a no-options call is byte-identical. */
209
210
  openaiBody(messages, tools, stream) {
210
- const b = { model: this.opts.model, messages };
211
+ const b = { model: this.opts.model, messages: toOpenAIWire(messages, this.wireOpts()) };
211
212
  if (tools.length) {
212
213
  b.tools = tools;
213
214
  b.tool_choice = "auto";
@@ -220,13 +221,17 @@ export class Client {
220
221
  }
221
222
  /** §8 Gap 5. Anthropic-style body; omits tools when the tool list is empty. */
222
223
  anthropicBody(system, messages, tools, stream) {
223
- const b = { model: this.opts.model, max_tokens: 4096, system, messages };
224
+ const b = { model: this.opts.model, max_tokens: 4096, system, messages: toAnthropicWire(messages, this.wireOpts()) };
224
225
  if (tools.length)
225
226
  b.tools = tools;
226
227
  if (stream)
227
228
  b.stream = true;
228
229
  return this.finalizeBody(b);
229
230
  }
231
+ /** The §8A/§1B shaping options every wire build shares. */
232
+ wireOpts() {
233
+ return { onUnsupportedPart: this.opts.onUnsupportedPart, maxPartBytes: this.opts.maxPartBytes };
234
+ }
230
235
  /** Prometheus text exposition of cumulative metrics (§8). Always valid, empty-but-valid before activity. */
231
236
  metrics() {
232
237
  return this.registry.render();
@@ -247,6 +252,7 @@ export class Client {
247
252
  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) });
248
253
  }
249
254
  async run(prompt, ctx) {
255
+ checkPromptParts(prompt, this.wireOpts());
250
256
  return this.opts.style === "anthropic"
251
257
  ? this.runAnthropic(prompt, ctx.toolkit, ctx.signal, ctx.history)
252
258
  : this.runOpenAI(prompt, ctx.toolkit, ctx.signal, ctx.history);
@@ -433,6 +439,7 @@ export class Client {
433
439
  * streaming, and saved back to the ConversationStore on the terminal `done` event. No `id` ⇒ stateless.
434
440
  */
435
441
  async *stream(prompt, ctx) {
442
+ checkPromptParts(prompt, this.wireOpts());
436
443
  const history = ctx.id ? (await this.store.get(ctx.id)) ?? [] : undefined;
437
444
  const gen = this.opts.style === "anthropic"
438
445
  ? this.streamAnthropic(prompt, ctx.toolkit, ctx.signal, history)
@@ -602,7 +609,7 @@ export class Client {
602
609
  // on resume). Mirrors the streaming path.
603
610
  for (const s of settled) {
604
611
  toolCalls.push({ name: s.call.function.name, args: s.args, output: s.result.output, isError: s.result.isError, metadata: s.result.metadata });
605
- messages.push({ role: "tool", tool_call_id: s.call.id, content: s.result.output });
612
+ messages.push(toolMessage(s.call.id, s.call.function.name, s.result));
606
613
  if (s.halted)
607
614
  return this.pendingRun(runStart, s.halted, messages, toolCalls, turns, usage);
608
615
  }
@@ -710,7 +717,7 @@ export class Client {
710
717
  let halted;
711
718
  for (const s of settled) {
712
719
  toolCalls.push({ name: s.use.name, args: s.args, output: s.result.output, isError: s.result.isError, metadata: s.result.metadata });
713
- content.push({ type: "tool_result", tool_use_id: s.use.id, content: s.result.output, is_error: s.result.isError });
720
+ content.push(toolResultBlock(s.use.id, s.result));
714
721
  if (s.halted) {
715
722
  halted = s.halted;
716
723
  break;
@@ -820,13 +827,13 @@ export class Client {
820
827
  result = r.result;
821
828
  if (r.halted) {
822
829
  toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
823
- messages.push({ role: "tool", tool_call_id: c.id, content: result.output });
830
+ messages.push(toolMessage(c.id, c.name, result));
824
831
  yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) };
825
832
  return;
826
833
  }
827
834
  }
828
835
  toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
829
- messages.push({ role: "tool", tool_call_id: c.id, content: result.output });
836
+ messages.push(toolMessage(c.id, c.name, result));
830
837
  yield { type: "tool_result", id: c.id, name: c.name, output: result.output, isError: result.isError };
831
838
  }
832
839
  }
@@ -927,14 +934,14 @@ export class Client {
927
934
  result = r.result;
928
935
  if (r.halted) {
929
936
  toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
930
- results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError });
937
+ results.push(toolResultBlock(u.id, result));
931
938
  messages.push({ role: "user", content: results });
932
939
  yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) };
933
940
  return;
934
941
  }
935
942
  }
936
943
  toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
937
- results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError });
944
+ results.push(toolResultBlock(u.id, result));
938
945
  yield { type: "tool_result", id: u.id, name: u.name, output: result.output, isError: result.isError };
939
946
  }
940
947
  messages.push({ role: "user", content: results });
@@ -950,6 +957,84 @@ export class Client {
950
957
  export function createClient(opts) {
951
958
  return new Client(opts);
952
959
  }
960
+ /** The sentinel base URL. Never dialled — the transport below answers every request
961
+ * before the network is reached — but the client builds a URL string internally, so
962
+ * it must be syntactically valid. `.invalid` is reserved by RFC 2606 precisely so a
963
+ * name can never resolve. */
964
+ const IN_PROCESS_BASE_URL = "http://in-process.invalid/v1";
965
+ /**
966
+ * A client backed by a model running IN THIS PROCESS — no server, no socket, and no
967
+ * HTTP types to construct.
968
+ *
969
+ * This is a second constructor, not a second seam: it builds on the same injectable
970
+ * transport that `fetch`/`httpClient` uses, so the tool-calling loop, MCP servers,
971
+ * skills, sub-agents, hooks, metrics and the completion gate all behave identically.
972
+ *
973
+ * Streaming is refused rather than faked — see the thrown error.
974
+ */
975
+ export function createInProcessClient(opts) {
976
+ const { generate, ...rest } = opts;
977
+ if (typeof generate !== "function") {
978
+ throw new Error("toolnexus: createInProcessClient requires a `generate` function");
979
+ }
980
+ const inProcessFetch = async (_url, init) => {
981
+ const body = JSON.parse(String(init?.body ?? "{}"));
982
+ // A generate() returns a whole answer, so a stream would be one chunk pretending
983
+ // to be many. Content and delta-count assertions cannot tell that from a real
984
+ // stream, so refuse instead of degrading silently.
985
+ if (body.stream) {
986
+ throw new Error("toolnexus: createInProcessClient does not support streaming — `generate` returns a " +
987
+ "complete answer. Use the non-streaming run, or supply a `fetch`/transport that streams.");
988
+ }
989
+ const answer = await generate({
990
+ messages: body.messages ?? [],
991
+ tools: body.tools,
992
+ model: body.model,
993
+ body,
994
+ });
995
+ const message = { role: "assistant" };
996
+ if (answer.toolCalls && answer.toolCalls.length > 0) {
997
+ message.tool_calls = answer.toolCalls.map((c, i) => ({
998
+ id: c.id ?? `call_${i}`,
999
+ type: "function",
1000
+ function: {
1001
+ name: c.name,
1002
+ // A string is already encoded; anything else is encoded here so a model
1003
+ // author never has to think about the wire.
1004
+ arguments: typeof c.arguments === "string" ? c.arguments : JSON.stringify(c.arguments ?? {}),
1005
+ },
1006
+ }));
1007
+ }
1008
+ else {
1009
+ message.content = answer.content ?? "";
1010
+ }
1011
+ const u = answer.usage ?? {};
1012
+ const prompt = u.promptTokens ?? 0;
1013
+ const completion = u.completionTokens ?? 0;
1014
+ return new Response(JSON.stringify({
1015
+ choices: [{ index: 0, message, finish_reason: message.tool_calls ? "tool_calls" : "stop" }],
1016
+ usage: {
1017
+ prompt_tokens: prompt,
1018
+ completion_tokens: completion,
1019
+ total_tokens: u.totalTokens ?? prompt + completion,
1020
+ },
1021
+ }), { status: 200, headers: { "content-type": "application/json" } });
1022
+ };
1023
+ return new Client({
1024
+ // Retries default to ZERO here, unlike a network client. There is no wire, so
1025
+ // there is no transient failure to ride out: whatever `generate` throws will
1026
+ // throw again. Retrying it only buys backoff before the user sees their own bug —
1027
+ // measured at 3.7s for the streaming refusal before this default. A host whose
1028
+ // model really is flaky (a GPU that OOMs, say) can still set `retries`.
1029
+ retries: 0,
1030
+ ...rest,
1031
+ baseUrl: IN_PROCESS_BASE_URL,
1032
+ style: "openai",
1033
+ // An in-process model has no endpoint to authenticate to, so the host must never need a key — but the client resolves one from the environment and fails when it finds none. A sentinel keeps that resolution from ever running. Caught by CI, which has no OPENROUTER_API_KEY; every local run passed because a developer shell has one.
1034
+ apiKey: "in-process",
1035
+ fetch: inProcessFetch,
1036
+ });
1037
+ }
953
1038
  /** Stateful multi-turn conversation: each send() continues the same transcript (memory). */
954
1039
  export class Conversation {
955
1040
  client;
@@ -1008,6 +1093,26 @@ function safeJson(s) {
1008
1093
  return {};
1009
1094
  }
1010
1095
  }
1096
+ /**
1097
+ * A canonical `tool` turn. With no parts this is byte-identical to the pre-0.17 message;
1098
+ * with parts it also carries them (and the tool `name` the §8A relocation header needs) —
1099
+ * both consumed and stripped by the wire build, so neither reaches OpenAI.
1100
+ */
1101
+ function toolMessage(id, name, result) {
1102
+ const m = { role: "tool", tool_call_id: id, content: result.output };
1103
+ if (result.parts?.length) {
1104
+ m.name = name;
1105
+ m.parts = result.parts;
1106
+ }
1107
+ return m;
1108
+ }
1109
+ /** A canonical Anthropic `tool_result` block; parts ride along and are emitted natively. */
1110
+ function toolResultBlock(id, result) {
1111
+ const b = { type: "tool_result", tool_use_id: id, content: result.output, is_error: result.isError };
1112
+ if (result.parts?.length)
1113
+ b.parts = result.parts;
1114
+ return b;
1115
+ }
1011
1116
  function lastText(messages) {
1012
1117
  for (let i = messages.length - 1; i >= 0; i--) {
1013
1118
  if (messages[i].role === "assistant" && typeof messages[i].content === "string")
@@ -0,0 +1,158 @@
1
+ export type ContentPart = {
2
+ type: "text";
3
+ text: string;
4
+ } | {
5
+ type: "image";
6
+ mimeType: string;
7
+ data?: string;
8
+ url?: string;
9
+ } | {
10
+ type: "file";
11
+ mimeType: string;
12
+ data?: string;
13
+ url?: string;
14
+ name?: string;
15
+ } | {
16
+ type: "audio";
17
+ mimeType: string;
18
+ data?: string;
19
+ url?: string;
20
+ };
21
+ /** A prompt is a string or an ordered list of parts — ordering is semantic to a model. */
22
+ export type PromptInput = string | ContentPart[];
23
+ /** The two provider styles that have a request path (`toGemini` is declarations only). */
24
+ export type PartStyle = "openai" | "anthropic";
25
+ /** How a part got here. Drives the unsupported-part rule (§8A): the caller asked for an
26
+ * attached part, so silently changing it is the betrayal; nobody asked for a part an MCP
27
+ * server volunteered, so failing the run over it is a regression. */
28
+ export type PartProvenance = "attached" | "derived";
29
+ /** What to do with a part the style cannot represent; overrides provenance uniformly. */
30
+ export type UnsupportedPartMode = "error" | "text";
31
+ /** Why a part could not be constructed or sent (§1B / §8A). `code` is the machine-readable half. */
32
+ export declare class ContentPartError extends Error {
33
+ readonly code: "source-conflict" | "source-missing" | "unknown-extension" | "too-large" | "unsupported";
34
+ constructor(message: string, code: "source-conflict" | "source-missing" | "unknown-extension" | "too-large" | "unsupported");
35
+ }
36
+ /**
37
+ * The media extension table (§6 `read`) — fixed, shared with the edge constructors,
38
+ * identical in every port. No magic-byte sniffing and no platform mime database:
39
+ * `/etc/mime.types` varies per machine and would break cross-port parity.
40
+ */
41
+ export declare const MEDIA_TYPES: Record<string, {
42
+ mimeType: string;
43
+ type: "image" | "file" | "audio";
44
+ }>;
45
+ /** Lower-cased extension without the dot, or "" when there is none. */
46
+ export declare function extensionOf(p: string): string;
47
+ /** The table entry for a path/URL, or undefined when the extension is not media. */
48
+ export declare function mediaTypeFor(p: string): {
49
+ mimeType: string;
50
+ type: "image" | "file" | "audio";
51
+ } | undefined;
52
+ /** Part kind for a mime type: `image/*` ⇒ image, `audio/*` ⇒ audio, everything else ⇒ file. */
53
+ export declare function partTypeForMime(mimeType: string): "image" | "file" | "audio";
54
+ /** Decoded byte length of standard, padded base64 — without decoding it (RFC 4648 §4:
55
+ * `floor(len/4)*3 - padCount`). `<bytes>` in every rendered string is a plain integer, never
56
+ * a fraction, so this floors the group count rather than assuming `len` is a multiple of 4. */
57
+ export declare function base64Bytes(b64: string): number;
58
+ /** Decoded byte length of a part's payload; a url-only part carries no bytes here. */
59
+ export declare function partBytes(part: ContentPart): number;
60
+ /** §1B token estimate for a part, from its decoded byte count: `max(85, floor(bytes/750))`.
61
+ * Measured against real providers: image cost is not proportional to bytes, so this is an
62
+ * explicit estimate, not a count — but it is normative and lives with the part logic so a
63
+ * budgeting helper (e.g. `agents.estimateTokens`) cannot silently diverge from it. */
64
+ export declare const PART_BYTES_PER_TOKEN = 750;
65
+ export declare const MIN_PART_TOKENS = 85;
66
+ export declare function partTokens(bytes: number): number;
67
+ /**
68
+ * How a part appears in a log line or a §9 event: `{type, mimeType, bytes}` — never `data`.
69
+ * Same rule as never-log-headers: part payloads are user content.
70
+ */
71
+ export declare function describePart(part: ContentPart): Record<string, unknown>;
72
+ /** A one-line, payload-free rendering of a part — for `output` text and placeholders. */
73
+ export declare function summarizePart(part: ContentPart): string;
74
+ /** Validate the §1B invariant: exactly one of `data` / `url`. */
75
+ export declare function validatePart(part: ContentPart, maxPartBytes?: number): ContentPart;
76
+ /** True when a value looks like a `ContentPart` (as opposed to a provider-native block). */
77
+ export declare function isContentPart(v: unknown): v is ContentPart;
78
+ /** A text part. */
79
+ export declare function text(value: string): ContentPart;
80
+ /** Options for {@link attach}. */
81
+ export interface AttachOptions {
82
+ /** Overrides the extension table; **required** for raw bytes and for an unlisted extension. */
83
+ mimeType?: string;
84
+ /** Filename carried on a `file` part (OpenAI's `file.filename`). Defaults to the basename. */
85
+ name?: string;
86
+ /** Reject a payload over this many **decoded** bytes (never the +33% base64 string). */
87
+ maxPartBytes?: number;
88
+ }
89
+ /** Everything the edge constructor accepts (§1B): a path / `data:` / `https:` string, native
90
+ * bytes in any of their JS spellings, or the host's own file object. Whatever comes in, only
91
+ * `mimeType` + base64 `data` (or a `url`) lands in the part — accept broadly, store narrowly. */
92
+ export type AttachSource = string | Uint8Array | ArrayBuffer | ArrayBufferView | Blob;
93
+ /**
94
+ * The edge constructor: hand it whatever you actually have and get a path-free part.
95
+ *
96
+ * | given | becomes |
97
+ * |---|---|
98
+ * | a filesystem path | bytes read now, base64d now; mime from the fixed extension table |
99
+ * | native bytes (`Uint8Array`/`Buffer`/`ArrayBuffer`/any `ArrayBufferView`) | base64d now; `mimeType` required |
100
+ * | a `Blob` / `File` | read to bytes **now**; mime from `blob.type`, else the extension table via `File.name` |
101
+ * | `data:<mime>;base64,<b64>` | parsed into `{mimeType, data}` — never stored as a url |
102
+ * | an `http(s):` URL | kept as `url` |
103
+ */
104
+ export declare function attach(source: AttachSource, opts?: AttachOptions): Promise<ContentPart>;
105
+ /**
106
+ * Build a part from a `Blob` or `File`, consuming it **eagerly** — a part holding a half-read
107
+ * stream would not survive the transcript boundary any better than a path does (§1B).
108
+ *
109
+ * Mime comes from `blob.type` when it is non-empty, else from the fixed extension table applied
110
+ * to `File.name`; an explicit `opts.mimeType` beats both. A `File`'s name is carried onto a
111
+ * `file` part unless `opts.name` overrides it.
112
+ */
113
+ export declare function fromBlob(blob: Blob, opts?: AttachOptions): Promise<ContentPart>;
114
+ /** Build a part from in-memory bytes. `mimeType` is required — bytes carry no extension. */
115
+ export declare function fromBytes(bytes: Uint8Array | ArrayBuffer | ArrayBufferView, mimeType: string, opts?: AttachOptions): ContentPart;
116
+ /** Read a file now and base64 it now, so the part never carries the path (§1B). */
117
+ export declare function fromPath(p: string, opts?: AttachOptions): Promise<ContentPart>;
118
+ /** Parse `data:<mime>;base64,<b64>` into `{mimeType, data}`, so two spellings of the same
119
+ * bytes cannot diverge downstream. */
120
+ export declare function fromDataUrl(url: string, opts?: AttachOptions): ContentPart;
121
+ /** Keep an `http(s):` URL as a url part; mime comes from the table or an explicit override. */
122
+ export declare function fromUrl(url: string, opts?: AttachOptions): ContentPart;
123
+ /**
124
+ * Encode one part into its provider block, or `undefined` when the style defines no shape
125
+ * for it. Refusals are explicit, never a fall-through: `openai × file+url` (Chat Completions
126
+ * has no URL form for a file) and `anthropic × audio` (the provider defines no audio block).
127
+ */
128
+ export declare function encodePart(part: ContentPart, style: PartStyle): Record<string, any> | undefined;
129
+ /** Context for {@link encodeParts}: which wire, who asked for the part, and the override. */
130
+ export interface EncodeOptions {
131
+ style: PartStyle;
132
+ provenance: PartProvenance;
133
+ onUnsupportedPart?: UnsupportedPartMode;
134
+ maxPartBytes?: number;
135
+ }
136
+ /** Test seam: forget which unsupported (style, type) pairs have already been warned about. */
137
+ export declare function resetUnsupportedWarnings(): void;
138
+ /** The text a degraded (non-failing) unsupported part leaves behind — never silence. */
139
+ export declare function unsupportedPlaceholder(part: ContentPart): string;
140
+ /**
141
+ * Encode a list of parts for one style, applying the §8A rules:
142
+ * an **attached** part the style cannot represent raises before any HTTP call; a **derived**
143
+ * part degrades to a text placeholder and warns once. `onUnsupportedPart` overrides both.
144
+ * Every emitted block is asserted against the style's allowlist.
145
+ *
146
+ * `maxPartBytes` (§1B) is enforced here too, at assembly, over EVERY part regardless of
147
+ * provenance — a part that arrived from an MCP server never passed through an edge
148
+ * constructor, so a limit it can walk around is not a limit. Going over follows the exact
149
+ * same provenance split as an unsupported block: an attached part errors, a derived one
150
+ * degrades to the placeholder so a remote server still cannot fail the run.
151
+ */
152
+ export declare function encodeParts(parts: readonly ContentPart[], opts: EncodeOptions): Record<string, any>[];
153
+ /**
154
+ * Read an inbound OpenAI-shaped content block back into a `ContentPart` (§11). Accepts both
155
+ * a `ContentPart` written literally and the provider-native block the same part encodes to,
156
+ * so a caller's own OpenAI messages translate as faithfully as ours do.
157
+ */
158
+ export declare function inboundPart(block: unknown): ContentPart | undefined;