@providerkit/core 0.3.0 → 0.4.1

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/src/types.ts CHANGED
@@ -161,6 +161,13 @@ export type ToolChoice = "auto" | "none" | "required" | { name: string };
161
161
  export interface JsonOutput {
162
162
  name: string;
163
163
  schema: JsonObjectSchema;
164
+ /**
165
+ * Force OpenAI's strict schema mode on or off. Left unset, the adapters ask
166
+ * `isStrictSchema` and enforce whenever the schema actually qualifies —
167
+ * which is what keeps an optional field from turning a working call into a
168
+ * 400. Set it only to overrule that reading.
169
+ */
170
+ strict?: boolean;
164
171
  }
165
172
 
166
173
  export interface StreamOptions {
@@ -172,6 +179,20 @@ export interface StreamOptions {
172
179
  * silently truncated mid-argument. */
173
180
  maxTokens?: number;
174
181
  temperature?: number;
182
+ /**
183
+ * Nucleus sampling. Set this OR `temperature`, not both — the vendors all
184
+ * document them as alternatives and some reject the pair outright.
185
+ */
186
+ topP?: number;
187
+ /**
188
+ * Strings that end the turn when generated. The only four-shape sampling
189
+ * field beyond these two; `top_k`, `metadata` and the rest are one vendor's
190
+ * each and stay off the seam, where a caller reaching for them is asking for
191
+ * that vendor rather than for a provider.
192
+ *
193
+ * Not sent on the Responses shape, which has no equivalent.
194
+ */
195
+ stopSequences?: string[];
175
196
  signal?: AbortSignal;
176
197
  toolChoice?: ToolChoice;
177
198
  json?: JsonOutput;
@@ -190,6 +211,8 @@ export interface Provider {
190
211
  export interface Completion {
191
212
  text: string;
192
213
  reasoning: string;
214
+ /** Present only where the provider sent one. See ChatMessage.reasoningDetails. */
215
+ reasoningDetails?: unknown[];
193
216
  usage: TokenUsage;
194
217
  finishReason: FinishReason | null;
195
218
  model: string;
@@ -203,19 +226,32 @@ export async function drainStream(
203
226
  ): Promise<Completion> {
204
227
  let text = "";
205
228
  let reasoning = "";
229
+ // Not concatenated: this half of the record is a payload the provider owns,
230
+ // and it arrives whole on one delta rather than in fragments. Dropped here,
231
+ // a drained turn replays only half its own reasoning on the next round —
232
+ // which is the failure `reasoningDetails` exists to prevent.
233
+ let reasoningDetails: unknown[] | undefined;
206
234
  let usage: TokenUsage = EMPTY_USAGE;
207
235
  let finishReason: FinishReason | null = null;
208
236
  for await (const chunk of stream) {
209
237
  if (chunk.type === "delta") {
210
238
  if (chunk.content) text += chunk.content;
211
239
  if (chunk.reasoning) reasoning += chunk.reasoning;
240
+ if (chunk.reasoningDetails?.length) reasoningDetails = chunk.reasoningDetails;
212
241
  } else if (chunk.type === "usage" && chunk.usage) {
213
242
  usage = chunk.usage;
214
243
  } else if (chunk.type === "finish" && chunk.finishReason) {
215
244
  finishReason = chunk.finishReason;
216
245
  }
217
246
  }
218
- return { text, reasoning, usage, finishReason, model };
247
+ return {
248
+ text,
249
+ reasoning,
250
+ ...(reasoningDetails ? { reasoningDetails } : {}),
251
+ usage,
252
+ finishReason,
253
+ model,
254
+ };
219
255
  }
220
256
 
221
257
  /**
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
+ }