@bitbaum/ai-kit 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/dist/complete.d.ts +160 -0
- package/dist/complete.js +237 -0
- package/dist/index.d.ts +15 -7
- package/dist/index.js +15 -7
- package/package.json +9 -7
- package/src/complete.ts +340 -0
- package/src/index.ts +24 -7
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ what to do when you're going too fast, how to share a free tier fairly between
|
|
|
6
6
|
users, and how to fill a form from plain language.
|
|
7
7
|
|
|
8
8
|
```bash
|
|
9
|
-
|
|
9
|
+
pnpm add @bitbaum/ai-kit
|
|
10
10
|
```
|
|
11
11
|
|
|
12
12
|
---
|
|
@@ -165,7 +165,7 @@ stays its own package — it works, four apps run it, and it is useful well outs
|
|
|
165
165
|
this fleet. Swallowing it would have broken those four for the sake of a filing
|
|
166
166
|
system.
|
|
167
167
|
|
|
168
|
-
**Note the subpath.** Form filling is at
|
|
168
|
+
**Note the subpath.** Form filling is at `@bitbaum/ai-kit/forms`, not at the root. For one
|
|
169
169
|
release it was both, and the first app to adopt the merged package paid for it:
|
|
170
170
|
`ai-forms` is ESM-only, so importing the *chain* from the root dragged the forms
|
|
171
171
|
package in behind it and the app's Jest run — which executes CJS — died inside a
|
|
@@ -173,8 +173,8 @@ module it never asked for. One install is still the whole promise; the exports
|
|
|
173
173
|
map is what keeps it, while letting a server that only wants a provider chain
|
|
174
174
|
stop paying for a form library.
|
|
175
175
|
|
|
176
|
-
React lives on its own subpath and is an **optional** peer, so importing
|
|
177
|
-
on a server never pulls in a UI library.
|
|
176
|
+
React lives on its own subpath and is an **optional** peer, so importing
|
|
177
|
+
`@bitbaum/ai-kit` on a server never pulls in a UI library.
|
|
178
178
|
|
|
179
179
|
---
|
|
180
180
|
|
|
@@ -210,7 +210,7 @@ model catalogue to do it.
|
|
|
210
210
|
## Development
|
|
211
211
|
|
|
212
212
|
```bash
|
|
213
|
-
|
|
213
|
+
pnpm run verify # lint + typecheck + build + test
|
|
214
214
|
```
|
|
215
215
|
|
|
216
216
|
MIT.
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The call itself — the one thing this package refused to ship, and the reason
|
|
3
|
+
* the rest of it went unused.
|
|
4
|
+
*
|
|
5
|
+
* ── WHY THIS REVERSES A STATED RULE ──────────────────────────────────────────
|
|
6
|
+
* Every module here was written against a real outage, and every one of them is
|
|
7
|
+
* correct. None of that reached the apps that were actually failing. Measured
|
|
8
|
+
* across the fleet on 2026-09-05:
|
|
9
|
+
*
|
|
10
|
+
* this package's decisions adopted by 2 repos
|
|
11
|
+
* `ai-forms`, which ships a working handler adopted by 5 repos
|
|
12
|
+
* hand-rolled LLM clients still in service 8, ~1400-1700 lines each
|
|
13
|
+
*
|
|
14
|
+
* The pattern is not about quality, it is about shape. `ai-forms` was adopted
|
|
15
|
+
* because `createFormAssistHandler` does the job; this package was not, because
|
|
16
|
+
* it hands back advice the caller must then wire up. The old rule — "every app
|
|
17
|
+
* has its own calling conventions, replacing them is a rewrite rather than an
|
|
18
|
+
* adoption" — describes the duplication accurately and then protects it. The
|
|
19
|
+
* conventions differ because nothing ever offered to own them.
|
|
20
|
+
*
|
|
21
|
+
* The cost of that is not theoretical. Of the 8 hand-rolled clients, 2 tell the
|
|
22
|
+
* three kinds of 429 apart; the other 6 treat a spent daily budget as a busy
|
|
23
|
+
* minute — `limits.ts` has explained why that is harmful since 2026-08-14, in a
|
|
24
|
+
* module those 6 apps do not import. `tryChain` says it plainly: a chain nobody
|
|
25
|
+
* walks is a list, not a fallback. A decision nobody calls is a comment.
|
|
26
|
+
*
|
|
27
|
+
* So: this owns the fetch. `tryChain` stays for callers with a genuinely
|
|
28
|
+
* unusual request to make; this is the answer for everyone else.
|
|
29
|
+
*
|
|
30
|
+
* ── WHAT IT KNOWS THAT A HAND-ROLLED LOOP DOES NOT ───────────────────────────
|
|
31
|
+
* Walking the chain is the easy half. Three behaviours below are the ones every
|
|
32
|
+
* hand-rolled client in this fleet got wrong, each traced to an incident:
|
|
33
|
+
*
|
|
34
|
+
* HTTP 200 IS NOT SUCCESS. `nvidia/nemotron-nano-12b-v2-vl` returns 200 with
|
|
35
|
+
* empty content, and `gemini-2.5-flash` does the same after a tool call — it
|
|
36
|
+
* spends its whole budget on internal thinking and emits no text part. A
|
|
37
|
+
* client that checks `res.ok` returns "" to the user and reports success, so
|
|
38
|
+
* the chain never advances and health stays green through a total outage.
|
|
39
|
+
* Empty content is a FAILURE here, and it demotes to the next link.
|
|
40
|
+
*
|
|
41
|
+
* A DAILY 429 CONDEMNS THE WHOLE VENDOR, not one model. The budget is
|
|
42
|
+
* org-wide and shared across models, so every remaining link at that provider
|
|
43
|
+
* is already dead. Walking them costs a dead round trip each and reaches the
|
|
44
|
+
* same failure. They are skipped.
|
|
45
|
+
*
|
|
46
|
+
* A SIZE 429 ENDS THE WALK. One request exceeded the entire per-minute
|
|
47
|
+
* allowance; the next model down has a SMALLER ceiling (measured: 12000 TPM
|
|
48
|
+
* vs 6000), so demoting makes it strictly worse. The only cure is a shorter
|
|
49
|
+
* prompt, and the caller is told exactly that instead of watching the chain
|
|
50
|
+
* burn itself down to reach a worse version of the same error.
|
|
51
|
+
*
|
|
52
|
+
* ── AND ONE IT INHERITS ──────────────────────────────────────────────────────
|
|
53
|
+
* The response BODY is kept in every error. A status-only message ("groq 429")
|
|
54
|
+
* makes an exhausted day indistinguishable from a momentary burst, and the
|
|
55
|
+
* obvious remedy for the latter — wait and retry — can never work for the
|
|
56
|
+
* former. That misdiagnosis cost an hour once; it is not free to repeat.
|
|
57
|
+
*/
|
|
58
|
+
import { type Env, type Link, type Provider } from "./chain.js";
|
|
59
|
+
import type { HealthTracker } from "./health.js";
|
|
60
|
+
import { type RateLimitKind } from "./limits.js";
|
|
61
|
+
/** One message in the OpenAI chat-completions shape every provider here speaks. */
|
|
62
|
+
export interface ChatMessage {
|
|
63
|
+
role: "system" | "user" | "assistant" | "tool";
|
|
64
|
+
content: string;
|
|
65
|
+
/** Present on `role: "tool"` replies; passed through untouched. */
|
|
66
|
+
tool_call_id?: string;
|
|
67
|
+
name?: string;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* A tool call the model asked for, normalised across the two protocols models
|
|
71
|
+
* actually answer on.
|
|
72
|
+
*
|
|
73
|
+
* Both exist in the default chain: of nine free models probed live, four
|
|
74
|
+
* answered with native `tool_calls` and five only in text. Callers get the
|
|
75
|
+
* native shape here; parsing the text protocol is the caller's business,
|
|
76
|
+
* because its convention differs per app.
|
|
77
|
+
*/
|
|
78
|
+
export interface ToolCall {
|
|
79
|
+
id: string;
|
|
80
|
+
name: string;
|
|
81
|
+
/** Raw JSON string as the model emitted it — NOT parsed, because a model can emit invalid JSON and the caller decides what to do about that. */
|
|
82
|
+
args: string;
|
|
83
|
+
}
|
|
84
|
+
export interface CompleteOptions {
|
|
85
|
+
messages: ChatMessage[];
|
|
86
|
+
/**
|
|
87
|
+
* Links to try, in order. Defaults to `usableChain(freeChain())` — every free
|
|
88
|
+
* provider that has a key in `env`.
|
|
89
|
+
*/
|
|
90
|
+
chain?: Link[];
|
|
91
|
+
/** Providers to derive the chain from when `chain` is not given. */
|
|
92
|
+
providers?: Provider[];
|
|
93
|
+
/**
|
|
94
|
+
* Start the chain at this model rather than the front, falling through to the
|
|
95
|
+
* rest. The usual home for an app's "use this model" env var.
|
|
96
|
+
*/
|
|
97
|
+
model?: string;
|
|
98
|
+
env?: Env;
|
|
99
|
+
health?: HealthTracker;
|
|
100
|
+
signal?: AbortSignal;
|
|
101
|
+
/**
|
|
102
|
+
* Set this GENEROUSLY, or a healthy model looks dead.
|
|
103
|
+
*
|
|
104
|
+
* The default chain leads with reasoning models, which spend this budget
|
|
105
|
+
* thinking before emitting a visible token. Set it too low and the vendor
|
|
106
|
+
* returns 200 with empty content — which this module correctly treats as a
|
|
107
|
+
* failure and demotes, so a small `maxTokens` silently walks the whole chain
|
|
108
|
+
* and reports every link broken. Measured 2026-09-05: groq/openai/gpt-oss-20b
|
|
109
|
+
* answered EMPTY at 16 and answered correctly at 256, for the same one-word
|
|
110
|
+
* question.
|
|
111
|
+
*/
|
|
112
|
+
maxTokens?: number;
|
|
113
|
+
temperature?: number;
|
|
114
|
+
/** Tool definitions in the OpenAI shape; passed through untouched. */
|
|
115
|
+
tools?: unknown[];
|
|
116
|
+
/** Extra body fields for a vendor-specific parameter. Merged last, so it can override. */
|
|
117
|
+
extraBody?: Record<string, unknown>;
|
|
118
|
+
/** Called on each link's failure before moving on — e.g. to log which id rotted. */
|
|
119
|
+
onLinkFailure?: (link: Link, error: Error) => void;
|
|
120
|
+
/** Injected for tests. Defaults to global `fetch`. */
|
|
121
|
+
fetchImpl?: typeof fetch;
|
|
122
|
+
}
|
|
123
|
+
export interface CompleteResult {
|
|
124
|
+
/** The assistant's text. Never empty — an empty completion is treated as a failure. */
|
|
125
|
+
text: string;
|
|
126
|
+
/** `provider/model`, the id worth logging: it says which link actually served the turn. */
|
|
127
|
+
id: string;
|
|
128
|
+
link: Link;
|
|
129
|
+
toolCalls: ToolCall[];
|
|
130
|
+
/** The parsed response body, for a caller that needs a field this does not surface. */
|
|
131
|
+
raw: unknown;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* A link failed in a way that says something about the WALK, not just this link.
|
|
135
|
+
*
|
|
136
|
+
* `kind` is what the walker acts on; it is carried on the error so a caller
|
|
137
|
+
* reading `ChainExhaustedError.failures` can see why the walk stopped where it
|
|
138
|
+
* did rather than inferring it from prose.
|
|
139
|
+
*/
|
|
140
|
+
export declare class LinkFailure extends Error {
|
|
141
|
+
readonly link: Link;
|
|
142
|
+
readonly status?: number;
|
|
143
|
+
readonly kind?: RateLimitKind;
|
|
144
|
+
readonly retryAfter?: number | null;
|
|
145
|
+
constructor(link: Link, message: string, init?: {
|
|
146
|
+
status?: number;
|
|
147
|
+
kind?: RateLimitKind;
|
|
148
|
+
retryAfter?: number | null;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
/** `provider/model` — the id worth logging, because the model alone does not say whose meter it drew on. */
|
|
152
|
+
export declare function linkId(link: Link): string;
|
|
153
|
+
/**
|
|
154
|
+
* Call the first link that works, and return what it said.
|
|
155
|
+
*
|
|
156
|
+
* Throws `ChainExhaustedError` carrying every link's failure, so a log shows
|
|
157
|
+
* what was actually tried — the failure that explains an outage is usually not
|
|
158
|
+
* the last one.
|
|
159
|
+
*/
|
|
160
|
+
export declare function complete(options: CompleteOptions): Promise<CompleteResult>;
|
package/dist/complete.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The call itself — the one thing this package refused to ship, and the reason
|
|
3
|
+
* the rest of it went unused.
|
|
4
|
+
*
|
|
5
|
+
* ── WHY THIS REVERSES A STATED RULE ──────────────────────────────────────────
|
|
6
|
+
* Every module here was written against a real outage, and every one of them is
|
|
7
|
+
* correct. None of that reached the apps that were actually failing. Measured
|
|
8
|
+
* across the fleet on 2026-09-05:
|
|
9
|
+
*
|
|
10
|
+
* this package's decisions adopted by 2 repos
|
|
11
|
+
* `ai-forms`, which ships a working handler adopted by 5 repos
|
|
12
|
+
* hand-rolled LLM clients still in service 8, ~1400-1700 lines each
|
|
13
|
+
*
|
|
14
|
+
* The pattern is not about quality, it is about shape. `ai-forms` was adopted
|
|
15
|
+
* because `createFormAssistHandler` does the job; this package was not, because
|
|
16
|
+
* it hands back advice the caller must then wire up. The old rule — "every app
|
|
17
|
+
* has its own calling conventions, replacing them is a rewrite rather than an
|
|
18
|
+
* adoption" — describes the duplication accurately and then protects it. The
|
|
19
|
+
* conventions differ because nothing ever offered to own them.
|
|
20
|
+
*
|
|
21
|
+
* The cost of that is not theoretical. Of the 8 hand-rolled clients, 2 tell the
|
|
22
|
+
* three kinds of 429 apart; the other 6 treat a spent daily budget as a busy
|
|
23
|
+
* minute — `limits.ts` has explained why that is harmful since 2026-08-14, in a
|
|
24
|
+
* module those 6 apps do not import. `tryChain` says it plainly: a chain nobody
|
|
25
|
+
* walks is a list, not a fallback. A decision nobody calls is a comment.
|
|
26
|
+
*
|
|
27
|
+
* So: this owns the fetch. `tryChain` stays for callers with a genuinely
|
|
28
|
+
* unusual request to make; this is the answer for everyone else.
|
|
29
|
+
*
|
|
30
|
+
* ── WHAT IT KNOWS THAT A HAND-ROLLED LOOP DOES NOT ───────────────────────────
|
|
31
|
+
* Walking the chain is the easy half. Three behaviours below are the ones every
|
|
32
|
+
* hand-rolled client in this fleet got wrong, each traced to an incident:
|
|
33
|
+
*
|
|
34
|
+
* HTTP 200 IS NOT SUCCESS. `nvidia/nemotron-nano-12b-v2-vl` returns 200 with
|
|
35
|
+
* empty content, and `gemini-2.5-flash` does the same after a tool call — it
|
|
36
|
+
* spends its whole budget on internal thinking and emits no text part. A
|
|
37
|
+
* client that checks `res.ok` returns "" to the user and reports success, so
|
|
38
|
+
* the chain never advances and health stays green through a total outage.
|
|
39
|
+
* Empty content is a FAILURE here, and it demotes to the next link.
|
|
40
|
+
*
|
|
41
|
+
* A DAILY 429 CONDEMNS THE WHOLE VENDOR, not one model. The budget is
|
|
42
|
+
* org-wide and shared across models, so every remaining link at that provider
|
|
43
|
+
* is already dead. Walking them costs a dead round trip each and reaches the
|
|
44
|
+
* same failure. They are skipped.
|
|
45
|
+
*
|
|
46
|
+
* A SIZE 429 ENDS THE WALK. One request exceeded the entire per-minute
|
|
47
|
+
* allowance; the next model down has a SMALLER ceiling (measured: 12000 TPM
|
|
48
|
+
* vs 6000), so demoting makes it strictly worse. The only cure is a shorter
|
|
49
|
+
* prompt, and the caller is told exactly that instead of watching the chain
|
|
50
|
+
* burn itself down to reach a worse version of the same error.
|
|
51
|
+
*
|
|
52
|
+
* ── AND ONE IT INHERITS ──────────────────────────────────────────────────────
|
|
53
|
+
* The response BODY is kept in every error. A status-only message ("groq 429")
|
|
54
|
+
* makes an exhausted day indistinguishable from a momentary burst, and the
|
|
55
|
+
* obvious remedy for the latter — wait and retry — can never work for the
|
|
56
|
+
* former. That misdiagnosis cost an hour once; it is not free to repeat.
|
|
57
|
+
*/
|
|
58
|
+
import { chainFrom, freeChain, usableChain } from "./chain.js";
|
|
59
|
+
import { ChainExhaustedError } from "./attempt.js";
|
|
60
|
+
import { classifyRateLimit, retryAfterSeconds } from "./limits.js";
|
|
61
|
+
/**
|
|
62
|
+
* A link failed in a way that says something about the WALK, not just this link.
|
|
63
|
+
*
|
|
64
|
+
* `kind` is what the walker acts on; it is carried on the error so a caller
|
|
65
|
+
* reading `ChainExhaustedError.failures` can see why the walk stopped where it
|
|
66
|
+
* did rather than inferring it from prose.
|
|
67
|
+
*/
|
|
68
|
+
export class LinkFailure extends Error {
|
|
69
|
+
link;
|
|
70
|
+
status;
|
|
71
|
+
kind;
|
|
72
|
+
retryAfter;
|
|
73
|
+
constructor(link, message, init = {}) {
|
|
74
|
+
super(message);
|
|
75
|
+
this.name = "LinkFailure";
|
|
76
|
+
this.link = link;
|
|
77
|
+
this.status = init.status;
|
|
78
|
+
this.kind = init.kind;
|
|
79
|
+
this.retryAfter = init.retryAfter;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** `provider/model` — the id worth logging, because the model alone does not say whose meter it drew on. */
|
|
83
|
+
export function linkId(link) {
|
|
84
|
+
return `${link.provider.id}/${link.model}`;
|
|
85
|
+
}
|
|
86
|
+
function firstText(message) {
|
|
87
|
+
if (!message)
|
|
88
|
+
return "";
|
|
89
|
+
const content = message.content;
|
|
90
|
+
if (typeof content === "string")
|
|
91
|
+
return content;
|
|
92
|
+
// Some vendors return content as an array of parts. Concatenate the text ones
|
|
93
|
+
// rather than stringifying the array, which would hand the caller JSON.
|
|
94
|
+
if (Array.isArray(content)) {
|
|
95
|
+
return content
|
|
96
|
+
.map((part) => part && typeof part === "object" && typeof part.text === "string"
|
|
97
|
+
? part.text
|
|
98
|
+
: "")
|
|
99
|
+
.join("");
|
|
100
|
+
}
|
|
101
|
+
return "";
|
|
102
|
+
}
|
|
103
|
+
function toolCallsFrom(message) {
|
|
104
|
+
const raw = message?.tool_calls;
|
|
105
|
+
if (!Array.isArray(raw))
|
|
106
|
+
return [];
|
|
107
|
+
const out = [];
|
|
108
|
+
for (const entry of raw) {
|
|
109
|
+
if (!entry || typeof entry !== "object")
|
|
110
|
+
continue;
|
|
111
|
+
const fn = entry.function;
|
|
112
|
+
if (!fn || typeof fn.name !== "string")
|
|
113
|
+
continue;
|
|
114
|
+
out.push({
|
|
115
|
+
id: String(entry.id ?? ""),
|
|
116
|
+
name: fn.name,
|
|
117
|
+
args: typeof fn.arguments === "string" ? fn.arguments : JSON.stringify(fn.arguments ?? {}),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Truncated so a failure message stays readable in a log line, but long enough
|
|
124
|
+
* to carry the sentence that matters: Groq states the real reset ~90 characters
|
|
125
|
+
* into a daily-cap body, and cutting before it throws away the one number the
|
|
126
|
+
* user can act on.
|
|
127
|
+
*/
|
|
128
|
+
function excerpt(body, limit = 300) {
|
|
129
|
+
const flat = body.replace(/\s+/g, " ").trim();
|
|
130
|
+
return flat.length > limit ? `${flat.slice(0, limit)}…` : flat;
|
|
131
|
+
}
|
|
132
|
+
async function callLink(link, options, key) {
|
|
133
|
+
const doFetch = options.fetchImpl ?? globalThis.fetch;
|
|
134
|
+
const body = {
|
|
135
|
+
model: link.model,
|
|
136
|
+
messages: options.messages,
|
|
137
|
+
...(options.maxTokens === undefined ? {} : { max_tokens: options.maxTokens }),
|
|
138
|
+
...(options.temperature === undefined ? {} : { temperature: options.temperature }),
|
|
139
|
+
...(options.tools === undefined ? {} : { tools: options.tools }),
|
|
140
|
+
...options.extraBody,
|
|
141
|
+
};
|
|
142
|
+
let res;
|
|
143
|
+
try {
|
|
144
|
+
res = await doFetch(`${link.provider.baseUrl}/chat/completions`, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
|
|
147
|
+
body: JSON.stringify(body),
|
|
148
|
+
signal: options.signal,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
// A transport failure (DNS, TLS, abort) is not the vendor's answer, so it
|
|
153
|
+
// carries no rate-limit kind — it demotes like any other link failure.
|
|
154
|
+
throw new LinkFailure(link, `${linkId(link)}: ${error.message}`);
|
|
155
|
+
}
|
|
156
|
+
const text = await res.text();
|
|
157
|
+
if (!res.ok) {
|
|
158
|
+
if (res.status === 429) {
|
|
159
|
+
const kind = classifyRateLimit(text);
|
|
160
|
+
const retryAfter = retryAfterSeconds(text);
|
|
161
|
+
throw new LinkFailure(link, `${linkId(link)}: 429 ${kind} — ${excerpt(text)}`, {
|
|
162
|
+
status: 429,
|
|
163
|
+
kind,
|
|
164
|
+
retryAfter,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
throw new LinkFailure(link, `${linkId(link)}: ${res.status} — ${excerpt(text)}`, {
|
|
168
|
+
status: res.status,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
let parsed;
|
|
172
|
+
try {
|
|
173
|
+
parsed = JSON.parse(text);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
throw new LinkFailure(link, `${linkId(link)}: 200 with unparseable body — ${excerpt(text)}`, {
|
|
177
|
+
status: res.status,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
const choice = parsed
|
|
181
|
+
?.choices?.[0];
|
|
182
|
+
const content = firstText(choice?.message);
|
|
183
|
+
const toolCalls = toolCallsFrom(choice?.message);
|
|
184
|
+
// A 200 that carries neither text nor a tool call is an outage wearing a
|
|
185
|
+
// success code — see the header. Demote, so the chain gets its chance.
|
|
186
|
+
if (content.trim() === "" && toolCalls.length === 0) {
|
|
187
|
+
throw new LinkFailure(link, `${linkId(link)}: 200 with empty content — model produced no output`, {
|
|
188
|
+
status: res.status,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
return { text: content, id: linkId(link), link, toolCalls, raw: parsed };
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Call the first link that works, and return what it said.
|
|
195
|
+
*
|
|
196
|
+
* Throws `ChainExhaustedError` carrying every link's failure, so a log shows
|
|
197
|
+
* what was actually tried — the failure that explains an outage is usually not
|
|
198
|
+
* the last one.
|
|
199
|
+
*/
|
|
200
|
+
export async function complete(options) {
|
|
201
|
+
const env = options.env ?? process.env;
|
|
202
|
+
const base = options.chain ?? usableChain(options.providers ?? freeChain(), env);
|
|
203
|
+
const chain = chainFrom(options.model, base);
|
|
204
|
+
const failures = [];
|
|
205
|
+
const deadProviders = new Set();
|
|
206
|
+
for (const link of chain) {
|
|
207
|
+
// A daily cap already condemned this vendor earlier in the walk. Its other
|
|
208
|
+
// models draw on the same exhausted budget, so trying them buys a dead
|
|
209
|
+
// round trip and the identical error.
|
|
210
|
+
if (deadProviders.has(link.provider.id))
|
|
211
|
+
continue;
|
|
212
|
+
const key = env[link.provider.keyEnv]?.trim();
|
|
213
|
+
if (!key) {
|
|
214
|
+
failures.push({ link, message: `${linkId(link)}: no ${link.provider.keyEnv} in env` });
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
const result = await callLink(link, options, key);
|
|
219
|
+
options.health?.recordSuccess();
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
const failure = error;
|
|
224
|
+
failures.push({ link, message: failure.message });
|
|
225
|
+
options.onLinkFailure?.(link, failure);
|
|
226
|
+
if (failure.kind === "daily")
|
|
227
|
+
deadProviders.add(link.provider.id);
|
|
228
|
+
// Stepping down after a size 429 reaches a model with a smaller ceiling —
|
|
229
|
+
// strictly worse. Stop, and let the caller shorten the prompt.
|
|
230
|
+
if (failure.kind === "size")
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const exhausted = new ChainExhaustedError(failures);
|
|
235
|
+
options.health?.recordFailure(exhausted);
|
|
236
|
+
throw exhausted;
|
|
237
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -34,17 +34,25 @@
|
|
|
34
34
|
* retired model id — the exact failure the `chain` and `catalog` modules exist
|
|
35
35
|
* to prevent. "Ration" described one of five modules and buried the other four.
|
|
36
36
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
37
|
+
* NOW INCLUDED: an HTTP client. `complete()` makes the call.
|
|
38
|
+
*
|
|
39
|
+
* The old rule was "every app has its own calling conventions, and replacing
|
|
40
|
+
* those is a rewrite rather than an adoption" — accurate about the fleet, and
|
|
41
|
+
* it protected the very duplication it described. The conventions differed
|
|
42
|
+
* because nothing ever offered to own them. Measured 2026-09-05: 8 hand-rolled
|
|
43
|
+
* clients in service, 2 of which tell the three kinds of 429 apart, while this
|
|
44
|
+
* package explained the distinction to the 2 repos that imported it.
|
|
45
|
+
* `ai-forms` — adopted by 5 — is not better code, it is code that does the job
|
|
46
|
+
* rather than advising on it. See `complete.ts` for the full argument.
|
|
47
|
+
*
|
|
48
|
+
* `tryChain` remains for a caller with a genuinely unusual request to make: it
|
|
49
|
+
* walks the chain and lets the caller keep the fetch. `complete` is the answer
|
|
50
|
+
* for everyone else.
|
|
44
51
|
*/
|
|
45
52
|
export { type Provider, type Env, type Link, type CostVerdict, providerModels, withEnvPrefix, freeChain, modelCost, modelCostAt, paidModelsIn, dayCapacityTokens, usableChain, chainFrom, } from "./chain.js";
|
|
46
53
|
export { type CatalogVerdict, type CheckCatalogOptions, checkCatalog, hasRot, deadProviders, catalogReport, } from "./catalog.js";
|
|
47
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";
|
|
48
56
|
export { type HealthStatus, type Health, type HealthTrackerOptions, type HealthTracker, createHealthTracker, } from "./health.js";
|
|
49
57
|
export { type RateLimitKind, classifyRateLimit, retryAfterSeconds, humanizeWait, rateLimitMessage, } from "./limits.js";
|
|
50
58
|
export { DAY_SECONDS, DEFAULT_BURST, type ShareInput, type ShareReason, type ShareDecision, fairShare, utcDayElapsed, utcDayKey, } from "./fair-share.js";
|
package/dist/index.js
CHANGED
|
@@ -34,17 +34,25 @@
|
|
|
34
34
|
* retired model id — the exact failure the `chain` and `catalog` modules exist
|
|
35
35
|
* to prevent. "Ration" described one of five modules and buried the other four.
|
|
36
36
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
37
|
+
* NOW INCLUDED: an HTTP client. `complete()` makes the call.
|
|
38
|
+
*
|
|
39
|
+
* The old rule was "every app has its own calling conventions, and replacing
|
|
40
|
+
* those is a rewrite rather than an adoption" — accurate about the fleet, and
|
|
41
|
+
* it protected the very duplication it described. The conventions differed
|
|
42
|
+
* because nothing ever offered to own them. Measured 2026-09-05: 8 hand-rolled
|
|
43
|
+
* clients in service, 2 of which tell the three kinds of 429 apart, while this
|
|
44
|
+
* package explained the distinction to the 2 repos that imported it.
|
|
45
|
+
* `ai-forms` — adopted by 5 — is not better code, it is code that does the job
|
|
46
|
+
* rather than advising on it. See `complete.ts` for the full argument.
|
|
47
|
+
*
|
|
48
|
+
* `tryChain` remains for a caller with a genuinely unusual request to make: it
|
|
49
|
+
* walks the chain and lets the caller keep the fetch. `complete` is the answer
|
|
50
|
+
* for everyone else.
|
|
44
51
|
*/
|
|
45
52
|
export { providerModels, withEnvPrefix, freeChain, modelCost, modelCostAt, paidModelsIn, dayCapacityTokens, usableChain, chainFrom, } from "./chain.js";
|
|
46
53
|
export { checkCatalog, hasRot, deadProviders, catalogReport, } from "./catalog.js";
|
|
47
54
|
export { ChainExhaustedError, tryChain, } from "./attempt.js";
|
|
55
|
+
export { LinkFailure, complete, linkId, } from "./complete.js";
|
|
48
56
|
export { createHealthTracker, } from "./health.js";
|
|
49
57
|
export { classifyRateLimit, retryAfterSeconds, humanizeWait, rateLimitMessage, } from "./limits.js";
|
|
50
58
|
export { DAY_SECONDS, DEFAULT_BURST, fairShare, utcDayElapsed, utcDayKey, } from "./fair-share.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bitbaum/ai-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "One install for the AI layer of an app: which model to call, what to do when the vendor retires it, how to walk the fallback chain and know when none of it worked, how to read the three kinds of 429, a fair daily budget across users, headless AI form filling — and now the model registry (one SSOT for every callable id, with the paid/free boundary as a field) and the grounding harness (facts, contract, deterministic fabrication check).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Mao Nakamoto",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
],
|
|
29
29
|
"type": "module",
|
|
30
30
|
"sideEffects": false,
|
|
31
|
+
"packageManager": "pnpm@11.25.0",
|
|
31
32
|
"engines": {
|
|
32
33
|
"node": ">=20"
|
|
33
34
|
},
|
|
@@ -73,20 +74,21 @@
|
|
|
73
74
|
"lint": "eslint .",
|
|
74
75
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
75
76
|
"test": "node --test test/*.test.js",
|
|
76
|
-
"check:catalog": "
|
|
77
|
-
"
|
|
78
|
-
"
|
|
77
|
+
"check:catalog": "pnpm run build && node scripts/check-catalog.mjs",
|
|
78
|
+
"smoke": "pnpm run build && node scripts/smoke.mjs",
|
|
79
|
+
"verify": "pnpm run format:check && pnpm run lint && pnpm run typecheck && pnpm run build && pnpm test",
|
|
80
|
+
"prepare": "pnpm run build",
|
|
79
81
|
"format": "prettier --write .",
|
|
80
82
|
"format:check": "prettier --check ."
|
|
81
83
|
},
|
|
82
84
|
"devDependencies": {
|
|
83
85
|
"@eslint/js": "^10.0.1",
|
|
84
|
-
"@types/node": "^26.4.
|
|
86
|
+
"@types/node": "^26.4.1",
|
|
85
87
|
"eslint": "^10.9.1",
|
|
86
|
-
"globals": "^17.
|
|
88
|
+
"globals": "^17.12.0",
|
|
87
89
|
"prettier": "3.9.6",
|
|
88
90
|
"typescript": "^6.0.3",
|
|
89
|
-
"typescript-eslint": "^8.
|
|
91
|
+
"typescript-eslint": "^8.69.0"
|
|
90
92
|
},
|
|
91
93
|
"dependencies": {
|
|
92
94
|
"ai-forms": "^0.1.2"
|
package/src/complete.ts
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The call itself — the one thing this package refused to ship, and the reason
|
|
3
|
+
* the rest of it went unused.
|
|
4
|
+
*
|
|
5
|
+
* ── WHY THIS REVERSES A STATED RULE ──────────────────────────────────────────
|
|
6
|
+
* Every module here was written against a real outage, and every one of them is
|
|
7
|
+
* correct. None of that reached the apps that were actually failing. Measured
|
|
8
|
+
* across the fleet on 2026-09-05:
|
|
9
|
+
*
|
|
10
|
+
* this package's decisions adopted by 2 repos
|
|
11
|
+
* `ai-forms`, which ships a working handler adopted by 5 repos
|
|
12
|
+
* hand-rolled LLM clients still in service 8, ~1400-1700 lines each
|
|
13
|
+
*
|
|
14
|
+
* The pattern is not about quality, it is about shape. `ai-forms` was adopted
|
|
15
|
+
* because `createFormAssistHandler` does the job; this package was not, because
|
|
16
|
+
* it hands back advice the caller must then wire up. The old rule — "every app
|
|
17
|
+
* has its own calling conventions, replacing them is a rewrite rather than an
|
|
18
|
+
* adoption" — describes the duplication accurately and then protects it. The
|
|
19
|
+
* conventions differ because nothing ever offered to own them.
|
|
20
|
+
*
|
|
21
|
+
* The cost of that is not theoretical. Of the 8 hand-rolled clients, 2 tell the
|
|
22
|
+
* three kinds of 429 apart; the other 6 treat a spent daily budget as a busy
|
|
23
|
+
* minute — `limits.ts` has explained why that is harmful since 2026-08-14, in a
|
|
24
|
+
* module those 6 apps do not import. `tryChain` says it plainly: a chain nobody
|
|
25
|
+
* walks is a list, not a fallback. A decision nobody calls is a comment.
|
|
26
|
+
*
|
|
27
|
+
* So: this owns the fetch. `tryChain` stays for callers with a genuinely
|
|
28
|
+
* unusual request to make; this is the answer for everyone else.
|
|
29
|
+
*
|
|
30
|
+
* ── WHAT IT KNOWS THAT A HAND-ROLLED LOOP DOES NOT ───────────────────────────
|
|
31
|
+
* Walking the chain is the easy half. Three behaviours below are the ones every
|
|
32
|
+
* hand-rolled client in this fleet got wrong, each traced to an incident:
|
|
33
|
+
*
|
|
34
|
+
* HTTP 200 IS NOT SUCCESS. `nvidia/nemotron-nano-12b-v2-vl` returns 200 with
|
|
35
|
+
* empty content, and `gemini-2.5-flash` does the same after a tool call — it
|
|
36
|
+
* spends its whole budget on internal thinking and emits no text part. A
|
|
37
|
+
* client that checks `res.ok` returns "" to the user and reports success, so
|
|
38
|
+
* the chain never advances and health stays green through a total outage.
|
|
39
|
+
* Empty content is a FAILURE here, and it demotes to the next link.
|
|
40
|
+
*
|
|
41
|
+
* A DAILY 429 CONDEMNS THE WHOLE VENDOR, not one model. The budget is
|
|
42
|
+
* org-wide and shared across models, so every remaining link at that provider
|
|
43
|
+
* is already dead. Walking them costs a dead round trip each and reaches the
|
|
44
|
+
* same failure. They are skipped.
|
|
45
|
+
*
|
|
46
|
+
* A SIZE 429 ENDS THE WALK. One request exceeded the entire per-minute
|
|
47
|
+
* allowance; the next model down has a SMALLER ceiling (measured: 12000 TPM
|
|
48
|
+
* vs 6000), so demoting makes it strictly worse. The only cure is a shorter
|
|
49
|
+
* prompt, and the caller is told exactly that instead of watching the chain
|
|
50
|
+
* burn itself down to reach a worse version of the same error.
|
|
51
|
+
*
|
|
52
|
+
* ── AND ONE IT INHERITS ──────────────────────────────────────────────────────
|
|
53
|
+
* The response BODY is kept in every error. A status-only message ("groq 429")
|
|
54
|
+
* makes an exhausted day indistinguishable from a momentary burst, and the
|
|
55
|
+
* obvious remedy for the latter — wait and retry — can never work for the
|
|
56
|
+
* former. That misdiagnosis cost an hour once; it is not free to repeat.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
import { type Env, type Link, type Provider, chainFrom, freeChain, usableChain } from "./chain.js";
|
|
60
|
+
import { ChainExhaustedError, type ChainAttemptFailure } from "./attempt.js";
|
|
61
|
+
import type { HealthTracker } from "./health.js";
|
|
62
|
+
import { classifyRateLimit, retryAfterSeconds, type RateLimitKind } from "./limits.js";
|
|
63
|
+
|
|
64
|
+
/** One message in the OpenAI chat-completions shape every provider here speaks. */
|
|
65
|
+
export interface ChatMessage {
|
|
66
|
+
role: "system" | "user" | "assistant" | "tool";
|
|
67
|
+
content: string;
|
|
68
|
+
/** Present on `role: "tool"` replies; passed through untouched. */
|
|
69
|
+
tool_call_id?: string;
|
|
70
|
+
name?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A tool call the model asked for, normalised across the two protocols models
|
|
75
|
+
* actually answer on.
|
|
76
|
+
*
|
|
77
|
+
* Both exist in the default chain: of nine free models probed live, four
|
|
78
|
+
* answered with native `tool_calls` and five only in text. Callers get the
|
|
79
|
+
* native shape here; parsing the text protocol is the caller's business,
|
|
80
|
+
* because its convention differs per app.
|
|
81
|
+
*/
|
|
82
|
+
export interface ToolCall {
|
|
83
|
+
id: string;
|
|
84
|
+
name: string;
|
|
85
|
+
/** Raw JSON string as the model emitted it — NOT parsed, because a model can emit invalid JSON and the caller decides what to do about that. */
|
|
86
|
+
args: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface CompleteOptions {
|
|
90
|
+
messages: ChatMessage[];
|
|
91
|
+
/**
|
|
92
|
+
* Links to try, in order. Defaults to `usableChain(freeChain())` — every free
|
|
93
|
+
* provider that has a key in `env`.
|
|
94
|
+
*/
|
|
95
|
+
chain?: Link[];
|
|
96
|
+
/** Providers to derive the chain from when `chain` is not given. */
|
|
97
|
+
providers?: Provider[];
|
|
98
|
+
/**
|
|
99
|
+
* Start the chain at this model rather than the front, falling through to the
|
|
100
|
+
* rest. The usual home for an app's "use this model" env var.
|
|
101
|
+
*/
|
|
102
|
+
model?: string;
|
|
103
|
+
env?: Env;
|
|
104
|
+
health?: HealthTracker;
|
|
105
|
+
signal?: AbortSignal;
|
|
106
|
+
/**
|
|
107
|
+
* Set this GENEROUSLY, or a healthy model looks dead.
|
|
108
|
+
*
|
|
109
|
+
* The default chain leads with reasoning models, which spend this budget
|
|
110
|
+
* thinking before emitting a visible token. Set it too low and the vendor
|
|
111
|
+
* returns 200 with empty content — which this module correctly treats as a
|
|
112
|
+
* failure and demotes, so a small `maxTokens` silently walks the whole chain
|
|
113
|
+
* and reports every link broken. Measured 2026-09-05: groq/openai/gpt-oss-20b
|
|
114
|
+
* answered EMPTY at 16 and answered correctly at 256, for the same one-word
|
|
115
|
+
* question.
|
|
116
|
+
*/
|
|
117
|
+
maxTokens?: number;
|
|
118
|
+
temperature?: number;
|
|
119
|
+
/** Tool definitions in the OpenAI shape; passed through untouched. */
|
|
120
|
+
tools?: unknown[];
|
|
121
|
+
/** Extra body fields for a vendor-specific parameter. Merged last, so it can override. */
|
|
122
|
+
extraBody?: Record<string, unknown>;
|
|
123
|
+
/** Called on each link's failure before moving on — e.g. to log which id rotted. */
|
|
124
|
+
onLinkFailure?: (link: Link, error: Error) => void;
|
|
125
|
+
/** Injected for tests. Defaults to global `fetch`. */
|
|
126
|
+
fetchImpl?: typeof fetch;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface CompleteResult {
|
|
130
|
+
/** The assistant's text. Never empty — an empty completion is treated as a failure. */
|
|
131
|
+
text: string;
|
|
132
|
+
/** `provider/model`, the id worth logging: it says which link actually served the turn. */
|
|
133
|
+
id: string;
|
|
134
|
+
link: Link;
|
|
135
|
+
toolCalls: ToolCall[];
|
|
136
|
+
/** The parsed response body, for a caller that needs a field this does not surface. */
|
|
137
|
+
raw: unknown;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A link failed in a way that says something about the WALK, not just this link.
|
|
142
|
+
*
|
|
143
|
+
* `kind` is what the walker acts on; it is carried on the error so a caller
|
|
144
|
+
* reading `ChainExhaustedError.failures` can see why the walk stopped where it
|
|
145
|
+
* did rather than inferring it from prose.
|
|
146
|
+
*/
|
|
147
|
+
export class LinkFailure extends Error {
|
|
148
|
+
readonly link: Link;
|
|
149
|
+
readonly status?: number;
|
|
150
|
+
readonly kind?: RateLimitKind;
|
|
151
|
+
readonly retryAfter?: number | null;
|
|
152
|
+
|
|
153
|
+
constructor(
|
|
154
|
+
link: Link,
|
|
155
|
+
message: string,
|
|
156
|
+
init: { status?: number; kind?: RateLimitKind; retryAfter?: number | null } = {},
|
|
157
|
+
) {
|
|
158
|
+
super(message);
|
|
159
|
+
this.name = "LinkFailure";
|
|
160
|
+
this.link = link;
|
|
161
|
+
this.status = init.status;
|
|
162
|
+
this.kind = init.kind;
|
|
163
|
+
this.retryAfter = init.retryAfter;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** `provider/model` — the id worth logging, because the model alone does not say whose meter it drew on. */
|
|
168
|
+
export function linkId(link: Link): string {
|
|
169
|
+
return `${link.provider.id}/${link.model}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function firstText(message: Record<string, unknown> | undefined): string {
|
|
173
|
+
if (!message) return "";
|
|
174
|
+
const content = message.content;
|
|
175
|
+
if (typeof content === "string") return content;
|
|
176
|
+
// Some vendors return content as an array of parts. Concatenate the text ones
|
|
177
|
+
// rather than stringifying the array, which would hand the caller JSON.
|
|
178
|
+
if (Array.isArray(content)) {
|
|
179
|
+
return content
|
|
180
|
+
.map((part) =>
|
|
181
|
+
part && typeof part === "object" && typeof (part as { text?: unknown }).text === "string"
|
|
182
|
+
? (part as { text: string }).text
|
|
183
|
+
: "",
|
|
184
|
+
)
|
|
185
|
+
.join("");
|
|
186
|
+
}
|
|
187
|
+
return "";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function toolCallsFrom(message: Record<string, unknown> | undefined): ToolCall[] {
|
|
191
|
+
const raw = message?.tool_calls;
|
|
192
|
+
if (!Array.isArray(raw)) return [];
|
|
193
|
+
const out: ToolCall[] = [];
|
|
194
|
+
for (const entry of raw) {
|
|
195
|
+
if (!entry || typeof entry !== "object") continue;
|
|
196
|
+
const fn = (entry as { function?: { name?: unknown; arguments?: unknown } }).function;
|
|
197
|
+
if (!fn || typeof fn.name !== "string") continue;
|
|
198
|
+
out.push({
|
|
199
|
+
id: String((entry as { id?: unknown }).id ?? ""),
|
|
200
|
+
name: fn.name,
|
|
201
|
+
args: typeof fn.arguments === "string" ? fn.arguments : JSON.stringify(fn.arguments ?? {}),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Truncated so a failure message stays readable in a log line, but long enough
|
|
209
|
+
* to carry the sentence that matters: Groq states the real reset ~90 characters
|
|
210
|
+
* into a daily-cap body, and cutting before it throws away the one number the
|
|
211
|
+
* user can act on.
|
|
212
|
+
*/
|
|
213
|
+
function excerpt(body: string, limit = 300): string {
|
|
214
|
+
const flat = body.replace(/\s+/g, " ").trim();
|
|
215
|
+
return flat.length > limit ? `${flat.slice(0, limit)}…` : flat;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function callLink(
|
|
219
|
+
link: Link,
|
|
220
|
+
options: CompleteOptions,
|
|
221
|
+
key: string,
|
|
222
|
+
): Promise<CompleteResult> {
|
|
223
|
+
const doFetch = options.fetchImpl ?? globalThis.fetch;
|
|
224
|
+
const body: Record<string, unknown> = {
|
|
225
|
+
model: link.model,
|
|
226
|
+
messages: options.messages,
|
|
227
|
+
...(options.maxTokens === undefined ? {} : { max_tokens: options.maxTokens }),
|
|
228
|
+
...(options.temperature === undefined ? {} : { temperature: options.temperature }),
|
|
229
|
+
...(options.tools === undefined ? {} : { tools: options.tools }),
|
|
230
|
+
...options.extraBody,
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
let res: Response;
|
|
234
|
+
try {
|
|
235
|
+
res = await doFetch(`${link.provider.baseUrl}/chat/completions`, {
|
|
236
|
+
method: "POST",
|
|
237
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
|
|
238
|
+
body: JSON.stringify(body),
|
|
239
|
+
signal: options.signal,
|
|
240
|
+
});
|
|
241
|
+
} catch (error) {
|
|
242
|
+
// A transport failure (DNS, TLS, abort) is not the vendor's answer, so it
|
|
243
|
+
// carries no rate-limit kind — it demotes like any other link failure.
|
|
244
|
+
throw new LinkFailure(link, `${linkId(link)}: ${(error as Error).message}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const text = await res.text();
|
|
248
|
+
|
|
249
|
+
if (!res.ok) {
|
|
250
|
+
if (res.status === 429) {
|
|
251
|
+
const kind = classifyRateLimit(text);
|
|
252
|
+
const retryAfter = retryAfterSeconds(text);
|
|
253
|
+
throw new LinkFailure(link, `${linkId(link)}: 429 ${kind} — ${excerpt(text)}`, {
|
|
254
|
+
status: 429,
|
|
255
|
+
kind,
|
|
256
|
+
retryAfter,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
throw new LinkFailure(link, `${linkId(link)}: ${res.status} — ${excerpt(text)}`, {
|
|
260
|
+
status: res.status,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let parsed: unknown;
|
|
265
|
+
try {
|
|
266
|
+
parsed = JSON.parse(text);
|
|
267
|
+
} catch {
|
|
268
|
+
throw new LinkFailure(link, `${linkId(link)}: 200 with unparseable body — ${excerpt(text)}`, {
|
|
269
|
+
status: res.status,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const choice = (parsed as { choices?: Array<{ message?: Record<string, unknown> }> })
|
|
274
|
+
?.choices?.[0];
|
|
275
|
+
const content = firstText(choice?.message);
|
|
276
|
+
const toolCalls = toolCallsFrom(choice?.message);
|
|
277
|
+
|
|
278
|
+
// A 200 that carries neither text nor a tool call is an outage wearing a
|
|
279
|
+
// success code — see the header. Demote, so the chain gets its chance.
|
|
280
|
+
if (content.trim() === "" && toolCalls.length === 0) {
|
|
281
|
+
throw new LinkFailure(
|
|
282
|
+
link,
|
|
283
|
+
`${linkId(link)}: 200 with empty content — model produced no output`,
|
|
284
|
+
{
|
|
285
|
+
status: res.status,
|
|
286
|
+
},
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return { text: content, id: linkId(link), link, toolCalls, raw: parsed };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Call the first link that works, and return what it said.
|
|
295
|
+
*
|
|
296
|
+
* Throws `ChainExhaustedError` carrying every link's failure, so a log shows
|
|
297
|
+
* what was actually tried — the failure that explains an outage is usually not
|
|
298
|
+
* the last one.
|
|
299
|
+
*/
|
|
300
|
+
export async function complete(options: CompleteOptions): Promise<CompleteResult> {
|
|
301
|
+
const env = options.env ?? process.env;
|
|
302
|
+
const base = options.chain ?? usableChain(options.providers ?? freeChain(), env);
|
|
303
|
+
const chain = chainFrom(options.model, base);
|
|
304
|
+
|
|
305
|
+
const failures: ChainAttemptFailure[] = [];
|
|
306
|
+
const deadProviders = new Set<string>();
|
|
307
|
+
|
|
308
|
+
for (const link of chain) {
|
|
309
|
+
// A daily cap already condemned this vendor earlier in the walk. Its other
|
|
310
|
+
// models draw on the same exhausted budget, so trying them buys a dead
|
|
311
|
+
// round trip and the identical error.
|
|
312
|
+
if (deadProviders.has(link.provider.id)) continue;
|
|
313
|
+
|
|
314
|
+
const key = env[link.provider.keyEnv]?.trim();
|
|
315
|
+
if (!key) {
|
|
316
|
+
failures.push({ link, message: `${linkId(link)}: no ${link.provider.keyEnv} in env` });
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
const result = await callLink(link, options, key);
|
|
322
|
+
options.health?.recordSuccess();
|
|
323
|
+
return result;
|
|
324
|
+
} catch (error) {
|
|
325
|
+
const failure = error as LinkFailure;
|
|
326
|
+
failures.push({ link, message: failure.message });
|
|
327
|
+
options.onLinkFailure?.(link, failure);
|
|
328
|
+
|
|
329
|
+
if (failure.kind === "daily") deadProviders.add(link.provider.id);
|
|
330
|
+
|
|
331
|
+
// Stepping down after a size 429 reaches a model with a smaller ceiling —
|
|
332
|
+
// strictly worse. Stop, and let the caller shorten the prompt.
|
|
333
|
+
if (failure.kind === "size") break;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const exhausted = new ChainExhaustedError(failures);
|
|
338
|
+
options.health?.recordFailure(exhausted);
|
|
339
|
+
throw exhausted;
|
|
340
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -34,13 +34,20 @@
|
|
|
34
34
|
* retired model id — the exact failure the `chain` and `catalog` modules exist
|
|
35
35
|
* to prevent. "Ration" described one of five modules and buried the other four.
|
|
36
36
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
37
|
+
* NOW INCLUDED: an HTTP client. `complete()` makes the call.
|
|
38
|
+
*
|
|
39
|
+
* The old rule was "every app has its own calling conventions, and replacing
|
|
40
|
+
* those is a rewrite rather than an adoption" — accurate about the fleet, and
|
|
41
|
+
* it protected the very duplication it described. The conventions differed
|
|
42
|
+
* because nothing ever offered to own them. Measured 2026-09-05: 8 hand-rolled
|
|
43
|
+
* clients in service, 2 of which tell the three kinds of 429 apart, while this
|
|
44
|
+
* package explained the distinction to the 2 repos that imported it.
|
|
45
|
+
* `ai-forms` — adopted by 5 — is not better code, it is code that does the job
|
|
46
|
+
* rather than advising on it. See `complete.ts` for the full argument.
|
|
47
|
+
*
|
|
48
|
+
* `tryChain` remains for a caller with a genuinely unusual request to make: it
|
|
49
|
+
* walks the chain and lets the caller keep the fetch. `complete` is the answer
|
|
50
|
+
* for everyone else.
|
|
44
51
|
*/
|
|
45
52
|
|
|
46
53
|
export {
|
|
@@ -75,6 +82,16 @@ export {
|
|
|
75
82
|
tryChain,
|
|
76
83
|
} from "./attempt.js";
|
|
77
84
|
|
|
85
|
+
export {
|
|
86
|
+
type ChatMessage,
|
|
87
|
+
type ToolCall,
|
|
88
|
+
type CompleteOptions,
|
|
89
|
+
type CompleteResult,
|
|
90
|
+
LinkFailure,
|
|
91
|
+
complete,
|
|
92
|
+
linkId,
|
|
93
|
+
} from "./complete.js";
|
|
94
|
+
|
|
78
95
|
export {
|
|
79
96
|
type HealthStatus,
|
|
80
97
|
type Health,
|