@bitbaum/ai-kit 1.4.0 → 1.4.2

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.
@@ -23,9 +23,10 @@ import type { Classification } from "./types.js";
23
23
  /**
24
24
  * Does this error body explicitly say the model cannot do tools?
25
25
  *
26
- * Conservative on purpose — see the header. A false positive here is a model
27
- * permanently downgraded for a reason nobody can see; a false negative just
28
- * means we ask again next time, which costs one request.
26
+ * Conservative on purpose — see the header. Both failures are real and neither
27
+ * is free: a false positive downgrades a working model for a reason nobody can
28
+ * see, and a false negative leaves the caller repeating a request it will
29
+ * never learn from. Match on "names tools AND negates support", nothing looser.
29
30
  */
30
31
  export declare function saysToolsUnsupported(body: string): boolean;
31
32
  export type ToolAttempt = {
@@ -4,29 +4,50 @@
4
4
  * Every entry names tools or functions explicitly. Deliberately absent:
5
5
  * "invalid request", "bad parameter", "unsupported" on its own — each of those
6
6
  * appears in vendor 400s for a dozen unrelated reasons, and a match on one of
7
- * them would silently disable a working model. When in doubt the answer is to
8
- * record nothing; an unobserved model gets asked again on the next message,
9
- * whereas a wrongly-negative one does not.
7
+ * them would silently disable a working model.
8
+ *
9
+ * But "when in doubt, record nothing" is only cheap for a caller that can
10
+ * retry freely, and the real caller cannot. A caller that sends definitions
11
+ * ALSO drops whatever prose fallback it has, because definitions are supposed
12
+ * to replace it — so a refusal this list fails to recognise costs a whole turn
13
+ * in which the model can neither call a tool nor be told how to act without
14
+ * one, and it costs that turn EVERY turn, forever, because nothing is ever
15
+ * learned. A missed phrasing is not one wasted request; it is a permanently
16
+ * mute model.
17
+ *
18
+ * So the bar is: does the vendor NAME tools or functions, and NEGATE support?
19
+ * Both halves, explicitly. Everything meeting that bar belongs here, including
20
+ * the boring grammatical variants — plural, `are`, `cannot use` — which is
21
+ * where the first real gap was found (`tools are not supported by this model`
22
+ * matched nothing at all).
10
23
  */
11
24
  const TOOLS_UNSUPPORTED_PATTERNS = [
12
- /tool[\s_-]?(use|call|calls|calling)\s+(is\s+)?(not|un)[\s_-]?support/i,
25
+ /tool[\s_-]?(use|call|calls|calling)\s+(is\s+|are\s+)?(not|un)[\s_-]?support/i,
13
26
  /does\s+not\s+support\s+tool/i,
14
27
  /doesn'?t\s+support\s+tool/i,
15
28
  /no\s+support\s+for\s+tool/i,
16
- /function[\s_-]?call(ing)?\s+(is\s+)?(not|un)[\s_-]?support/i,
29
+ /function[\s_-]?call(ing)?\s+(is\s+|are\s+)?(not|un)[\s_-]?support/i,
17
30
  /does\s+not\s+support\s+function/i,
18
31
  /doesn'?t\s+support\s+function/i,
19
32
  /model\s+.{0,60}?\s+does\s+not\s+support\s+(the\s+)?(`?tools`?|`?functions`?)/i,
20
33
  /unsupported\s+parameter:?\s*'?"?tools?"?'?/i,
21
34
  /unknown\s+(field|parameter):?\s*'?"?tools?"?'?/i,
22
- /`?tools`?\s+is\s+not\s+(a\s+)?(valid|supported|allowed)/i,
35
+ // "tools are not supported", "tool is not valid", "functions are unsupported".
36
+ // The singular-and-`is` form of this was already here; the plural-and-`are`
37
+ // form is the one vendors actually write, and it matched nothing.
38
+ /`?(tools?|functions?)`?\s+(is|are)\s+(not\s+(a\s+)?(valid|supported|allowed|available)|unsupported)/i,
39
+ // "this model cannot use tools" / "can't call functions".
40
+ /(cannot|can'?t|is\s+unable\s+to)\s+(use|call|handle|execute)\s+(`?tools?`?|`?functions?`?)/i,
41
+ // "no tool support", "without function support".
42
+ /no\s+(`?tools?`?|`?functions?`?)\s+support/i,
23
43
  ];
24
44
  /**
25
45
  * Does this error body explicitly say the model cannot do tools?
26
46
  *
27
- * Conservative on purpose — see the header. A false positive here is a model
28
- * permanently downgraded for a reason nobody can see; a false negative just
29
- * means we ask again next time, which costs one request.
47
+ * Conservative on purpose — see the header. Both failures are real and neither
48
+ * is free: a false positive downgrades a working model for a reason nobody can
49
+ * see, and a false negative leaves the caller repeating a request it will
50
+ * never learn from. Match on "names tools AND negates support", nothing looser.
30
51
  */
31
52
  export function saysToolsUnsupported(body) {
32
53
  if (!body)
@@ -59,10 +59,38 @@ import { type Env, type Link, type Provider } from "./chain.js";
59
59
  import type { HealthTracker } from "./health.js";
60
60
  import { type RateLimitKind } from "./limits.js";
61
61
  import { type QuotaReading } from "./meter.js";
62
- /** One message in the OpenAI chat-completions shape every provider here speaks. */
62
+ /**
63
+ * A piece of a message, for the models that accept more than text.
64
+ *
65
+ * The same shape every provider here already speaks, because it is OpenAI's —
66
+ * `image_url.url` takes a `data:` URL or an `https:` one.
67
+ */
68
+ export type ContentPart = {
69
+ type: "text";
70
+ text: string;
71
+ } | {
72
+ type: "image_url";
73
+ image_url: {
74
+ url: string;
75
+ detail?: "auto" | "low" | "high";
76
+ };
77
+ };
78
+ /**
79
+ * One message in the OpenAI chat-completions shape every provider here speaks.
80
+ *
81
+ * `content` accepts parts as well as a string because the implementation
82
+ * always carried them: `callLink` forwards `messages` into the request body
83
+ * untouched, so a multimodal message has worked at runtime since the first
84
+ * release while the type insisted it could not. A consumer that needed to send
85
+ * a screenshot therefore had to cast around our own type — and a cast written
86
+ * to work around a library is a thing the next consumer copies.
87
+ *
88
+ * Backward compatible by construction: every existing caller passes a string,
89
+ * and a string is still a `ChatMessage["content"]`.
90
+ */
63
91
  export interface ChatMessage {
64
92
  role: "system" | "user" | "assistant" | "tool";
65
- content: string;
93
+ content: string | ContentPart[];
66
94
  /** Present on `role: "tool"` replies; passed through untouched. */
67
95
  tool_call_id?: string;
68
96
  name?: string;
@@ -183,7 +183,25 @@ function properNounRuns(text) {
183
183
  const out = [];
184
184
  // Strip fenced and inline code — quoted identifiers are usually the user's
185
185
  // own or a literal under discussion, not a claim about the world.
186
- const prose = text.replace(/```[\s\S]*?```/g, " ").replace(/`[^`]*`/g, " ");
186
+ //
187
+ // Markdown table pipes become line breaks for the same reason a full stop is
188
+ // a boundary: a cell is its own utterance, and a run must not span two of
189
+ // them. Observed on a live answer that laid its findings out as a table —
190
+ // the header row `| Category | Item | Status | Notes |` yielded the runs
191
+ // "Item" and "Item Status Notes Pending", the last of those having run on
192
+ // into the FIRST CELL OF THE NEXT ROW. The answer was entirely grounded and
193
+ // was reported as fabricating, which is the worst direction for this check
194
+ // to fail in: a warning that fires on correct answers teaches the operator
195
+ // to dismiss the warning.
196
+ //
197
+ // Splitting per cell also makes each header word cell-initial, and
198
+ // sentence-initial single words are already skipped for exactly this reason.
199
+ // Recall is barely touched: a fabricated name INSIDE a cell still trips its
200
+ // remaining tokens, the same trade already accepted at sentence starts.
201
+ const prose = text
202
+ .replace(/```[\s\S]*?```/g, " ")
203
+ .replace(/`[^`]*`/g, " ")
204
+ .replace(/\|/g, "\n");
187
205
  for (const sentence of prose.split(/(?<=[.!?:\n])\s+/)) {
188
206
  const tokens = sentence.match(/[A-Za-z][A-Za-z0-9&.'’-]*/g) ?? [];
189
207
  let run = [];
package/dist/index.d.ts CHANGED
@@ -52,7 +52,7 @@
52
52
  export { type Provider, type Env, type Link, type CostVerdict, providerModels, withEnvPrefix, freeChain, modelCost, modelCostAt, paidModelsIn, dayCapacityTokens, usableChain, chainFrom, } from "./chain.js";
53
53
  export { type CatalogVerdict, type CheckCatalogOptions, checkCatalog, hasRot, deadProviders, catalogReport, } from "./catalog.js";
54
54
  export { type ChainAttemptFailure, type TryChainOptions, ChainExhaustedError, tryChain, } from "./attempt.js";
55
- export { type ChatMessage, type ToolCall, type CompleteOptions, type CompleteResult, LinkFailure, complete, linkId, } from "./complete.js";
55
+ export { type ChatMessage, type ContentPart, type ToolCall, type CompleteOptions, type CompleteResult, LinkFailure, complete, linkId, } from "./complete.js";
56
56
  export { type HealthStatus, type Health, type HealthTrackerOptions, type HealthTracker, createHealthTracker, } from "./health.js";
57
57
  export { type LivenessResult, type LivenessOptions, type LivenessProbe, type AiHealthHandlerOptions, createLivenessProbe, createAiHealthHandler, } from "./liveness.js";
58
58
  export { type RateLimitKind, classifyRateLimit, retryAfterSeconds, humanizeWait, rateLimitMessage, } from "./limits.js";
@@ -187,7 +187,25 @@ function properNounRuns(text) {
187
187
  const out = [];
188
188
  // Strip fenced and inline code — quoted identifiers are usually the user's
189
189
  // own or a literal under discussion, not a claim about the world.
190
- const prose = text.replace(/```[\s\S]*?```/g, " ").replace(/`[^`]*`/g, " ");
190
+ //
191
+ // Markdown table pipes become line breaks for the same reason a full stop is
192
+ // a boundary: a cell is its own utterance, and a run must not span two of
193
+ // them. Observed on a live answer that laid its findings out as a table —
194
+ // the header row `| Category | Item | Status | Notes |` yielded the runs
195
+ // "Item" and "Item Status Notes Pending", the last of those having run on
196
+ // into the FIRST CELL OF THE NEXT ROW. The answer was entirely grounded and
197
+ // was reported as fabricating, which is the worst direction for this check
198
+ // to fail in: a warning that fires on correct answers teaches the operator
199
+ // to dismiss the warning.
200
+ //
201
+ // Splitting per cell also makes each header word cell-initial, and
202
+ // sentence-initial single words are already skipped for exactly this reason.
203
+ // Recall is barely touched: a fabricated name INSIDE a cell still trips its
204
+ // remaining tokens, the same trade already accepted at sentence starts.
205
+ const prose = text
206
+ .replace(/```[\s\S]*?```/g, " ")
207
+ .replace(/`[^`]*`/g, " ")
208
+ .replace(/\|/g, "\n");
191
209
  for (const sentence of prose.split(/(?<=[.!?:\n])\s+/)) {
192
210
  const tokens = sentence.match(/[A-Za-z][A-Za-z0-9&.'’-]*/g) ?? [];
193
211
  let run = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitbaum/ai-kit",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "license": "MIT",
5
5
  "author": "Mao Nakamoto",
6
6
  "homepage": "https://github.com/bitbaum/ai-kit#readme",
@@ -90,12 +90,12 @@
90
90
  },
91
91
  "devDependencies": {
92
92
  "@eslint/js": "^10.0.1",
93
- "@types/node": "^26.4.1",
94
- "eslint": "^10.9.1",
93
+ "@types/node": "^26.5.0",
94
+ "eslint": "^10.10.0",
95
95
  "globals": "^17.12.0",
96
96
  "prettier": "3.9.6",
97
97
  "typescript": "^6.0.3",
98
- "typescript-eslint": "^8.69.0"
98
+ "typescript-eslint": "^8.70.0"
99
99
  },
100
100
  "dependencies": {
101
101
  "ai-forms": "^0.1.2"
@@ -27,30 +27,51 @@ import type { Classification } from "./types.js";
27
27
  * Every entry names tools or functions explicitly. Deliberately absent:
28
28
  * "invalid request", "bad parameter", "unsupported" on its own — each of those
29
29
  * appears in vendor 400s for a dozen unrelated reasons, and a match on one of
30
- * them would silently disable a working model. When in doubt the answer is to
31
- * record nothing; an unobserved model gets asked again on the next message,
32
- * whereas a wrongly-negative one does not.
30
+ * them would silently disable a working model.
31
+ *
32
+ * But "when in doubt, record nothing" is only cheap for a caller that can
33
+ * retry freely, and the real caller cannot. A caller that sends definitions
34
+ * ALSO drops whatever prose fallback it has, because definitions are supposed
35
+ * to replace it — so a refusal this list fails to recognise costs a whole turn
36
+ * in which the model can neither call a tool nor be told how to act without
37
+ * one, and it costs that turn EVERY turn, forever, because nothing is ever
38
+ * learned. A missed phrasing is not one wasted request; it is a permanently
39
+ * mute model.
40
+ *
41
+ * So the bar is: does the vendor NAME tools or functions, and NEGATE support?
42
+ * Both halves, explicitly. Everything meeting that bar belongs here, including
43
+ * the boring grammatical variants — plural, `are`, `cannot use` — which is
44
+ * where the first real gap was found (`tools are not supported by this model`
45
+ * matched nothing at all).
33
46
  */
34
47
  const TOOLS_UNSUPPORTED_PATTERNS: RegExp[] = [
35
- /tool[\s_-]?(use|call|calls|calling)\s+(is\s+)?(not|un)[\s_-]?support/i,
48
+ /tool[\s_-]?(use|call|calls|calling)\s+(is\s+|are\s+)?(not|un)[\s_-]?support/i,
36
49
  /does\s+not\s+support\s+tool/i,
37
50
  /doesn'?t\s+support\s+tool/i,
38
51
  /no\s+support\s+for\s+tool/i,
39
- /function[\s_-]?call(ing)?\s+(is\s+)?(not|un)[\s_-]?support/i,
52
+ /function[\s_-]?call(ing)?\s+(is\s+|are\s+)?(not|un)[\s_-]?support/i,
40
53
  /does\s+not\s+support\s+function/i,
41
54
  /doesn'?t\s+support\s+function/i,
42
55
  /model\s+.{0,60}?\s+does\s+not\s+support\s+(the\s+)?(`?tools`?|`?functions`?)/i,
43
56
  /unsupported\s+parameter:?\s*'?"?tools?"?'?/i,
44
57
  /unknown\s+(field|parameter):?\s*'?"?tools?"?'?/i,
45
- /`?tools`?\s+is\s+not\s+(a\s+)?(valid|supported|allowed)/i,
58
+ // "tools are not supported", "tool is not valid", "functions are unsupported".
59
+ // The singular-and-`is` form of this was already here; the plural-and-`are`
60
+ // form is the one vendors actually write, and it matched nothing.
61
+ /`?(tools?|functions?)`?\s+(is|are)\s+(not\s+(a\s+)?(valid|supported|allowed|available)|unsupported)/i,
62
+ // "this model cannot use tools" / "can't call functions".
63
+ /(cannot|can'?t|is\s+unable\s+to)\s+(use|call|handle|execute)\s+(`?tools?`?|`?functions?`?)/i,
64
+ // "no tool support", "without function support".
65
+ /no\s+(`?tools?`?|`?functions?`?)\s+support/i,
46
66
  ];
47
67
 
48
68
  /**
49
69
  * Does this error body explicitly say the model cannot do tools?
50
70
  *
51
- * Conservative on purpose — see the header. A false positive here is a model
52
- * permanently downgraded for a reason nobody can see; a false negative just
53
- * means we ask again next time, which costs one request.
71
+ * Conservative on purpose — see the header. Both failures are real and neither
72
+ * is free: a false positive downgrades a working model for a reason nobody can
73
+ * see, and a false negative leaves the caller repeating a request it will
74
+ * never learn from. Match on "names tools AND negates support", nothing looser.
54
75
  */
55
76
  export function saysToolsUnsupported(body: string): boolean {
56
77
  if (!body) return false;
package/src/complete.ts CHANGED
@@ -63,10 +63,32 @@ import { classifyRateLimit, retryAfterSeconds, type RateLimitKind } from "./limi
63
63
  import { readQuota, readingFromRefusal, type QuotaReading } from "./meter.js";
64
64
  import { parseTextToolCalls, stripToolCallLines, toolNamesFrom } from "./tool-protocol.js";
65
65
 
66
- /** One message in the OpenAI chat-completions shape every provider here speaks. */
66
+ /**
67
+ * A piece of a message, for the models that accept more than text.
68
+ *
69
+ * The same shape every provider here already speaks, because it is OpenAI's —
70
+ * `image_url.url` takes a `data:` URL or an `https:` one.
71
+ */
72
+ export type ContentPart =
73
+ | { type: "text"; text: string }
74
+ | { type: "image_url"; image_url: { url: string; detail?: "auto" | "low" | "high" } };
75
+
76
+ /**
77
+ * One message in the OpenAI chat-completions shape every provider here speaks.
78
+ *
79
+ * `content` accepts parts as well as a string because the implementation
80
+ * always carried them: `callLink` forwards `messages` into the request body
81
+ * untouched, so a multimodal message has worked at runtime since the first
82
+ * release while the type insisted it could not. A consumer that needed to send
83
+ * a screenshot therefore had to cast around our own type — and a cast written
84
+ * to work around a library is a thing the next consumer copies.
85
+ *
86
+ * Backward compatible by construction: every existing caller passes a string,
87
+ * and a string is still a `ChatMessage["content"]`.
88
+ */
67
89
  export interface ChatMessage {
68
90
  role: "system" | "user" | "assistant" | "tool";
69
- content: string;
91
+ content: string | ContentPart[];
70
92
  /** Present on `role: "tool"` replies; passed through untouched. */
71
93
  tool_call_id?: string;
72
94
  name?: string;
@@ -201,7 +201,25 @@ function properNounRuns(text: string): string[] {
201
201
  const out: string[] = [];
202
202
  // Strip fenced and inline code — quoted identifiers are usually the user's
203
203
  // own or a literal under discussion, not a claim about the world.
204
- const prose = text.replace(/```[\s\S]*?```/g, " ").replace(/`[^`]*`/g, " ");
204
+ //
205
+ // Markdown table pipes become line breaks for the same reason a full stop is
206
+ // a boundary: a cell is its own utterance, and a run must not span two of
207
+ // them. Observed on a live answer that laid its findings out as a table —
208
+ // the header row `| Category | Item | Status | Notes |` yielded the runs
209
+ // "Item" and "Item Status Notes Pending", the last of those having run on
210
+ // into the FIRST CELL OF THE NEXT ROW. The answer was entirely grounded and
211
+ // was reported as fabricating, which is the worst direction for this check
212
+ // to fail in: a warning that fires on correct answers teaches the operator
213
+ // to dismiss the warning.
214
+ //
215
+ // Splitting per cell also makes each header word cell-initial, and
216
+ // sentence-initial single words are already skipped for exactly this reason.
217
+ // Recall is barely touched: a fabricated name INSIDE a cell still trips its
218
+ // remaining tokens, the same trade already accepted at sentence starts.
219
+ const prose = text
220
+ .replace(/```[\s\S]*?```/g, " ")
221
+ .replace(/`[^`]*`/g, " ")
222
+ .replace(/\|/g, "\n");
205
223
 
206
224
  for (const sentence of prose.split(/(?<=[.!?:\n])\s+/)) {
207
225
  const tokens = sentence.match(/[A-Za-z][A-Za-z0-9&.'’-]*/g) ?? [];
package/src/index.ts CHANGED
@@ -84,6 +84,7 @@ export {
84
84
 
85
85
  export {
86
86
  type ChatMessage,
87
+ type ContentPart,
87
88
  type ToolCall,
88
89
  type CompleteOptions,
89
90
  type CompleteResult,