tina4-nodejs 3.13.113 → 3.13.114
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 +161 -11
- package/packages/core/dist/index.js +161 -11
- package/packages/core/src/aiClient.ts +218 -17
- package/packages/core/src/index.ts +1 -1
- package/packages/orm/dist/index.js +161 -11
- package/types/core/src/aiClient.d.ts +91 -7
- package/types/core/src/index.d.ts +1 -1
package/CLAUDE.md
CHANGED
|
@@ -13,13 +13,13 @@ Even if the skill text is not currently loaded, these are non-negotiable:
|
|
|
13
13
|
|
|
14
14
|
The full discipline lives in `.claude/skills/tina4-maintainer/SKILL.md`; this block is the always-on floor.
|
|
15
15
|
|
|
16
|
-
# CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.
|
|
16
|
+
# CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.114)
|
|
17
17
|
|
|
18
18
|
> This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
|
|
19
19
|
|
|
20
20
|
## What This Project Is
|
|
21
21
|
|
|
22
|
-
Tina4 for Node.js/TypeScript v3.13.
|
|
22
|
+
Tina4 for Node.js/TypeScript v3.13.114 - The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
|
|
23
23
|
|
|
24
24
|
The philosophy: zero ceremony, batteries included, file system as source of truth.
|
|
25
25
|
|
package/package.json
CHANGED
package/packages/cli/dist/bin.js
CHANGED
|
@@ -42043,6 +42043,8 @@ var init_aiClient = __esm({
|
|
|
42043
42043
|
Ai = class {
|
|
42044
42044
|
static chat(messages, options = {}) {
|
|
42045
42045
|
this.validateMessages(messages);
|
|
42046
|
+
if (options.tools !== void 0) this.validateTools(options.tools);
|
|
42047
|
+
if (options.toolChoice !== void 0) this.validateToolChoice(options.toolChoice);
|
|
42046
42048
|
const config = this.config("chat", options);
|
|
42047
42049
|
const body = this.chatBody(config, messages, options);
|
|
42048
42050
|
const headers = this.headers(config);
|
|
@@ -42072,15 +42074,31 @@ var init_aiClient = __esm({
|
|
|
42072
42074
|
}
|
|
42073
42075
|
/**
|
|
42074
42076
|
* Validate role + content shape. Content may be a string OR a non-empty
|
|
42075
|
-
* list of {type:'text'|'image', ...} parts (ADR-0060
|
|
42076
|
-
*
|
|
42077
|
+
* list of {type:'text'|'image'|'tool_result', ...} parts (ADR-0060 +
|
|
42078
|
+
* ADR-0061). The `tool` role is the OpenAI-style tool-result turn
|
|
42079
|
+
* (ADR-0061). Malformed parts fail fast with AiConfigError, never
|
|
42080
|
+
* reaching the wire.
|
|
42077
42081
|
*/
|
|
42078
42082
|
static validateMessages(messages) {
|
|
42079
42083
|
if (!Array.isArray(messages) || messages.length === 0) {
|
|
42080
42084
|
throw new AiConfigError("AI messages must contain supported roles and string content");
|
|
42081
42085
|
}
|
|
42082
|
-
for (const
|
|
42083
|
-
if (!
|
|
42086
|
+
for (const raw of messages) {
|
|
42087
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
42088
|
+
throw new AiConfigError("AI messages must contain supported roles and string content");
|
|
42089
|
+
}
|
|
42090
|
+
const message = raw;
|
|
42091
|
+
const role = message.role;
|
|
42092
|
+
if (role === "tool") {
|
|
42093
|
+
if (typeof message.tool_call_id !== "string" || message.tool_call_id.length === 0) {
|
|
42094
|
+
throw new AiConfigError("AI tool message requires a non-empty string 'tool_call_id'");
|
|
42095
|
+
}
|
|
42096
|
+
if (typeof message.content !== "string") {
|
|
42097
|
+
throw new AiConfigError("AI tool message requires a string 'content'");
|
|
42098
|
+
}
|
|
42099
|
+
continue;
|
|
42100
|
+
}
|
|
42101
|
+
if (role !== "system" && role !== "user" && role !== "assistant") {
|
|
42084
42102
|
throw new AiConfigError("AI messages must contain supported roles and string content");
|
|
42085
42103
|
}
|
|
42086
42104
|
this.validateContent(message.content);
|
|
@@ -42111,11 +42129,64 @@ var init_aiClient = __esm({
|
|
|
42111
42129
|
if (record.source.startsWith("data:") && !/^data:[^;,\s]+;base64,[A-Za-z0-9+/=]+$/.test(record.source)) {
|
|
42112
42130
|
throw new AiConfigError("AI image data URI must be data:<media_type>;base64,<payload>");
|
|
42113
42131
|
}
|
|
42132
|
+
} else if (partType === "tool_result") {
|
|
42133
|
+
if (typeof record.tool_use_id !== "string" || record.tool_use_id.length === 0) {
|
|
42134
|
+
throw new AiConfigError("AI tool_result part requires a non-empty string 'tool_use_id'");
|
|
42135
|
+
}
|
|
42136
|
+
if (typeof record.content !== "string") {
|
|
42137
|
+
throw new AiConfigError("AI tool_result part requires a string 'content'");
|
|
42138
|
+
}
|
|
42114
42139
|
} else {
|
|
42115
42140
|
throw new AiConfigError(`AI content part has unknown type '${String(partType)}'`);
|
|
42116
42141
|
}
|
|
42117
42142
|
}
|
|
42118
42143
|
}
|
|
42144
|
+
/**
|
|
42145
|
+
* Validate the outbound tool declarations (ADR-0061). Each tool needs a
|
|
42146
|
+
* non-empty `name`, a string `description`, and a JSON-Schema-shaped
|
|
42147
|
+
* `parameters` object. Malformed tools fail fast with AiConfigError,
|
|
42148
|
+
* never reaching the wire.
|
|
42149
|
+
*/
|
|
42150
|
+
static validateTools(tools) {
|
|
42151
|
+
if (!Array.isArray(tools) || tools.length === 0) {
|
|
42152
|
+
throw new AiConfigError("AI tools must be a non-empty list of {name, description, parameters}");
|
|
42153
|
+
}
|
|
42154
|
+
for (const tool of tools) {
|
|
42155
|
+
if (!tool || typeof tool !== "object" || Array.isArray(tool)) {
|
|
42156
|
+
throw new AiConfigError("AI tool must be an object with name, description, parameters");
|
|
42157
|
+
}
|
|
42158
|
+
const record = tool;
|
|
42159
|
+
if (typeof record.name !== "string" || record.name.length === 0) {
|
|
42160
|
+
throw new AiConfigError("AI tool requires a non-empty string 'name'");
|
|
42161
|
+
}
|
|
42162
|
+
if (typeof record.description !== "string") {
|
|
42163
|
+
throw new AiConfigError("AI tool requires a string 'description'");
|
|
42164
|
+
}
|
|
42165
|
+
if (!record.parameters || typeof record.parameters !== "object" || Array.isArray(record.parameters)) {
|
|
42166
|
+
throw new AiConfigError("AI tool requires a JSON-Schema object 'parameters'");
|
|
42167
|
+
}
|
|
42168
|
+
}
|
|
42169
|
+
}
|
|
42170
|
+
/**
|
|
42171
|
+
* Validate the outbound tool_choice value (ADR-0061). The four accepted
|
|
42172
|
+
* shapes are 'auto', 'none', 'required', and {name: 'x'}.
|
|
42173
|
+
*/
|
|
42174
|
+
static validateToolChoice(choice) {
|
|
42175
|
+
if (typeof choice === "string") {
|
|
42176
|
+
if (choice !== "auto" && choice !== "none" && choice !== "required") {
|
|
42177
|
+
throw new AiConfigError("AI toolChoice string must be 'auto', 'none', or 'required'");
|
|
42178
|
+
}
|
|
42179
|
+
return;
|
|
42180
|
+
}
|
|
42181
|
+
if (choice && typeof choice === "object" && !Array.isArray(choice)) {
|
|
42182
|
+
const record = choice;
|
|
42183
|
+
if (typeof record.name !== "string" || record.name.length === 0) {
|
|
42184
|
+
throw new AiConfigError("AI toolChoice object requires a non-empty string 'name'");
|
|
42185
|
+
}
|
|
42186
|
+
return;
|
|
42187
|
+
}
|
|
42188
|
+
throw new AiConfigError("AI toolChoice must be 'auto'|'none'|'required' or {name: string}");
|
|
42189
|
+
}
|
|
42119
42190
|
static number(name, fallback, minimum) {
|
|
42120
42191
|
const value = process.env[name] === void 0 ? fallback : Number(process.env[name]);
|
|
42121
42192
|
if (!Number.isFinite(value) || value < minimum) throw new AiConfigError(`${name} must be numeric and at least ${minimum}`);
|
|
@@ -42164,25 +42235,100 @@ var init_aiClient = __esm({
|
|
|
42164
42235
|
}
|
|
42165
42236
|
/**
|
|
42166
42237
|
* Build the provider-specific request body from a Tina4-shaped message
|
|
42167
|
-
* list
|
|
42168
|
-
*
|
|
42169
|
-
*
|
|
42238
|
+
* list plus optional tool declarations (ADR-0060 + ADR-0061).
|
|
42239
|
+
*
|
|
42240
|
+
* Content parts translate per provider:
|
|
42241
|
+
* - OpenAI/local: image → {type:'image_url', image_url:{url}}
|
|
42242
|
+
* - Anthropic: image → {type:'image', source:{type:'base64'|'url', ...}}
|
|
42170
42243
|
* String content is preserved verbatim in the OpenAI/local shape and
|
|
42171
42244
|
* likewise for Anthropic (both accept a bare string).
|
|
42245
|
+
*
|
|
42246
|
+
* Tool-result turns are normalised to the current provider's expected
|
|
42247
|
+
* shape (either the OpenAI `{role:"tool", tool_call_id, content}` turn or
|
|
42248
|
+
* the Anthropic `{role:"user", content:[{type:"tool_result", ...}]}`
|
|
42249
|
+
* turn), so an agent-loop written against Tina4 never has to fork on
|
|
42250
|
+
* TINA4_AI_PROVIDER (ADR-0061 wire translation).
|
|
42172
42251
|
*/
|
|
42173
42252
|
static chatBody(config, messages, options) {
|
|
42174
|
-
const
|
|
42175
|
-
const body = { model: config.model, messages:
|
|
42253
|
+
const normalized = this.normalizeMessagesForProvider(messages, config.provider);
|
|
42254
|
+
const body = { model: config.model, messages: normalized, stream: options.stream ?? false };
|
|
42176
42255
|
if (options.temperature !== void 0) body.temperature = options.temperature;
|
|
42177
42256
|
if (options.maxTokens !== void 0) body.max_tokens = options.maxTokens;
|
|
42178
42257
|
if (config.provider === "anthropic") {
|
|
42179
|
-
const systemParts =
|
|
42180
|
-
|
|
42258
|
+
const systemParts = [];
|
|
42259
|
+
for (const message of messages) {
|
|
42260
|
+
if (message.role !== "system") continue;
|
|
42261
|
+
const content = message.content;
|
|
42262
|
+
systemParts.push(typeof content === "string" ? content : this.contentToPlainText(content));
|
|
42263
|
+
}
|
|
42264
|
+
body.messages = normalized.filter((message) => message.role !== "system");
|
|
42181
42265
|
body.max_tokens = options.maxTokens ?? 1024;
|
|
42182
42266
|
if (systemParts.length) body.system = systemParts.join("\n\n");
|
|
42183
42267
|
}
|
|
42268
|
+
this.applyTools(body, config.provider, options);
|
|
42184
42269
|
return body;
|
|
42185
42270
|
}
|
|
42271
|
+
/**
|
|
42272
|
+
* Normalise the Tina4-shaped messages into the provider's on-wire shape.
|
|
42273
|
+
* The `tool` role and the `tool_result` content part are translated
|
|
42274
|
+
* between the OpenAI and Anthropic forms so either input works against
|
|
42275
|
+
* either provider (ADR-0061 return-path table).
|
|
42276
|
+
*/
|
|
42277
|
+
static normalizeMessagesForProvider(messages, provider) {
|
|
42278
|
+
const out = [];
|
|
42279
|
+
for (const message of messages) {
|
|
42280
|
+
if (message.role === "tool") {
|
|
42281
|
+
if (provider === "anthropic") {
|
|
42282
|
+
out.push({
|
|
42283
|
+
role: "user",
|
|
42284
|
+
content: [{ type: "tool_result", tool_use_id: message.tool_call_id, content: message.content }]
|
|
42285
|
+
});
|
|
42286
|
+
} else {
|
|
42287
|
+
out.push({ role: "tool", tool_call_id: message.tool_call_id, content: message.content });
|
|
42288
|
+
}
|
|
42289
|
+
continue;
|
|
42290
|
+
}
|
|
42291
|
+
if (Array.isArray(message.content) && message.content.some((part) => part.type === "tool_result")) {
|
|
42292
|
+
if (provider === "anthropic") {
|
|
42293
|
+
out.push({ role: message.role, content: this.translateContent(message.content, provider) });
|
|
42294
|
+
} else {
|
|
42295
|
+
for (const part of message.content) {
|
|
42296
|
+
if (part.type === "tool_result") {
|
|
42297
|
+
out.push({ role: "tool", tool_call_id: part.tool_use_id, content: part.content });
|
|
42298
|
+
}
|
|
42299
|
+
}
|
|
42300
|
+
}
|
|
42301
|
+
continue;
|
|
42302
|
+
}
|
|
42303
|
+
out.push({ role: message.role, content: this.translateContent(message.content, provider) });
|
|
42304
|
+
}
|
|
42305
|
+
return out;
|
|
42306
|
+
}
|
|
42307
|
+
/**
|
|
42308
|
+
* Attach the outbound `tools` and `tool_choice` (ADR-0061 outbound
|
|
42309
|
+
* translation tables) to the body in place. When toolChoice is 'none'
|
|
42310
|
+
* on Anthropic (Anthropic has no "none" mode) the tools list is omitted
|
|
42311
|
+
* entirely — the model cannot call what it cannot see.
|
|
42312
|
+
*/
|
|
42313
|
+
static applyTools(body, provider, options) {
|
|
42314
|
+
const choice = options.toolChoice;
|
|
42315
|
+
const suppressToolsForAnthropic = provider === "anthropic" && choice === "none";
|
|
42316
|
+
if (options.tools !== void 0 && !suppressToolsForAnthropic) {
|
|
42317
|
+
body.tools = options.tools.map(
|
|
42318
|
+
(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 } }
|
|
42319
|
+
);
|
|
42320
|
+
}
|
|
42321
|
+
if (choice === void 0) return;
|
|
42322
|
+
if (provider === "anthropic") {
|
|
42323
|
+
if (choice === "none") return;
|
|
42324
|
+
if (choice === "auto") body.tool_choice = { type: "auto" };
|
|
42325
|
+
else if (choice === "required") body.tool_choice = { type: "any" };
|
|
42326
|
+
else body.tool_choice = { type: "tool", name: choice.name };
|
|
42327
|
+
} else {
|
|
42328
|
+
if (typeof choice === "string") body.tool_choice = choice;
|
|
42329
|
+
else body.tool_choice = { type: "function", function: { name: choice.name } };
|
|
42330
|
+
}
|
|
42331
|
+
}
|
|
42186
42332
|
/**
|
|
42187
42333
|
* Translate one message content value into the provider's on-wire shape.
|
|
42188
42334
|
* A plain string is passed through (both providers accept a string
|
|
@@ -42193,6 +42339,7 @@ var init_aiClient = __esm({
|
|
|
42193
42339
|
if (provider === "anthropic") {
|
|
42194
42340
|
return content.map((part) => {
|
|
42195
42341
|
if (part.type === "text") return { type: "text", text: part.text };
|
|
42342
|
+
if (part.type === "tool_result") return { type: "tool_result", tool_use_id: part.tool_use_id, content: part.content };
|
|
42196
42343
|
if (part.source.startsWith("data:")) {
|
|
42197
42344
|
const parsed = this.parseDataUri(part.source);
|
|
42198
42345
|
return { type: "image", source: { type: "base64", media_type: parsed.mediaType, data: parsed.data } };
|
|
@@ -42202,6 +42349,9 @@ var init_aiClient = __esm({
|
|
|
42202
42349
|
}
|
|
42203
42350
|
return content.map((part) => {
|
|
42204
42351
|
if (part.type === "text") return { type: "text", text: part.text };
|
|
42352
|
+
if (part.type === "tool_result") {
|
|
42353
|
+
return { type: "text", text: part.content };
|
|
42354
|
+
}
|
|
42205
42355
|
return { type: "image_url", image_url: { url: part.source } };
|
|
42206
42356
|
});
|
|
42207
42357
|
}
|
|
@@ -42004,6 +42004,8 @@ var init_aiClient = __esm({
|
|
|
42004
42004
|
Ai = class {
|
|
42005
42005
|
static chat(messages, options = {}) {
|
|
42006
42006
|
this.validateMessages(messages);
|
|
42007
|
+
if (options.tools !== void 0) this.validateTools(options.tools);
|
|
42008
|
+
if (options.toolChoice !== void 0) this.validateToolChoice(options.toolChoice);
|
|
42007
42009
|
const config = this.config("chat", options);
|
|
42008
42010
|
const body = this.chatBody(config, messages, options);
|
|
42009
42011
|
const headers = this.headers(config);
|
|
@@ -42033,15 +42035,31 @@ var init_aiClient = __esm({
|
|
|
42033
42035
|
}
|
|
42034
42036
|
/**
|
|
42035
42037
|
* Validate role + content shape. Content may be a string OR a non-empty
|
|
42036
|
-
* list of {type:'text'|'image', ...} parts (ADR-0060
|
|
42037
|
-
*
|
|
42038
|
+
* list of {type:'text'|'image'|'tool_result', ...} parts (ADR-0060 +
|
|
42039
|
+
* ADR-0061). The `tool` role is the OpenAI-style tool-result turn
|
|
42040
|
+
* (ADR-0061). Malformed parts fail fast with AiConfigError, never
|
|
42041
|
+
* reaching the wire.
|
|
42038
42042
|
*/
|
|
42039
42043
|
static validateMessages(messages) {
|
|
42040
42044
|
if (!Array.isArray(messages) || messages.length === 0) {
|
|
42041
42045
|
throw new AiConfigError("AI messages must contain supported roles and string content");
|
|
42042
42046
|
}
|
|
42043
|
-
for (const
|
|
42044
|
-
if (!
|
|
42047
|
+
for (const raw of messages) {
|
|
42048
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
42049
|
+
throw new AiConfigError("AI messages must contain supported roles and string content");
|
|
42050
|
+
}
|
|
42051
|
+
const message = raw;
|
|
42052
|
+
const role = message.role;
|
|
42053
|
+
if (role === "tool") {
|
|
42054
|
+
if (typeof message.tool_call_id !== "string" || message.tool_call_id.length === 0) {
|
|
42055
|
+
throw new AiConfigError("AI tool message requires a non-empty string 'tool_call_id'");
|
|
42056
|
+
}
|
|
42057
|
+
if (typeof message.content !== "string") {
|
|
42058
|
+
throw new AiConfigError("AI tool message requires a string 'content'");
|
|
42059
|
+
}
|
|
42060
|
+
continue;
|
|
42061
|
+
}
|
|
42062
|
+
if (role !== "system" && role !== "user" && role !== "assistant") {
|
|
42045
42063
|
throw new AiConfigError("AI messages must contain supported roles and string content");
|
|
42046
42064
|
}
|
|
42047
42065
|
this.validateContent(message.content);
|
|
@@ -42072,11 +42090,64 @@ var init_aiClient = __esm({
|
|
|
42072
42090
|
if (record.source.startsWith("data:") && !/^data:[^;,\s]+;base64,[A-Za-z0-9+/=]+$/.test(record.source)) {
|
|
42073
42091
|
throw new AiConfigError("AI image data URI must be data:<media_type>;base64,<payload>");
|
|
42074
42092
|
}
|
|
42093
|
+
} else if (partType === "tool_result") {
|
|
42094
|
+
if (typeof record.tool_use_id !== "string" || record.tool_use_id.length === 0) {
|
|
42095
|
+
throw new AiConfigError("AI tool_result part requires a non-empty string 'tool_use_id'");
|
|
42096
|
+
}
|
|
42097
|
+
if (typeof record.content !== "string") {
|
|
42098
|
+
throw new AiConfigError("AI tool_result part requires a string 'content'");
|
|
42099
|
+
}
|
|
42075
42100
|
} else {
|
|
42076
42101
|
throw new AiConfigError(`AI content part has unknown type '${String(partType)}'`);
|
|
42077
42102
|
}
|
|
42078
42103
|
}
|
|
42079
42104
|
}
|
|
42105
|
+
/**
|
|
42106
|
+
* Validate the outbound tool declarations (ADR-0061). Each tool needs a
|
|
42107
|
+
* non-empty `name`, a string `description`, and a JSON-Schema-shaped
|
|
42108
|
+
* `parameters` object. Malformed tools fail fast with AiConfigError,
|
|
42109
|
+
* never reaching the wire.
|
|
42110
|
+
*/
|
|
42111
|
+
static validateTools(tools) {
|
|
42112
|
+
if (!Array.isArray(tools) || tools.length === 0) {
|
|
42113
|
+
throw new AiConfigError("AI tools must be a non-empty list of {name, description, parameters}");
|
|
42114
|
+
}
|
|
42115
|
+
for (const tool of tools) {
|
|
42116
|
+
if (!tool || typeof tool !== "object" || Array.isArray(tool)) {
|
|
42117
|
+
throw new AiConfigError("AI tool must be an object with name, description, parameters");
|
|
42118
|
+
}
|
|
42119
|
+
const record = tool;
|
|
42120
|
+
if (typeof record.name !== "string" || record.name.length === 0) {
|
|
42121
|
+
throw new AiConfigError("AI tool requires a non-empty string 'name'");
|
|
42122
|
+
}
|
|
42123
|
+
if (typeof record.description !== "string") {
|
|
42124
|
+
throw new AiConfigError("AI tool requires a string 'description'");
|
|
42125
|
+
}
|
|
42126
|
+
if (!record.parameters || typeof record.parameters !== "object" || Array.isArray(record.parameters)) {
|
|
42127
|
+
throw new AiConfigError("AI tool requires a JSON-Schema object 'parameters'");
|
|
42128
|
+
}
|
|
42129
|
+
}
|
|
42130
|
+
}
|
|
42131
|
+
/**
|
|
42132
|
+
* Validate the outbound tool_choice value (ADR-0061). The four accepted
|
|
42133
|
+
* shapes are 'auto', 'none', 'required', and {name: 'x'}.
|
|
42134
|
+
*/
|
|
42135
|
+
static validateToolChoice(choice) {
|
|
42136
|
+
if (typeof choice === "string") {
|
|
42137
|
+
if (choice !== "auto" && choice !== "none" && choice !== "required") {
|
|
42138
|
+
throw new AiConfigError("AI toolChoice string must be 'auto', 'none', or 'required'");
|
|
42139
|
+
}
|
|
42140
|
+
return;
|
|
42141
|
+
}
|
|
42142
|
+
if (choice && typeof choice === "object" && !Array.isArray(choice)) {
|
|
42143
|
+
const record = choice;
|
|
42144
|
+
if (typeof record.name !== "string" || record.name.length === 0) {
|
|
42145
|
+
throw new AiConfigError("AI toolChoice object requires a non-empty string 'name'");
|
|
42146
|
+
}
|
|
42147
|
+
return;
|
|
42148
|
+
}
|
|
42149
|
+
throw new AiConfigError("AI toolChoice must be 'auto'|'none'|'required' or {name: string}");
|
|
42150
|
+
}
|
|
42080
42151
|
static number(name, fallback, minimum) {
|
|
42081
42152
|
const value = process.env[name] === void 0 ? fallback : Number(process.env[name]);
|
|
42082
42153
|
if (!Number.isFinite(value) || value < minimum) throw new AiConfigError(`${name} must be numeric and at least ${minimum}`);
|
|
@@ -42125,25 +42196,100 @@ var init_aiClient = __esm({
|
|
|
42125
42196
|
}
|
|
42126
42197
|
/**
|
|
42127
42198
|
* Build the provider-specific request body from a Tina4-shaped message
|
|
42128
|
-
* list
|
|
42129
|
-
*
|
|
42130
|
-
*
|
|
42199
|
+
* list plus optional tool declarations (ADR-0060 + ADR-0061).
|
|
42200
|
+
*
|
|
42201
|
+
* Content parts translate per provider:
|
|
42202
|
+
* - OpenAI/local: image → {type:'image_url', image_url:{url}}
|
|
42203
|
+
* - Anthropic: image → {type:'image', source:{type:'base64'|'url', ...}}
|
|
42131
42204
|
* String content is preserved verbatim in the OpenAI/local shape and
|
|
42132
42205
|
* likewise for Anthropic (both accept a bare string).
|
|
42206
|
+
*
|
|
42207
|
+
* Tool-result turns are normalised to the current provider's expected
|
|
42208
|
+
* shape (either the OpenAI `{role:"tool", tool_call_id, content}` turn or
|
|
42209
|
+
* the Anthropic `{role:"user", content:[{type:"tool_result", ...}]}`
|
|
42210
|
+
* turn), so an agent-loop written against Tina4 never has to fork on
|
|
42211
|
+
* TINA4_AI_PROVIDER (ADR-0061 wire translation).
|
|
42133
42212
|
*/
|
|
42134
42213
|
static chatBody(config, messages, options) {
|
|
42135
|
-
const
|
|
42136
|
-
const body = { model: config.model, messages:
|
|
42214
|
+
const normalized = this.normalizeMessagesForProvider(messages, config.provider);
|
|
42215
|
+
const body = { model: config.model, messages: normalized, stream: options.stream ?? false };
|
|
42137
42216
|
if (options.temperature !== void 0) body.temperature = options.temperature;
|
|
42138
42217
|
if (options.maxTokens !== void 0) body.max_tokens = options.maxTokens;
|
|
42139
42218
|
if (config.provider === "anthropic") {
|
|
42140
|
-
const systemParts =
|
|
42141
|
-
|
|
42219
|
+
const systemParts = [];
|
|
42220
|
+
for (const message of messages) {
|
|
42221
|
+
if (message.role !== "system") continue;
|
|
42222
|
+
const content = message.content;
|
|
42223
|
+
systemParts.push(typeof content === "string" ? content : this.contentToPlainText(content));
|
|
42224
|
+
}
|
|
42225
|
+
body.messages = normalized.filter((message) => message.role !== "system");
|
|
42142
42226
|
body.max_tokens = options.maxTokens ?? 1024;
|
|
42143
42227
|
if (systemParts.length) body.system = systemParts.join("\n\n");
|
|
42144
42228
|
}
|
|
42229
|
+
this.applyTools(body, config.provider, options);
|
|
42145
42230
|
return body;
|
|
42146
42231
|
}
|
|
42232
|
+
/**
|
|
42233
|
+
* Normalise the Tina4-shaped messages into the provider's on-wire shape.
|
|
42234
|
+
* The `tool` role and the `tool_result` content part are translated
|
|
42235
|
+
* between the OpenAI and Anthropic forms so either input works against
|
|
42236
|
+
* either provider (ADR-0061 return-path table).
|
|
42237
|
+
*/
|
|
42238
|
+
static normalizeMessagesForProvider(messages, provider) {
|
|
42239
|
+
const out = [];
|
|
42240
|
+
for (const message of messages) {
|
|
42241
|
+
if (message.role === "tool") {
|
|
42242
|
+
if (provider === "anthropic") {
|
|
42243
|
+
out.push({
|
|
42244
|
+
role: "user",
|
|
42245
|
+
content: [{ type: "tool_result", tool_use_id: message.tool_call_id, content: message.content }]
|
|
42246
|
+
});
|
|
42247
|
+
} else {
|
|
42248
|
+
out.push({ role: "tool", tool_call_id: message.tool_call_id, content: message.content });
|
|
42249
|
+
}
|
|
42250
|
+
continue;
|
|
42251
|
+
}
|
|
42252
|
+
if (Array.isArray(message.content) && message.content.some((part) => part.type === "tool_result")) {
|
|
42253
|
+
if (provider === "anthropic") {
|
|
42254
|
+
out.push({ role: message.role, content: this.translateContent(message.content, provider) });
|
|
42255
|
+
} else {
|
|
42256
|
+
for (const part of message.content) {
|
|
42257
|
+
if (part.type === "tool_result") {
|
|
42258
|
+
out.push({ role: "tool", tool_call_id: part.tool_use_id, content: part.content });
|
|
42259
|
+
}
|
|
42260
|
+
}
|
|
42261
|
+
}
|
|
42262
|
+
continue;
|
|
42263
|
+
}
|
|
42264
|
+
out.push({ role: message.role, content: this.translateContent(message.content, provider) });
|
|
42265
|
+
}
|
|
42266
|
+
return out;
|
|
42267
|
+
}
|
|
42268
|
+
/**
|
|
42269
|
+
* Attach the outbound `tools` and `tool_choice` (ADR-0061 outbound
|
|
42270
|
+
* translation tables) to the body in place. When toolChoice is 'none'
|
|
42271
|
+
* on Anthropic (Anthropic has no "none" mode) the tools list is omitted
|
|
42272
|
+
* entirely — the model cannot call what it cannot see.
|
|
42273
|
+
*/
|
|
42274
|
+
static applyTools(body, provider, options) {
|
|
42275
|
+
const choice = options.toolChoice;
|
|
42276
|
+
const suppressToolsForAnthropic = provider === "anthropic" && choice === "none";
|
|
42277
|
+
if (options.tools !== void 0 && !suppressToolsForAnthropic) {
|
|
42278
|
+
body.tools = options.tools.map(
|
|
42279
|
+
(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 } }
|
|
42280
|
+
);
|
|
42281
|
+
}
|
|
42282
|
+
if (choice === void 0) return;
|
|
42283
|
+
if (provider === "anthropic") {
|
|
42284
|
+
if (choice === "none") return;
|
|
42285
|
+
if (choice === "auto") body.tool_choice = { type: "auto" };
|
|
42286
|
+
else if (choice === "required") body.tool_choice = { type: "any" };
|
|
42287
|
+
else body.tool_choice = { type: "tool", name: choice.name };
|
|
42288
|
+
} else {
|
|
42289
|
+
if (typeof choice === "string") body.tool_choice = choice;
|
|
42290
|
+
else body.tool_choice = { type: "function", function: { name: choice.name } };
|
|
42291
|
+
}
|
|
42292
|
+
}
|
|
42147
42293
|
/**
|
|
42148
42294
|
* Translate one message content value into the provider's on-wire shape.
|
|
42149
42295
|
* A plain string is passed through (both providers accept a string
|
|
@@ -42154,6 +42300,7 @@ var init_aiClient = __esm({
|
|
|
42154
42300
|
if (provider === "anthropic") {
|
|
42155
42301
|
return content.map((part) => {
|
|
42156
42302
|
if (part.type === "text") return { type: "text", text: part.text };
|
|
42303
|
+
if (part.type === "tool_result") return { type: "tool_result", tool_use_id: part.tool_use_id, content: part.content };
|
|
42157
42304
|
if (part.source.startsWith("data:")) {
|
|
42158
42305
|
const parsed = this.parseDataUri(part.source);
|
|
42159
42306
|
return { type: "image", source: { type: "base64", media_type: parsed.mediaType, data: parsed.data } };
|
|
@@ -42163,6 +42310,9 @@ var init_aiClient = __esm({
|
|
|
42163
42310
|
}
|
|
42164
42311
|
return content.map((part) => {
|
|
42165
42312
|
if (part.type === "text") return { type: "text", text: part.text };
|
|
42313
|
+
if (part.type === "tool_result") {
|
|
42314
|
+
return { type: "text", text: part.content };
|
|
42315
|
+
}
|
|
42166
42316
|
return { type: "image_url", image_url: { url: part.source } };
|
|
42167
42317
|
});
|
|
42168
42318
|
}
|
|
@@ -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";
|
|
@@ -30831,6 +30831,8 @@ var init_aiClient = __esm({
|
|
|
30831
30831
|
Ai = class {
|
|
30832
30832
|
static chat(messages, options = {}) {
|
|
30833
30833
|
this.validateMessages(messages);
|
|
30834
|
+
if (options.tools !== void 0) this.validateTools(options.tools);
|
|
30835
|
+
if (options.toolChoice !== void 0) this.validateToolChoice(options.toolChoice);
|
|
30834
30836
|
const config = this.config("chat", options);
|
|
30835
30837
|
const body = this.chatBody(config, messages, options);
|
|
30836
30838
|
const headers = this.headers(config);
|
|
@@ -30860,15 +30862,31 @@ var init_aiClient = __esm({
|
|
|
30860
30862
|
}
|
|
30861
30863
|
/**
|
|
30862
30864
|
* Validate role + content shape. Content may be a string OR a non-empty
|
|
30863
|
-
* list of {type:'text'|'image', ...} parts (ADR-0060
|
|
30864
|
-
*
|
|
30865
|
+
* list of {type:'text'|'image'|'tool_result', ...} parts (ADR-0060 +
|
|
30866
|
+
* ADR-0061). The `tool` role is the OpenAI-style tool-result turn
|
|
30867
|
+
* (ADR-0061). Malformed parts fail fast with AiConfigError, never
|
|
30868
|
+
* reaching the wire.
|
|
30865
30869
|
*/
|
|
30866
30870
|
static validateMessages(messages) {
|
|
30867
30871
|
if (!Array.isArray(messages) || messages.length === 0) {
|
|
30868
30872
|
throw new AiConfigError("AI messages must contain supported roles and string content");
|
|
30869
30873
|
}
|
|
30870
|
-
for (const
|
|
30871
|
-
if (!
|
|
30874
|
+
for (const raw of messages) {
|
|
30875
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
30876
|
+
throw new AiConfigError("AI messages must contain supported roles and string content");
|
|
30877
|
+
}
|
|
30878
|
+
const message = raw;
|
|
30879
|
+
const role = message.role;
|
|
30880
|
+
if (role === "tool") {
|
|
30881
|
+
if (typeof message.tool_call_id !== "string" || message.tool_call_id.length === 0) {
|
|
30882
|
+
throw new AiConfigError("AI tool message requires a non-empty string 'tool_call_id'");
|
|
30883
|
+
}
|
|
30884
|
+
if (typeof message.content !== "string") {
|
|
30885
|
+
throw new AiConfigError("AI tool message requires a string 'content'");
|
|
30886
|
+
}
|
|
30887
|
+
continue;
|
|
30888
|
+
}
|
|
30889
|
+
if (role !== "system" && role !== "user" && role !== "assistant") {
|
|
30872
30890
|
throw new AiConfigError("AI messages must contain supported roles and string content");
|
|
30873
30891
|
}
|
|
30874
30892
|
this.validateContent(message.content);
|
|
@@ -30899,11 +30917,64 @@ var init_aiClient = __esm({
|
|
|
30899
30917
|
if (record.source.startsWith("data:") && !/^data:[^;,\s]+;base64,[A-Za-z0-9+/=]+$/.test(record.source)) {
|
|
30900
30918
|
throw new AiConfigError("AI image data URI must be data:<media_type>;base64,<payload>");
|
|
30901
30919
|
}
|
|
30920
|
+
} else if (partType === "tool_result") {
|
|
30921
|
+
if (typeof record.tool_use_id !== "string" || record.tool_use_id.length === 0) {
|
|
30922
|
+
throw new AiConfigError("AI tool_result part requires a non-empty string 'tool_use_id'");
|
|
30923
|
+
}
|
|
30924
|
+
if (typeof record.content !== "string") {
|
|
30925
|
+
throw new AiConfigError("AI tool_result part requires a string 'content'");
|
|
30926
|
+
}
|
|
30902
30927
|
} else {
|
|
30903
30928
|
throw new AiConfigError(`AI content part has unknown type '${String(partType)}'`);
|
|
30904
30929
|
}
|
|
30905
30930
|
}
|
|
30906
30931
|
}
|
|
30932
|
+
/**
|
|
30933
|
+
* Validate the outbound tool declarations (ADR-0061). Each tool needs a
|
|
30934
|
+
* non-empty `name`, a string `description`, and a JSON-Schema-shaped
|
|
30935
|
+
* `parameters` object. Malformed tools fail fast with AiConfigError,
|
|
30936
|
+
* never reaching the wire.
|
|
30937
|
+
*/
|
|
30938
|
+
static validateTools(tools) {
|
|
30939
|
+
if (!Array.isArray(tools) || tools.length === 0) {
|
|
30940
|
+
throw new AiConfigError("AI tools must be a non-empty list of {name, description, parameters}");
|
|
30941
|
+
}
|
|
30942
|
+
for (const tool of tools) {
|
|
30943
|
+
if (!tool || typeof tool !== "object" || Array.isArray(tool)) {
|
|
30944
|
+
throw new AiConfigError("AI tool must be an object with name, description, parameters");
|
|
30945
|
+
}
|
|
30946
|
+
const record = tool;
|
|
30947
|
+
if (typeof record.name !== "string" || record.name.length === 0) {
|
|
30948
|
+
throw new AiConfigError("AI tool requires a non-empty string 'name'");
|
|
30949
|
+
}
|
|
30950
|
+
if (typeof record.description !== "string") {
|
|
30951
|
+
throw new AiConfigError("AI tool requires a string 'description'");
|
|
30952
|
+
}
|
|
30953
|
+
if (!record.parameters || typeof record.parameters !== "object" || Array.isArray(record.parameters)) {
|
|
30954
|
+
throw new AiConfigError("AI tool requires a JSON-Schema object 'parameters'");
|
|
30955
|
+
}
|
|
30956
|
+
}
|
|
30957
|
+
}
|
|
30958
|
+
/**
|
|
30959
|
+
* Validate the outbound tool_choice value (ADR-0061). The four accepted
|
|
30960
|
+
* shapes are 'auto', 'none', 'required', and {name: 'x'}.
|
|
30961
|
+
*/
|
|
30962
|
+
static validateToolChoice(choice) {
|
|
30963
|
+
if (typeof choice === "string") {
|
|
30964
|
+
if (choice !== "auto" && choice !== "none" && choice !== "required") {
|
|
30965
|
+
throw new AiConfigError("AI toolChoice string must be 'auto', 'none', or 'required'");
|
|
30966
|
+
}
|
|
30967
|
+
return;
|
|
30968
|
+
}
|
|
30969
|
+
if (choice && typeof choice === "object" && !Array.isArray(choice)) {
|
|
30970
|
+
const record = choice;
|
|
30971
|
+
if (typeof record.name !== "string" || record.name.length === 0) {
|
|
30972
|
+
throw new AiConfigError("AI toolChoice object requires a non-empty string 'name'");
|
|
30973
|
+
}
|
|
30974
|
+
return;
|
|
30975
|
+
}
|
|
30976
|
+
throw new AiConfigError("AI toolChoice must be 'auto'|'none'|'required' or {name: string}");
|
|
30977
|
+
}
|
|
30907
30978
|
static number(name, fallback, minimum) {
|
|
30908
30979
|
const value = process.env[name] === void 0 ? fallback : Number(process.env[name]);
|
|
30909
30980
|
if (!Number.isFinite(value) || value < minimum) throw new AiConfigError(`${name} must be numeric and at least ${minimum}`);
|
|
@@ -30952,25 +31023,100 @@ var init_aiClient = __esm({
|
|
|
30952
31023
|
}
|
|
30953
31024
|
/**
|
|
30954
31025
|
* Build the provider-specific request body from a Tina4-shaped message
|
|
30955
|
-
* list
|
|
30956
|
-
*
|
|
30957
|
-
*
|
|
31026
|
+
* list plus optional tool declarations (ADR-0060 + ADR-0061).
|
|
31027
|
+
*
|
|
31028
|
+
* Content parts translate per provider:
|
|
31029
|
+
* - OpenAI/local: image → {type:'image_url', image_url:{url}}
|
|
31030
|
+
* - Anthropic: image → {type:'image', source:{type:'base64'|'url', ...}}
|
|
30958
31031
|
* String content is preserved verbatim in the OpenAI/local shape and
|
|
30959
31032
|
* likewise for Anthropic (both accept a bare string).
|
|
31033
|
+
*
|
|
31034
|
+
* Tool-result turns are normalised to the current provider's expected
|
|
31035
|
+
* shape (either the OpenAI `{role:"tool", tool_call_id, content}` turn or
|
|
31036
|
+
* the Anthropic `{role:"user", content:[{type:"tool_result", ...}]}`
|
|
31037
|
+
* turn), so an agent-loop written against Tina4 never has to fork on
|
|
31038
|
+
* TINA4_AI_PROVIDER (ADR-0061 wire translation).
|
|
30960
31039
|
*/
|
|
30961
31040
|
static chatBody(config, messages, options) {
|
|
30962
|
-
const
|
|
30963
|
-
const body = { model: config.model, messages:
|
|
31041
|
+
const normalized = this.normalizeMessagesForProvider(messages, config.provider);
|
|
31042
|
+
const body = { model: config.model, messages: normalized, stream: options.stream ?? false };
|
|
30964
31043
|
if (options.temperature !== void 0) body.temperature = options.temperature;
|
|
30965
31044
|
if (options.maxTokens !== void 0) body.max_tokens = options.maxTokens;
|
|
30966
31045
|
if (config.provider === "anthropic") {
|
|
30967
|
-
const systemParts =
|
|
30968
|
-
|
|
31046
|
+
const systemParts = [];
|
|
31047
|
+
for (const message of messages) {
|
|
31048
|
+
if (message.role !== "system") continue;
|
|
31049
|
+
const content = message.content;
|
|
31050
|
+
systemParts.push(typeof content === "string" ? content : this.contentToPlainText(content));
|
|
31051
|
+
}
|
|
31052
|
+
body.messages = normalized.filter((message) => message.role !== "system");
|
|
30969
31053
|
body.max_tokens = options.maxTokens ?? 1024;
|
|
30970
31054
|
if (systemParts.length) body.system = systemParts.join("\n\n");
|
|
30971
31055
|
}
|
|
31056
|
+
this.applyTools(body, config.provider, options);
|
|
30972
31057
|
return body;
|
|
30973
31058
|
}
|
|
31059
|
+
/**
|
|
31060
|
+
* Normalise the Tina4-shaped messages into the provider's on-wire shape.
|
|
31061
|
+
* The `tool` role and the `tool_result` content part are translated
|
|
31062
|
+
* between the OpenAI and Anthropic forms so either input works against
|
|
31063
|
+
* either provider (ADR-0061 return-path table).
|
|
31064
|
+
*/
|
|
31065
|
+
static normalizeMessagesForProvider(messages, provider) {
|
|
31066
|
+
const out = [];
|
|
31067
|
+
for (const message of messages) {
|
|
31068
|
+
if (message.role === "tool") {
|
|
31069
|
+
if (provider === "anthropic") {
|
|
31070
|
+
out.push({
|
|
31071
|
+
role: "user",
|
|
31072
|
+
content: [{ type: "tool_result", tool_use_id: message.tool_call_id, content: message.content }]
|
|
31073
|
+
});
|
|
31074
|
+
} else {
|
|
31075
|
+
out.push({ role: "tool", tool_call_id: message.tool_call_id, content: message.content });
|
|
31076
|
+
}
|
|
31077
|
+
continue;
|
|
31078
|
+
}
|
|
31079
|
+
if (Array.isArray(message.content) && message.content.some((part) => part.type === "tool_result")) {
|
|
31080
|
+
if (provider === "anthropic") {
|
|
31081
|
+
out.push({ role: message.role, content: this.translateContent(message.content, provider) });
|
|
31082
|
+
} else {
|
|
31083
|
+
for (const part of message.content) {
|
|
31084
|
+
if (part.type === "tool_result") {
|
|
31085
|
+
out.push({ role: "tool", tool_call_id: part.tool_use_id, content: part.content });
|
|
31086
|
+
}
|
|
31087
|
+
}
|
|
31088
|
+
}
|
|
31089
|
+
continue;
|
|
31090
|
+
}
|
|
31091
|
+
out.push({ role: message.role, content: this.translateContent(message.content, provider) });
|
|
31092
|
+
}
|
|
31093
|
+
return out;
|
|
31094
|
+
}
|
|
31095
|
+
/**
|
|
31096
|
+
* Attach the outbound `tools` and `tool_choice` (ADR-0061 outbound
|
|
31097
|
+
* translation tables) to the body in place. When toolChoice is 'none'
|
|
31098
|
+
* on Anthropic (Anthropic has no "none" mode) the tools list is omitted
|
|
31099
|
+
* entirely — the model cannot call what it cannot see.
|
|
31100
|
+
*/
|
|
31101
|
+
static applyTools(body, provider, options) {
|
|
31102
|
+
const choice = options.toolChoice;
|
|
31103
|
+
const suppressToolsForAnthropic = provider === "anthropic" && choice === "none";
|
|
31104
|
+
if (options.tools !== void 0 && !suppressToolsForAnthropic) {
|
|
31105
|
+
body.tools = options.tools.map(
|
|
31106
|
+
(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 } }
|
|
31107
|
+
);
|
|
31108
|
+
}
|
|
31109
|
+
if (choice === void 0) return;
|
|
31110
|
+
if (provider === "anthropic") {
|
|
31111
|
+
if (choice === "none") return;
|
|
31112
|
+
if (choice === "auto") body.tool_choice = { type: "auto" };
|
|
31113
|
+
else if (choice === "required") body.tool_choice = { type: "any" };
|
|
31114
|
+
else body.tool_choice = { type: "tool", name: choice.name };
|
|
31115
|
+
} else {
|
|
31116
|
+
if (typeof choice === "string") body.tool_choice = choice;
|
|
31117
|
+
else body.tool_choice = { type: "function", function: { name: choice.name } };
|
|
31118
|
+
}
|
|
31119
|
+
}
|
|
30974
31120
|
/**
|
|
30975
31121
|
* Translate one message content value into the provider's on-wire shape.
|
|
30976
31122
|
* A plain string is passed through (both providers accept a string
|
|
@@ -30981,6 +31127,7 @@ var init_aiClient = __esm({
|
|
|
30981
31127
|
if (provider === "anthropic") {
|
|
30982
31128
|
return content.map((part) => {
|
|
30983
31129
|
if (part.type === "text") return { type: "text", text: part.text };
|
|
31130
|
+
if (part.type === "tool_result") return { type: "tool_result", tool_use_id: part.tool_use_id, content: part.content };
|
|
30984
31131
|
if (part.source.startsWith("data:")) {
|
|
30985
31132
|
const parsed = this.parseDataUri(part.source);
|
|
30986
31133
|
return { type: "image", source: { type: "base64", media_type: parsed.mediaType, data: parsed.data } };
|
|
@@ -30990,6 +31137,9 @@ var init_aiClient = __esm({
|
|
|
30990
31137
|
}
|
|
30991
31138
|
return content.map((part) => {
|
|
30992
31139
|
if (part.type === "text") return { type: "text", text: part.text };
|
|
31140
|
+
if (part.type === "tool_result") {
|
|
31141
|
+
return { type: "text", text: part.content };
|
|
31142
|
+
}
|
|
30993
31143
|
return { type: "image_url", image_url: { url: part.source } };
|
|
30994
31144
|
});
|
|
30995
31145
|
}
|
|
@@ -24,7 +24,10 @@ export interface ChatResponse {
|
|
|
24
24
|
/**
|
|
25
25
|
* A multimodal content part. `text` carries plain UTF-8 prose; `image`
|
|
26
26
|
* carries a `data:<media_type>;base64,<payload>` URI or an https:// URL
|
|
27
|
-
* (the client translates to each provider's shape
|
|
27
|
+
* (the client translates to each provider's shape, ADR-0060). `tool_result`
|
|
28
|
+
* carries the Anthropic-style return of a locally-executed tool call
|
|
29
|
+
* (ADR-0061); the client translates it to OpenAI's `{role: "tool", ...}`
|
|
30
|
+
* turn on non-Anthropic providers.
|
|
28
31
|
*/
|
|
29
32
|
export type ContentPart = {
|
|
30
33
|
type: "text";
|
|
@@ -32,13 +35,50 @@ export type ContentPart = {
|
|
|
32
35
|
} | {
|
|
33
36
|
type: "image";
|
|
34
37
|
source: string;
|
|
38
|
+
} | {
|
|
39
|
+
type: "tool_result";
|
|
40
|
+
tool_use_id: string;
|
|
41
|
+
content: string;
|
|
35
42
|
};
|
|
36
43
|
/** The value a caller may pass for `message.content`. ADR-0060. */
|
|
37
44
|
export type AiMessageContent = string | ContentPart[];
|
|
38
|
-
|
|
45
|
+
/**
|
|
46
|
+
* One conversation turn. The three "chat" roles carry a string OR a
|
|
47
|
+
* content-parts array (ADR-0060). The `tool` role is the OpenAI-style
|
|
48
|
+
* return of a tool call (ADR-0061); the client translates it to the
|
|
49
|
+
* Anthropic user-turn form when the current provider is Anthropic.
|
|
50
|
+
*/
|
|
51
|
+
export type AiMessage = {
|
|
39
52
|
role: "system" | "user" | "assistant";
|
|
40
53
|
content: AiMessageContent;
|
|
54
|
+
} | {
|
|
55
|
+
role: "tool";
|
|
56
|
+
tool_call_id: string;
|
|
57
|
+
content: string;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* A tool declaration the model may call (named `AiToolDeclaration` to
|
|
61
|
+
* stay out of the way of {@link ./ai.ts}'s existing `AiTool` interface
|
|
62
|
+
* for AI-coding-tool context installation). `parameters` is a JSON
|
|
63
|
+
* Schema object; it is passed to the provider unchanged (ADR-0061
|
|
64
|
+
* `parameters-passthrough`).
|
|
65
|
+
*/
|
|
66
|
+
export interface AiToolDeclaration {
|
|
67
|
+
name: string;
|
|
68
|
+
description: string;
|
|
69
|
+
parameters: Record<string, unknown>;
|
|
41
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* How the model picks a tool. Four Tina4 values that span the useful cases
|
|
73
|
+
* across providers (ADR-0061 wire-translation table):
|
|
74
|
+
* 'auto' — model may call any tool or answer with text
|
|
75
|
+
* 'none' — model must not call a tool (Anthropic omits `tools`)
|
|
76
|
+
* 'required' — model must call some tool
|
|
77
|
+
* {name: 'x'} — model must call tool 'x'
|
|
78
|
+
*/
|
|
79
|
+
export type AiToolChoice = "auto" | "none" | "required" | {
|
|
80
|
+
name: string;
|
|
81
|
+
};
|
|
42
82
|
/**
|
|
43
83
|
* One event yielded by `Ai.chat(stream: true)`. The four variants
|
|
44
84
|
* discriminated by `type`. Text deltas arrive per chunk (typewriter UX);
|
|
@@ -74,6 +114,14 @@ export interface AiChatOptions {
|
|
|
74
114
|
stream?: boolean;
|
|
75
115
|
timeout?: number;
|
|
76
116
|
provider?: "local" | "openai" | "anthropic";
|
|
117
|
+
/** Tools the model may call. ADR-0061 — translated per provider. */
|
|
118
|
+
tools?: AiToolDeclaration[];
|
|
119
|
+
/**
|
|
120
|
+
* How the model picks a tool. ADR-0061 — translated per provider. If
|
|
121
|
+
* `'none'` on Anthropic (which has no "none" mode), `tools` is omitted
|
|
122
|
+
* from the outbound body entirely.
|
|
123
|
+
*/
|
|
124
|
+
toolChoice?: AiToolChoice;
|
|
77
125
|
}
|
|
78
126
|
export interface AiEmbedOptions {
|
|
79
127
|
model?: string;
|
|
@@ -91,24 +139,60 @@ export declare class Ai {
|
|
|
91
139
|
static embed(textOrTexts: string | string[], options?: AiEmbedOptions): Promise<number[] | number[][]>;
|
|
92
140
|
/**
|
|
93
141
|
* Validate role + content shape. Content may be a string OR a non-empty
|
|
94
|
-
* list of {type:'text'|'image', ...} parts (ADR-0060
|
|
95
|
-
*
|
|
142
|
+
* list of {type:'text'|'image'|'tool_result', ...} parts (ADR-0060 +
|
|
143
|
+
* ADR-0061). The `tool` role is the OpenAI-style tool-result turn
|
|
144
|
+
* (ADR-0061). Malformed parts fail fast with AiConfigError, never
|
|
145
|
+
* reaching the wire.
|
|
96
146
|
*/
|
|
97
147
|
private static validateMessages;
|
|
98
148
|
private static validateContent;
|
|
149
|
+
/**
|
|
150
|
+
* Validate the outbound tool declarations (ADR-0061). Each tool needs a
|
|
151
|
+
* non-empty `name`, a string `description`, and a JSON-Schema-shaped
|
|
152
|
+
* `parameters` object. Malformed tools fail fast with AiConfigError,
|
|
153
|
+
* never reaching the wire.
|
|
154
|
+
*/
|
|
155
|
+
private static validateTools;
|
|
156
|
+
/**
|
|
157
|
+
* Validate the outbound tool_choice value (ADR-0061). The four accepted
|
|
158
|
+
* shapes are 'auto', 'none', 'required', and {name: 'x'}.
|
|
159
|
+
*/
|
|
160
|
+
private static validateToolChoice;
|
|
99
161
|
private static number;
|
|
100
162
|
private static config;
|
|
101
163
|
private static endpoint;
|
|
102
164
|
private static headers;
|
|
103
165
|
/**
|
|
104
166
|
* Build the provider-specific request body from a Tina4-shaped message
|
|
105
|
-
* list
|
|
106
|
-
*
|
|
107
|
-
*
|
|
167
|
+
* list plus optional tool declarations (ADR-0060 + ADR-0061).
|
|
168
|
+
*
|
|
169
|
+
* Content parts translate per provider:
|
|
170
|
+
* - OpenAI/local: image → {type:'image_url', image_url:{url}}
|
|
171
|
+
* - Anthropic: image → {type:'image', source:{type:'base64'|'url', ...}}
|
|
108
172
|
* String content is preserved verbatim in the OpenAI/local shape and
|
|
109
173
|
* likewise for Anthropic (both accept a bare string).
|
|
174
|
+
*
|
|
175
|
+
* Tool-result turns are normalised to the current provider's expected
|
|
176
|
+
* shape (either the OpenAI `{role:"tool", tool_call_id, content}` turn or
|
|
177
|
+
* the Anthropic `{role:"user", content:[{type:"tool_result", ...}]}`
|
|
178
|
+
* turn), so an agent-loop written against Tina4 never has to fork on
|
|
179
|
+
* TINA4_AI_PROVIDER (ADR-0061 wire translation).
|
|
110
180
|
*/
|
|
111
181
|
private static chatBody;
|
|
182
|
+
/**
|
|
183
|
+
* Normalise the Tina4-shaped messages into the provider's on-wire shape.
|
|
184
|
+
* The `tool` role and the `tool_result` content part are translated
|
|
185
|
+
* between the OpenAI and Anthropic forms so either input works against
|
|
186
|
+
* either provider (ADR-0061 return-path table).
|
|
187
|
+
*/
|
|
188
|
+
private static normalizeMessagesForProvider;
|
|
189
|
+
/**
|
|
190
|
+
* Attach the outbound `tools` and `tool_choice` (ADR-0061 outbound
|
|
191
|
+
* translation tables) to the body in place. When toolChoice is 'none'
|
|
192
|
+
* on Anthropic (Anthropic has no "none" mode) the tools list is omitted
|
|
193
|
+
* entirely — the model cannot call what it cannot see.
|
|
194
|
+
*/
|
|
195
|
+
private static applyTools;
|
|
112
196
|
/**
|
|
113
197
|
* Translate one message content value into the provider's on-wire shape.
|
|
114
198
|
* A plain string is passed through (both providers accept a string
|
|
@@ -58,7 +58,7 @@ export type { AiTool } from "./ai.js";
|
|
|
58
58
|
export { Sso, SSO, SsoError } from "./sso.js";
|
|
59
59
|
export type { SsoOptions } from "./sso.js";
|
|
60
60
|
export { Ai, AiError, AiConfigError, AiHTTPError, AiTimeoutError, AiParseError } from "./aiClient.js";
|
|
61
|
-
export type { ChatResponse, AiMessage, AiChatOptions, AiEmbedOptions, AiEvent, ContentPart, AiMessageContent } from "./aiClient.js";
|
|
61
|
+
export type { ChatResponse, AiMessage, AiChatOptions, AiEmbedOptions, AiEvent, ContentPart, AiMessageContent, AiToolDeclaration, AiToolChoice } from "./aiClient.js";
|
|
62
62
|
export type { ImapMessage, ImapFullMessage, ImapAttachment } from "./messenger.js";
|
|
63
63
|
export { LiteBackend } from "./queueBackends/liteBackend.js";
|
|
64
64
|
export { RabbitMQBackend, parseAmqpUrl } from "./queueBackends/rabbitmqBackend.js";
|