toolnexus 0.15.0 → 0.16.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 +45 -0
- package/dist/client.js +78 -0
- package/package.json +2 -2
- package/src/client.ts +129 -0
package/dist/client.d.ts
CHANGED
|
@@ -333,6 +333,51 @@ export declare class Client {
|
|
|
333
333
|
private streamAnthropic;
|
|
334
334
|
}
|
|
335
335
|
export declare function createClient(opts: ClientOptions): Client;
|
|
336
|
+
/** One tool call an in-process model asks for. Flat on purpose: the nested
|
|
337
|
+
* `function: {}` wrapper is a wire detail, not something a model author should type.
|
|
338
|
+
* `arguments` may be a structured value (encoded for you) or an encoded string. */
|
|
339
|
+
export interface InProcessToolCall {
|
|
340
|
+
id?: string;
|
|
341
|
+
name: string;
|
|
342
|
+
arguments?: unknown;
|
|
343
|
+
}
|
|
344
|
+
/** What an in-process model is handed: the assembled request for THIS call. */
|
|
345
|
+
export interface InProcessRequest {
|
|
346
|
+
messages: any[];
|
|
347
|
+
tools?: any[];
|
|
348
|
+
model: string;
|
|
349
|
+
/** Every other key the client assembled, for a model that wants them. */
|
|
350
|
+
body: Record<string, any>;
|
|
351
|
+
}
|
|
352
|
+
/** What an in-process model returns: exactly one assistant message. Return `content`
|
|
353
|
+
* to finish, or `toolCalls` to ask for tools — never both. `usage` is optional; when
|
|
354
|
+
* omitted the run reports zero tokens rather than failing. */
|
|
355
|
+
export interface InProcessResponse {
|
|
356
|
+
content?: string;
|
|
357
|
+
toolCalls?: InProcessToolCall[];
|
|
358
|
+
usage?: {
|
|
359
|
+
promptTokens?: number;
|
|
360
|
+
completionTokens?: number;
|
|
361
|
+
totalTokens?: number;
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
/** Options for {@link createInProcessClient}: every client option EXCEPT the three
|
|
365
|
+
* that describe a wire — `baseUrl`, `apiKey` and `style` — plus `generate`. */
|
|
366
|
+
export type InProcessOptions = Omit<ClientOptions, "baseUrl" | "apiKey" | "style" | "fetch"> & {
|
|
367
|
+
/** Your model. Receives the assembled request, returns one assistant message. */
|
|
368
|
+
generate: (request: InProcessRequest) => InProcessResponse | Promise<InProcessResponse>;
|
|
369
|
+
};
|
|
370
|
+
/**
|
|
371
|
+
* A client backed by a model running IN THIS PROCESS — no server, no socket, and no
|
|
372
|
+
* HTTP types to construct.
|
|
373
|
+
*
|
|
374
|
+
* This is a second constructor, not a second seam: it builds on the same injectable
|
|
375
|
+
* transport that `fetch`/`httpClient` uses, so the tool-calling loop, MCP servers,
|
|
376
|
+
* skills, sub-agents, hooks, metrics and the completion gate all behave identically.
|
|
377
|
+
*
|
|
378
|
+
* Streaming is refused rather than faked — see the thrown error.
|
|
379
|
+
*/
|
|
380
|
+
export declare function createInProcessClient(opts: InProcessOptions): Client;
|
|
336
381
|
/** Stateful multi-turn conversation: each send() continues the same transcript (memory). */
|
|
337
382
|
export declare class Conversation {
|
|
338
383
|
private readonly client;
|
package/dist/client.js
CHANGED
|
@@ -950,6 +950,84 @@ export class Client {
|
|
|
950
950
|
export function createClient(opts) {
|
|
951
951
|
return new Client(opts);
|
|
952
952
|
}
|
|
953
|
+
/** The sentinel base URL. Never dialled — the transport below answers every request
|
|
954
|
+
* before the network is reached — but the client builds a URL string internally, so
|
|
955
|
+
* it must be syntactically valid. `.invalid` is reserved by RFC 2606 precisely so a
|
|
956
|
+
* name can never resolve. */
|
|
957
|
+
const IN_PROCESS_BASE_URL = "http://in-process.invalid/v1";
|
|
958
|
+
/**
|
|
959
|
+
* A client backed by a model running IN THIS PROCESS — no server, no socket, and no
|
|
960
|
+
* HTTP types to construct.
|
|
961
|
+
*
|
|
962
|
+
* This is a second constructor, not a second seam: it builds on the same injectable
|
|
963
|
+
* transport that `fetch`/`httpClient` uses, so the tool-calling loop, MCP servers,
|
|
964
|
+
* skills, sub-agents, hooks, metrics and the completion gate all behave identically.
|
|
965
|
+
*
|
|
966
|
+
* Streaming is refused rather than faked — see the thrown error.
|
|
967
|
+
*/
|
|
968
|
+
export function createInProcessClient(opts) {
|
|
969
|
+
const { generate, ...rest } = opts;
|
|
970
|
+
if (typeof generate !== "function") {
|
|
971
|
+
throw new Error("toolnexus: createInProcessClient requires a `generate` function");
|
|
972
|
+
}
|
|
973
|
+
const inProcessFetch = async (_url, init) => {
|
|
974
|
+
const body = JSON.parse(String(init?.body ?? "{}"));
|
|
975
|
+
// A generate() returns a whole answer, so a stream would be one chunk pretending
|
|
976
|
+
// to be many. Content and delta-count assertions cannot tell that from a real
|
|
977
|
+
// stream, so refuse instead of degrading silently.
|
|
978
|
+
if (body.stream) {
|
|
979
|
+
throw new Error("toolnexus: createInProcessClient does not support streaming — `generate` returns a " +
|
|
980
|
+
"complete answer. Use the non-streaming run, or supply a `fetch`/transport that streams.");
|
|
981
|
+
}
|
|
982
|
+
const answer = await generate({
|
|
983
|
+
messages: body.messages ?? [],
|
|
984
|
+
tools: body.tools,
|
|
985
|
+
model: body.model,
|
|
986
|
+
body,
|
|
987
|
+
});
|
|
988
|
+
const message = { role: "assistant" };
|
|
989
|
+
if (answer.toolCalls && answer.toolCalls.length > 0) {
|
|
990
|
+
message.tool_calls = answer.toolCalls.map((c, i) => ({
|
|
991
|
+
id: c.id ?? `call_${i}`,
|
|
992
|
+
type: "function",
|
|
993
|
+
function: {
|
|
994
|
+
name: c.name,
|
|
995
|
+
// A string is already encoded; anything else is encoded here so a model
|
|
996
|
+
// author never has to think about the wire.
|
|
997
|
+
arguments: typeof c.arguments === "string" ? c.arguments : JSON.stringify(c.arguments ?? {}),
|
|
998
|
+
},
|
|
999
|
+
}));
|
|
1000
|
+
}
|
|
1001
|
+
else {
|
|
1002
|
+
message.content = answer.content ?? "";
|
|
1003
|
+
}
|
|
1004
|
+
const u = answer.usage ?? {};
|
|
1005
|
+
const prompt = u.promptTokens ?? 0;
|
|
1006
|
+
const completion = u.completionTokens ?? 0;
|
|
1007
|
+
return new Response(JSON.stringify({
|
|
1008
|
+
choices: [{ index: 0, message, finish_reason: message.tool_calls ? "tool_calls" : "stop" }],
|
|
1009
|
+
usage: {
|
|
1010
|
+
prompt_tokens: prompt,
|
|
1011
|
+
completion_tokens: completion,
|
|
1012
|
+
total_tokens: u.totalTokens ?? prompt + completion,
|
|
1013
|
+
},
|
|
1014
|
+
}), { status: 200, headers: { "content-type": "application/json" } });
|
|
1015
|
+
};
|
|
1016
|
+
return new Client({
|
|
1017
|
+
// Retries default to ZERO here, unlike a network client. There is no wire, so
|
|
1018
|
+
// there is no transient failure to ride out: whatever `generate` throws will
|
|
1019
|
+
// throw again. Retrying it only buys backoff before the user sees their own bug —
|
|
1020
|
+
// measured at 3.7s for the streaming refusal before this default. A host whose
|
|
1021
|
+
// model really is flaky (a GPU that OOMs, say) can still set `retries`.
|
|
1022
|
+
retries: 0,
|
|
1023
|
+
...rest,
|
|
1024
|
+
baseUrl: IN_PROCESS_BASE_URL,
|
|
1025
|
+
style: "openai",
|
|
1026
|
+
// 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.
|
|
1027
|
+
apiKey: "in-process",
|
|
1028
|
+
fetch: inProcessFetch,
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
953
1031
|
/** Stateful multi-turn conversation: each send() continues the same transcript (memory). */
|
|
954
1032
|
export class Conversation {
|
|
955
1033
|
client;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolnexus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Dynamic MCP servers + agent skills as uniform tools for any LLM (opencode-style).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
19
|
"build": "tsc -p tsconfig.json",
|
|
20
|
-
"test": "npm run build && node --experimental-strip-types --test test/unit.test.ts test/agents.test.ts test/agent-hooks.test.ts test/home.test.ts test/compaction.test.ts test/loop.test.ts",
|
|
20
|
+
"test": "npm run build && node --experimental-strip-types --test test/unit.test.ts test/agents.test.ts test/agent-hooks.test.ts test/home.test.ts test/compaction.test.ts test/loop.test.ts test/in-process.test.ts",
|
|
21
21
|
"example": "node --experimental-strip-types examples/basic.ts"
|
|
22
22
|
},
|
|
23
23
|
"keywords": [
|
package/src/client.ts
CHANGED
|
@@ -1097,6 +1097,135 @@ export function createClient(opts: ClientOptions): Client {
|
|
|
1097
1097
|
return new Client(opts)
|
|
1098
1098
|
}
|
|
1099
1099
|
|
|
1100
|
+
|
|
1101
|
+
// --------------------------------------------------------------------------- //
|
|
1102
|
+
// In-process models (SPEC §8 Gap 2, semantic form).
|
|
1103
|
+
// --------------------------------------------------------------------------- //
|
|
1104
|
+
|
|
1105
|
+
/** One tool call an in-process model asks for. Flat on purpose: the nested
|
|
1106
|
+
* `function: {}` wrapper is a wire detail, not something a model author should type.
|
|
1107
|
+
* `arguments` may be a structured value (encoded for you) or an encoded string. */
|
|
1108
|
+
export interface InProcessToolCall {
|
|
1109
|
+
id?: string
|
|
1110
|
+
name: string
|
|
1111
|
+
arguments?: unknown
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/** What an in-process model is handed: the assembled request for THIS call. */
|
|
1115
|
+
export interface InProcessRequest {
|
|
1116
|
+
messages: any[]
|
|
1117
|
+
tools?: any[]
|
|
1118
|
+
model: string
|
|
1119
|
+
/** Every other key the client assembled, for a model that wants them. */
|
|
1120
|
+
body: Record<string, any>
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/** What an in-process model returns: exactly one assistant message. Return `content`
|
|
1124
|
+
* to finish, or `toolCalls` to ask for tools — never both. `usage` is optional; when
|
|
1125
|
+
* omitted the run reports zero tokens rather than failing. */
|
|
1126
|
+
export interface InProcessResponse {
|
|
1127
|
+
content?: string
|
|
1128
|
+
toolCalls?: InProcessToolCall[]
|
|
1129
|
+
usage?: { promptTokens?: number; completionTokens?: number; totalTokens?: number }
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/** Options for {@link createInProcessClient}: every client option EXCEPT the three
|
|
1133
|
+
* that describe a wire — `baseUrl`, `apiKey` and `style` — plus `generate`. */
|
|
1134
|
+
export type InProcessOptions = Omit<ClientOptions, "baseUrl" | "apiKey" | "style" | "fetch"> & {
|
|
1135
|
+
/** Your model. Receives the assembled request, returns one assistant message. */
|
|
1136
|
+
generate: (request: InProcessRequest) => InProcessResponse | Promise<InProcessResponse>
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/** The sentinel base URL. Never dialled — the transport below answers every request
|
|
1140
|
+
* before the network is reached — but the client builds a URL string internally, so
|
|
1141
|
+
* it must be syntactically valid. `.invalid` is reserved by RFC 2606 precisely so a
|
|
1142
|
+
* name can never resolve. */
|
|
1143
|
+
const IN_PROCESS_BASE_URL = "http://in-process.invalid/v1"
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* A client backed by a model running IN THIS PROCESS — no server, no socket, and no
|
|
1147
|
+
* HTTP types to construct.
|
|
1148
|
+
*
|
|
1149
|
+
* This is a second constructor, not a second seam: it builds on the same injectable
|
|
1150
|
+
* transport that `fetch`/`httpClient` uses, so the tool-calling loop, MCP servers,
|
|
1151
|
+
* skills, sub-agents, hooks, metrics and the completion gate all behave identically.
|
|
1152
|
+
*
|
|
1153
|
+
* Streaming is refused rather than faked — see the thrown error.
|
|
1154
|
+
*/
|
|
1155
|
+
export function createInProcessClient(opts: InProcessOptions): Client {
|
|
1156
|
+
const { generate, ...rest } = opts
|
|
1157
|
+
if (typeof generate !== "function") {
|
|
1158
|
+
throw new Error("toolnexus: createInProcessClient requires a `generate` function")
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
const inProcessFetch: typeof fetch = async (_url, init) => {
|
|
1162
|
+
const body = JSON.parse(String((init as any)?.body ?? "{}"))
|
|
1163
|
+
|
|
1164
|
+
// A generate() returns a whole answer, so a stream would be one chunk pretending
|
|
1165
|
+
// to be many. Content and delta-count assertions cannot tell that from a real
|
|
1166
|
+
// stream, so refuse instead of degrading silently.
|
|
1167
|
+
if (body.stream) {
|
|
1168
|
+
throw new Error(
|
|
1169
|
+
"toolnexus: createInProcessClient does not support streaming — `generate` returns a " +
|
|
1170
|
+
"complete answer. Use the non-streaming run, or supply a `fetch`/transport that streams.",
|
|
1171
|
+
)
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
const answer = await generate({
|
|
1175
|
+
messages: body.messages ?? [],
|
|
1176
|
+
tools: body.tools,
|
|
1177
|
+
model: body.model,
|
|
1178
|
+
body,
|
|
1179
|
+
})
|
|
1180
|
+
|
|
1181
|
+
const message: Record<string, any> = { role: "assistant" }
|
|
1182
|
+
if (answer.toolCalls && answer.toolCalls.length > 0) {
|
|
1183
|
+
message.tool_calls = answer.toolCalls.map((c, i) => ({
|
|
1184
|
+
id: c.id ?? `call_${i}`,
|
|
1185
|
+
type: "function",
|
|
1186
|
+
function: {
|
|
1187
|
+
name: c.name,
|
|
1188
|
+
// A string is already encoded; anything else is encoded here so a model
|
|
1189
|
+
// author never has to think about the wire.
|
|
1190
|
+
arguments: typeof c.arguments === "string" ? c.arguments : JSON.stringify(c.arguments ?? {}),
|
|
1191
|
+
},
|
|
1192
|
+
}))
|
|
1193
|
+
} else {
|
|
1194
|
+
message.content = answer.content ?? ""
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
const u = answer.usage ?? {}
|
|
1198
|
+
const prompt = u.promptTokens ?? 0
|
|
1199
|
+
const completion = u.completionTokens ?? 0
|
|
1200
|
+
return new Response(
|
|
1201
|
+
JSON.stringify({
|
|
1202
|
+
choices: [{ index: 0, message, finish_reason: message.tool_calls ? "tool_calls" : "stop" }],
|
|
1203
|
+
usage: {
|
|
1204
|
+
prompt_tokens: prompt,
|
|
1205
|
+
completion_tokens: completion,
|
|
1206
|
+
total_tokens: u.totalTokens ?? prompt + completion,
|
|
1207
|
+
},
|
|
1208
|
+
}),
|
|
1209
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
1210
|
+
)
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
return new Client({
|
|
1214
|
+
// Retries default to ZERO here, unlike a network client. There is no wire, so
|
|
1215
|
+
// there is no transient failure to ride out: whatever `generate` throws will
|
|
1216
|
+
// throw again. Retrying it only buys backoff before the user sees their own bug —
|
|
1217
|
+
// measured at 3.7s for the streaming refusal before this default. A host whose
|
|
1218
|
+
// model really is flaky (a GPU that OOMs, say) can still set `retries`.
|
|
1219
|
+
retries: 0,
|
|
1220
|
+
...(rest as ClientOptions),
|
|
1221
|
+
baseUrl: IN_PROCESS_BASE_URL,
|
|
1222
|
+
style: "openai",
|
|
1223
|
+
// 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.
|
|
1224
|
+
apiKey: "in-process",
|
|
1225
|
+
fetch: inProcessFetch,
|
|
1226
|
+
})
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1100
1229
|
/** Stateful multi-turn conversation: each send() continues the same transcript (memory). */
|
|
1101
1230
|
export class Conversation {
|
|
1102
1231
|
/** Full running transcript (system + user + assistant + tool messages). */
|