tina4-nodejs 3.13.113 → 3.13.115
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/CLAUDE.md +2 -2
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +173 -17
- package/packages/core/dist/index.js +173 -17
- package/packages/core/src/aiClient.ts +218 -17
- package/packages/core/src/index.ts +1 -1
- package/packages/core/src/server.ts +34 -9
- package/packages/orm/dist/index.js +173 -17
- package/types/core/src/aiClient.d.ts +91 -7
- package/types/core/src/index.d.ts +1 -1
- package/types/core/src/server.d.ts +23 -1
|
@@ -22,16 +22,47 @@ export interface ChatResponse {
|
|
|
22
22
|
/**
|
|
23
23
|
* A multimodal content part. `text` carries plain UTF-8 prose; `image`
|
|
24
24
|
* carries a `data:<media_type>;base64,<payload>` URI or an https:// URL
|
|
25
|
-
* (the client translates to each provider's shape
|
|
25
|
+
* (the client translates to each provider's shape, ADR-0060). `tool_result`
|
|
26
|
+
* carries the Anthropic-style return of a locally-executed tool call
|
|
27
|
+
* (ADR-0061); the client translates it to OpenAI's `{role: "tool", ...}`
|
|
28
|
+
* turn on non-Anthropic providers.
|
|
26
29
|
*/
|
|
27
30
|
export type ContentPart =
|
|
28
31
|
| { type: "text"; text: string }
|
|
29
|
-
| { type: "image"; source: string }
|
|
32
|
+
| { type: "image"; source: string }
|
|
33
|
+
| { type: "tool_result"; tool_use_id: string; content: string };
|
|
30
34
|
|
|
31
35
|
/** The value a caller may pass for `message.content`. ADR-0060. */
|
|
32
36
|
export type AiMessageContent = string | ContentPart[];
|
|
33
37
|
|
|
34
|
-
|
|
38
|
+
/**
|
|
39
|
+
* One conversation turn. The three "chat" roles carry a string OR a
|
|
40
|
+
* content-parts array (ADR-0060). The `tool` role is the OpenAI-style
|
|
41
|
+
* return of a tool call (ADR-0061); the client translates it to the
|
|
42
|
+
* Anthropic user-turn form when the current provider is Anthropic.
|
|
43
|
+
*/
|
|
44
|
+
export type AiMessage =
|
|
45
|
+
| { role: "system" | "user" | "assistant"; content: AiMessageContent }
|
|
46
|
+
| { role: "tool"; tool_call_id: string; content: string };
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A tool declaration the model may call (named `AiToolDeclaration` to
|
|
50
|
+
* stay out of the way of {@link ./ai.ts}'s existing `AiTool` interface
|
|
51
|
+
* for AI-coding-tool context installation). `parameters` is a JSON
|
|
52
|
+
* Schema object; it is passed to the provider unchanged (ADR-0061
|
|
53
|
+
* `parameters-passthrough`).
|
|
54
|
+
*/
|
|
55
|
+
export interface AiToolDeclaration { name: string; description: string; parameters: Record<string, unknown> }
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* How the model picks a tool. Four Tina4 values that span the useful cases
|
|
59
|
+
* across providers (ADR-0061 wire-translation table):
|
|
60
|
+
* 'auto' — model may call any tool or answer with text
|
|
61
|
+
* 'none' — model must not call a tool (Anthropic omits `tools`)
|
|
62
|
+
* 'required' — model must call some tool
|
|
63
|
+
* {name: 'x'} — model must call tool 'x'
|
|
64
|
+
*/
|
|
65
|
+
export type AiToolChoice = "auto" | "none" | "required" | { name: string };
|
|
35
66
|
|
|
36
67
|
/**
|
|
37
68
|
* One event yielded by `Ai.chat(stream: true)`. The four variants
|
|
@@ -57,6 +88,14 @@ export interface AiChatOptions {
|
|
|
57
88
|
stream?: boolean;
|
|
58
89
|
timeout?: number;
|
|
59
90
|
provider?: "local" | "openai" | "anthropic";
|
|
91
|
+
/** Tools the model may call. ADR-0061 — translated per provider. */
|
|
92
|
+
tools?: AiToolDeclaration[];
|
|
93
|
+
/**
|
|
94
|
+
* How the model picks a tool. ADR-0061 — translated per provider. If
|
|
95
|
+
* `'none'` on Anthropic (which has no "none" mode), `tools` is omitted
|
|
96
|
+
* from the outbound body entirely.
|
|
97
|
+
*/
|
|
98
|
+
toolChoice?: AiToolChoice;
|
|
60
99
|
}
|
|
61
100
|
export interface AiEmbedOptions { model?: string; timeout?: number; provider?: "local" | "openai" | "anthropic" }
|
|
62
101
|
|
|
@@ -76,6 +115,8 @@ export class Ai {
|
|
|
76
115
|
static chat(messages: AiMessage[], options?: AiChatOptions & { stream?: false }): Promise<ChatResponse>;
|
|
77
116
|
static chat(messages: AiMessage[], options: AiChatOptions = {}): Promise<ChatResponse> | AsyncGenerator<AiEvent> {
|
|
78
117
|
this.validateMessages(messages);
|
|
118
|
+
if (options.tools !== undefined) this.validateTools(options.tools);
|
|
119
|
+
if (options.toolChoice !== undefined) this.validateToolChoice(options.toolChoice);
|
|
79
120
|
const config = this.config("chat", options);
|
|
80
121
|
const body = this.chatBody(config, messages, options);
|
|
81
122
|
const headers = this.headers(config);
|
|
@@ -108,15 +149,31 @@ export class Ai {
|
|
|
108
149
|
|
|
109
150
|
/**
|
|
110
151
|
* Validate role + content shape. Content may be a string OR a non-empty
|
|
111
|
-
* list of {type:'text'|'image', ...} parts (ADR-0060
|
|
112
|
-
*
|
|
152
|
+
* list of {type:'text'|'image'|'tool_result', ...} parts (ADR-0060 +
|
|
153
|
+
* ADR-0061). The `tool` role is the OpenAI-style tool-result turn
|
|
154
|
+
* (ADR-0061). Malformed parts fail fast with AiConfigError, never
|
|
155
|
+
* reaching the wire.
|
|
113
156
|
*/
|
|
114
157
|
private static validateMessages(messages: AiMessage[]): void {
|
|
115
158
|
if (!Array.isArray(messages) || messages.length === 0) {
|
|
116
159
|
throw new AiConfigError("AI messages must contain supported roles and string content");
|
|
117
160
|
}
|
|
118
|
-
for (const
|
|
119
|
-
if (!
|
|
161
|
+
for (const raw of messages) {
|
|
162
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
163
|
+
throw new AiConfigError("AI messages must contain supported roles and string content");
|
|
164
|
+
}
|
|
165
|
+
const message = raw as Record<string, unknown>;
|
|
166
|
+
const role = message.role;
|
|
167
|
+
if (role === "tool") {
|
|
168
|
+
if (typeof message.tool_call_id !== "string" || message.tool_call_id.length === 0) {
|
|
169
|
+
throw new AiConfigError("AI tool message requires a non-empty string 'tool_call_id'");
|
|
170
|
+
}
|
|
171
|
+
if (typeof message.content !== "string") {
|
|
172
|
+
throw new AiConfigError("AI tool message requires a string 'content'");
|
|
173
|
+
}
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (role !== "system" && role !== "user" && role !== "assistant") {
|
|
120
177
|
throw new AiConfigError("AI messages must contain supported roles and string content");
|
|
121
178
|
}
|
|
122
179
|
this.validateContent(message.content);
|
|
@@ -148,12 +205,67 @@ export class Ai {
|
|
|
148
205
|
if (record.source.startsWith("data:") && !/^data:[^;,\s]+;base64,[A-Za-z0-9+/=]+$/.test(record.source)) {
|
|
149
206
|
throw new AiConfigError("AI image data URI must be data:<media_type>;base64,<payload>");
|
|
150
207
|
}
|
|
208
|
+
} else if (partType === "tool_result") {
|
|
209
|
+
if (typeof record.tool_use_id !== "string" || record.tool_use_id.length === 0) {
|
|
210
|
+
throw new AiConfigError("AI tool_result part requires a non-empty string 'tool_use_id'");
|
|
211
|
+
}
|
|
212
|
+
if (typeof record.content !== "string") {
|
|
213
|
+
throw new AiConfigError("AI tool_result part requires a string 'content'");
|
|
214
|
+
}
|
|
151
215
|
} else {
|
|
152
216
|
throw new AiConfigError(`AI content part has unknown type '${String(partType)}'`);
|
|
153
217
|
}
|
|
154
218
|
}
|
|
155
219
|
}
|
|
156
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Validate the outbound tool declarations (ADR-0061). Each tool needs a
|
|
223
|
+
* non-empty `name`, a string `description`, and a JSON-Schema-shaped
|
|
224
|
+
* `parameters` object. Malformed tools fail fast with AiConfigError,
|
|
225
|
+
* never reaching the wire.
|
|
226
|
+
*/
|
|
227
|
+
private static validateTools(tools: unknown): void {
|
|
228
|
+
if (!Array.isArray(tools) || tools.length === 0) {
|
|
229
|
+
throw new AiConfigError("AI tools must be a non-empty list of {name, description, parameters}");
|
|
230
|
+
}
|
|
231
|
+
for (const tool of tools) {
|
|
232
|
+
if (!tool || typeof tool !== "object" || Array.isArray(tool)) {
|
|
233
|
+
throw new AiConfigError("AI tool must be an object with name, description, parameters");
|
|
234
|
+
}
|
|
235
|
+
const record = tool as Record<string, unknown>;
|
|
236
|
+
if (typeof record.name !== "string" || record.name.length === 0) {
|
|
237
|
+
throw new AiConfigError("AI tool requires a non-empty string 'name'");
|
|
238
|
+
}
|
|
239
|
+
if (typeof record.description !== "string") {
|
|
240
|
+
throw new AiConfigError("AI tool requires a string 'description'");
|
|
241
|
+
}
|
|
242
|
+
if (!record.parameters || typeof record.parameters !== "object" || Array.isArray(record.parameters)) {
|
|
243
|
+
throw new AiConfigError("AI tool requires a JSON-Schema object 'parameters'");
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Validate the outbound tool_choice value (ADR-0061). The four accepted
|
|
250
|
+
* shapes are 'auto', 'none', 'required', and {name: 'x'}.
|
|
251
|
+
*/
|
|
252
|
+
private static validateToolChoice(choice: unknown): void {
|
|
253
|
+
if (typeof choice === "string") {
|
|
254
|
+
if (choice !== "auto" && choice !== "none" && choice !== "required") {
|
|
255
|
+
throw new AiConfigError("AI toolChoice string must be 'auto', 'none', or 'required'");
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (choice && typeof choice === "object" && !Array.isArray(choice)) {
|
|
260
|
+
const record = choice as Record<string, unknown>;
|
|
261
|
+
if (typeof record.name !== "string" || record.name.length === 0) {
|
|
262
|
+
throw new AiConfigError("AI toolChoice object requires a non-empty string 'name'");
|
|
263
|
+
}
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
throw new AiConfigError("AI toolChoice must be 'auto'|'none'|'required' or {name: string}");
|
|
267
|
+
}
|
|
268
|
+
|
|
157
269
|
private static number(name: string, fallback: number, minimum: number): number {
|
|
158
270
|
const value = process.env[name] === undefined ? fallback : Number(process.env[name]);
|
|
159
271
|
if (!Number.isFinite(value) || value < minimum) throw new AiConfigError(`${name} must be numeric and at least ${minimum}`);
|
|
@@ -199,29 +311,110 @@ export class Ai {
|
|
|
199
311
|
|
|
200
312
|
/**
|
|
201
313
|
* Build the provider-specific request body from a Tina4-shaped message
|
|
202
|
-
* list
|
|
203
|
-
*
|
|
204
|
-
*
|
|
314
|
+
* list plus optional tool declarations (ADR-0060 + ADR-0061).
|
|
315
|
+
*
|
|
316
|
+
* Content parts translate per provider:
|
|
317
|
+
* - OpenAI/local: image → {type:'image_url', image_url:{url}}
|
|
318
|
+
* - Anthropic: image → {type:'image', source:{type:'base64'|'url', ...}}
|
|
205
319
|
* String content is preserved verbatim in the OpenAI/local shape and
|
|
206
320
|
* likewise for Anthropic (both accept a bare string).
|
|
321
|
+
*
|
|
322
|
+
* Tool-result turns are normalised to the current provider's expected
|
|
323
|
+
* shape (either the OpenAI `{role:"tool", tool_call_id, content}` turn or
|
|
324
|
+
* the Anthropic `{role:"user", content:[{type:"tool_result", ...}]}`
|
|
325
|
+
* turn), so an agent-loop written against Tina4 never has to fork on
|
|
326
|
+
* TINA4_AI_PROVIDER (ADR-0061 wire translation).
|
|
207
327
|
*/
|
|
208
328
|
private static chatBody(config: Config, messages: AiMessage[], options: AiChatOptions): Record<string, unknown> {
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
const body: Record<string, unknown> = { model: config.model, messages: translate(messages), stream: options.stream ?? false };
|
|
329
|
+
const normalized = this.normalizeMessagesForProvider(messages, config.provider);
|
|
330
|
+
const body: Record<string, unknown> = { model: config.model, messages: normalized, stream: options.stream ?? false };
|
|
212
331
|
if (options.temperature !== undefined) body.temperature = options.temperature;
|
|
213
332
|
if (options.maxTokens !== undefined) body.max_tokens = options.maxTokens;
|
|
214
333
|
if (config.provider === "anthropic") {
|
|
215
|
-
const systemParts =
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
334
|
+
const systemParts: string[] = [];
|
|
335
|
+
for (const message of messages) {
|
|
336
|
+
if (message.role !== "system") continue;
|
|
337
|
+
const content = message.content; // narrowed away from tool variant
|
|
338
|
+
systemParts.push(typeof content === "string" ? content : this.contentToPlainText(content));
|
|
339
|
+
}
|
|
340
|
+
body.messages = normalized.filter((message) => message.role !== "system");
|
|
219
341
|
body.max_tokens = options.maxTokens ?? 1024;
|
|
220
342
|
if (systemParts.length) body.system = systemParts.join("\n\n");
|
|
221
343
|
}
|
|
344
|
+
this.applyTools(body, config.provider, options);
|
|
222
345
|
return body;
|
|
223
346
|
}
|
|
224
347
|
|
|
348
|
+
/**
|
|
349
|
+
* Normalise the Tina4-shaped messages into the provider's on-wire shape.
|
|
350
|
+
* The `tool` role and the `tool_result` content part are translated
|
|
351
|
+
* between the OpenAI and Anthropic forms so either input works against
|
|
352
|
+
* either provider (ADR-0061 return-path table).
|
|
353
|
+
*/
|
|
354
|
+
private static normalizeMessagesForProvider(messages: AiMessage[], provider: Config["provider"]): Array<Record<string, unknown>> {
|
|
355
|
+
const out: Array<Record<string, unknown>> = [];
|
|
356
|
+
for (const message of messages) {
|
|
357
|
+
if (message.role === "tool") {
|
|
358
|
+
// OpenAI-style tool-result turn. Passthrough on OpenAI/local;
|
|
359
|
+
// translate to Anthropic's user-turn form on Anthropic.
|
|
360
|
+
if (provider === "anthropic") {
|
|
361
|
+
out.push({
|
|
362
|
+
role: "user",
|
|
363
|
+
content: [{ type: "tool_result", tool_use_id: message.tool_call_id, content: message.content }],
|
|
364
|
+
});
|
|
365
|
+
} else {
|
|
366
|
+
out.push({ role: "tool", tool_call_id: message.tool_call_id, content: message.content });
|
|
367
|
+
}
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (Array.isArray(message.content) && message.content.some((part) => part.type === "tool_result")) {
|
|
371
|
+
// Anthropic-style tool-result turn inside a user message.
|
|
372
|
+
// Passthrough on Anthropic; on OpenAI/local, split each tool_result
|
|
373
|
+
// part into its own {role:'tool', ...} turn.
|
|
374
|
+
if (provider === "anthropic") {
|
|
375
|
+
out.push({ role: message.role, content: this.translateContent(message.content, provider) });
|
|
376
|
+
} else {
|
|
377
|
+
for (const part of message.content) {
|
|
378
|
+
if (part.type === "tool_result") {
|
|
379
|
+
out.push({ role: "tool", tool_call_id: part.tool_use_id, content: part.content });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
out.push({ role: message.role, content: this.translateContent(message.content, provider) });
|
|
386
|
+
}
|
|
387
|
+
return out;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Attach the outbound `tools` and `tool_choice` (ADR-0061 outbound
|
|
392
|
+
* translation tables) to the body in place. When toolChoice is 'none'
|
|
393
|
+
* on Anthropic (Anthropic has no "none" mode) the tools list is omitted
|
|
394
|
+
* entirely — the model cannot call what it cannot see.
|
|
395
|
+
*/
|
|
396
|
+
private static applyTools(body: Record<string, unknown>, provider: Config["provider"], options: AiChatOptions): void {
|
|
397
|
+
const choice = options.toolChoice;
|
|
398
|
+
const suppressToolsForAnthropic = provider === "anthropic" && choice === "none";
|
|
399
|
+
if (options.tools !== undefined && !suppressToolsForAnthropic) {
|
|
400
|
+
body.tools = options.tools.map((tool) =>
|
|
401
|
+
provider === "anthropic"
|
|
402
|
+
? { name: tool.name, description: tool.description, input_schema: tool.parameters }
|
|
403
|
+
: { type: "function", function: { name: tool.name, description: tool.description, parameters: tool.parameters } },
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
if (choice === undefined) return;
|
|
407
|
+
if (provider === "anthropic") {
|
|
408
|
+
if (choice === "none") return; // omit tools + tool_choice
|
|
409
|
+
if (choice === "auto") body.tool_choice = { type: "auto" };
|
|
410
|
+
else if (choice === "required") body.tool_choice = { type: "any" };
|
|
411
|
+
else body.tool_choice = { type: "tool", name: choice.name };
|
|
412
|
+
} else {
|
|
413
|
+
if (typeof choice === "string") body.tool_choice = choice; // 'auto' | 'none' | 'required'
|
|
414
|
+
else body.tool_choice = { type: "function", function: { name: choice.name } };
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
225
418
|
/**
|
|
226
419
|
* Translate one message content value into the provider's on-wire shape.
|
|
227
420
|
* A plain string is passed through (both providers accept a string
|
|
@@ -232,6 +425,7 @@ export class Ai {
|
|
|
232
425
|
if (provider === "anthropic") {
|
|
233
426
|
return content.map((part) => {
|
|
234
427
|
if (part.type === "text") return { type: "text", text: part.text };
|
|
428
|
+
if (part.type === "tool_result") return { type: "tool_result", tool_use_id: part.tool_use_id, content: part.content };
|
|
235
429
|
if (part.source.startsWith("data:")) {
|
|
236
430
|
const parsed = this.parseDataUri(part.source);
|
|
237
431
|
return { type: "image", source: { type: "base64", media_type: parsed.mediaType, data: parsed.data } };
|
|
@@ -241,6 +435,13 @@ export class Ai {
|
|
|
241
435
|
}
|
|
242
436
|
return content.map((part) => {
|
|
243
437
|
if (part.type === "text") return { type: "text", text: part.text };
|
|
438
|
+
if (part.type === "tool_result") {
|
|
439
|
+
// Reached only when a non-tool_result part sits next to a
|
|
440
|
+
// tool_result in a user message on OpenAI/local; the tool_result
|
|
441
|
+
// parts are split out by normalizeMessagesForProvider(), so this
|
|
442
|
+
// branch is a safe no-op fallback.
|
|
443
|
+
return { type: "text", text: part.content };
|
|
444
|
+
}
|
|
244
445
|
return { type: "image_url", image_url: { url: part.source } };
|
|
245
446
|
});
|
|
246
447
|
}
|
|
@@ -110,7 +110,7 @@ export type { AiTool } from "./ai.js";
|
|
|
110
110
|
export { Sso, SSO, SsoError } from "./sso.js";
|
|
111
111
|
export type { SsoOptions } from "./sso.js";
|
|
112
112
|
export { Ai, AiError, AiConfigError, AiHTTPError, AiTimeoutError, AiParseError } from "./aiClient.js";
|
|
113
|
-
export type { ChatResponse, AiMessage, AiChatOptions, AiEmbedOptions, AiEvent, ContentPart, AiMessageContent } from "./aiClient.js";
|
|
113
|
+
export type { ChatResponse, AiMessage, AiChatOptions, AiEmbedOptions, AiEvent, ContentPart, AiMessageContent, AiToolDeclaration, AiToolChoice } from "./aiClient.js";
|
|
114
114
|
export type { ImapMessage, ImapFullMessage, ImapAttachment } from "./messenger.js";
|
|
115
115
|
export { LiteBackend } from "./queueBackends/liteBackend.js";
|
|
116
116
|
export { RabbitMQBackend, parseAmqpUrl } from "./queueBackends/rabbitmqBackend.js";
|
|
@@ -999,24 +999,49 @@ async function runGlobalMiddlewarePass(
|
|
|
999
999
|
* or nothing - and each parameter is resolved by its name: a path param wins,
|
|
1000
1000
|
* then `request`/`req`, then the response.
|
|
1001
1001
|
*/
|
|
1002
|
-
|
|
1003
|
-
|
|
1002
|
+
/**
|
|
1003
|
+
* Resolve a handler's argument list to `[reqOrParam, resOrParam, ...]` values.
|
|
1004
|
+
*
|
|
1005
|
+
* By-name path preserved (route-param names, `req`/`request`, `res`/`response`)
|
|
1006
|
+
* so existing code keeps its DX. Any remaining unmatched name falls back to
|
|
1007
|
+
* POSITIONAL binding: first unmatched -> request, rest -> response. That makes
|
|
1008
|
+
* dispatch bundler-safe by construction: Bun `--compile` and terser/esbuild
|
|
1009
|
+
* identifier-mangling rename `(req, res)` to `(req2, r$0)` — the by-name path
|
|
1010
|
+
* misses, the positional fallback catches, and the handler still receives the
|
|
1011
|
+
* request as its first argument. Fixes #56.
|
|
1012
|
+
*
|
|
1013
|
+
* Exported so the regression test can pin the behaviour directly, without a
|
|
1014
|
+
* live HTTP server.
|
|
1015
|
+
*/
|
|
1016
|
+
export function resolveHandlerArgs(
|
|
1017
|
+
handler: unknown,
|
|
1004
1018
|
req: Tina4Request,
|
|
1005
1019
|
res: Tina4Response,
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
const fnStr = (
|
|
1020
|
+
routeParams: Record<string, unknown>,
|
|
1021
|
+
): unknown[] {
|
|
1022
|
+
const fnStr = (handler as { toString(): string }).toString();
|
|
1009
1023
|
const argMatch = fnStr.match(/^(?:async\s*)?(?:function\s*\w*)?\s*\(([^)]*)\)/);
|
|
1010
1024
|
const argNames = argMatch?.[1]?.split(",").map((a: string) => a.trim().replace(/[:=].*/, "")) ?? [];
|
|
1011
1025
|
const filteredArgs = argNames.filter((n: string) => n.length > 0);
|
|
1026
|
+
if (filteredArgs.length === 0) return [];
|
|
1012
1027
|
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
const args = filteredArgs.map((name: string) => {
|
|
1028
|
+
let unmatchedPos = 0;
|
|
1029
|
+
return filteredArgs.map((name: string) => {
|
|
1016
1030
|
if (name in routeParams) return routeParams[name];
|
|
1017
1031
|
if (name === "request" || name === "req") return req;
|
|
1018
|
-
return res;
|
|
1032
|
+
if (name === "response" || name === "res") return res;
|
|
1033
|
+
return (unmatchedPos++ === 0) ? req : res;
|
|
1019
1034
|
});
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
async function invokeRouteHandler(
|
|
1038
|
+
match: { handler: unknown },
|
|
1039
|
+
req: Tina4Request,
|
|
1040
|
+
res: Tina4Response,
|
|
1041
|
+
): Promise<unknown> {
|
|
1042
|
+
const routeParams = req.params || {};
|
|
1043
|
+
const args = resolveHandlerArgs(match.handler, req, res, routeParams);
|
|
1044
|
+
if (args.length === 0) return await (match.handler as any)();
|
|
1020
1045
|
return await (match.handler as any)(...args);
|
|
1021
1046
|
}
|
|
1022
1047
|
|
|
@@ -25065,18 +25065,24 @@ async function runGlobalMiddlewarePass(middleware, req2, res) {
|
|
|
25065
25065
|
if (!res.raw.writableEnded) res.raw.end();
|
|
25066
25066
|
return true;
|
|
25067
25067
|
}
|
|
25068
|
-
|
|
25069
|
-
const
|
|
25070
|
-
const fnStr = match.handler.toString();
|
|
25068
|
+
function resolveHandlerArgs(handler, req2, res, routeParams) {
|
|
25069
|
+
const fnStr = handler.toString();
|
|
25071
25070
|
const argMatch = fnStr.match(/^(?:async\s*)?(?:function\s*\w*)?\s*\(([^)]*)\)/);
|
|
25072
25071
|
const argNames = argMatch?.[1]?.split(",").map((a) => a.trim().replace(/[:=].*/, "")) ?? [];
|
|
25073
25072
|
const filteredArgs = argNames.filter((n) => n.length > 0);
|
|
25074
|
-
if (filteredArgs.length === 0) return
|
|
25075
|
-
|
|
25073
|
+
if (filteredArgs.length === 0) return [];
|
|
25074
|
+
let unmatchedPos = 0;
|
|
25075
|
+
return filteredArgs.map((name) => {
|
|
25076
25076
|
if (name in routeParams) return routeParams[name];
|
|
25077
25077
|
if (name === "request" || name === "req") return req2;
|
|
25078
|
-
return res;
|
|
25078
|
+
if (name === "response" || name === "res") return res;
|
|
25079
|
+
return unmatchedPos++ === 0 ? req2 : res;
|
|
25079
25080
|
});
|
|
25081
|
+
}
|
|
25082
|
+
async function invokeRouteHandler(match, req2, res) {
|
|
25083
|
+
const routeParams = req2.params || {};
|
|
25084
|
+
const args = resolveHandlerArgs(match.handler, req2, res, routeParams);
|
|
25085
|
+
if (args.length === 0) return await match.handler();
|
|
25080
25086
|
return await match.handler(...args);
|
|
25081
25087
|
}
|
|
25082
25088
|
async function renderIfTemplateRoute(match, res, result) {
|
|
@@ -30831,6 +30837,8 @@ var init_aiClient = __esm({
|
|
|
30831
30837
|
Ai = class {
|
|
30832
30838
|
static chat(messages, options = {}) {
|
|
30833
30839
|
this.validateMessages(messages);
|
|
30840
|
+
if (options.tools !== void 0) this.validateTools(options.tools);
|
|
30841
|
+
if (options.toolChoice !== void 0) this.validateToolChoice(options.toolChoice);
|
|
30834
30842
|
const config = this.config("chat", options);
|
|
30835
30843
|
const body = this.chatBody(config, messages, options);
|
|
30836
30844
|
const headers = this.headers(config);
|
|
@@ -30860,15 +30868,31 @@ var init_aiClient = __esm({
|
|
|
30860
30868
|
}
|
|
30861
30869
|
/**
|
|
30862
30870
|
* Validate role + content shape. Content may be a string OR a non-empty
|
|
30863
|
-
* list of {type:'text'|'image', ...} parts (ADR-0060
|
|
30864
|
-
*
|
|
30871
|
+
* list of {type:'text'|'image'|'tool_result', ...} parts (ADR-0060 +
|
|
30872
|
+
* ADR-0061). The `tool` role is the OpenAI-style tool-result turn
|
|
30873
|
+
* (ADR-0061). Malformed parts fail fast with AiConfigError, never
|
|
30874
|
+
* reaching the wire.
|
|
30865
30875
|
*/
|
|
30866
30876
|
static validateMessages(messages) {
|
|
30867
30877
|
if (!Array.isArray(messages) || messages.length === 0) {
|
|
30868
30878
|
throw new AiConfigError("AI messages must contain supported roles and string content");
|
|
30869
30879
|
}
|
|
30870
|
-
for (const
|
|
30871
|
-
if (!
|
|
30880
|
+
for (const raw of messages) {
|
|
30881
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
30882
|
+
throw new AiConfigError("AI messages must contain supported roles and string content");
|
|
30883
|
+
}
|
|
30884
|
+
const message = raw;
|
|
30885
|
+
const role = message.role;
|
|
30886
|
+
if (role === "tool") {
|
|
30887
|
+
if (typeof message.tool_call_id !== "string" || message.tool_call_id.length === 0) {
|
|
30888
|
+
throw new AiConfigError("AI tool message requires a non-empty string 'tool_call_id'");
|
|
30889
|
+
}
|
|
30890
|
+
if (typeof message.content !== "string") {
|
|
30891
|
+
throw new AiConfigError("AI tool message requires a string 'content'");
|
|
30892
|
+
}
|
|
30893
|
+
continue;
|
|
30894
|
+
}
|
|
30895
|
+
if (role !== "system" && role !== "user" && role !== "assistant") {
|
|
30872
30896
|
throw new AiConfigError("AI messages must contain supported roles and string content");
|
|
30873
30897
|
}
|
|
30874
30898
|
this.validateContent(message.content);
|
|
@@ -30899,11 +30923,64 @@ var init_aiClient = __esm({
|
|
|
30899
30923
|
if (record.source.startsWith("data:") && !/^data:[^;,\s]+;base64,[A-Za-z0-9+/=]+$/.test(record.source)) {
|
|
30900
30924
|
throw new AiConfigError("AI image data URI must be data:<media_type>;base64,<payload>");
|
|
30901
30925
|
}
|
|
30926
|
+
} else if (partType === "tool_result") {
|
|
30927
|
+
if (typeof record.tool_use_id !== "string" || record.tool_use_id.length === 0) {
|
|
30928
|
+
throw new AiConfigError("AI tool_result part requires a non-empty string 'tool_use_id'");
|
|
30929
|
+
}
|
|
30930
|
+
if (typeof record.content !== "string") {
|
|
30931
|
+
throw new AiConfigError("AI tool_result part requires a string 'content'");
|
|
30932
|
+
}
|
|
30902
30933
|
} else {
|
|
30903
30934
|
throw new AiConfigError(`AI content part has unknown type '${String(partType)}'`);
|
|
30904
30935
|
}
|
|
30905
30936
|
}
|
|
30906
30937
|
}
|
|
30938
|
+
/**
|
|
30939
|
+
* Validate the outbound tool declarations (ADR-0061). Each tool needs a
|
|
30940
|
+
* non-empty `name`, a string `description`, and a JSON-Schema-shaped
|
|
30941
|
+
* `parameters` object. Malformed tools fail fast with AiConfigError,
|
|
30942
|
+
* never reaching the wire.
|
|
30943
|
+
*/
|
|
30944
|
+
static validateTools(tools) {
|
|
30945
|
+
if (!Array.isArray(tools) || tools.length === 0) {
|
|
30946
|
+
throw new AiConfigError("AI tools must be a non-empty list of {name, description, parameters}");
|
|
30947
|
+
}
|
|
30948
|
+
for (const tool of tools) {
|
|
30949
|
+
if (!tool || typeof tool !== "object" || Array.isArray(tool)) {
|
|
30950
|
+
throw new AiConfigError("AI tool must be an object with name, description, parameters");
|
|
30951
|
+
}
|
|
30952
|
+
const record = tool;
|
|
30953
|
+
if (typeof record.name !== "string" || record.name.length === 0) {
|
|
30954
|
+
throw new AiConfigError("AI tool requires a non-empty string 'name'");
|
|
30955
|
+
}
|
|
30956
|
+
if (typeof record.description !== "string") {
|
|
30957
|
+
throw new AiConfigError("AI tool requires a string 'description'");
|
|
30958
|
+
}
|
|
30959
|
+
if (!record.parameters || typeof record.parameters !== "object" || Array.isArray(record.parameters)) {
|
|
30960
|
+
throw new AiConfigError("AI tool requires a JSON-Schema object 'parameters'");
|
|
30961
|
+
}
|
|
30962
|
+
}
|
|
30963
|
+
}
|
|
30964
|
+
/**
|
|
30965
|
+
* Validate the outbound tool_choice value (ADR-0061). The four accepted
|
|
30966
|
+
* shapes are 'auto', 'none', 'required', and {name: 'x'}.
|
|
30967
|
+
*/
|
|
30968
|
+
static validateToolChoice(choice) {
|
|
30969
|
+
if (typeof choice === "string") {
|
|
30970
|
+
if (choice !== "auto" && choice !== "none" && choice !== "required") {
|
|
30971
|
+
throw new AiConfigError("AI toolChoice string must be 'auto', 'none', or 'required'");
|
|
30972
|
+
}
|
|
30973
|
+
return;
|
|
30974
|
+
}
|
|
30975
|
+
if (choice && typeof choice === "object" && !Array.isArray(choice)) {
|
|
30976
|
+
const record = choice;
|
|
30977
|
+
if (typeof record.name !== "string" || record.name.length === 0) {
|
|
30978
|
+
throw new AiConfigError("AI toolChoice object requires a non-empty string 'name'");
|
|
30979
|
+
}
|
|
30980
|
+
return;
|
|
30981
|
+
}
|
|
30982
|
+
throw new AiConfigError("AI toolChoice must be 'auto'|'none'|'required' or {name: string}");
|
|
30983
|
+
}
|
|
30907
30984
|
static number(name, fallback, minimum) {
|
|
30908
30985
|
const value = process.env[name] === void 0 ? fallback : Number(process.env[name]);
|
|
30909
30986
|
if (!Number.isFinite(value) || value < minimum) throw new AiConfigError(`${name} must be numeric and at least ${minimum}`);
|
|
@@ -30952,25 +31029,100 @@ var init_aiClient = __esm({
|
|
|
30952
31029
|
}
|
|
30953
31030
|
/**
|
|
30954
31031
|
* Build the provider-specific request body from a Tina4-shaped message
|
|
30955
|
-
* list
|
|
30956
|
-
*
|
|
30957
|
-
*
|
|
31032
|
+
* list plus optional tool declarations (ADR-0060 + ADR-0061).
|
|
31033
|
+
*
|
|
31034
|
+
* Content parts translate per provider:
|
|
31035
|
+
* - OpenAI/local: image → {type:'image_url', image_url:{url}}
|
|
31036
|
+
* - Anthropic: image → {type:'image', source:{type:'base64'|'url', ...}}
|
|
30958
31037
|
* String content is preserved verbatim in the OpenAI/local shape and
|
|
30959
31038
|
* likewise for Anthropic (both accept a bare string).
|
|
31039
|
+
*
|
|
31040
|
+
* Tool-result turns are normalised to the current provider's expected
|
|
31041
|
+
* shape (either the OpenAI `{role:"tool", tool_call_id, content}` turn or
|
|
31042
|
+
* the Anthropic `{role:"user", content:[{type:"tool_result", ...}]}`
|
|
31043
|
+
* turn), so an agent-loop written against Tina4 never has to fork on
|
|
31044
|
+
* TINA4_AI_PROVIDER (ADR-0061 wire translation).
|
|
30960
31045
|
*/
|
|
30961
31046
|
static chatBody(config, messages, options) {
|
|
30962
|
-
const
|
|
30963
|
-
const body = { model: config.model, messages:
|
|
31047
|
+
const normalized = this.normalizeMessagesForProvider(messages, config.provider);
|
|
31048
|
+
const body = { model: config.model, messages: normalized, stream: options.stream ?? false };
|
|
30964
31049
|
if (options.temperature !== void 0) body.temperature = options.temperature;
|
|
30965
31050
|
if (options.maxTokens !== void 0) body.max_tokens = options.maxTokens;
|
|
30966
31051
|
if (config.provider === "anthropic") {
|
|
30967
|
-
const systemParts =
|
|
30968
|
-
|
|
31052
|
+
const systemParts = [];
|
|
31053
|
+
for (const message of messages) {
|
|
31054
|
+
if (message.role !== "system") continue;
|
|
31055
|
+
const content = message.content;
|
|
31056
|
+
systemParts.push(typeof content === "string" ? content : this.contentToPlainText(content));
|
|
31057
|
+
}
|
|
31058
|
+
body.messages = normalized.filter((message) => message.role !== "system");
|
|
30969
31059
|
body.max_tokens = options.maxTokens ?? 1024;
|
|
30970
31060
|
if (systemParts.length) body.system = systemParts.join("\n\n");
|
|
30971
31061
|
}
|
|
31062
|
+
this.applyTools(body, config.provider, options);
|
|
30972
31063
|
return body;
|
|
30973
31064
|
}
|
|
31065
|
+
/**
|
|
31066
|
+
* Normalise the Tina4-shaped messages into the provider's on-wire shape.
|
|
31067
|
+
* The `tool` role and the `tool_result` content part are translated
|
|
31068
|
+
* between the OpenAI and Anthropic forms so either input works against
|
|
31069
|
+
* either provider (ADR-0061 return-path table).
|
|
31070
|
+
*/
|
|
31071
|
+
static normalizeMessagesForProvider(messages, provider) {
|
|
31072
|
+
const out = [];
|
|
31073
|
+
for (const message of messages) {
|
|
31074
|
+
if (message.role === "tool") {
|
|
31075
|
+
if (provider === "anthropic") {
|
|
31076
|
+
out.push({
|
|
31077
|
+
role: "user",
|
|
31078
|
+
content: [{ type: "tool_result", tool_use_id: message.tool_call_id, content: message.content }]
|
|
31079
|
+
});
|
|
31080
|
+
} else {
|
|
31081
|
+
out.push({ role: "tool", tool_call_id: message.tool_call_id, content: message.content });
|
|
31082
|
+
}
|
|
31083
|
+
continue;
|
|
31084
|
+
}
|
|
31085
|
+
if (Array.isArray(message.content) && message.content.some((part) => part.type === "tool_result")) {
|
|
31086
|
+
if (provider === "anthropic") {
|
|
31087
|
+
out.push({ role: message.role, content: this.translateContent(message.content, provider) });
|
|
31088
|
+
} else {
|
|
31089
|
+
for (const part of message.content) {
|
|
31090
|
+
if (part.type === "tool_result") {
|
|
31091
|
+
out.push({ role: "tool", tool_call_id: part.tool_use_id, content: part.content });
|
|
31092
|
+
}
|
|
31093
|
+
}
|
|
31094
|
+
}
|
|
31095
|
+
continue;
|
|
31096
|
+
}
|
|
31097
|
+
out.push({ role: message.role, content: this.translateContent(message.content, provider) });
|
|
31098
|
+
}
|
|
31099
|
+
return out;
|
|
31100
|
+
}
|
|
31101
|
+
/**
|
|
31102
|
+
* Attach the outbound `tools` and `tool_choice` (ADR-0061 outbound
|
|
31103
|
+
* translation tables) to the body in place. When toolChoice is 'none'
|
|
31104
|
+
* on Anthropic (Anthropic has no "none" mode) the tools list is omitted
|
|
31105
|
+
* entirely — the model cannot call what it cannot see.
|
|
31106
|
+
*/
|
|
31107
|
+
static applyTools(body, provider, options) {
|
|
31108
|
+
const choice = options.toolChoice;
|
|
31109
|
+
const suppressToolsForAnthropic = provider === "anthropic" && choice === "none";
|
|
31110
|
+
if (options.tools !== void 0 && !suppressToolsForAnthropic) {
|
|
31111
|
+
body.tools = options.tools.map(
|
|
31112
|
+
(tool) => provider === "anthropic" ? { name: tool.name, description: tool.description, input_schema: tool.parameters } : { type: "function", function: { name: tool.name, description: tool.description, parameters: tool.parameters } }
|
|
31113
|
+
);
|
|
31114
|
+
}
|
|
31115
|
+
if (choice === void 0) return;
|
|
31116
|
+
if (provider === "anthropic") {
|
|
31117
|
+
if (choice === "none") return;
|
|
31118
|
+
if (choice === "auto") body.tool_choice = { type: "auto" };
|
|
31119
|
+
else if (choice === "required") body.tool_choice = { type: "any" };
|
|
31120
|
+
else body.tool_choice = { type: "tool", name: choice.name };
|
|
31121
|
+
} else {
|
|
31122
|
+
if (typeof choice === "string") body.tool_choice = choice;
|
|
31123
|
+
else body.tool_choice = { type: "function", function: { name: choice.name } };
|
|
31124
|
+
}
|
|
31125
|
+
}
|
|
30974
31126
|
/**
|
|
30975
31127
|
* Translate one message content value into the provider's on-wire shape.
|
|
30976
31128
|
* A plain string is passed through (both providers accept a string
|
|
@@ -30981,6 +31133,7 @@ var init_aiClient = __esm({
|
|
|
30981
31133
|
if (provider === "anthropic") {
|
|
30982
31134
|
return content.map((part) => {
|
|
30983
31135
|
if (part.type === "text") return { type: "text", text: part.text };
|
|
31136
|
+
if (part.type === "tool_result") return { type: "tool_result", tool_use_id: part.tool_use_id, content: part.content };
|
|
30984
31137
|
if (part.source.startsWith("data:")) {
|
|
30985
31138
|
const parsed = this.parseDataUri(part.source);
|
|
30986
31139
|
return { type: "image", source: { type: "base64", media_type: parsed.mediaType, data: parsed.data } };
|
|
@@ -30990,6 +31143,9 @@ var init_aiClient = __esm({
|
|
|
30990
31143
|
}
|
|
30991
31144
|
return content.map((part) => {
|
|
30992
31145
|
if (part.type === "text") return { type: "text", text: part.text };
|
|
31146
|
+
if (part.type === "tool_result") {
|
|
31147
|
+
return { type: "text", text: part.content };
|
|
31148
|
+
}
|
|
30993
31149
|
return { type: "image_url", image_url: { url: part.source } };
|
|
30994
31150
|
});
|
|
30995
31151
|
}
|