@providerkit/core 0.1.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.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +245 -0
  3. package/dist/context.d.ts +69 -0
  4. package/dist/context.d.ts.map +1 -0
  5. package/dist/context.js +132 -0
  6. package/dist/context.js.map +1 -0
  7. package/dist/errors.d.ts +86 -0
  8. package/dist/errors.d.ts.map +1 -0
  9. package/dist/errors.js +356 -0
  10. package/dist/errors.js.map +1 -0
  11. package/dist/index.d.ts +13 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +13 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/providers/anthropic.d.ts +26 -0
  16. package/dist/providers/anthropic.d.ts.map +1 -0
  17. package/dist/providers/anthropic.js +245 -0
  18. package/dist/providers/anthropic.js.map +1 -0
  19. package/dist/providers/openai.d.ts +30 -0
  20. package/dist/providers/openai.d.ts.map +1 -0
  21. package/dist/providers/openai.js +185 -0
  22. package/dist/providers/openai.js.map +1 -0
  23. package/dist/retry.d.ts +79 -0
  24. package/dist/retry.d.ts.map +1 -0
  25. package/dist/retry.js +200 -0
  26. package/dist/retry.js.map +1 -0
  27. package/dist/schema.d.ts +2 -0
  28. package/dist/schema.d.ts.map +1 -0
  29. package/dist/schema.js +48 -0
  30. package/dist/schema.js.map +1 -0
  31. package/dist/tool-args.d.ts +12 -0
  32. package/dist/tool-args.d.ts.map +1 -0
  33. package/dist/tool-args.js +113 -0
  34. package/dist/tool-args.js.map +1 -0
  35. package/dist/tools.d.ts +82 -0
  36. package/dist/tools.d.ts.map +1 -0
  37. package/dist/tools.js +155 -0
  38. package/dist/tools.js.map +1 -0
  39. package/dist/transport.d.ts +31 -0
  40. package/dist/transport.d.ts.map +1 -0
  41. package/dist/transport.js +157 -0
  42. package/dist/transport.js.map +1 -0
  43. package/dist/types.d.ts +168 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +75 -0
  46. package/dist/types.js.map +1 -0
  47. package/dist/usage.d.ts +50 -0
  48. package/dist/usage.d.ts.map +1 -0
  49. package/dist/usage.js +71 -0
  50. package/dist/usage.js.map +1 -0
  51. package/dist/watchdog.d.ts +34 -0
  52. package/dist/watchdog.d.ts.map +1 -0
  53. package/dist/watchdog.js +85 -0
  54. package/dist/watchdog.js.map +1 -0
  55. package/dist/zod.d.ts +32 -0
  56. package/dist/zod.d.ts.map +1 -0
  57. package/dist/zod.js +49 -0
  58. package/dist/zod.js.map +1 -0
  59. package/package.json +76 -0
  60. package/src/context.ts +150 -0
  61. package/src/errors.ts +398 -0
  62. package/src/index.ts +12 -0
  63. package/src/providers/anthropic.ts +315 -0
  64. package/src/providers/openai.ts +246 -0
  65. package/src/retry.ts +246 -0
  66. package/src/schema.ts +67 -0
  67. package/src/tool-args.ts +117 -0
  68. package/src/tools.ts +237 -0
  69. package/src/transport.ts +162 -0
  70. package/src/types.ts +231 -0
  71. package/src/usage.ts +106 -0
  72. package/src/watchdog.ts +119 -0
  73. package/src/zod.ts +74 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gustavo Salomé
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,245 @@
1
+ <img src="brand/providerkit-mark.svg" alt="" width="76" align="left" hspace="4" vspace="2">
2
+
3
+ # providerkit
4
+
5
+ **The layer under your agent loop.** One seam for every LLM provider, plus the failure
6
+ handling you only learn in production.
7
+
8
+ ```bash
9
+ bun add @providerkit/core # npm / pnpm / yarn all fine
10
+ ```
11
+
12
+ Zero runtime dependencies. `fetch` only — no vendor SDKs, no Node built-ins — so the same
13
+ build runs in Bun, Node, Cloudflare Workers, Deno and a Chrome MV3 service worker.
14
+
15
+ ## What this is
16
+
17
+ Most "unified LLM interface" libraries stop at the interface. That part is easy, and it is
18
+ not where the time goes. The time goes here:
19
+
20
+ - a stream that opens, sends nothing, and never ends
21
+ - a socket that dies four `cause` levels down, with no HTTP status to read
22
+ - quota exhaustion arriving as `429` from one vendor, `402` from another, `403` from a
23
+ third and `400` from a fourth — with "retry" being the wrong advice for all of them
24
+ - a `429` that is really a context overflow, where waiting fixes nothing and compaction
25
+ fixes everything
26
+ - a plan that never included the API, which neither a new key nor a top-up will fix
27
+ - Anthropic reporting cache tokens _outside_ the input count while OpenAI reports them
28
+ _inside_ it — so the same conversation costs two different things
29
+ - `reasoning_content` that must be replayed on tool-call turns, and must _not_ be sent
30
+ when thinking is off
31
+ - a tool call's JSON truncated mid-argument, throwing away an answer that was right there
32
+
33
+ providerkit is that knowledge, as a library.
34
+
35
+ ## What this is not
36
+
37
+ **Not an agent framework.** There is no loop here, no prompt, no memory, no graph. Your
38
+ loop is where your product lives; it should stay yours. This is everything underneath it.
39
+
40
+ ## Use
41
+
42
+ ```ts
43
+ import { createAnthropicProvider, drainStream } from "@providerkit/core";
44
+
45
+ const provider = createAnthropicProvider({
46
+ apiKey: process.env.ANTHROPIC_API_KEY!,
47
+ model: "claude-sonnet-5",
48
+ });
49
+
50
+ for await (const chunk of provider.createStream(messages, tools, { effort: "medium" })) {
51
+ if (chunk.content) process.stdout.write(chunk.content);
52
+ if (chunk.usage) console.log(chunk.usage); // one meaning across every provider
53
+ }
54
+ ```
55
+
56
+ `createOpenAIProvider` speaks the dialect most gateways do, so it serves OpenAI, OpenRouter,
57
+ DeepSeek, GLM, Kimi, Groq, Together, vLLM, Ollama and LM Studio:
58
+
59
+ ```ts
60
+ const provider = createOpenAIProvider({
61
+ apiKey: process.env.OPENROUTER_API_KEY!,
62
+ baseUrl: "https://openrouter.ai/api",
63
+ id: "openrouter",
64
+ model: "deepseek/deepseek-v4-pro",
65
+ // Keep the prompt cache warm: it lives on the upstream host's account, and
66
+ // OpenRouter's default routing hops hosts between rounds — every hop is a
67
+ // cold cache, in both latency and effective input cost.
68
+ providerOrder: ["deepinfra", "fireworks"],
69
+ });
70
+ ```
71
+
72
+ ### Failures, named by what fixes them
73
+
74
+ ```ts
75
+ import { ProviderError, isTransient, isBackupEligible } from "@providerkit/core";
76
+
77
+ try {
78
+ // …
79
+ } catch (raw) {
80
+ const err = ProviderError.from("anthropic", raw);
81
+
82
+ err.kind; // "overload" | "rate" | "quota" | "entitlement" | "auth" | "context" | …
83
+ err.retryAfterMs; // honoured from Retry-After, vendor reset headers, or Gemini's RetryInfo
84
+ err.body; // the provider's actual words, never "400 status code (no body)"
85
+
86
+ if (err.kind === "context") compactAndRetry();
87
+ else if (isBackupEligible(err.kind)) tryAnotherModel();
88
+ else if (isTransient(err.kind)) retry();
89
+ else surface(err);
90
+ }
91
+ ```
92
+
93
+ ### Retrying, safely
94
+
95
+ ```ts
96
+ import { withStreamRetry, streamWithBackupModels } from "@providerkit/core";
97
+
98
+ // Retries only while NOTHING has been emitted. Past the first chunk the stream
99
+ // is committed — a retry would replay tokens already on the reader's screen.
100
+ const stream = withStreamRetry((signal) => provider.createStream(messages, tools, { signal }));
101
+
102
+ // Walks [primary, ...backups] on overload and rate limits only; an auth failure
103
+ // or an invalid request would land identically on every backup.
104
+ const withFallback = streamWithBackupModels((model) => run(model), {
105
+ models: ["claude-opus-5", "claude-sonnet-5"],
106
+ });
107
+ ```
108
+
109
+ ### The watchdog
110
+
111
+ ```ts
112
+ import { streamWatch, watchChunks } from "@providerkit/core";
113
+
114
+ const watch = streamWatch({ provider: "openai", signal: userSignal });
115
+ for await (const chunk of watchChunks(
116
+ watch,
117
+ provider.createStream(messages, tools, {
118
+ signal: watch.signal,
119
+ }),
120
+ )) {
121
+ // any byte re-arms the deadline
122
+ }
123
+ watch.firstChunkMs(); // TTFT — the number a prompt-cache pin exists to shrink
124
+ ```
125
+
126
+ A stream that goes 60 seconds without a byte is aborted and surfaced as a transient
127
+ `timeout`. The watchdog aborts its _own_ controller and only bridges the caller's, so your
128
+ user's Stop stays distinguishable from our deadline: one is never retried, the other always
129
+ is.
130
+
131
+ ### Tools
132
+
133
+ JSON Schema by default, so the core needs no zod:
134
+
135
+ ```ts
136
+ import { defineTool, ToolRegistry } from "@providerkit/core";
137
+
138
+ const search = defineTool({
139
+ name: "search",
140
+ description: "Search the corpus",
141
+ inputSchema: { type: "object", properties: { q: { type: "string" } }, required: ["q"] },
142
+ run: async ({ q }, ctx) => findAll(q, { signal: ctx.signal }),
143
+ });
144
+
145
+ const outcome = await search.invoke(rawArgsFromModel);
146
+ // { ok: false, kind: "invalid_input" | "timeout" | "aborted" | "failed", error }
147
+ ```
148
+
149
+ A failing tool is **data**, not an exception — the loop feeds `error` back so the model can
150
+ correct itself. Only a caller's abort escapes.
151
+
152
+ With zod, from the optional entry point:
153
+
154
+ ```ts
155
+ import { zodTool } from "@providerkit/core/zod";
156
+
157
+ const submit = zodTool({
158
+ name: "submit",
159
+ description: "Submit the final answer",
160
+ isTerminal: true,
161
+ clampOverflow: true, // a terminal tool gets no second chance
162
+ input: z.object({ summary: z.string().max(2000) }),
163
+ run: async (input) => input,
164
+ });
165
+ ```
166
+
167
+ ### Truncated tool calls
168
+
169
+ ```ts
170
+ import { parseToolArgs, isCompleteJson } from "@providerkit/core";
171
+
172
+ // Never throws. A turn cut off mid-argument keeps every field that closed
173
+ // before the cut, plus the half-written one the cut landed in.
174
+ const args = parseToolArgs(rawArgumentString);
175
+ ```
176
+
177
+ ### Compaction
178
+
179
+ The decisions, not the prompt — the summary is yours to write:
180
+
181
+ ```ts
182
+ import { needsCompaction, pickCut, applyCompaction, historyBudgetTokens } from "@providerkit/core";
183
+
184
+ if (needsCompaction(lastInputTokens, contextWindow)) {
185
+ const cut = pickCut(messages, historyBudgetTokens(contextWindow));
186
+ messages = applyCompaction(messages, cut, await summarize(messages.slice(0, cut)));
187
+ }
188
+ ```
189
+
190
+ `pickCut` never splits a tool call from its result (every provider rejects the orphan with a
191
+ 400 — the failure compaction was called to avoid), never cuts into the system prompt, and
192
+ always keeps the newest message.
193
+
194
+ ### Cost
195
+
196
+ The arithmetic, not the rates. A price table is volatile data about a catalogue that differs
197
+ per application, and a wrong number shipped in a library is a wrong number in everyone's
198
+ ledger — so you keep the numbers, verifiable line by line against a vendor's price sheet:
199
+
200
+ ```ts
201
+ import { UsageTracker } from "@providerkit/core";
202
+
203
+ const tracker = new UsageTracker();
204
+ tracker.add(usage, { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }); // USD per Mtok
205
+ tracker.costUsd;
206
+ tracker.cacheSavingsUsd; // what the cache saved, for the "is caching even working" question
207
+ ```
208
+
209
+ ## Origin
210
+
211
+ This was extracted from five production codebases that had each independently grown the same
212
+ layer — about 9,100 lines solving one ~2,000-line problem. They had three separate
213
+ 60-second idle watchdogs, identical down to the constant. On one day in September 2026, two
214
+ of them shipped the same five fixes independently.
215
+
216
+ They had also each learned a _different_ part of the problem. One walked the `cause` chain
217
+ for dead sockets; one parsed Gemini's `RetryInfo`; one read the body before the status and
218
+ knew the quota wordings in five languages; one knew Anthropic's `529` and when a failure is
219
+ worth a different model; one could rescue an answer from a tool call the model truncated.
220
+
221
+ The classifier here is the union of all five, and the suite is every failure any of them
222
+ ever saw. That is the part worth having.
223
+
224
+ ## Repo
225
+
226
+ ```
227
+ core/ the npm package `providerkit`
228
+ site/ providerkit.dev — also open source
229
+ ```
230
+
231
+ `bun install`, then `cd core && bun run test`. See [AGENTS.md](./AGENTS.md) for the layout,
232
+ the invariants worth not regressing, and what is left to build.
233
+
234
+ ## Status
235
+
236
+ Working and tested (218 tests): the seam, the error classifier, retry and backup-model
237
+ fallback, the idle watchdog, cost math, the fetch/SSE transport, tool-argument salvage, the
238
+ Anthropic and OpenAI-shape adapters, the tool kernel, schema clamping, and the compaction
239
+ decisions.
240
+
241
+ Not yet: the Gemini and Responses adapters, and the multi-key rotation pool.
242
+
243
+ ## License
244
+
245
+ MIT
@@ -0,0 +1,69 @@
1
+ import type { ChatMessage } from "./types.ts";
2
+ /**
3
+ * What the answer needs after the prompt: an output budget, plus the tools'
4
+ * own schemas, plus the slack no provider documents.
5
+ *
6
+ * Absolute rather than a percentage on purpose — a 1M window does not need a
7
+ * 100k cushion, and a 128k window needs more than 12k.
8
+ */
9
+ export declare const CONTEXT_RESERVE_TOKENS = 32000;
10
+ /**
11
+ * Four characters per token — the rule of thumb every provider's own
12
+ * calculator agrees with to within a fifth, which is all the precision this
13
+ * needs. It decides WHEN to fold: folding one turn early costs a cheap model
14
+ * call, folding one turn late costs the whole turn.
15
+ */
16
+ export declare function estimateTokens(text: string): number;
17
+ /** What one message costs to re-send: its words, its reasoning, and the
18
+ * arguments of any calls it made. */
19
+ export declare function messageTokens(message: ChatMessage): number;
20
+ export declare function conversationTokens(messages: readonly ChatMessage[]): number;
21
+ /**
22
+ * The provider's own input count says the wall is close — fold before the next
23
+ * step rather than after the 400.
24
+ *
25
+ * Takes the REPORTED count, not an estimate, because it is the only figure
26
+ * that is not a guess. Estimate only when there is no reported count yet.
27
+ */
28
+ export declare function needsCompaction(inputTokens: number, contextWindow: number): boolean;
29
+ /**
30
+ * How much of the window the history BEHIND the current turn may spend: a
31
+ * tenth, floored so a small window still gets a usable memory. The tail stays
32
+ * verbatim and the summary is short, so the rest is what folding buys back.
33
+ */
34
+ export declare function historyBudgetTokens(contextWindow: number): number;
35
+ /**
36
+ * Where to cut so the messages AFTER the cut fit `budget`, walking backwards
37
+ * from the newest.
38
+ *
39
+ * Two invariants the cut must respect, and both are correctness rather than
40
+ * taste:
41
+ *
42
+ * 1. Never split a tool call from its result. Every provider rejects a tool
43
+ * result whose call is missing, so a cut landing between them produces a
44
+ * 400 on the very next turn — the failure compaction was called to avoid.
45
+ * 2. Never cut into the system prompt. It is not history.
46
+ *
47
+ * Returns the index the kept tail starts at, or 0 when everything already fits.
48
+ */
49
+ export declare function pickCut(messages: readonly ChatMessage[], budget: number): number;
50
+ /**
51
+ * Fold `messages` into `[…system, summary, …tail]`.
52
+ *
53
+ * The summary arrives as a user turn rather than a system one: a system
54
+ * message added mid-conversation reads to the model as a new instruction, and
55
+ * several providers require the system block to be first and singular anyway.
56
+ */
57
+ export declare function applyCompaction(messages: readonly ChatMessage[], cut: number, summary: string): ChatMessage[];
58
+ /**
59
+ * The context window for a model, when nothing volunteered one.
60
+ *
61
+ * The ladder exists because the endpoints disagree about whether to tell you:
62
+ * OpenRouter, LM Studio and Ollama publish `context_length`; Anthropic and
63
+ * OpenAI do not. A reported number always wins; this is the floor under it.
64
+ *
65
+ * A conservative default is the right failure: too small folds one turn early
66
+ * and costs a cheap call, too large hits a hard 400 mid-run.
67
+ */
68
+ export declare function guessContextWindow(model: string): number;
69
+ //# sourceMappingURL=context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,QAAS,CAAC;AAE7C;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEnD;AAWD;sCACsC;AACtC,wBAAgB,aAAa,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,CAO1D;AAED,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,SAAS,WAAW,EAAE,GAAG,MAAM,CAE3E;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,WAAW,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAEnF;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAEjE;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,OAAO,CAAC,QAAQ,EAAE,SAAS,WAAW,EAAE,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CA0BhF;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,SAAS,WAAW,EAAE,EAChC,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,MAAM,GACd,WAAW,EAAE,CAQf;AAED;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAMxD"}
@@ -0,0 +1,132 @@
1
+ /**
2
+ * What the answer needs after the prompt: an output budget, plus the tools'
3
+ * own schemas, plus the slack no provider documents.
4
+ *
5
+ * Absolute rather than a percentage on purpose — a 1M window does not need a
6
+ * 100k cushion, and a 128k window needs more than 12k.
7
+ */
8
+ export const CONTEXT_RESERVE_TOKENS = 32_000;
9
+ /**
10
+ * Four characters per token — the rule of thumb every provider's own
11
+ * calculator agrees with to within a fifth, which is all the precision this
12
+ * needs. It decides WHEN to fold: folding one turn early costs a cheap model
13
+ * call, folding one turn late costs the whole turn.
14
+ */
15
+ export function estimateTokens(text) {
16
+ return Math.ceil(text.length / 4);
17
+ }
18
+ function textOf(message) {
19
+ if (message.role === "user" && typeof message.content !== "string") {
20
+ // Only the words are countable; an image's cost is the provider's own
21
+ // arithmetic and no character count approximates it.
22
+ return message.content.map((part) => (part.type === "text" ? part.text : "")).join("");
23
+ }
24
+ return typeof message.content === "string" ? message.content : "";
25
+ }
26
+ /** What one message costs to re-send: its words, its reasoning, and the
27
+ * arguments of any calls it made. */
28
+ export function messageTokens(message) {
29
+ const extra = message.role === "assistant"
30
+ ? (message.reasoning ?? "") +
31
+ (message.toolCalls?.map((call) => call.name + call.arguments).join("") ?? "")
32
+ : "";
33
+ return estimateTokens(textOf(message) + extra);
34
+ }
35
+ export function conversationTokens(messages) {
36
+ return messages.reduce((total, message) => total + messageTokens(message), 0);
37
+ }
38
+ /**
39
+ * The provider's own input count says the wall is close — fold before the next
40
+ * step rather than after the 400.
41
+ *
42
+ * Takes the REPORTED count, not an estimate, because it is the only figure
43
+ * that is not a guess. Estimate only when there is no reported count yet.
44
+ */
45
+ export function needsCompaction(inputTokens, contextWindow) {
46
+ return inputTokens >= contextWindow - CONTEXT_RESERVE_TOKENS;
47
+ }
48
+ /**
49
+ * How much of the window the history BEHIND the current turn may spend: a
50
+ * tenth, floored so a small window still gets a usable memory. The tail stays
51
+ * verbatim and the summary is short, so the rest is what folding buys back.
52
+ */
53
+ export function historyBudgetTokens(contextWindow) {
54
+ return Math.max(6_000, Math.floor(contextWindow * 0.1));
55
+ }
56
+ /**
57
+ * Where to cut so the messages AFTER the cut fit `budget`, walking backwards
58
+ * from the newest.
59
+ *
60
+ * Two invariants the cut must respect, and both are correctness rather than
61
+ * taste:
62
+ *
63
+ * 1. Never split a tool call from its result. Every provider rejects a tool
64
+ * result whose call is missing, so a cut landing between them produces a
65
+ * 400 on the very next turn — the failure compaction was called to avoid.
66
+ * 2. Never cut into the system prompt. It is not history.
67
+ *
68
+ * Returns the index the kept tail starts at, or 0 when everything already fits.
69
+ */
70
+ export function pickCut(messages, budget) {
71
+ const firstNonSystem = messages.findIndex((message) => message.role !== "system");
72
+ // Nothing but a system prompt: there is no history to fold.
73
+ if (firstNonSystem === -1)
74
+ return messages.length;
75
+ // The newest message is always kept, budget or not. A conversation whose
76
+ // latest turn alone overruns the budget is not fixable by cutting history,
77
+ // and answering a summary instead of the question the person just asked is
78
+ // never the right failure.
79
+ const latest = messages.length - 1;
80
+ let cut = latest;
81
+ let total = messageTokens(messages[latest]);
82
+ for (let i = latest - 1; i >= firstNonSystem; i--) {
83
+ total += messageTokens(messages[i]);
84
+ if (total > budget)
85
+ break;
86
+ cut = i;
87
+ }
88
+ // Walk back off any tool result whose assistant turn would be left behind
89
+ // it. Bounded by the first non-system message, so it can never run off the
90
+ // front. This can exceed the budget by a message or two, which is the right
91
+ // trade: an orphaned tool result is a hard 400, not an overrun.
92
+ while (cut > firstNonSystem && messages[cut].role === "tool")
93
+ cut--;
94
+ return cut;
95
+ }
96
+ /**
97
+ * Fold `messages` into `[…system, summary, …tail]`.
98
+ *
99
+ * The summary arrives as a user turn rather than a system one: a system
100
+ * message added mid-conversation reads to the model as a new instruction, and
101
+ * several providers require the system block to be first and singular anyway.
102
+ */
103
+ export function applyCompaction(messages, cut, summary) {
104
+ const system = messages.filter((message) => message.role === "system");
105
+ const tail = messages.slice(cut).filter((message) => message.role !== "system");
106
+ return [
107
+ ...system,
108
+ { role: "user", content: `[Earlier conversation, summarized]\n\n${summary}` },
109
+ ...tail,
110
+ ];
111
+ }
112
+ /**
113
+ * The context window for a model, when nothing volunteered one.
114
+ *
115
+ * The ladder exists because the endpoints disagree about whether to tell you:
116
+ * OpenRouter, LM Studio and Ollama publish `context_length`; Anthropic and
117
+ * OpenAI do not. A reported number always wins; this is the floor under it.
118
+ *
119
+ * A conservative default is the right failure: too small folds one turn early
120
+ * and costs a cheap call, too large hits a hard 400 mid-run.
121
+ */
122
+ export function guessContextWindow(model) {
123
+ const id = model.toLowerCase();
124
+ if (/gemini|gpt-4\.1|grok-4|llama-4/.test(id))
125
+ return 1_000_000;
126
+ if (/claude|gpt-5|o[34]|deepseek|kimi|glm|qwen/.test(id))
127
+ return 200_000;
128
+ if (/gpt-4o|mistral|command-r/.test(id))
129
+ return 128_000;
130
+ return 128_000;
131
+ }
132
+ //# sourceMappingURL=context.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.js","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,86 @@
1
+ /** What kind of failure this is, named by what actually fixes it. */
2
+ export type ErrorKind =
3
+ /** Our own cancel — the caller pressed Stop. Never retried. */
4
+ "aborted"
5
+ /** Our own deadline, or the provider's 408. Retry. */
6
+ | "timeout"
7
+ /** The request never reached the provider: socket died, DNS, proxy. Retry. */
8
+ | "network"
9
+ /** Theirs, and temporary: 5xx, Anthropic's 529, "overloaded". Retry, and
10
+ * worth trying a different model. */
11
+ | "overload"
12
+ /** 429 per-minute throttle. Wait — or rotate to another key or model. */
13
+ | "rate"
14
+ /** Balance or usage window exhausted. Waiting minutes will not fix it. */
15
+ | "quota"
16
+ /** The plan never included this API. Neither a new key nor a top-up fixes it. */
17
+ | "entitlement"
18
+ /** 401/403 — the key is wrong, not the request. */
19
+ | "auth"
20
+ /** The model id does not exist or is not served here. */
21
+ | "model"
22
+ /** The prompt outgrew the context window. The fix is to send less. */
23
+ | "context"
24
+ /** Safety filter or refusal. */
25
+ | "content"
26
+ /** Any other 4xx — a bug in what we sent. */
27
+ | "invalid" | "unknown";
28
+ export declare function isTransient(kind: ErrorKind): boolean;
29
+ export declare function isBackupEligible(kind: ErrorKind): boolean;
30
+ export declare class ProviderError extends Error {
31
+ readonly provider: string;
32
+ readonly kind: ErrorKind;
33
+ readonly status?: number;
34
+ readonly code?: string;
35
+ /** Honoured when the provider said how long to wait (Retry-After, or
36
+ * Gemini's RetryInfo.retryDelay). */
37
+ readonly retryAfterMs?: number;
38
+ /** The provider's own response body, truncated — the actual reason, which is
39
+ * otherwise lost behind "400 status code (no body)". */
40
+ readonly body?: string;
41
+ constructor(provider: string, kind: ErrorKind, message: string, opts?: {
42
+ status?: number;
43
+ code?: string;
44
+ retryAfterMs?: number;
45
+ body?: string;
46
+ cause?: unknown;
47
+ });
48
+ get isTransient(): boolean;
49
+ get isBackupEligible(): boolean;
50
+ /** Wrap anything thrown into a classified ProviderError. Already-wrapped
51
+ * errors pass through untouched, so classification happens exactly once. */
52
+ static from(provider: string, err: unknown): ProviderError;
53
+ }
54
+ export declare function messageOf(err: unknown): string;
55
+ /**
56
+ * A transport fault: the socket died before or during the response, so there
57
+ * is NO status and no body for the patterns below to read.
58
+ *
59
+ * The chain is walked because the useful code is rarely on the thrown error —
60
+ * it sits on `cause`, sometimes several wrappers deep. Without this walk every
61
+ * network blip classifies as permanent, and a long run dies on its first
62
+ * hiccup, which is the single likeliest way to lose a minutes-long job.
63
+ *
64
+ * A deliberate abort short-circuits to false: the caller pressed Stop, and
65
+ * retrying that just re-fails against the same dead signal.
66
+ */
67
+ export declare function isTransportFailure(err: unknown): boolean;
68
+ /**
69
+ * The kind of failure, from whatever was thrown.
70
+ *
71
+ * Body patterns outrank status for the 4xx family; within them, context beats
72
+ * entitlement beats quota beats auth — each earlier category's fix is useless
73
+ * for the later ones.
74
+ */
75
+ export declare function classify(err: unknown, status?: number, body?: string): ErrorKind;
76
+ /**
77
+ * How long the provider asked us to wait, in ms. Two dialects: Gemini's
78
+ * RetryInfo (`"retryDelay": "52s"`, inside the body) and the `Retry-After`
79
+ * header, which SDKs keep on the error. Honouring it beats guessing — a
80
+ * backoff shorter than the window just burns an attempt.
81
+ */
82
+ export declare function parseRetryAfterMs(err: unknown, body?: string): number | undefined;
83
+ /** The loggable surface of a failure — so a dead run never reads
84
+ * "400 status code (no body)". */
85
+ export declare function describeProviderError(err: unknown): Record<string, unknown>;
86
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAYA,qEAAqE;AACrE,MAAM,MAAM,SAAS;AACnB,+DAA+D;AAC7D,SAAS;AACX,sDAAsD;GACpD,SAAS;AACX,8EAA8E;GAC5E,SAAS;AACX;sCACsC;GACpC,UAAU;AACZ,yEAAyE;GACvE,MAAM;AACR,0EAA0E;GACxE,OAAO;AACT,iFAAiF;GAC/E,aAAa;AACf,mDAAmD;GACjD,MAAM;AACR,yDAAyD;GACvD,OAAO;AACT,sEAAsE;GACpE,SAAS;AACX,gCAAgC;GAC9B,SAAS;AACX,6CAA6C;GAC3C,SAAS,GACT,SAAS,CAAC;AAcd,wBAAgB,WAAW,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAEpD;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAEzD;AAED,qBAAa,aAAc,SAAQ,KAAK;IACtC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB;0CACsC;IACtC,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;6DACyD;IACzD,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;gBAGrB,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,SAAS,EACf,OAAO,EAAE,MAAM,EACf,IAAI,GAAE;QACJ,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,KAAK,CAAC,EAAE,OAAO,CAAC;KACZ;IAYR,IAAI,WAAW,IAAI,OAAO,CAEzB;IAED,IAAI,gBAAgB,IAAI,OAAO,CAE9B;IAED;iFAC6E;IAC7E,MAAM,CAAC,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,GAAG,aAAa;CAc3D;AAgBD,wBAAgB,SAAS,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,CAU9C;AAwDD;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAiBxD;AAqFD;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAgChF;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAkBjF;AAED;mCACmC;AACnC,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAa3E"}