@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
@@ -0,0 +1,162 @@
1
+ // The wire. One `fetch`, one SSE reader, one error envelope — no vendor SDK,
2
+ // no Node built-ins, so the same build runs in Bun, Node, Workers, Deno and an
3
+ // MV3 service worker.
4
+ //
5
+ // Adapters keep only their per-event mapping; everything about being an HTTP
6
+ // client lives here once.
7
+ import { classify, isTransportFailure, ProviderError } from "./errors.ts";
8
+
9
+ export interface RequestInit_ {
10
+ url: string;
11
+ headers?: Record<string, string>;
12
+ body: unknown;
13
+ /** Names the provider in the error envelope. */
14
+ provider: string;
15
+ signal?: AbortSignal;
16
+ /** Swapped in tests, or to route through a proxy. Defaults to global fetch. */
17
+ fetchImpl?: typeof fetch;
18
+ }
19
+
20
+ /** Join a base URL and a path without doubling or dropping the slash. */
21
+ export function apiUrl(baseUrl: string, path: string): string {
22
+ return `${baseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
23
+ }
24
+
25
+ /**
26
+ * How long the provider says to wait, read from the RESPONSE rather than from
27
+ * a thrown error. Headers are authoritative — `Retry-After` first, then the
28
+ * vendor reset headers that name a subscription window rather than a
29
+ * per-minute throttle, because "try again in a moment" is a lie for those.
30
+ */
31
+ export function retryAfterFromHeaders(headers: Headers, now = Date.now()): number | undefined {
32
+ const retryAfter = headers.get("retry-after");
33
+ if (retryAfter) {
34
+ if (/^\d+$/.test(retryAfter)) return Number(retryAfter) * 1000;
35
+ const date = Date.parse(retryAfter);
36
+ if (!Number.isNaN(date)) return Math.max(0, date - now);
37
+ }
38
+ // Anthropic and several gateways publish an epoch-seconds reset instead.
39
+ for (const name of [
40
+ "anthropic-ratelimit-unified-reset",
41
+ "anthropic-ratelimit-requests-reset",
42
+ "anthropic-ratelimit-tokens-reset",
43
+ "x-ratelimit-reset-requests",
44
+ "x-ratelimit-reset-tokens",
45
+ "x-ratelimit-reset",
46
+ ]) {
47
+ const value = headers.get(name);
48
+ if (!value) continue;
49
+ if (/^\d+$/.test(value)) {
50
+ const seconds = Number(value);
51
+ // Epoch seconds (a big number) or a relative count — tell them apart by
52
+ // magnitude rather than by trusting one vendor's convention.
53
+ const ms = seconds > 1_000_000_000 ? seconds * 1000 - now : seconds * 1000;
54
+ if (ms > 0) return ms;
55
+ }
56
+ const date = Date.parse(value);
57
+ if (!Number.isNaN(date)) return Math.max(0, date - now);
58
+ }
59
+ return undefined;
60
+ }
61
+
62
+ /** Turn a non-2xx response into the classified error every caller branches on. */
63
+ async function errorFor(provider: string, res: Response): Promise<ProviderError> {
64
+ const text = await res.text().catch(() => "");
65
+ const kind = classify({ status: res.status, error: text }, res.status, text);
66
+ const message = text
67
+ ? `${provider} ${res.status}: ${text.slice(0, 500)}`
68
+ : `${provider} ${res.status} ${res.statusText}`;
69
+ return new ProviderError(provider, kind, message, {
70
+ status: res.status,
71
+ retryAfterMs: retryAfterFromHeaders(res.headers),
72
+ body: text.slice(0, 2_000) || undefined,
73
+ });
74
+ }
75
+
76
+ async function send(opts: RequestInit_): Promise<Response> {
77
+ const doFetch = opts.fetchImpl ?? globalThis.fetch;
78
+ try {
79
+ return await doFetch(opts.url, {
80
+ method: "POST",
81
+ headers: { "content-type": "application/json", ...opts.headers },
82
+ body: typeof opts.body === "string" ? opts.body : JSON.stringify(opts.body),
83
+ signal: opts.signal,
84
+ });
85
+ } catch (err) {
86
+ // A stopped run rejects here too, and that is not a failure — let it pass
87
+ // through untouched. Anything that is not a recognizable transport
88
+ // rejection is a bug of ours and keeps its own loud shape.
89
+ if (opts.signal?.aborted || !isTransportFailure(err)) throw err;
90
+ throw new ProviderError(opts.provider, "network", `no response from ${opts.url}`, {
91
+ cause: err,
92
+ });
93
+ }
94
+ }
95
+
96
+ /** POST and parse one JSON response. For the endpoints that do not stream. */
97
+ export async function postJson<T = unknown>(opts: RequestInit_): Promise<T> {
98
+ const res = await send(opts);
99
+ if (!res.ok) throw await errorFor(opts.provider, res);
100
+ return (await res.json()) as T;
101
+ }
102
+
103
+ /**
104
+ * POST an SSE request and yield each `data:` payload, trimmed.
105
+ *
106
+ * Frames are split on the blank line the spec requires, so a payload
107
+ * containing a bare newline survives; `[DONE]` is swallowed here rather than in
108
+ * every adapter. CRLF is normalized — some gateways send it, and a `\r` left on
109
+ * the end of a JSON payload is a parse error nobody enjoys debugging.
110
+ */
111
+ export async function* streamSse(opts: RequestInit_): AsyncGenerator<string> {
112
+ const res = await send(opts);
113
+ if (!res.ok) throw await errorFor(opts.provider, res);
114
+ // A 2xx with no body at all is an upstream anomaly, not a request we got
115
+ // wrong — worth the same retry a 5xx gets.
116
+ if (!res.body) {
117
+ throw new ProviderError(opts.provider, "overload", `${opts.provider}: empty response body`, {
118
+ status: res.status,
119
+ });
120
+ }
121
+
122
+ const reader = res.body.getReader();
123
+ const decoder = new TextDecoder();
124
+ let buffer = "";
125
+
126
+ /** Pull the `data:` payload out of one SSE frame, joining continuation
127
+ * lines the way the spec says to. Returns null for a comment or a frame
128
+ * carrying only an `event:` name. */
129
+ function payloadOf(frame: string): string | null {
130
+ const parts: string[] = [];
131
+ for (const line of frame.split("\n")) {
132
+ if (!line.startsWith("data:")) continue;
133
+ parts.push(line.slice(5).replace(/^ /, ""));
134
+ }
135
+ if (parts.length === 0) return null;
136
+ const payload = parts.join("\n").trim();
137
+ return payload.length > 0 ? payload : null;
138
+ }
139
+
140
+ try {
141
+ for (;;) {
142
+ const { done, value } = await reader.read();
143
+ if (done) break;
144
+ buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
145
+
146
+ let boundary = buffer.indexOf("\n\n");
147
+ while (boundary !== -1) {
148
+ const frame = buffer.slice(0, boundary);
149
+ buffer = buffer.slice(boundary + 2);
150
+ const payload = payloadOf(frame);
151
+ if (payload !== null && payload !== "[DONE]") yield payload;
152
+ boundary = buffer.indexOf("\n\n");
153
+ }
154
+ }
155
+ // A stream that ends without its final blank line still has an event in
156
+ // hand — dropping it loses the last delta, or the usage record.
157
+ const tail = payloadOf(buffer);
158
+ if (tail !== null && tail !== "[DONE]") yield tail;
159
+ } finally {
160
+ reader.releaseLock();
161
+ }
162
+ }
package/src/types.ts ADDED
@@ -0,0 +1,231 @@
1
+ // The seam — provider-neutral messages, tools and chunks. Every adapter
2
+ // (OpenAI-compatible, Anthropic, Responses, Gemini) translates to and from
3
+ // exactly these shapes, so a caller never sees a vendor's dialect.
4
+ //
5
+ // Deliberately NOT OpenAI's parameter types. Using one vendor's wire shape as
6
+ // the lingua franca forces the other adapters to round-trip through a dialect
7
+ // that isn't theirs, and every quirk of that dialect then leaks into callers
8
+ // who never asked for it.
9
+
10
+ /**
11
+ * How hard the model thinks before answering. Absent = the provider's own
12
+ * default (never sent). Passed through verbatim on OpenAI-shape
13
+ * (`reasoning_effort`); mapped to thinking budgets on Anthropic-shape.
14
+ * Support varies per model — an unsupported level comes back as a clean 400.
15
+ *
16
+ * Ordered least → most; the type derives from the array so the runtime guard
17
+ * and the union can never drift apart.
18
+ */
19
+ export const EFFORTS = ["none", "low", "medium", "high", "max"] as const;
20
+ export type Effort = (typeof EFFORTS)[number];
21
+
22
+ /** The one place the effort union meets raw input (pickers, CLI flags). */
23
+ export function isEffort(value: string): value is Effort {
24
+ return EFFORTS.some((effort) => effort === value);
25
+ }
26
+
27
+ export type ImageMimeType = "image/jpeg" | "image/png" | "image/webp" | "image/gif";
28
+
29
+ export interface TextPart {
30
+ type: "text";
31
+ text: string;
32
+ }
33
+
34
+ /** An image the model looks at (vision) — bytes as base64, never a URL the
35
+ * provider would have to fetch on our behalf. */
36
+ export interface ImagePart {
37
+ type: "image";
38
+ mimeType: ImageMimeType;
39
+ data: string;
40
+ }
41
+
42
+ export type ContentPart = TextPart | ImagePart;
43
+
44
+ /**
45
+ * A tool the model asked to run. `arguments` is the RAW JSON string, not a
46
+ * parsed object: it arrives in fragments and can be truncated mid-stream, so
47
+ * assembly and validation are the consumer's job (see tools/define).
48
+ */
49
+ export interface ToolCall {
50
+ id: string;
51
+ name: string;
52
+ arguments: string;
53
+ /** Gemini's opaque reasoning token. It must ride back on the next turn
54
+ * verbatim or the model loses its own chain of thought. */
55
+ thoughtSignature?: string;
56
+ }
57
+
58
+ export type ChatMessage =
59
+ | { role: "system"; content: string }
60
+ | { role: "user"; content: string | ContentPart[] }
61
+ | {
62
+ role: "assistant";
63
+ content: string;
64
+ /**
65
+ * The model's chain-of-thought. Thinking-mode providers require it
66
+ * replayed on a turn that made a tool call (DeepSeek 400s without it).
67
+ * OpenAI-shape serializes it as `reasoning_content`; Anthropic-shape
68
+ * drops it, since its thinking blocks carry signatures we never capture.
69
+ *
70
+ * A turn that DISABLES thinking must not carry it — mixing the two is
71
+ * unsupported. `stripReasoning` below is that rule, once.
72
+ */
73
+ reasoning?: string;
74
+ toolCalls?: ToolCall[];
75
+ }
76
+ | {
77
+ role: "tool";
78
+ toolCallId: string;
79
+ name: string;
80
+ content: string;
81
+ /** Images a tool hands back (a screenshot, a rendered chart). */
82
+ images?: ImagePart[];
83
+ };
84
+
85
+ /** JSON Schema for an object — what every provider's tool contract wants. */
86
+ export interface JsonObjectSchema {
87
+ type: "object";
88
+ properties?: Record<string, unknown>;
89
+ required?: string[];
90
+ additionalProperties?: boolean;
91
+ [key: string]: unknown;
92
+ }
93
+
94
+ export interface ToolDefinition {
95
+ name: string;
96
+ description: string;
97
+ inputSchema: JsonObjectSchema;
98
+ }
99
+
100
+ export type FinishReason = "stop" | "length" | "tool_calls" | "content_filter";
101
+
102
+ /** Incremental tool-call fragment, assembled by `index` by the consumer. */
103
+ export interface ToolCallDelta {
104
+ index: number;
105
+ id?: string;
106
+ name?: string;
107
+ arguments?: string;
108
+ thoughtSignature?: string;
109
+ }
110
+
111
+ export interface TokenUsage {
112
+ inputTokens: number;
113
+ /** Cache-HIT subset of `inputTokens` — providers auto-cache repeated
114
+ * prefixes and bill the hit portion far cheaper, so it must be tracked
115
+ * separately to cost a turn correctly. 0 when the provider reports none. */
116
+ cachedInputTokens: number;
117
+ /** Tokens WRITTEN to cache. Anthropic bills these above the input rate;
118
+ * the OpenAI-shape auto-cachers bill them at it. 0 when not reported. */
119
+ cacheWriteTokens?: number;
120
+ outputTokens: number;
121
+ }
122
+
123
+ export const EMPTY_USAGE: TokenUsage = {
124
+ inputTokens: 0,
125
+ cachedInputTokens: 0,
126
+ outputTokens: 0,
127
+ };
128
+
129
+ /** Normalized streaming chunk — every provider's shape collapses into this. */
130
+ export interface ProviderChunk {
131
+ type: "delta" | "usage" | "finish";
132
+ content?: string;
133
+ reasoning?: string;
134
+ toolCalls?: ToolCallDelta[];
135
+ usage?: TokenUsage;
136
+ finishReason?: FinishReason;
137
+ }
138
+
139
+ /** Pin or deny tool use. `{ name }` forces one specific tool — how a run is
140
+ * made to commit an answer at its step budget's edge. */
141
+ export type ToolChoice = "auto" | "none" | "required" | { name: string };
142
+
143
+ /**
144
+ * Ask for a JSON object matching `schema`. Providers that enforce schemas get
145
+ * it verbatim; the rest get JSON mode plus the schema in the prompt. Either
146
+ * way the CALLER validates — a provider's "guaranteed" JSON is not one.
147
+ */
148
+ export interface JsonOutput {
149
+ name: string;
150
+ schema: JsonObjectSchema;
151
+ }
152
+
153
+ export interface StreamOptions {
154
+ /** Override the provider's bound model for this call. */
155
+ model?: string;
156
+ effort?: Effort;
157
+ /** Output ceiling. On most providers thinking and answer SHARE it, so a
158
+ * task emitting a large artifact must raise it or the tool-call JSON is
159
+ * silently truncated mid-argument. */
160
+ maxTokens?: number;
161
+ temperature?: number;
162
+ signal?: AbortSignal;
163
+ toolChoice?: ToolChoice;
164
+ json?: JsonOutput;
165
+ }
166
+
167
+ export interface Provider {
168
+ readonly id: string;
169
+ readonly model: string;
170
+ createStream(
171
+ messages: ChatMessage[],
172
+ tools: ToolDefinition[],
173
+ opts?: StreamOptions,
174
+ ): AsyncIterable<ProviderChunk>;
175
+ }
176
+
177
+ export interface Completion {
178
+ text: string;
179
+ reasoning: string;
180
+ usage: TokenUsage;
181
+ finishReason: FinishReason | null;
182
+ model: string;
183
+ }
184
+
185
+ /** Drain a no-tools stream into a Completion. Usage chunks are cumulative on
186
+ * some providers and final-only on others: the LAST one wins. */
187
+ export async function drainStream(
188
+ stream: AsyncIterable<ProviderChunk>,
189
+ model: string,
190
+ ): Promise<Completion> {
191
+ let text = "";
192
+ let reasoning = "";
193
+ let usage: TokenUsage = EMPTY_USAGE;
194
+ let finishReason: FinishReason | null = null;
195
+ for await (const chunk of stream) {
196
+ if (chunk.type === "delta") {
197
+ if (chunk.content) text += chunk.content;
198
+ if (chunk.reasoning) reasoning += chunk.reasoning;
199
+ } else if (chunk.type === "usage" && chunk.usage) {
200
+ usage = chunk.usage;
201
+ } else if (chunk.type === "finish" && chunk.finishReason) {
202
+ finishReason = chunk.finishReason;
203
+ }
204
+ }
205
+ return { text, reasoning, usage, finishReason, model };
206
+ }
207
+
208
+ /**
209
+ * Prepare a history for a thinking-DISABLED turn: strip `reasoning` from every
210
+ * assistant message.
211
+ *
212
+ * The chain-of-thought belongs only to thinking turns. A provider that
213
+ * requires it replayed while thinking is ON (DeepSeek) rejects it when
214
+ * thinking is OFF — which is exactly the shape of a forced-submit salvage
215
+ * turn, where a run reasons through its whole investigation and then drops
216
+ * thinking to serialize what it already found.
217
+ *
218
+ * Returns a shallow-cleaned copy; the caller's array is left untouched.
219
+ */
220
+ export function stripReasoning(messages: readonly ChatMessage[]): ChatMessage[] {
221
+ return messages.map((message) => {
222
+ if (message.role !== "assistant" || message.reasoning === undefined) return message;
223
+ const { reasoning: _dropped, ...rest } = message;
224
+ return rest;
225
+ });
226
+ }
227
+
228
+ /** `data:` URI for an image part — what the OpenAI dialect wants inline. */
229
+ export function toDataUri(part: ImagePart): string {
230
+ return `data:${part.mimeType};base64,${part.data}`;
231
+ }
package/src/usage.ts ADDED
@@ -0,0 +1,106 @@
1
+ // Counting tokens and pricing them.
2
+ //
3
+ // The RATES are not here on purpose. A price table is volatile data about a
4
+ // model catalogue that differs per application, it goes stale on a vendor's
5
+ // schedule rather than this package's, and a wrong number shipped in a library
6
+ // is a wrong number in everyone's ledger. So the arithmetic lives here and the
7
+ // numbers stay with the caller, who can verify them line by line against a
8
+ // price sheet.
9
+ import type { TokenUsage } from "./types.ts";
10
+ import { EMPTY_USAGE } from "./types.ts";
11
+
12
+ /** USD per MILLION tokens — how every vendor prints its price sheet, so a row
13
+ * can be checked against one without arithmetic. */
14
+ export interface ModelRate {
15
+ /** Cache-MISS input: fresh tokens the provider had to read. */
16
+ input: number;
17
+ output: number;
18
+ /** Cache-HIT input. Providers auto-cache repeated prefixes and bill the hit
19
+ * portion far cheaper — 0.1× on Anthropic, ~0.1× on Gemini, 0.5× on some
20
+ * OpenAI models. In an agent loop the re-sent context is overwhelmingly
21
+ * hits, so billing it at the miss rate overcounts by up to 10×. */
22
+ cacheRead: number;
23
+ /** Input WRITTEN to cache. Anthropic bills this ABOVE the input rate
24
+ * (1.25×); the OpenAI-shape auto-cachers bill it at the input rate.
25
+ * Defaults to `input` when a vendor does not price it separately. */
26
+ cacheWrite?: number;
27
+ }
28
+
29
+ /** Add two usage records. */
30
+ export function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage {
31
+ return {
32
+ inputTokens: a.inputTokens + b.inputTokens,
33
+ cachedInputTokens: a.cachedInputTokens + b.cachedInputTokens,
34
+ cacheWriteTokens: (a.cacheWriteTokens ?? 0) + (b.cacheWriteTokens ?? 0),
35
+ outputTokens: a.outputTokens + b.outputTokens,
36
+ };
37
+ }
38
+
39
+ /**
40
+ * USD for one call.
41
+ *
42
+ * Cached tokens are a SUBSET of input, not an addition to it: the miss part
43
+ * bills at the full rate and the hit part at the cache rate. `cached` is
44
+ * clamped to `input` so an over-reporting provider can neither drive the miss
45
+ * count negative nor bill for more prompt than it was sent.
46
+ */
47
+ export function costUsd(usage: TokenUsage, rate: ModelRate): number {
48
+ const input = Math.max(0, usage.inputTokens);
49
+ const cached = Math.min(Math.max(0, usage.cachedInputTokens), input);
50
+ const written = Math.max(0, usage.cacheWriteTokens ?? 0);
51
+ const miss = input - cached;
52
+ const writeRate = rate.cacheWrite ?? rate.input;
53
+ return (
54
+ (miss * rate.input +
55
+ cached * rate.cacheRead +
56
+ written * writeRate +
57
+ Math.max(0, usage.outputTokens) * rate.output) /
58
+ 1_000_000
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Accumulates usage and cost across the calls of one run, pricing each with
64
+ * the rate in effect for the model that served it — so a run that switches to
65
+ * a backup model, or spans a time-of-day price boundary, still bills correctly.
66
+ */
67
+ export class UsageTracker {
68
+ private usage: TokenUsage = { ...EMPTY_USAGE, cacheWriteTokens: 0 };
69
+ private cost = 0;
70
+ /** USD the cache hits saved, versus billing them all as misses.
71
+ * Observability only — never billed. */
72
+ private saved = 0;
73
+
74
+ add(usage: TokenUsage, rate?: ModelRate): void {
75
+ this.usage = addUsage(this.usage, usage);
76
+ if (!rate) return;
77
+ this.cost += costUsd(usage, rate);
78
+ const cached = Math.min(Math.max(0, usage.cachedInputTokens), Math.max(0, usage.inputTokens));
79
+ this.saved += (cached * (rate.input - rate.cacheRead)) / 1_000_000;
80
+ }
81
+
82
+ get totals(): TokenUsage {
83
+ return { ...this.usage };
84
+ }
85
+
86
+ get costUsd(): number {
87
+ return this.cost;
88
+ }
89
+
90
+ get cacheSavingsUsd(): number {
91
+ return this.saved;
92
+ }
93
+
94
+ /** A generous runaway guard, not a quality cap: the loop's step budget is
95
+ * the real bound, and a run that trips this should finalize rather than
96
+ * fail, so set it well above any legitimate run. */
97
+ isOverBudget(maxUsd: number): boolean {
98
+ return this.cost >= maxUsd;
99
+ }
100
+
101
+ reset(): void {
102
+ this.usage = { ...EMPTY_USAGE, cacheWriteTokens: 0 };
103
+ this.cost = 0;
104
+ this.saved = 0;
105
+ }
106
+ }
@@ -0,0 +1,119 @@
1
+ // The stream-idle watchdog.
2
+ //
3
+ // A provider that stops sending bytes is indistinguishable from a long prefill
4
+ // — except that it never ends, and every SDK's default is to wait forever. A
5
+ // queued route or a wedged prefill upstream hangs the caller indefinitely, and
6
+ // the symptom is the worst kind: nothing. No error, no log, no timeout.
7
+ //
8
+ // So the seam gives itself a deadline. Any byte of any kind re-arms it
9
+ // (reasoning models emit thinking deltas continuously, so silence really is
10
+ // silence). When it fires, the watchdog aborts ITS OWN controller and the
11
+ // caller's signal is only bridged in — which is what keeps a person's Stop
12
+ // distinguishable from our timeout. One is their cancel and is never retried;
13
+ // the other is ours, is transient, and fires while nothing has streamed yet,
14
+ // so the retry is always safe.
15
+ import { ProviderError } from "./errors.ts";
16
+
17
+ /** No byte at all for this long and the stream is considered wedged. */
18
+ export const STREAM_IDLE_MS = 60_000;
19
+
20
+ export interface StreamWatch {
21
+ /** Hand this to the provider in place of the caller's signal. */
22
+ readonly signal: AbortSignal;
23
+ /** A byte arrived: re-arm the deadline, and mark TTFT if it was the first. */
24
+ sawByte(): void;
25
+ /**
26
+ * Milliseconds from the call opening to its first byte of any kind — the
27
+ * wait a person actually experiences, and the number a prompt-cache pin
28
+ * exists to shrink. Null until something arrives.
29
+ */
30
+ firstChunkMs(): number | null;
31
+ /**
32
+ * Re-issue a provider failure as the idle timeout when — and only when — it
33
+ * was our deadline that aborted. A caller's Stop passes through untouched.
34
+ */
35
+ classify(err: unknown): unknown;
36
+ /** Clear the deadline timer. Safe to call more than once. */
37
+ dispose(): void;
38
+ }
39
+
40
+ export interface StreamWatchOptions {
41
+ provider?: string;
42
+ idleMs?: number;
43
+ signal?: AbortSignal;
44
+ }
45
+
46
+ export function streamWatch(opts: StreamWatchOptions = {}): StreamWatch {
47
+ const provider = opts.provider ?? "provider";
48
+ const idleMs = opts.idleMs ?? STREAM_IDLE_MS;
49
+ const callerSignal = opts.signal;
50
+ const started = Date.now();
51
+ const timeout = new AbortController();
52
+
53
+ let firstChunk: number | null = null;
54
+ let idle = false;
55
+ let disposed = false;
56
+
57
+ // The bridge is structural rather than an event listener: AbortSignal.any
58
+ // aborts synchronously when an input is ALREADY aborted, which is the race
59
+ // no listener can catch (the event fired before we subscribed).
60
+ const signal = callerSignal ? AbortSignal.any([callerSignal, timeout.signal]) : timeout.signal;
61
+
62
+ const idleError = (cause?: unknown) =>
63
+ new ProviderError(provider, "timeout", `stream went ${idleMs / 1000}s without a byte`, {
64
+ cause,
65
+ });
66
+
67
+ function arm(): ReturnType<typeof setTimeout> {
68
+ const timer = setTimeout(() => {
69
+ idle = true;
70
+ timeout.abort(idleError());
71
+ }, idleMs);
72
+ // An orphaned watch — its consumer gone, dispose never called — must not
73
+ // hold a Node event loop open for a full deadline.
74
+ (timer as { unref?: () => void }).unref?.();
75
+ return timer;
76
+ }
77
+
78
+ let timer = arm();
79
+
80
+ return {
81
+ signal,
82
+ sawByte() {
83
+ firstChunk ??= Date.now() - started;
84
+ clearTimeout(timer);
85
+ if (!disposed && !signal.aborted) timer = arm();
86
+ },
87
+ firstChunkMs: () => firstChunk,
88
+ classify(err: unknown) {
89
+ // Our deadline, not theirs — and not the caller's Stop.
90
+ if (idle && !(callerSignal?.aborted ?? false)) return idleError(err);
91
+ return err;
92
+ },
93
+ dispose() {
94
+ disposed = true;
95
+ clearTimeout(timer);
96
+ },
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Wrap a stream so every chunk re-arms `watch`, and a failure is re-classified
102
+ * through it. Disposes on any exit — completion, throw, or the consumer
103
+ * breaking out of the loop.
104
+ */
105
+ export async function* watchChunks<T>(
106
+ watch: StreamWatch,
107
+ chunks: AsyncIterable<T>,
108
+ ): AsyncGenerator<T> {
109
+ try {
110
+ for await (const chunk of chunks) {
111
+ watch.sawByte();
112
+ yield chunk;
113
+ }
114
+ } catch (err) {
115
+ throw watch.classify(err);
116
+ } finally {
117
+ watch.dispose();
118
+ }
119
+ }
package/src/zod.ts ADDED
@@ -0,0 +1,74 @@
1
+ // The optional zod ergonomics — `@providerkit/core/zod`.
2
+ //
3
+ // Kept behind its own entry point so the core stays dependency-free for
4
+ // consumers that have no zod (a browser extension counting every byte, for
5
+ // one). Import this and tools become typed end to end.
6
+ import { z } from "zod";
7
+ import { defineTool, type Tool, type ToolContext } from "./tools.ts";
8
+ import { clampToSchema } from "./schema.ts";
9
+ import type { JsonObjectSchema } from "./types.ts";
10
+
11
+ /** A zod schema as the JSON Schema every provider's tool contract wants. */
12
+ export function toJsonObjectSchema(schema: z.ZodType, label = "schema"): JsonObjectSchema {
13
+ const json = z.toJSONSchema(schema, { io: "input" }) as Record<string, unknown>;
14
+ if (json.type !== "object") {
15
+ // Every provider requires an object at the top level of a tool's
16
+ // parameters; a bare string or array is rejected at the wire, far from
17
+ // here, with a message that names none of this.
18
+ throw new Error(`providerkit: ${label} must be an object schema, got ${String(json.type)}`);
19
+ }
20
+ return json as JsonObjectSchema;
21
+ }
22
+
23
+ export interface ZodToolSpec<I, O> {
24
+ name: string;
25
+ description: string;
26
+ input: z.ZodType<I>;
27
+ run: (input: I, ctx: ToolContext) => Promise<O>;
28
+ summarize?: (output: O) => string;
29
+ timeoutMs?: number;
30
+ isReadOnly?: boolean;
31
+ needsApproval?: boolean;
32
+ isConcurrencySafe?: boolean;
33
+ isTerminal?: boolean;
34
+ /**
35
+ * Clamp overflow to the bounds the schema already advertised instead of
36
+ * rejecting the call. Worth it for a TERMINAL tool, which gets no second
37
+ * chance: a forced-submit salvage turn runs exactly once, and discarding an
38
+ * otherwise-valid answer over a few extra characters loses the whole run.
39
+ * Off by default — an ordinary tool can simply be called again.
40
+ */
41
+ clampOverflow?: boolean;
42
+ }
43
+
44
+ /**
45
+ * A tool whose arguments are validated by zod, with the failure reported to
46
+ * the MODEL in words it can act on — `topic: expected string, received number`
47
+ * beats a stack trace it cannot read.
48
+ */
49
+ export function zodTool<I, O>(spec: ZodToolSpec<I, O>): Tool<I, O> {
50
+ const inputSchema = toJsonObjectSchema(spec.input, `Tool "${spec.name}" input`);
51
+
52
+ return defineTool<I, O>({
53
+ name: spec.name,
54
+ description: spec.description,
55
+ inputSchema,
56
+ ...(spec.summarize ? { summarize: spec.summarize } : {}),
57
+ ...(spec.timeoutMs !== undefined ? { timeoutMs: spec.timeoutMs } : {}),
58
+ ...(spec.isReadOnly !== undefined ? { isReadOnly: spec.isReadOnly } : {}),
59
+ ...(spec.needsApproval !== undefined ? { needsApproval: spec.needsApproval } : {}),
60
+ ...(spec.isConcurrencySafe !== undefined ? { isConcurrencySafe: spec.isConcurrencySafe } : {}),
61
+ ...(spec.isTerminal !== undefined ? { isTerminal: spec.isTerminal } : {}),
62
+ validate: (raw) => {
63
+ const candidate = spec.clampOverflow ? clampToSchema(raw, inputSchema) : raw;
64
+ const parsed = spec.input.safeParse(candidate);
65
+ if (parsed.success) return parsed.data;
66
+ throw new Error(
67
+ parsed.error.issues
68
+ .map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`)
69
+ .join("; "),
70
+ );
71
+ },
72
+ run: spec.run,
73
+ });
74
+ }