@providerkit/core 0.3.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.
- package/README.md +14 -8
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +14 -1
- package/dist/errors.js.map +1 -1
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +28 -2
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +4 -0
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +10 -1
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/responses.d.ts.map +1 -1
- package/dist/providers/responses.js +6 -1
- package/dist/providers/responses.js.map +1 -1
- package/dist/schema.d.ts +21 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +40 -0
- package/dist/schema.js.map +1 -1
- package/dist/types.d.ts +23 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +15 -1
- package/dist/types.js.map +1 -1
- package/dist/watchdog.d.ts +46 -0
- package/dist/watchdog.d.ts.map +1 -1
- package/dist/watchdog.js +92 -1
- package/dist/watchdog.js.map +1 -1
- package/package.json +4 -1
- package/src/errors.ts +13 -1
- package/src/providers/anthropic.ts +28 -1
- package/src/providers/gemini.ts +2 -0
- package/src/providers/openai.ts +8 -1
- package/src/providers/responses.ts +5 -1
- package/src/schema.ts +41 -0
- package/src/types.ts +37 -1
- package/src/watchdog.ts +124 -1
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
|
+
}
|