@providerkit/core 0.2.0 → 0.4.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 (44) hide show
  1. package/README.md +16 -9
  2. package/dist/errors.d.ts.map +1 -1
  3. package/dist/errors.js +17 -2
  4. package/dist/errors.js.map +1 -1
  5. package/dist/providers/anthropic.d.ts +1 -6
  6. package/dist/providers/anthropic.d.ts.map +1 -1
  7. package/dist/providers/anthropic.js +46 -3
  8. package/dist/providers/anthropic.js.map +1 -1
  9. package/dist/providers/gemini.d.ts.map +1 -1
  10. package/dist/providers/gemini.js +4 -0
  11. package/dist/providers/gemini.js.map +1 -1
  12. package/dist/providers/openai.d.ts +38 -1
  13. package/dist/providers/openai.d.ts.map +1 -1
  14. package/dist/providers/openai.js +122 -16
  15. package/dist/providers/openai.js.map +1 -1
  16. package/dist/providers/responses.d.ts.map +1 -1
  17. package/dist/providers/responses.js +6 -1
  18. package/dist/providers/responses.js.map +1 -1
  19. package/dist/schema.d.ts +21 -0
  20. package/dist/schema.d.ts.map +1 -1
  21. package/dist/schema.js +40 -0
  22. package/dist/schema.js.map +1 -1
  23. package/dist/types.d.ts +36 -0
  24. package/dist/types.d.ts.map +1 -1
  25. package/dist/types.js +22 -3
  26. package/dist/types.js.map +1 -1
  27. package/dist/watchdog.d.ts +46 -0
  28. package/dist/watchdog.d.ts.map +1 -1
  29. package/dist/watchdog.js +92 -1
  30. package/dist/watchdog.js.map +1 -1
  31. package/dist/zod.d.ts +8 -1
  32. package/dist/zod.d.ts.map +1 -1
  33. package/dist/zod.js +9 -2
  34. package/dist/zod.js.map +1 -1
  35. package/package.json +4 -1
  36. package/src/errors.ts +16 -2
  37. package/src/providers/anthropic.ts +48 -3
  38. package/src/providers/gemini.ts +2 -0
  39. package/src/providers/openai.ts +148 -16
  40. package/src/providers/responses.ts +5 -1
  41. package/src/schema.ts +41 -0
  42. package/src/types.ts +56 -3
  43. package/src/watchdog.ts +124 -1
  44. package/src/zod.ts +12 -2
package/src/watchdog.ts CHANGED
@@ -1,3 +1,6 @@
1
+ // The two ways a stream fails without failing: it goes silent, or it ends
2
+ // having said nothing at all.
3
+ //
1
4
  // The stream-idle watchdog.
2
5
  //
3
6
  // A provider that stops sending bytes is indistinguishable from a long prefill
@@ -13,6 +16,13 @@
13
16
  // the other is ours, is transient, and fires while nothing has streamed yet,
14
17
  // so the retry is always safe.
15
18
  import { ProviderError } from "./errors.ts";
19
+ import type {
20
+ ChatMessage,
21
+ Provider,
22
+ ProviderChunk,
23
+ StreamOptions,
24
+ ToolDefinition,
25
+ } from "./types.ts";
16
26
 
17
27
  /** No byte at all for this long and the stream is considered wedged. */
18
28
  export const STREAM_IDLE_MS = 60_000;
@@ -56,7 +66,9 @@ export function streamWatch(opts: StreamWatchOptions = {}): StreamWatch {
56
66
 
57
67
  // The bridge is structural rather than an event listener: AbortSignal.any
58
68
  // aborts synchronously when an input is ALREADY aborted, which is the race
59
- // no listener can catch (the event fired before we subscribed).
69
+ // no listener can catch (the event fired before we subscribed). It is also
70
+ // the package's runtime floor — see `engines` — rather than a polyfilled
71
+ // nicety: every runtime this package targets has had it for years.
60
72
  const signal = callerSignal ? AbortSignal.any([callerSignal, timeout.signal]) : timeout.signal;
61
73
 
62
74
  const idleError = (cause?: unknown) =>
@@ -117,3 +129,114 @@ export async function* watchChunks<T>(
117
129
  watch.dispose();
118
130
  }
119
131
  }
