@providerkit/core 0.21.1 → 0.23.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 +7 -7
- package/dist/attribution.d.ts +2 -2
- package/dist/auth/flows.d.ts.map +1 -1
- package/dist/auth/flows.js +5 -5
- package/dist/auth/flows.js.map +1 -1
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +1 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/oauth.d.ts +12 -0
- package/dist/auth/oauth.d.ts.map +1 -1
- package/dist/auth/oauth.js +19 -1
- package/dist/auth/oauth.js.map +1 -1
- package/dist/auth/token-source.d.ts +9 -3
- package/dist/auth/token-source.d.ts.map +1 -1
- package/dist/auth/token-source.js +24 -9
- package/dist/auth/token-source.js.map +1 -1
- package/dist/auth/with-token.d.ts +29 -0
- package/dist/auth/with-token.d.ts.map +1 -0
- package/dist/auth/with-token.js +85 -0
- package/dist/auth/with-token.js.map +1 -0
- package/dist/capability.d.ts +9 -9
- package/dist/capability.js +9 -9
- package/dist/capability.js.map +1 -1
- package/dist/context.d.ts +43 -5
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +123 -4
- package/dist/context.js.map +1 -1
- package/dist/errors.d.ts +26 -17
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +57 -18
- package/dist/errors.js.map +1 -1
- package/dist/fallback.d.ts +45 -1
- package/dist/fallback.d.ts.map +1 -1
- package/dist/fallback.js +138 -75
- package/dist/fallback.js.map +1 -1
- package/dist/image.d.ts +1 -1
- package/dist/image.d.ts.map +1 -1
- package/dist/jev.d.ts +9 -9
- package/dist/jev.d.ts.map +1 -1
- package/dist/jev.js +1 -1
- package/dist/key-pool.d.ts +14 -8
- package/dist/key-pool.d.ts.map +1 -1
- package/dist/key-pool.js +35 -18
- package/dist/key-pool.js.map +1 -1
- package/dist/presets.d.ts +33 -24
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +25 -14
- package/dist/presets.js.map +1 -1
- package/dist/providers/anthropic.d.ts +5 -5
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +89 -34
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/factory.d.ts +2 -2
- package/dist/providers/factory.d.ts.map +1 -1
- package/dist/providers/factory.js +2 -1
- package/dist/providers/factory.js.map +1 -1
- package/dist/providers/gemini.d.ts +8 -2
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +117 -41
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/openai.d.ts +12 -9
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +72 -21
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/responses.d.ts +3 -3
- package/dist/providers/responses.d.ts.map +1 -1
- package/dist/providers/responses.js +103 -57
- package/dist/providers/responses.js.map +1 -1
- package/dist/providers/zai.d.ts +2 -2
- package/dist/providers/zai.js +2 -2
- package/dist/rate-limit.d.ts +13 -3
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +10 -4
- package/dist/rate-limit.js.map +1 -1
- package/dist/retry.d.ts +16 -5
- package/dist/retry.d.ts.map +1 -1
- package/dist/retry.js +29 -16
- package/dist/retry.js.map +1 -1
- package/dist/schema.d.ts +6 -6
- package/dist/schema.js +8 -8
- package/dist/tool-args.d.ts +23 -1
- package/dist/tool-args.d.ts.map +1 -1
- package/dist/tool-args.js +30 -2
- package/dist/tool-args.js.map +1 -1
- package/dist/tools.d.ts +3 -3
- package/dist/tools.js +2 -2
- package/dist/tools.js.map +1 -1
- package/dist/transport.d.ts +40 -10
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +82 -21
- package/dist/transport.js.map +1 -1
- package/dist/types.d.ts +37 -23
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +3 -11
- package/dist/types.js.map +1 -1
- package/dist/usage.d.ts +4 -4
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +3 -3
- package/dist/usage.js.map +1 -1
- package/dist/watchdog.d.ts +14 -12
- package/dist/watchdog.d.ts.map +1 -1
- package/dist/watchdog.js +35 -16
- package/dist/watchdog.js.map +1 -1
- package/dist/zod.d.ts +4 -4
- package/dist/zod.js +3 -3
- package/package.json +1 -1
- package/src/attribution.ts +2 -2
- package/src/auth/flows.ts +5 -4
- package/src/auth/index.ts +1 -0
- package/src/auth/oauth.ts +23 -1
- package/src/auth/token-source.ts +30 -11
- package/src/auth/with-token.ts +97 -0
- package/src/capability.ts +12 -12
- package/src/context.ts +141 -6
- package/src/errors.ts +63 -23
- package/src/fallback.ts +173 -75
- package/src/image.ts +1 -1
- package/src/jev.ts +11 -11
- package/src/key-pool.ts +37 -20
- package/src/presets.ts +38 -24
- package/src/providers/anthropic.ts +97 -38
- package/src/providers/factory.ts +4 -3
- package/src/providers/gemini.ts +121 -44
- package/src/providers/openai.ts +78 -25
- package/src/providers/responses.ts +113 -61
- package/src/providers/zai.ts +2 -2
- package/src/rate-limit.ts +35 -7
- package/src/retry.ts +46 -15
- package/src/schema.ts +8 -8
- package/src/tool-args.ts +46 -2
- package/src/tools.ts +4 -4
- package/src/transport.ts +103 -23
- package/src/types.ts +37 -23
- package/src/usage.ts +5 -5
- package/src/watchdog.ts +38 -19
- package/src/zod.ts +4 -4
package/src/tool-args.ts
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
// `\uXXXX`, which JSON.parse decodes correctly. Some escape it twice and
|
|
13
13
|
// emit `\\u00e7`, so even a clean parse leaves six literal characters
|
|
14
14
|
// standing and a Portuguese answer reaches the user as `atenção`.
|
|
15
|
+
import { ProviderError } from "./errors.ts";
|
|
16
|
+
import type { ToolCallDelta } from "./types.ts";
|
|
15
17
|
|
|
16
18
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
17
19
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -58,7 +60,7 @@ function unescapeJson(text: string): string {
|
|
|
58
60
|
/**
|
|
59
61
|
* Best-effort recovery of `"key": "value"` string fields from truncated JSON.
|
|
60
62
|
*
|
|
61
|
-
* Only strings: they are what a summary field
|
|
63
|
+
* Only strings: they are what a summary field (the one worth rescuing) is
|
|
62
64
|
* made of, and a closing quote is the single reliable boundary in a partial
|
|
63
65
|
* stream. Numbers, booleans and objects are dropped, because a salvaged
|
|
64
66
|
* half-value is worse than none.
|
|
@@ -129,6 +131,48 @@ export function normalizeToolId(id: string | undefined | null, prefix = "call_pk
|
|
|
129
131
|
return clean;
|
|
130
132
|
}
|
|
131
133
|
|
|
134
|
+
/** A tool call's identity as a stream stated it: the last id and name it gave. */
|
|
135
|
+
export interface ToolCallIdentity {
|
|
136
|
+
index: number;
|
|
137
|
+
id: string;
|
|
138
|
+
name: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Make a turn's tool calls runnable before its finish goes out, or fail the turn.
|
|
143
|
+
*
|
|
144
|
+
* A call with no name can't run: nothing says which tool to dispatch. It used to
|
|
145
|
+
* pass through (the Messages reader wrote `""` for both fields), and the loop
|
|
146
|
+
* failed one step later with nothing to say why. It throws `overload` now, a
|
|
147
|
+
* broken turn the same request usually does not repeat; cc-proxy answers it
|
|
148
|
+
* with a 502.
|
|
149
|
+
*
|
|
150
|
+
* A call with no id still can run. It gets one from `normalizeToolId`, written
|
|
151
|
+
* back into `calls` and returned as the deltas to send, so the result has an id
|
|
152
|
+
* to quote. An id the vendor did send is never rewritten: the four wires must
|
|
153
|
+
* agree on it (invariant 10), and repeated ids are history repair's job.
|
|
154
|
+
*/
|
|
155
|
+
export function settleToolCalls(
|
|
156
|
+
provider: string,
|
|
157
|
+
calls: Iterable<ToolCallIdentity>,
|
|
158
|
+
): ToolCallDelta[] {
|
|
159
|
+
const minted: ToolCallDelta[] = [];
|
|
160
|
+
for (const call of calls) {
|
|
161
|
+
if (!call.name.trim()) {
|
|
162
|
+
throw new ProviderError(
|
|
163
|
+
provider,
|
|
164
|
+
"overload",
|
|
165
|
+
`${provider}: tool call ${call.index} came with no name, so no tool can run it`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (!call.id.trim()) {
|
|
169
|
+
call.id = normalizeToolId(call.id);
|
|
170
|
+
minted.push({ index: call.index, id: call.id });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return minted;
|
|
174
|
+
}
|
|
175
|
+
|
|
132
176
|
/**
|
|
133
177
|
* Parse a tool call's raw argument string into an object, salvaging what a
|
|
134
178
|
* truncated stream left behind, resolving concatenated JSON, and healing
|
|
@@ -151,7 +195,7 @@ export function parseToolArgs(raw: string): Record<string, unknown> {
|
|
|
151
195
|
return healArgs(isRecord(parsed) ? parsed : {});
|
|
152
196
|
}
|
|
153
197
|
|
|
154
|
-
/** Whether `raw` parses at all
|
|
198
|
+
/** Whether `raw` parses at all. Lets a caller tell a truncated tool call from
|
|
155
199
|
* an intact one, since `parseToolArgs` deliberately never throws. */
|
|
156
200
|
export function isCompleteJson(raw: string): boolean {
|
|
157
201
|
if (!raw.trim()) return false;
|
package/src/tools.ts
CHANGED
|
@@ -13,7 +13,7 @@ import type { JsonObjectSchema, ToolDefinition } from "./types.ts";
|
|
|
13
13
|
import { messageOf } from "./errors.ts";
|
|
14
14
|
|
|
15
15
|
export interface ToolContext {
|
|
16
|
-
/** The model's own call id when there is one
|
|
16
|
+
/** The model's own call id when there is one. The event, any approval row
|
|
17
17
|
* and the tool message must all key on what the provider expects back. */
|
|
18
18
|
callId?: string;
|
|
19
19
|
signal?: AbortSignal;
|
|
@@ -29,7 +29,7 @@ export type ToolOutcome<O> =
|
|
|
29
29
|
ok: false;
|
|
30
30
|
callId: string;
|
|
31
31
|
kind: ToolFailure;
|
|
32
|
-
/** Fed back to the model verbatim
|
|
32
|
+
/** Fed back to the model verbatim, so it must read as an instruction to
|
|
33
33
|
* a reader who cannot see our stack trace. */
|
|
34
34
|
error: string;
|
|
35
35
|
durationMs: number;
|
|
@@ -83,7 +83,7 @@ export class ToolTimeoutError extends Error {
|
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
/** Rejects when `signal` aborts
|
|
86
|
+
/** Rejects when `signal` aborts. Races a `run` that ignores its own signal. */
|
|
87
87
|
function abortion(signal: AbortSignal): Promise<never> {
|
|
88
88
|
return new Promise((_resolve, reject) => {
|
|
89
89
|
if (signal.aborted) reject(signal.reason);
|
|
@@ -220,7 +220,7 @@ export class ToolRegistry {
|
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
/**
|
|
223
|
-
* Definitions for an allow-list, in the order given
|
|
223
|
+
* Definitions for an allow-list, in the order given. This is the order the
|
|
224
224
|
* model reads them in, and it is part of the cached prompt prefix. Reordering
|
|
225
225
|
* or appending mid-conversation invalidates that prefix, so a caller that
|
|
226
226
|
* cares should freeze the list when the session opens.
|
package/src/transport.ts
CHANGED
|
@@ -4,8 +4,12 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Adapters keep only their per-event mapping; everything about being an HTTP
|
|
6
6
|
// client lives here once.
|
|
7
|
-
import { classifyHttp,
|
|
8
|
-
import {
|
|
7
|
+
import { classifyHttp, parseRetryAfterMs, ProviderError } from "./errors.ts";
|
|
8
|
+
import {
|
|
9
|
+
parseRateLimitReset,
|
|
10
|
+
parseRateLimitResponse,
|
|
11
|
+
type RateLimitReading,
|
|
12
|
+
} from "./rate-limit.ts";
|
|
9
13
|
|
|
10
14
|
export interface RequestInit_ {
|
|
11
15
|
url: string;
|
|
@@ -16,11 +20,16 @@ export interface RequestInit_ {
|
|
|
16
20
|
signal?: AbortSignal;
|
|
17
21
|
/** Swapped in tests, or to route through a proxy. Defaults to global fetch. */
|
|
18
22
|
fetchImpl?: typeof fetch;
|
|
19
|
-
/** Called when the response headers arrive and on every body read after
|
|
23
|
+
/** Called when the response headers arrive and on every body read after,
|
|
20
24
|
* keep-alives included. What the watchdog's idle clock runs on. */
|
|
21
25
|
onActivity?: () => void;
|
|
22
26
|
/** Called when the body states `[DONE]`. See `SseHooks.onDone`. */
|
|
23
27
|
onDone?: () => void;
|
|
28
|
+
/** Called when the response headers report how full a subscription window
|
|
29
|
+
* is. See `StreamOptions.onRateLimit`. */
|
|
30
|
+
onRateLimit?: (reading: RateLimitReading) => void;
|
|
31
|
+
/** Whether the turn already stated its finish. See `SseHooks.finished`. */
|
|
32
|
+
finished?: () => boolean;
|
|
24
33
|
}
|
|
25
34
|
|
|
26
35
|
export interface SseHooks {
|
|
@@ -33,6 +42,18 @@ export interface SseHooks {
|
|
|
33
42
|
* was not simply cut off.
|
|
34
43
|
*/
|
|
35
44
|
onDone?: () => void;
|
|
45
|
+
/**
|
|
46
|
+
* Whether the turn already stated its finish, so only trailing frames (the
|
|
47
|
+
* usage record, `[DONE]`) are left. Once this says true, a read that fails
|
|
48
|
+
* ends the stream instead of throwing. Kimi, GLM and OpenCode Go drop the
|
|
49
|
+
* socket there, and the failure threw away an answer the reader had already
|
|
50
|
+
* been shown (cc-proxy, measured on live traffic). The usage may be lost;
|
|
51
|
+
* the answer is not.
|
|
52
|
+
*/
|
|
53
|
+
finished?: () => boolean;
|
|
54
|
+
/** The request's signal. A caller's Stop is never swallowed, finished or not:
|
|
55
|
+
* a loop that sees a clean end goes on to run the turn's tools. */
|
|
56
|
+
signal?: AbortSignal;
|
|
36
57
|
/** Largest partial frame held before giving up. Defaults to 8 MiB. */
|
|
37
58
|
maxFrameChars?: number;
|
|
38
59
|
/** Names the provider on the oversized-frame error. */
|
|
@@ -41,8 +62,8 @@ export interface SseHooks {
|
|
|
41
62
|
|
|
42
63
|
/**
|
|
43
64
|
* One frame larger than this is not a frame. Without a cap a malformed or
|
|
44
|
-
* runaway upstream grows the buffer until the runtime dies
|
|
45
|
-
*
|
|
65
|
+
* runaway upstream grows the buffer until the runtime dies, starting with an
|
|
66
|
+
* MV3 worker. cc-proxy caps the ChatGPT backend at the same 8 MiB.
|
|
46
67
|
*/
|
|
47
68
|
export const MAX_SSE_FRAME_CHARS = 8 * 1024 * 1024;
|
|
48
69
|
|
|
@@ -53,7 +74,7 @@ export function apiUrl(baseUrl: string, path: string): string {
|
|
|
53
74
|
|
|
54
75
|
/**
|
|
55
76
|
* How long the provider says to wait, read from the RESPONSE rather than from
|
|
56
|
-
* a thrown error. Headers are authoritative
|
|
77
|
+
* a thrown error. Headers are authoritative: `Retry-After` first, then the
|
|
57
78
|
* vendor reset headers that name a subscription window rather than a
|
|
58
79
|
* per-minute throttle, because "try again in a moment" is a lie for those.
|
|
59
80
|
*/
|
|
@@ -120,18 +141,25 @@ async function errorFor(provider: string, res: Response): Promise<ProviderError>
|
|
|
120
141
|
|
|
121
142
|
async function send(opts: RequestInit_): Promise<Response> {
|
|
122
143
|
const doFetch = opts.fetchImpl ?? globalThis.fetch;
|
|
144
|
+
// Serialized outside the catch below: a body that cannot become JSON is a
|
|
145
|
+
// bug of ours, and must not be dressed as a network blip and retried.
|
|
146
|
+
const body = typeof opts.body === "string" ? opts.body : JSON.stringify(opts.body);
|
|
123
147
|
try {
|
|
124
148
|
return await doFetch(opts.url, {
|
|
125
149
|
method: "POST",
|
|
126
150
|
headers: { "content-type": "application/json", ...opts.headers },
|
|
127
|
-
body
|
|
151
|
+
body,
|
|
128
152
|
signal: opts.signal,
|
|
129
153
|
});
|
|
130
154
|
} catch (err) {
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
155
|
+
// An aborted signal passes through untouched: the caller's Stop stays
|
|
156
|
+
// `aborted`, and the watchdog re-issues its own abort as `timeout`
|
|
157
|
+
// (invariant 2). Every other rejection means no response came back, so it
|
|
158
|
+
// is a transport failure whatever its wording. Matching known wordings
|
|
159
|
+
// missed each runtime's next one, and a missed one was never retried: a
|
|
160
|
+
// long run died on its first blip. cc-proxy reads every failed send the
|
|
161
|
+
// same way.
|
|
162
|
+
if (opts.signal?.aborted) throw err;
|
|
135
163
|
throw new ProviderError(opts.provider, "network", `no response from ${opts.url}`, {
|
|
136
164
|
cause: err,
|
|
137
165
|
});
|
|
@@ -168,21 +196,22 @@ function payloadOf(frame: string): string | null {
|
|
|
168
196
|
}
|
|
169
197
|
|
|
170
198
|
/**
|
|
171
|
-
* Yield each `data:` payload of an SSE body, trimmed
|
|
199
|
+
* Yield each `data:` payload of an SSE body, trimmed, plus the bare JSON
|
|
172
200
|
* object a vendor appends outside the framing, which is only ever an error
|
|
173
201
|
* (see `payloadOf`).
|
|
174
202
|
*
|
|
175
203
|
* Frames are split on the blank line the spec requires, so a payload containing
|
|
176
|
-
* a bare newline survives
|
|
177
|
-
* adapter,
|
|
178
|
-
*
|
|
179
|
-
*
|
|
204
|
+
* a bare newline survives. `[DONE]` is swallowed here rather than in every
|
|
205
|
+
* adapter, reported through `hooks.onDone`, and ends the stream: it is the
|
|
206
|
+
* body's last word, and a read after it can only fail a finished turn. CRLF and
|
|
207
|
+
* bare CR are normalized: some gateways send them, and a `\r` left on the end
|
|
208
|
+
* of a JSON payload is a parse error nobody enjoys debugging.
|
|
180
209
|
*
|
|
181
210
|
* Exported apart from `streamSse` because the envelope above it is the half an
|
|
182
211
|
* adopting app most often cannot take: an app with its own translated error
|
|
183
212
|
* copy, its own log levels, or its own auth refresh has to keep building the
|
|
184
213
|
* request and reading the failure itself. Framing is the half nobody should
|
|
185
|
-
* write twice
|
|
214
|
+
* write twice. Hand it a `res.body` and keep your own envelope.
|
|
186
215
|
*/
|
|
187
216
|
export async function* parseSseStream(
|
|
188
217
|
body: ReadableStream<Uint8Array>,
|
|
@@ -195,17 +224,36 @@ export async function* parseSseStream(
|
|
|
195
224
|
// A CR at the very end of a read may be the first half of a CRLF; it waits
|
|
196
225
|
// for the next read before it is turned into a line break.
|
|
197
226
|
let heldCR = false;
|
|
227
|
+
// Whether the body was read to its end. Any other exit (`[DONE]`, a failed
|
|
228
|
+
// read, a consumer that stopped at its own terminal event) cancels it, so the
|
|
229
|
+
// connection is let go now rather than whenever the body is collected.
|
|
230
|
+
let drained = false;
|
|
198
231
|
|
|
199
|
-
|
|
232
|
+
/** Yields the frame's payload, and returns true when the frame was `[DONE]`. */
|
|
233
|
+
function* emit(frame: string): Generator<string, boolean> {
|
|
200
234
|
const payload = payloadOf(frame);
|
|
201
|
-
if (payload === "[DONE]")
|
|
202
|
-
|
|
235
|
+
if (payload === "[DONE]") {
|
|
236
|
+
hooks.onDone?.();
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
if (payload !== null) yield payload;
|
|
240
|
+
return false;
|
|
203
241
|
}
|
|
204
242
|
|
|
205
243
|
try {
|
|
206
244
|
for (;;) {
|
|
207
|
-
|
|
208
|
-
|
|
245
|
+
let read: ReadableStreamReadResult<Uint8Array>;
|
|
246
|
+
try {
|
|
247
|
+
read = await reader.read();
|
|
248
|
+
} catch (err) {
|
|
249
|
+
if (hooks.finished?.() && !hooks.signal?.aborted) return;
|
|
250
|
+
throw err;
|
|
251
|
+
}
|
|
252
|
+
const { done, value } = read;
|
|
253
|
+
if (done) {
|
|
254
|
+
drained = true;
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
209
257
|
hooks.onActivity?.();
|
|
210
258
|
let text: string = (heldCR ? "\r" : "") + decoder.decode(value, { stream: true });
|
|
211
259
|
heldCR = text.endsWith("\r");
|
|
@@ -222,7 +270,7 @@ export async function* parseSseStream(
|
|
|
222
270
|
|
|
223
271
|
let boundary = buffer.indexOf("\n\n", from);
|
|
224
272
|
while (boundary !== -1) {
|
|
225
|
-
yield* emit(buffer.slice(0, boundary));
|
|
273
|
+
if (yield* emit(buffer.slice(0, boundary))) return;
|
|
226
274
|
buffer = buffer.slice(boundary + 2);
|
|
227
275
|
from = 0;
|
|
228
276
|
boundary = buffer.indexOf("\n\n");
|
|
@@ -240,10 +288,32 @@ export async function* parseSseStream(
|
|
|
240
288
|
// hand — dropping it loses the last delta, or the usage record.
|
|
241
289
|
yield* emit(buffer + decoder.decode() + (heldCR ? "\n" : ""));
|
|
242
290
|
} finally {
|
|
291
|
+
if (!drained) reader.cancel().catch(() => {});
|
|
243
292
|
reader.releaseLock();
|
|
244
293
|
}
|
|
245
294
|
}
|
|
246
295
|
|
|
296
|
+
/**
|
|
297
|
+
* Parse one SSE payload as the JSON event it must be.
|
|
298
|
+
*
|
|
299
|
+
* A frame that is not JSON used to be skipped as "a keep-alive or a frame we do
|
|
300
|
+
* not model". But `parseSseStream` already drops comments and empty frames,
|
|
301
|
+
* which is every keep-alive the vendors send, so what reaches here unparsable
|
|
302
|
+
* is a broken stream: a delta, a tool call or the finish itself, gone, with the
|
|
303
|
+
* turn still reading as whole. It fails as `network`, the same as a socket that
|
|
304
|
+
* dies mid-read (cc-proxy fails these too).
|
|
305
|
+
*/
|
|
306
|
+
export function parseFrame<T>(data: string, provider: string): T {
|
|
307
|
+
try {
|
|
308
|
+
return JSON.parse(data) as T;
|
|
309
|
+
} catch (err) {
|
|
310
|
+
throw new ProviderError(provider, "network", `${provider}: a stream frame was not JSON`, {
|
|
311
|
+
body: data.slice(0, 2_000),
|
|
312
|
+
cause: err,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
247
317
|
/**
|
|
248
318
|
* POST an SSE request and stream its payloads: the envelope (auth, the
|
|
249
319
|
* classified error, the retry hints) plus `parseSseStream`.
|
|
@@ -254,6 +324,16 @@ export async function* streamSse(opts: RequestInit_): AsyncGenerator<string> {
|
|
|
254
324
|
// the POST: a backend that withholds them until its first output token (the
|
|
255
325
|
// ChatGPT backend does, for minutes on a large high-effort turn) is working.
|
|
256
326
|
opts.onActivity?.();
|
|
327
|
+
// The window's fill rides every response, the ones that go through too.
|
|
328
|
+
// Read only off a refusal, a caller learns the window was filling once it
|
|
329
|
+
// is already full.
|
|
330
|
+
if (opts.onRateLimit) {
|
|
331
|
+
const reading = parseRateLimitReset(res.headers);
|
|
332
|
+
const { utilization, resetAtMs } = reading;
|
|
333
|
+
if (utilization !== undefined && resetAtMs !== undefined) {
|
|
334
|
+
opts.onRateLimit({ ...reading, utilization, resetAtMs });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
257
337
|
if (!res.ok) throw await errorFor(opts.provider, res);
|
|
258
338
|
// A 2xx with no body at all is an upstream anomaly, not a request we got
|
|
259
339
|
// wrong — worth the same retry a 5xx gets.
|
package/src/types.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// the lingua franca forces the other adapters to round-trip through a dialect
|
|
7
7
|
// that isn't theirs, and every quirk of that dialect then leaks into callers
|
|
8
8
|
// who never asked for it.
|
|
9
|
+
import type { RateLimitReading } from "./rate-limit.ts";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* How hard the model thinks before answering. Absent = the provider's own
|
|
@@ -14,7 +15,7 @@
|
|
|
14
15
|
* `output_config.effort` for current Claude models, where `none` is the lowest
|
|
15
16
|
* effort on a model that cannot stop thinking; and to thinking budgets for
|
|
16
17
|
* Claude 4.5 and older and for every other vendor on that wire.
|
|
17
|
-
* Support varies per model
|
|
18
|
+
* Support varies per model. An unsupported level comes back as a clean 400.
|
|
18
19
|
*
|
|
19
20
|
* Ordered least → most; the type derives from the array so the runtime guard
|
|
20
21
|
* and the union can never drift apart.
|
|
@@ -34,7 +35,7 @@ export interface TextPart {
|
|
|
34
35
|
text: string;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
/** An image the model looks at (vision)
|
|
38
|
+
/** An image the model looks at (vision), with bytes as base64, never a URL the
|
|
38
39
|
* provider would have to fetch on our behalf. */
|
|
39
40
|
export interface ImagePart {
|
|
40
41
|
type: "image";
|
|
@@ -48,7 +49,7 @@ export type FileMimeType =
|
|
|
48
49
|
`application/${string}` | `audio/${string}` | `video/${string}` | `text/${string}`;
|
|
49
50
|
|
|
50
51
|
/**
|
|
51
|
-
* A file the model reads
|
|
52
|
+
* A file the model reads (a PDF, a recording, a video) as base64 bytes,
|
|
52
53
|
* never a URL. Only the Gemini adapter sends one, as `inlineData`, the way it
|
|
53
54
|
* sends an image. Every other adapter refuses it with an `invalid`
|
|
54
55
|
* ProviderError before any request goes out, rather than drop it: a dropped
|
|
@@ -94,7 +95,7 @@ export type ChatMessage =
|
|
|
94
95
|
* OpenAI-shape serializes it as `reasoning_content`; Anthropic-shape
|
|
95
96
|
* drops it, since its thinking blocks carry signatures we never capture.
|
|
96
97
|
*
|
|
97
|
-
* A turn that
|
|
98
|
+
* A turn that disables thinking must not carry it. Mixing the two is
|
|
98
99
|
* unsupported. `stripReasoning` below is that rule, once.
|
|
99
100
|
*/
|
|
100
101
|
reasoning?: string;
|
|
@@ -102,7 +103,7 @@ export type ChatMessage =
|
|
|
102
103
|
* OpenRouter's normalized reasoning payload, arriving on the stream and
|
|
103
104
|
* riding back UNMODIFIED on the next turn's assistant message.
|
|
104
105
|
*
|
|
105
|
-
* Opaque on purpose
|
|
106
|
+
* Opaque on purpose, with the same contract as Gemini's `thoughtSignature`,
|
|
106
107
|
* and for the same reason: it is the provider's own record of how it got
|
|
107
108
|
* here, and reading, reshaping or dropping it costs the model its
|
|
108
109
|
* continuity across a tool round. Absent on every other dialect.
|
|
@@ -133,7 +134,7 @@ export interface ReasoningItems {
|
|
|
133
134
|
items: unknown[];
|
|
134
135
|
}
|
|
135
136
|
|
|
136
|
-
/** JSON Schema for an object
|
|
137
|
+
/** JSON Schema for an object, as every provider's tool contract requires. */
|
|
137
138
|
export interface JsonObjectSchema {
|
|
138
139
|
type: "object";
|
|
139
140
|
properties?: Record<string, unknown>;
|
|
@@ -161,19 +162,19 @@ export interface ToolCallDelta {
|
|
|
161
162
|
|
|
162
163
|
export interface TokenUsage {
|
|
163
164
|
inputTokens: number;
|
|
164
|
-
/** Cache-
|
|
165
|
+
/** Cache-hit subset of `inputTokens`. Providers auto-cache repeated
|
|
165
166
|
* prefixes and bill the hit portion far cheaper, so it must be tracked
|
|
166
167
|
* separately to cost a turn correctly. 0 when the provider reports none. */
|
|
167
168
|
cachedInputTokens: number;
|
|
168
|
-
/** Tokens
|
|
169
|
+
/** Tokens written to cache, also a subset of `inputTokens`, so the total
|
|
169
170
|
* stays the whole prompt the window has to hold. Anthropic bills these
|
|
170
171
|
* above the input rate; the OpenAI-shape auto-cachers bill them at it.
|
|
171
172
|
* 0 when not reported. */
|
|
172
173
|
cacheWriteTokens?: number;
|
|
173
174
|
outputTokens: number;
|
|
174
175
|
/**
|
|
175
|
-
* The part of `outputTokens` spent thinking, when the provider reports it
|
|
176
|
-
*
|
|
176
|
+
* The part of `outputTokens` spent thinking, when the provider reports it.
|
|
177
|
+
* Already inside `outputTokens`, so never add it again. It is the number
|
|
177
178
|
* that explains a turn that came back empty with a length finish: the
|
|
178
179
|
* reasoning ate the budget. Anthropic does not report it; absent there.
|
|
179
180
|
*/
|
|
@@ -195,7 +196,7 @@ export const EMPTY_USAGE: TokenUsage = {
|
|
|
195
196
|
outputTokens: 0,
|
|
196
197
|
};
|
|
197
198
|
|
|
198
|
-
/** Normalized streaming chunk
|
|
199
|
+
/** Normalized streaming chunk. Every provider's shape maps to this. */
|
|
199
200
|
export interface ProviderChunk {
|
|
200
201
|
type: "delta" | "usage" | "finish";
|
|
201
202
|
/** The selected endpoint and model when a fallback provider emits this chunk.
|
|
@@ -203,7 +204,7 @@ export interface ProviderChunk {
|
|
|
203
204
|
source?: { provider: string; model: string };
|
|
204
205
|
content?: string;
|
|
205
206
|
reasoning?: string;
|
|
206
|
-
/** OpenRouter's normalized reasoning payload
|
|
207
|
+
/** OpenRouter's normalized reasoning payload. Hand it back on the next
|
|
207
208
|
* turn's assistant message verbatim. See ChatMessage.reasoningDetails. */
|
|
208
209
|
reasoningDetails?: unknown[];
|
|
209
210
|
/** Once per turn, where a provider replays its reasoning. See ChatMessage.reasoningItems. */
|
|
@@ -213,22 +214,22 @@ export interface ProviderChunk {
|
|
|
213
214
|
finishReason?: FinishReason;
|
|
214
215
|
}
|
|
215
216
|
|
|
216
|
-
/** Pin or deny tool use. `{ name }` forces one specific tool
|
|
217
|
+
/** Pin or deny tool use. `{ name }` forces one specific tool, so a run is
|
|
217
218
|
* made to commit an answer at its step budget's edge. */
|
|
218
219
|
export type ToolChoice = "auto" | "none" | "required" | { name: string };
|
|
219
220
|
|
|
220
221
|
/**
|
|
221
222
|
* Ask for a JSON object matching `schema`. Providers that enforce schemas get
|
|
222
223
|
* it verbatim; the rest get JSON mode plus the schema in the prompt. Either
|
|
223
|
-
* way the
|
|
224
|
+
* way the caller validates: a provider's "guaranteed" JSON is not one.
|
|
224
225
|
*/
|
|
225
226
|
export interface JsonOutput {
|
|
226
227
|
name: string;
|
|
227
228
|
schema: JsonObjectSchema;
|
|
228
229
|
/**
|
|
229
230
|
* Force OpenAI's strict schema mode on or off. Left unset, the adapters ask
|
|
230
|
-
* `isStrictSchema` and enforce whenever the schema actually qualifies
|
|
231
|
-
*
|
|
231
|
+
* `isStrictSchema` and enforce whenever the schema actually qualifies.
|
|
232
|
+
* This keeps an optional field from turning a working call into a
|
|
232
233
|
* 400. Set it only to overrule that reading.
|
|
233
234
|
*/
|
|
234
235
|
strict?: boolean;
|
|
@@ -339,13 +340,13 @@ function bracketed(text: string): string {
|
|
|
339
340
|
/**
|
|
340
341
|
* How the schema rides on a call that ALSO carries tools.
|
|
341
342
|
*
|
|
342
|
-
* `"response_format"` sends both
|
|
343
|
+
* `"response_format"` sends both, as the shapes document and most models
|
|
343
344
|
* honour. `"prompt"` leaves the format off that call and sends the schema as
|
|
344
345
|
* prompt instead, which is what the Anthropic shape has always done.
|
|
345
346
|
*
|
|
346
347
|
* It is a setting because a model that cannot serve both never says so: its
|
|
347
348
|
* decoder is pinned to the schema, the tool call has nowhere to go, and it
|
|
348
|
-
* writes the announcement instead
|
|
349
|
+
* writes the announcement instead ("let me look that up") and stops. Nothing
|
|
349
350
|
* is logged, because nothing failed. `probeJsonWithTools` answers it in one
|
|
350
351
|
* call; there is no default that is right for every model.
|
|
351
352
|
*/
|
|
@@ -359,9 +360,10 @@ export interface StreamOptions {
|
|
|
359
360
|
* task emitting a large artifact must raise it or the tool-call JSON is
|
|
360
361
|
* silently truncated mid-argument. */
|
|
361
362
|
maxTokens?: number;
|
|
363
|
+
/** Not sent to Gemini, which fixes its own sampling. Same for `topP`. */
|
|
362
364
|
temperature?: number;
|
|
363
365
|
/**
|
|
364
|
-
* Nucleus sampling. Set this
|
|
366
|
+
* Nucleus sampling. Set this or `temperature`, not both. The vendors all
|
|
365
367
|
* document them as alternatives and some reject the pair outright.
|
|
366
368
|
*/
|
|
367
369
|
topP?: number;
|
|
@@ -378,7 +380,7 @@ export interface StreamOptions {
|
|
|
378
380
|
toolChoice?: ToolChoice;
|
|
379
381
|
json?: JsonOutput;
|
|
380
382
|
/**
|
|
381
|
-
* Overrides the provider's own setting for this call
|
|
383
|
+
* Overrides the provider's own setting for this call, so `probeJsonWithTools`
|
|
382
384
|
* asks the same model both ways. See {@link JsonWithTools}.
|
|
383
385
|
*/
|
|
384
386
|
jsonWithTools?: JsonWithTools;
|
|
@@ -397,7 +399,19 @@ export interface StreamOptions {
|
|
|
397
399
|
*/
|
|
398
400
|
onActivity?: () => void;
|
|
399
401
|
/**
|
|
400
|
-
*
|
|
402
|
+
* Called once per response whose headers report how full a subscription
|
|
403
|
+
* window is, whatever its status: `utilization` (1 is full), `resetAtMs`,
|
|
404
|
+
* and `window` when the headers name it. A caller sees the window fill
|
|
405
|
+
* turn by turn, instead of learning about it from the 429 that ends a run.
|
|
406
|
+
*
|
|
407
|
+
* Every adapter forwards it. Two backends send the headers today: Claude
|
|
408
|
+
* subscriptions (Anthropic's unified 5h/7d headers) and the ChatGPT backend
|
|
409
|
+
* (`x-codex-*`). A response that reports no window never calls it, which
|
|
410
|
+
* covers API-key accounts on every vendor and all of Gemini.
|
|
411
|
+
*/
|
|
412
|
+
onRateLimit?: (reading: RateLimitReading) => void;
|
|
413
|
+
/**
|
|
414
|
+
* `false` asks for at most one tool call per turn, for tools that must run
|
|
401
415
|
* in order, or a backend lane that refuses parallel calls. Absent or `true`
|
|
402
416
|
* is every vendor's default and sends nothing. Gemini has no such switch,
|
|
403
417
|
* so it refuses `false` rather than silently ignore it.
|
|
@@ -488,7 +502,7 @@ export async function drainStream(
|
|
|
488
502
|
*
|
|
489
503
|
* The chain-of-thought belongs only to thinking turns. A provider that
|
|
490
504
|
* requires it replayed while thinking is ON (DeepSeek) rejects it when
|
|
491
|
-
* thinking is
|
|
505
|
+
* thinking is off. This is exactly the shape of a forced-submit salvage
|
|
492
506
|
* turn, where a run reasons through its whole investigation and then drops
|
|
493
507
|
* thinking to serialize what it already found.
|
|
494
508
|
*
|
|
@@ -518,7 +532,7 @@ export function stripReasoning(messages: readonly ChatMessage[]): ChatMessage[]
|
|
|
518
532
|
});
|
|
519
533
|
}
|
|
520
534
|
|
|
521
|
-
/** `data:` URI for an image part
|
|
535
|
+
/** `data:` URI for an image part, as the OpenAI dialect requires inline. */
|
|
522
536
|
export function toDataUri(part: ImagePart): string {
|
|
523
537
|
return `data:${part.mimeType};base64,${part.data}`;
|
|
524
538
|
}
|
package/src/usage.ts
CHANGED
|
@@ -15,14 +15,14 @@
|
|
|
15
15
|
import type { TokenUsage } from "./types.ts";
|
|
16
16
|
import { EMPTY_USAGE } from "./types.ts";
|
|
17
17
|
|
|
18
|
-
/** USD per
|
|
18
|
+
/** USD per million tokens, as every vendor prints its price sheet, so a row
|
|
19
19
|
* can be checked against one without arithmetic. */
|
|
20
20
|
export interface ModelRate {
|
|
21
21
|
/** Cache-MISS input: fresh tokens the provider had to read. */
|
|
22
22
|
input: number;
|
|
23
23
|
output: number;
|
|
24
24
|
/** Cache-HIT input. Providers auto-cache repeated prefixes and bill the hit
|
|
25
|
-
* portion far cheaper
|
|
25
|
+
* portion far cheaper: 0.1× on Anthropic, ~0.1× on Gemini, 0.5× on some
|
|
26
26
|
* OpenAI models. In an agent loop the re-sent context is overwhelmingly
|
|
27
27
|
* hits, so billing it at the miss rate overcounts by up to 10×. */
|
|
28
28
|
cacheRead: number;
|
|
@@ -95,14 +95,14 @@ export function costUsd(usage: TokenUsage, rate: ModelRate): number {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
/** What one call adds to a run: its reported cost, else its rate, else
|
|
98
|
-
* nothing
|
|
98
|
+
* nothing. An unpriced call still counts its tokens. */
|
|
99
99
|
function callCost(usage: TokenUsage, rate: ModelRate | undefined): number {
|
|
100
100
|
return rate ? costUsd(usage, rate) : (usage.reportedCostUsd ?? 0);
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
/**
|
|
104
104
|
* Accumulates usage and cost across the calls of one run, pricing each with
|
|
105
|
-
* the rate in effect for the model that served it
|
|
105
|
+
* the rate in effect for the model that served it, so a run that switches to
|
|
106
106
|
* a backup model, or spans a time-of-day price boundary, still bills correctly.
|
|
107
107
|
* A call that carries a reported cost is billed that, with or without a rate.
|
|
108
108
|
*/
|
|
@@ -110,7 +110,7 @@ export class UsageTracker {
|
|
|
110
110
|
private usage: TokenUsage = { ...EMPTY_USAGE, cacheWriteTokens: 0 };
|
|
111
111
|
private cost = 0;
|
|
112
112
|
/** USD the cache hits saved, versus billing them all as misses.
|
|
113
|
-
* Observability only
|
|
113
|
+
* Observability only. Never billed. */
|
|
114
114
|
private saved = 0;
|
|
115
115
|
|
|
116
116
|
add(usage: TokenUsage, rate?: ModelRate): void {
|