@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/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 +9 -2
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +20 -4
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/responses.d.ts.map +1 -1
- package/dist/providers/responses.js +16 -4
- 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 +18 -4
- package/src/providers/responses.ts +15 -4
- package/src/schema.ts +41 -0
- package/src/types.ts +37 -1
- package/src/watchdog.ts +124 -1
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 {
|
|
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
|
+
}
|