132
+
133
+ /**
134
+ * Reject a turn that completed but produced nothing usable.
135
+ *
136
+ * A stream that ends with no text, no reasoning and no tool call is a failure
137
+ * wearing a success's clothes: `stop_reason: end_turn` with zero content
138
+ * blocks, which the vendors emit under load and after a thinking block eats
139
+ * the whole `max_tokens`. Nothing throws, so nothing retries — the caller
140
+ * simply shows a person an empty answer, and the only trace is a bill.
141
+ *
142
+ * Classified `overload` because that is both true and useful: it is theirs and
143
+ * temporary, so it is transient (the same model, retried, usually answers) and
144
+ * backup-eligible (a model that keeps doing it should be walked away from).
145
+ * The throw lands before any chunk is yielded downstream, so the retry rule
146
+ * that matters — retry only while nothing was emitted — still holds.
147
+ */
148
+ export async function* requireContent<T extends ProviderChunk>(
149
+ provider: string,
150
+ chunks: AsyncIterable<T>,
151
+ ): AsyncGenerator<T> {
152
+ const held: T[] = [];
153
+ let usable = false;
154
+
155
+ for await (const chunk of chunks) {
156
+ if (!usable) {
157
+ usable = Boolean(chunk.content || chunk.reasoning || chunk.toolCalls?.length);
158
+ // Held rather than forwarded: once a chunk is out, the stream is
159
+ // committed and the retry this guard exists to trigger can no longer
160
+ // fire. Nothing content-bearing has arrived yet, so there is nothing to
161
+ // hold back but the empty frames.
162
+ if (!usable) {
163
+ held.push(chunk);
164
+ continue;
165
+ }
166
+ yield* held;
167
+ held.length = 0;
168
+ }
169
+ yield chunk;
170
+ }
171
+
172
+ if (!usable) {
173
+ throw new ProviderError(provider, "overload", `${provider}: completed with no content`);
174
+ }
175
+ }
176
+
177
+ export interface WatchdogOptions {
178
+ /** Silence this long and the stream is wedged. Defaults to `STREAM_IDLE_MS`. */
179
+ idleMs?: number;
180
+ /**
181
+ * Reject a turn that ends having said nothing, as `requireContent` does. On
182
+ * by default: an empty completion is a failure in every loop, and the one
183
+ * shaped like a success is the one nobody catches.
184
+ */
185
+ requireContent?: boolean;
186
+ /** Time to first byte for this call, reported once the byte arrives. */
187
+ onFirstChunk?: (ms: number) => void;
188
+ }
189
+
190
+ /**
191
+ * A provider with both silent failures already handled.
192
+ *
193
+ * Every consumer of this package wrote the same three lines around every
194
+ * `createStream` — build a watch, hand the provider the WATCH's signal, wrap
195
+ * the chunks — and the middle one is the trap. Pass the caller's signal
196
+ * instead and everything still compiles, still streams, still passes the
197
+ * tests: the watchdog simply never aborts anything, because the request it was
198
+ * meant to cancel was never told about it. The failure has no symptom until
199
+ * production, where it is the exact hang the watchdog was added to end.
200
+ *
201
+ * So the composition belongs here rather than in a docs snippet each app
202
+ * copies. The result is still a `Provider`, so it composes unchanged with
203
+ * `withStreamRetry` and `streamWithBackupModels` — and both of the failures it
204
+ * catches are transient, which is what makes wrapping it in a retry correct.
205
+ */
206
+ export function withWatchdog(provider: Provider, opts: WatchdogOptions = {}): Provider {
207
+ return {
208
+ ...provider,
209
+ createStream(
210
+ messages: ChatMessage[],
211
+ tools: ToolDefinition[],
212
+ streamOpts: StreamOptions = {},
213
+ ): AsyncIterable<ProviderChunk> {
214
+ // Armed on first read, not here: a stream built now and iterated later
215
+ // must not spend its deadline sitting in a variable.
216
+ async function* watched(): AsyncGenerator<ProviderChunk> {
217
+ // idleMs and signal both default inside streamWatch.
218
+ const watch = streamWatch({
219
+ provider: provider.id,
220
+ idleMs: opts.idleMs,
221
+ signal: streamOpts.signal,
222
+ });
223
+ const source = provider.createStream(messages, tools, {
224
+ ...streamOpts,
225
+ signal: watch.signal,
226
+ });
227
+ let reported = false;
228
+ for await (const chunk of watchChunks(watch, source)) {
229
+ // Before `requireContent` holds anything back — TTFT is the first
230
+ // byte of any kind, not the first byte worth showing.
231
+ if (!reported) {
232
+ reported = true;
233
+ opts.onFirstChunk?.(watch.firstChunkMs() ?? 0);
234
+ }
235
+ yield chunk;
236
+ }
237
+ }
238
+
239
+ return opts.requireContent === false ? watched() : requireContent(provider.id, watched());
240
+ },
241
+ };
242
+ }
package/src/zod.ts CHANGED
@@ -8,9 +8,19 @@ import { defineTool, type Tool, type ToolContext } from "./tools.ts";
8
8
  import { clampToSchema } from "./schema.ts";
9
9
  import type { JsonObjectSchema } from "./types.ts";
10
10
 
11
- /** A zod schema as the JSON Schema every provider's tool contract wants. */
11
+ /**
12
+ * A zod schema as the JSON Schema every provider's tool contract wants.
13
+ *
14
+ * `$schema` is dropped. zod emits the dialect URI at the root, and a provider
15
+ * validating a tool's `parameters` against its own supported subset — OpenAI
16
+ * under `strict: true`, Gemini's `parametersJsonSchema` — rejects the whole
17
+ * tool over that one key, with a message that names neither zod nor the field.
18
+ */
12
19
  export function toJsonObjectSchema(schema: z.ZodType, label = "schema"): JsonObjectSchema {
13
- const json = z.toJSONSchema(schema, { io: "input" }) as Record<string, unknown>;
20
+ const { $schema: _dialect, ...json } = z.toJSONSchema(schema, { io: "input" }) as Record<
21
+ string,
22
+ unknown
23
+ >;
14
24
  if (json.type !== "object") {
15
25
  // Every provider requires an object at the top level of a tool's
16
26
  // parameters; a bare string or array is rejected at the wire, far from