@providerkit/core 0.1.0 → 0.2.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 +33 -157
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +8 -0
- package/dist/context.js.map +1 -1
- package/dist/errors.d.ts +36 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +72 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/key-pool.d.ts +60 -0
- package/dist/key-pool.d.ts.map +1 -0
- package/dist/key-pool.js +235 -0
- package/dist/key-pool.js.map +1 -0
- package/dist/providers/anthropic.d.ts +9 -0
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +12 -13
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/gemini.d.ts +40 -0
- package/dist/providers/gemini.d.ts.map +1 -0
- package/dist/providers/gemini.js +303 -0
- package/dist/providers/gemini.js.map +1 -0
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +9 -0
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/responses.d.ts +38 -0
- package/dist/providers/responses.d.ts.map +1 -0
- package/dist/providers/responses.js +341 -0
- package/dist/providers/responses.js.map +1 -0
- package/dist/rate-limit.d.ts +29 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +194 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/transport.d.ts +18 -5
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +61 -35
- package/dist/transport.js.map +1 -1
- package/package.json +2 -2
- package/src/context.ts +7 -0
- package/src/errors.ts +79 -1
- package/src/index.ts +4 -0
- package/src/key-pool.ts +272 -0
- package/src/providers/anthropic.ts +21 -18
- package/src/providers/gemini.ts +386 -0
- package/src/providers/openai.ts +12 -0
- package/src/providers/responses.ts +455 -0
- package/src/rate-limit.ts +217 -0
- package/src/transport.ts +61 -35
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
// Responses-shape adapter — SSE from POST /v1/responses.
|
|
2
|
+
//
|
|
3
|
+
// OpenAI's second wire format, and the only one some backends expose: the
|
|
4
|
+
// ChatGPT subscription surface (chatgpt.com/backend-api/codex) has no
|
|
5
|
+
// chat/completions endpoint at all. It is not chat/completions with a new path
|
|
6
|
+
// — the input is an ITEM LIST rather than a message list, the stream is
|
|
7
|
+
// event-typed rather than choice-delta'd, and the terminal event carries the
|
|
8
|
+
// usage record instead of a trailing usage-only frame.
|
|
9
|
+
//
|
|
10
|
+
// The event names arrive on the SSE `event:` line and are repeated inside each
|
|
11
|
+
// payload's own `type`. The transport yields only `data:` payloads, so this
|
|
12
|
+
// adapter reads `type` — which is what survives, and what gateways agree on.
|
|
13
|
+
import { streamError } from "../errors.ts";
|
|
14
|
+
import { streamSse, apiUrl } from "../transport.ts";
|
|
15
|
+
import type {
|
|
16
|
+
ChatMessage,
|
|
17
|
+
ContentPart,
|
|
18
|
+
Effort,
|
|
19
|
+
FinishReason,
|
|
20
|
+
ImagePart,
|
|
21
|
+
Provider,
|
|
22
|
+
ProviderChunk,
|
|
23
|
+
StreamOptions,
|
|
24
|
+
ToolDefinition,
|
|
25
|
+
} from "../types.ts";
|
|
26
|
+
import { toDataUri } from "../types.ts";
|
|
27
|
+
|
|
28
|
+
export interface ResponsesConfig {
|
|
29
|
+
apiKey: string;
|
|
30
|
+
model: string;
|
|
31
|
+
/** Any endpoint speaking the Responses format. Defaults to OpenAI itself. */
|
|
32
|
+
baseUrl?: string;
|
|
33
|
+
/** Names the provider in errors and logs. */
|
|
34
|
+
id?: string;
|
|
35
|
+
effort?: Effort;
|
|
36
|
+
maxTokens?: number;
|
|
37
|
+
fetchImpl?: typeof fetch;
|
|
38
|
+
/** Extra request headers — where a subscription backend's account id goes
|
|
39
|
+
* (`ChatGPT-Account-Id`), which those backends reject the request without. */
|
|
40
|
+
headers?: Record<string, string>;
|
|
41
|
+
/**
|
|
42
|
+
* Where this backend serves the endpoint, when it is not `/v1/responses`.
|
|
43
|
+
* The ChatGPT subscription surface serves it at `/backend-api/codex/responses`
|
|
44
|
+
* with no version segment, so that backend needs
|
|
45
|
+
* `{ baseUrl: "https://chatgpt.com/backend-api/codex", path: "/responses" }`.
|
|
46
|
+
* Without the override the POST 404s, and a 404 classifies as "model" — the
|
|
47
|
+
* user is told the model id does not exist when the path was the problem.
|
|
48
|
+
*/
|
|
49
|
+
path?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const DEFAULT_BASE_URL = "https://api.openai.com";
|
|
53
|
+
const DEFAULT_PATH = "/v1/responses";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `response.incomplete` means the turn was cut short, and the seam has one word
|
|
57
|
+
* for that: "length". `content_filter` is the only other reason this shape
|
|
58
|
+
* documents; anything new stays on "length" rather than reporting a clean stop,
|
|
59
|
+
* because a caller that believes a truncated answer finished will act on it.
|
|
60
|
+
*/
|
|
61
|
+
function mapIncompleteReason(reason: string | undefined): FinishReason {
|
|
62
|
+
return reason === "content_filter" ? "content_filter" : "length";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── input items ───────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
type ResponsesContentPart =
|
|
68
|
+
| { type: "input_text"; text: string }
|
|
69
|
+
| { type: "output_text"; text: string }
|
|
70
|
+
| { type: "input_image"; image_url: string };
|
|
71
|
+
|
|
72
|
+
type ResponsesInputItem =
|
|
73
|
+
| { type: "message"; role: "user" | "assistant"; content: ResponsesContentPart[] }
|
|
74
|
+
| { type: "function_call"; call_id: string; name: string; arguments: string }
|
|
75
|
+
| { type: "function_call_output"; call_id: string; output: string | ResponsesContentPart[] };
|
|
76
|
+
|
|
77
|
+
function partsToResponses(content: string | ContentPart[]): ResponsesContentPart[] {
|
|
78
|
+
if (typeof content === "string") return [{ type: "input_text", text: content }];
|
|
79
|
+
return content.map((part): ResponsesContentPart =>
|
|
80
|
+
part.type === "text"
|
|
81
|
+
? { type: "input_text", text: part.text }
|
|
82
|
+
: { type: "input_image", image_url: toDataUri(part) },
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A tool result is a bare string unless it carried images — then the content-
|
|
87
|
+
* array form, the only way to hand this shape a screenshot back. */
|
|
88
|
+
function toolOutput(
|
|
89
|
+
content: string,
|
|
90
|
+
images: readonly ImagePart[],
|
|
91
|
+
): string | ResponsesContentPart[] {
|
|
92
|
+
if (images.length === 0) return content;
|
|
93
|
+
const parts: ResponsesContentPart[] = [];
|
|
94
|
+
if (content) parts.push({ type: "input_text", text: content });
|
|
95
|
+
for (const image of images) parts.push({ type: "input_image", image_url: toDataUri(image) });
|
|
96
|
+
return parts;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Flatten a history into `instructions` plus the input item list.
|
|
101
|
+
*
|
|
102
|
+
* This shape has no system ROLE — the system prompt is a top-level
|
|
103
|
+
* `instructions` string, and everything else is items. One assistant turn can
|
|
104
|
+
* become several items (its text, then one `function_call` per tool it asked
|
|
105
|
+
* for), which is why a message maps to a list rather than to one item.
|
|
106
|
+
*/
|
|
107
|
+
export function toResponsesInput(messages: readonly ChatMessage[]): {
|
|
108
|
+
instructions?: string;
|
|
109
|
+
input: unknown[];
|
|
110
|
+
} {
|
|
111
|
+
const instructions = messages
|
|
112
|
+
.filter((message) => message.role === "system")
|
|
113
|
+
.map((message) => message.content)
|
|
114
|
+
.join("\n\n");
|
|
115
|
+
|
|
116
|
+
const input: ResponsesInputItem[] = [];
|
|
117
|
+
for (const message of messages) {
|
|
118
|
+
switch (message.role) {
|
|
119
|
+
case "system":
|
|
120
|
+
break; // lifted into `instructions` above
|
|
121
|
+
|
|
122
|
+
case "user":
|
|
123
|
+
input.push({ type: "message", role: "user", content: partsToResponses(message.content) });
|
|
124
|
+
break;
|
|
125
|
+
|
|
126
|
+
case "tool":
|
|
127
|
+
input.push({
|
|
128
|
+
// `call_id` — the id the model coined for the CALL, not the `fc_…` id
|
|
129
|
+
// of the output item that carried it. Sending the wrong one is a 400
|
|
130
|
+
// reading "No tool output found for function call", one turn later.
|
|
131
|
+
type: "function_call_output",
|
|
132
|
+
call_id: message.toolCallId,
|
|
133
|
+
output: toolOutput(message.content, message.images ?? []),
|
|
134
|
+
});
|
|
135
|
+
break;
|
|
136
|
+
|
|
137
|
+
case "assistant": {
|
|
138
|
+
// Reasoning is deliberately NOT replayed. This shape wants the ORIGINAL
|
|
139
|
+
// reasoning item back — its `rs_…` id, and under `store: false` its
|
|
140
|
+
// `encrypted_content` blob — and the seam carries neither, only the
|
|
141
|
+
// plain summary text a caller renders. A synthesized reasoning item is
|
|
142
|
+
// rejected; omitting it costs only the model re-deriving its own chain
|
|
143
|
+
// of thought, which is what every stateless caller already lives with.
|
|
144
|
+
if (message.content) {
|
|
145
|
+
input.push({
|
|
146
|
+
type: "message",
|
|
147
|
+
role: "assistant",
|
|
148
|
+
content: [{ type: "output_text", text: message.content }],
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
for (const call of message.toolCalls ?? []) {
|
|
152
|
+
input.push({
|
|
153
|
+
type: "function_call",
|
|
154
|
+
call_id: call.id,
|
|
155
|
+
name: call.name,
|
|
156
|
+
arguments: call.arguments,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { ...(instructions ? { instructions } : {}), input };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── stream events ─────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
interface ResponsesUsage {
|
|
170
|
+
input_tokens?: number;
|
|
171
|
+
input_tokens_details?: { cached_tokens?: number };
|
|
172
|
+
output_tokens?: number;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
interface ResponsesItem {
|
|
176
|
+
type?: string;
|
|
177
|
+
/** The output item's own id (`fc_…`) — what the argument deltas reference. */
|
|
178
|
+
id?: string;
|
|
179
|
+
/** The id a `function_call_output` must quote on the next turn. */
|
|
180
|
+
call_id?: string;
|
|
181
|
+
name?: string;
|
|
182
|
+
arguments?: string;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
interface ResponsesEvent {
|
|
186
|
+
type?: string;
|
|
187
|
+
delta?: string;
|
|
188
|
+
item_id?: string;
|
|
189
|
+
item?: ResponsesItem;
|
|
190
|
+
response?: {
|
|
191
|
+
usage?: ResponsesUsage;
|
|
192
|
+
incomplete_details?: { reason?: string };
|
|
193
|
+
error?: { message?: string; code?: string };
|
|
194
|
+
};
|
|
195
|
+
error?: { message?: string; code?: string };
|
|
196
|
+
message?: string;
|
|
197
|
+
code?: string;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function usageChunk(usage: ResponsesUsage): ProviderChunk {
|
|
201
|
+
return {
|
|
202
|
+
type: "usage",
|
|
203
|
+
usage: {
|
|
204
|
+
inputTokens: usage.input_tokens ?? 0,
|
|
205
|
+
// Already a SUBSET of input_tokens on this shape, as on chat/completions
|
|
206
|
+
// — and the only signal that its automatic prefix caching is working.
|
|
207
|
+
cachedInputTokens: usage.input_tokens_details?.cached_tokens ?? 0,
|
|
208
|
+
// Reasoning tokens are billed INSIDE output_tokens, not beside them.
|
|
209
|
+
outputTokens: usage.output_tokens ?? 0,
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** What has already gone out for one in-flight function call — identity and
|
|
215
|
+
* arguments both — so the authoritative snapshot on `.done` can be diffed
|
|
216
|
+
* against it instead of duplicated. */
|
|
217
|
+
interface PendingCall {
|
|
218
|
+
index: number;
|
|
219
|
+
id: string;
|
|
220
|
+
name: string;
|
|
221
|
+
streamed: string;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
225
|
+
const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
|
|
226
|
+
const id = config.id ?? "openai-responses";
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
id,
|
|
230
|
+
model: config.model,
|
|
231
|
+
|
|
232
|
+
async *createStream(
|
|
233
|
+
messages: ChatMessage[],
|
|
234
|
+
tools: ToolDefinition[],
|
|
235
|
+
opts: StreamOptions = {},
|
|
236
|
+
): AsyncIterable<ProviderChunk> {
|
|
237
|
+
const effort = opts.effort ?? config.effort;
|
|
238
|
+
const { instructions, input } = toResponsesInput(messages);
|
|
239
|
+
|
|
240
|
+
const request: Record<string, unknown> = {
|
|
241
|
+
model: opts.model ?? config.model,
|
|
242
|
+
input,
|
|
243
|
+
stream: true,
|
|
244
|
+
// The caller's history is the entire state of a run. Server-side
|
|
245
|
+
// storage adds a retention surface nobody asked for, and is refused
|
|
246
|
+
// outright on zero-data-retention accounts.
|
|
247
|
+
store: false,
|
|
248
|
+
};
|
|
249
|
+
if (instructions) request.instructions = instructions;
|
|
250
|
+
const maxTokens = opts.maxTokens ?? config.maxTokens;
|
|
251
|
+
if (maxTokens !== undefined) request.max_output_tokens = maxTokens;
|
|
252
|
+
if (opts.temperature !== undefined) request.temperature = opts.temperature;
|
|
253
|
+
if (effort && effort !== "none") {
|
|
254
|
+
// `summary` is what switches the reasoning stream ON. Without it this
|
|
255
|
+
// shape emits no reasoning_summary_text events at all, and a caller
|
|
256
|
+
// rendering a thinking pane silently gets nothing while the tokens are
|
|
257
|
+
// billed either way.
|
|
258
|
+
request.reasoning = { effort, summary: "auto" };
|
|
259
|
+
}
|
|
260
|
+
if (tools.length > 0) {
|
|
261
|
+
// Flat here — no nested `function` envelope, unlike chat/completions.
|
|
262
|
+
request.tools = tools.map((tool) => ({
|
|
263
|
+
type: "function",
|
|
264
|
+
name: tool.name,
|
|
265
|
+
description: tool.description,
|
|
266
|
+
parameters: tool.inputSchema,
|
|
267
|
+
}));
|
|
268
|
+
}
|
|
269
|
+
if (opts.toolChoice && opts.toolChoice !== "auto") {
|
|
270
|
+
request.tool_choice =
|
|
271
|
+
typeof opts.toolChoice === "string"
|
|
272
|
+
? opts.toolChoice
|
|
273
|
+
: { type: "function", name: opts.toolChoice.name };
|
|
274
|
+
}
|
|
275
|
+
if (opts.json) {
|
|
276
|
+
// The schema rides in `text.format`, not `response_format`.
|
|
277
|
+
request.text = {
|
|
278
|
+
format: {
|
|
279
|
+
type: "json_schema",
|
|
280
|
+
name: opts.json.name,
|
|
281
|
+
schema: opts.json.schema,
|
|
282
|
+
strict: true,
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Tool calls arrive as an item skeleton plus argument deltas; keyed by the
|
|
288
|
+
// output item id so parallel calls never cross wires. The seam's index is
|
|
289
|
+
// ours to assign — `output_index` counts reasoning and message items too.
|
|
290
|
+
const pending = new Map<string, PendingCall>();
|
|
291
|
+
let nextIndex = 0;
|
|
292
|
+
// This shape never states a stop reason on a clean finish, so it is
|
|
293
|
+
// inferred from whether the turn produced a function call.
|
|
294
|
+
let sawToolCall = false;
|
|
295
|
+
|
|
296
|
+
for await (const data of streamSse({
|
|
297
|
+
url: apiUrl(baseUrl, config.path ?? DEFAULT_PATH),
|
|
298
|
+
headers: { authorization: `Bearer ${config.apiKey}`, ...config.headers },
|
|
299
|
+
body: request,
|
|
300
|
+
provider: id,
|
|
301
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
302
|
+
...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
|
|
303
|
+
})) {
|
|
304
|
+
let event: ResponsesEvent;
|
|
305
|
+
try {
|
|
306
|
+
event = JSON.parse(data) as ResponsesEvent;
|
|
307
|
+
} catch {
|
|
308
|
+
continue; // a keep-alive or a frame we do not model
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
switch (event.type) {
|
|
312
|
+
case "response.output_text.delta":
|
|
313
|
+
if (event.delta) yield { type: "delta", content: event.delta };
|
|
314
|
+
break;
|
|
315
|
+
|
|
316
|
+
// Two names for the same stream: `reasoning_summary_text` is the
|
|
317
|
+
// redacted summary the API returns, `reasoning_text` the raw trace
|
|
318
|
+
// the ChatGPT backend streams. A caller wants whichever it gets.
|
|
319
|
+
case "response.reasoning_summary_text.delta":
|
|
320
|
+
case "response.reasoning_text.delta":
|
|
321
|
+
if (event.delta) yield { type: "delta", reasoning: event.delta };
|
|
322
|
+
break;
|
|
323
|
+
|
|
324
|
+
case "response.output_item.added": {
|
|
325
|
+
const item = event.item;
|
|
326
|
+
if (item?.type !== "function_call" || !item.id) break;
|
|
327
|
+
sawToolCall = true;
|
|
328
|
+
const index = nextIndex++;
|
|
329
|
+
// Normally empty here, but a backend that already has the whole
|
|
330
|
+
// call sends it in the skeleton.
|
|
331
|
+
const seeded = item.arguments ?? "";
|
|
332
|
+
pending.set(item.id, {
|
|
333
|
+
index,
|
|
334
|
+
id: item.call_id ?? "",
|
|
335
|
+
name: item.name ?? "",
|
|
336
|
+
streamed: seeded,
|
|
337
|
+
});
|
|
338
|
+
// Only the fields the skeleton actually states. An empty `id` here
|
|
339
|
+
// is not "unknown", it is a wrong answer: a consumer takes the last
|
|
340
|
+
// stated value, so `""` written into the slot survives the real
|
|
341
|
+
// `call_id` arriving on `.done`.
|
|
342
|
+
yield {
|
|
343
|
+
type: "delta",
|
|
344
|
+
toolCalls: [
|
|
345
|
+
{
|
|
346
|
+
index,
|
|
347
|
+
...(item.call_id ? { id: item.call_id } : {}),
|
|
348
|
+
...(item.name ? { name: item.name } : {}),
|
|
349
|
+
...(seeded ? { arguments: seeded } : {}),
|
|
350
|
+
},
|
|
351
|
+
],
|
|
352
|
+
};
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
case "response.function_call_arguments.delta": {
|
|
357
|
+
const call = event.item_id ? pending.get(event.item_id) : undefined;
|
|
358
|
+
if (!call || !event.delta) break;
|
|
359
|
+
call.streamed += event.delta;
|
|
360
|
+
yield { type: "delta", toolCalls: [{ index: call.index, arguments: event.delta }] };
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
case "response.output_item.done": {
|
|
365
|
+
const item = event.item;
|
|
366
|
+
if (item?.type !== "function_call") break;
|
|
367
|
+
const known = item.id ? pending.get(item.id) : undefined;
|
|
368
|
+
if (item.id) pending.delete(item.id);
|
|
369
|
+
const snapshot = item.arguments ?? "";
|
|
370
|
+
|
|
371
|
+
if (!known) {
|
|
372
|
+
// A backend that emits neither the skeleton nor the deltas — the
|
|
373
|
+
// whole call arrives here or not at all. Without a `call_id`
|
|
374
|
+
// there is nothing to answer it with: the caller's
|
|
375
|
+
// `function_call_output` would quote `""` and take a 400 reading
|
|
376
|
+
// "No tool output found for function call" one turn later, so the
|
|
377
|
+
// call is dropped rather than handed over unrunnable. Dropping is
|
|
378
|
+
// only possible here, where nothing has been streamed for it yet.
|
|
379
|
+
if (!item.call_id) break;
|
|
380
|
+
sawToolCall = true;
|
|
381
|
+
yield {
|
|
382
|
+
type: "delta",
|
|
383
|
+
toolCalls: [
|
|
384
|
+
{
|
|
385
|
+
index: nextIndex++,
|
|
386
|
+
id: item.call_id,
|
|
387
|
+
...(item.name ? { name: item.name } : {}),
|
|
388
|
+
arguments: snapshot,
|
|
389
|
+
},
|
|
390
|
+
],
|
|
391
|
+
};
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// The snapshot is authoritative, but the fragments already went
|
|
396
|
+
// out: re-emitting it whole concatenates the JSON with itself and
|
|
397
|
+
// every argument parse fails. Send only what the deltas missed —
|
|
398
|
+
// which is all of it when they never came.
|
|
399
|
+
const tail =
|
|
400
|
+
snapshot.length > known.streamed.length && snapshot.startsWith(known.streamed)
|
|
401
|
+
? snapshot.slice(known.streamed.length)
|
|
402
|
+
: "";
|
|
403
|
+
// `.done` restates the identity, and on a backend that leaves it
|
|
404
|
+
// out of the skeleton this is the only frame that carries it. It
|
|
405
|
+
// rides last so it WINS: the alternative is a caller assembling a
|
|
406
|
+
// nameless call it has no tool to dispatch, quoting an empty
|
|
407
|
+
// `call_id` back on the turn after.
|
|
408
|
+
const restated = {
|
|
409
|
+
...(item.call_id && item.call_id !== known.id ? { id: item.call_id } : {}),
|
|
410
|
+
...(item.name && item.name !== known.name ? { name: item.name } : {}),
|
|
411
|
+
...(tail ? { arguments: tail } : {}),
|
|
412
|
+
};
|
|
413
|
+
if (Object.keys(restated).length > 0) {
|
|
414
|
+
yield { type: "delta", toolCalls: [{ index: known.index, ...restated }] };
|
|
415
|
+
}
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
case "response.completed": {
|
|
420
|
+
const usage = event.response?.usage;
|
|
421
|
+
if (usage) yield usageChunk(usage);
|
|
422
|
+
yield { type: "finish", finishReason: sawToolCall ? "tool_calls" : "stop" };
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
case "response.incomplete": {
|
|
427
|
+
// A turn that ran out of output tokens still billed for its input,
|
|
428
|
+
// and this event carries usage in the same shape as `completed`.
|
|
429
|
+
const usage = event.response?.usage;
|
|
430
|
+
if (usage) yield usageChunk(usage);
|
|
431
|
+
yield {
|
|
432
|
+
type: "finish",
|
|
433
|
+
finishReason: mapIncompleteReason(event.response?.incomplete_details?.reason),
|
|
434
|
+
};
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// The two ways this shape reports a failure after its headers went
|
|
439
|
+
// out: a terminal `response.failed`, or a bare error frame from a
|
|
440
|
+
// gateway in front of it.
|
|
441
|
+
case "response.failed":
|
|
442
|
+
throw streamError(id, event.response?.error);
|
|
443
|
+
|
|
444
|
+
case "error":
|
|
445
|
+
case "response.error":
|
|
446
|
+
throw streamError(id, event.error ?? { message: event.message, code: event.code });
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// The stream closed without a terminal event. Nothing is lost but the
|
|
451
|
+
// finish reason and the usage record — every delta, tool-call fragment
|
|
452
|
+
// included, was already yielded as it arrived.
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Rate-limit headers, read for the one thing a 429 never says in its body: WHEN.
|
|
2
|
+
//
|
|
3
|
+
// A 429 discloses that a limit was hit, never which limit. A per-minute
|
|
4
|
+
// throttle and a Claude OAuth subscription window (5-hour, or weekly) arrive
|
|
5
|
+
// as the same status with the same wording, and the second one resets in DAYS.
|
|
6
|
+
// Only the headers tell them apart. Without them a caller can say nothing
|
|
7
|
+
// better than "try again in a moment", which is a lie for the multi-day case.
|
|
8
|
+
//
|
|
9
|
+
// Parsing only. Rendering `resetAtMs` as "in 4 hours (6:47 PM)" belongs to the
|
|
10
|
+
// caller: relative-time wording is locale work, and a library that ships it
|
|
11
|
+
// either drags in an i18n dependency or hardcodes English.
|
|
12
|
+
//
|
|
13
|
+
// The overlap with `retryAfterFromHeaders` in transport.ts is deliberate, and
|
|
14
|
+
// the two are not interchangeable. That one answers the retry policy's
|
|
15
|
+
// question — one number, how long to sleep — so it treats `Retry-After` as
|
|
16
|
+
// authoritative whenever it is present, and falls back to any vendor reset
|
|
17
|
+
// header it can find. This one answers the user-facing question, where the
|
|
18
|
+
// unified window's reset OUTRANKS `Retry-After`: Anthropic sends
|
|
19
|
+
// `retry-after: 60` beside a weekly window that lifts in three days, and 60
|
|
20
|
+
// seconds is the correct sleep and the wrong horizon. Delegating here would
|
|
21
|
+
// also fill `retryAfterMs` — documented as what the SERVER asked for — with a
|
|
22
|
+
// vendor reset header on every response that carries no `Retry-After` at all.
|
|
23
|
+
// What must NOT differ is which headers the two can date: the fallback list
|
|
24
|
+
// below is transport's list, because a header only one of them reads is a 429
|
|
25
|
+
// whose `ProviderError.retryAfterMs` knows the wait is three days while the
|
|
26
|
+
// user-facing answer is empty.
|
|
27
|
+
|
|
28
|
+
/** Which subscription window bound. Only Anthropic's unified headers name one
|
|
29
|
+
* outright; the codex body shape has it inferred from the wait. */
|
|
30
|
+
export type RateLimitWindow = "5h" | "weekly" | "monthly";
|
|
31
|
+
|
|
32
|
+
export interface RateLimitReset {
|
|
33
|
+
/** Absolute time the binding window resets, when any header disclosed it. */
|
|
34
|
+
resetAtMs?: number;
|
|
35
|
+
/** Server-requested wait (`retry-after`) — what a retry policy honours. */
|
|
36
|
+
retryAfterMs?: number;
|
|
37
|
+
/** Which subscription window bound, when it could be named at all. */
|
|
38
|
+
window?: RateLimitWindow;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const RETRY_AFTER = "retry-after";
|
|
42
|
+
const UNIFIED_5H = "anthropic-ratelimit-unified-5h-";
|
|
43
|
+
const UNIFIED_7D = "anthropic-ratelimit-unified-7d-";
|
|
44
|
+
|
|
45
|
+
/** Reset headers that name a time rather than a window, in the order
|
|
46
|
+
* transport.ts reads them and kept in step with that list. The singular
|
|
47
|
+
* `anthropic-ratelimit-unified-reset` rides on OAuth responses that carry no
|
|
48
|
+
* 5h/7d pair, and the `x-ratelimit-*` spellings are what every non-Anthropic
|
|
49
|
+
* provider and gateway sends. */
|
|
50
|
+
const RESET_HEADERS = [
|
|
51
|
+
"anthropic-ratelimit-unified-reset",
|
|
52
|
+
"anthropic-ratelimit-requests-reset",
|
|
53
|
+
"anthropic-ratelimit-tokens-reset",
|
|
54
|
+
"x-ratelimit-reset-requests",
|
|
55
|
+
"x-ratelimit-reset-tokens",
|
|
56
|
+
"x-ratelimit-reset",
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/** Seconds since 1970 passed a billion in 2001, so a bare integer above this is
|
|
60
|
+
* a timestamp and anything below it is a countdown. Vendors disagree on which
|
|
61
|
+
* they send under the same header name, so magnitude decides. */
|
|
62
|
+
const EPOCH_SECONDS_FLOOR = 1_000_000_000;
|
|
63
|
+
|
|
64
|
+
/** A non-negative finite number, or nothing. The null guard carries weight:
|
|
65
|
+
* `Number("")` is 0, so an absent header would otherwise read as a real zero. */
|
|
66
|
+
function numeric(value: string | null): number | undefined {
|
|
67
|
+
if (!value) return undefined;
|
|
68
|
+
const n = Number(value);
|
|
69
|
+
return Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function unixSecondsToMs(value: string | null): number | undefined {
|
|
73
|
+
const n = numeric(value);
|
|
74
|
+
return n === undefined ? undefined : n * 1000;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Absolute reset time from a header that spells it either way: epoch seconds
|
|
78
|
+
* (the unified reset, most gateways) or an RFC 3339 / HTTP-date string (the
|
|
79
|
+
* API-key reset headers). The digit test runs first because `Date.parse("120")`
|
|
80
|
+
* is not a rejection — it is the year 120, two millennia in the past. */
|
|
81
|
+
function resetHeaderMs(value: string | null, now: number): number | undefined {
|
|
82
|
+
if (!value) return undefined;
|
|
83
|
+
if (/^\d+$/.test(value)) {
|
|
84
|
+
const seconds = Number(value);
|
|
85
|
+
return seconds > EPOCH_SECONDS_FLOOR ? seconds * 1000 : now + seconds * 1000;
|
|
86
|
+
}
|
|
87
|
+
const at = Date.parse(value);
|
|
88
|
+
return Number.isNaN(at) ? undefined : at;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** `retry-after` is delay-seconds, but RFC 9110 also allows an absolute date,
|
|
92
|
+
* and both forms occur across the providers this package speaks to. */
|
|
93
|
+
function parseRetryAfter(value: string | null, now: number): number | undefined {
|
|
94
|
+
if (!value) return undefined;
|
|
95
|
+
const n = Number(value);
|
|
96
|
+
if (Number.isFinite(n) && n >= 0) return n * 1000;
|
|
97
|
+
const at = Date.parse(value);
|
|
98
|
+
return Number.isNaN(at) ? undefined : Math.max(0, at - now);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Parse the rate-limit headers off a (usually 429) response.
|
|
103
|
+
*
|
|
104
|
+
* Anthropic's `anthropic-ratelimit-unified-*` pair rides on every OAuth
|
|
105
|
+
* response, so both windows are always reported and only one of them is the
|
|
106
|
+
* reason for this 429. API-key accounts get the `anthropic-ratelimit-*-reset`
|
|
107
|
+
* RFC 3339 timestamps instead. Everything is optional — an unrecognized shape
|
|
108
|
+
* yields an empty result, and the caller falls back to its generic message.
|
|
109
|
+
*/
|
|
110
|
+
export function parseRateLimitReset(headers: Headers, now = Date.now()): RateLimitReset {
|
|
111
|
+
const result: RateLimitReset = {};
|
|
112
|
+
|
|
113
|
+
const after = parseRetryAfter(headers.get(RETRY_AFTER), now);
|
|
114
|
+
if (after !== undefined) {
|
|
115
|
+
result.retryAfterMs = after;
|
|
116
|
+
result.resetAtMs = now + after;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The window with the higher utilization is the one a "would exceed your
|
|
120
|
+
// rate limit" 429 is about. A reset already in the past belongs to a window
|
|
121
|
+
// that rolled over between the response and this parse — naming it would put
|
|
122
|
+
// a past time in front of the user — so it drops out here.
|
|
123
|
+
const windows = [
|
|
124
|
+
{
|
|
125
|
+
window: "5h" as const,
|
|
126
|
+
utilization: numeric(headers.get(`${UNIFIED_5H}utilization`)),
|
|
127
|
+
reset: unixSecondsToMs(headers.get(`${UNIFIED_5H}reset`)),
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
window: "weekly" as const,
|
|
131
|
+
utilization: numeric(headers.get(`${UNIFIED_7D}utilization`)),
|
|
132
|
+
reset: unixSecondsToMs(headers.get(`${UNIFIED_7D}reset`)),
|
|
133
|
+
},
|
|
134
|
+
].filter((w) => w.utilization !== undefined && w.reset !== undefined && w.reset > now);
|
|
135
|
+
const binding = windows.sort((a, b) => (b.utilization ?? 0) - (a.utilization ?? 0))[0];
|
|
136
|
+
if (binding) {
|
|
137
|
+
result.window = binding.window;
|
|
138
|
+
// The window's own reset outranks retry-after: it names the real horizon,
|
|
139
|
+
// where retry-after names the next polite attempt.
|
|
140
|
+
if (binding.reset !== undefined) result.resetAtMs = binding.reset;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// No window named the horizon, so take the first reset header that dates the
|
|
144
|
+
// future. These stay below `retry-after`, which is the server speaking about
|
|
145
|
+
// this request; they only fill a silence.
|
|
146
|
+
if (result.resetAtMs === undefined) {
|
|
147
|
+
for (const name of RESET_HEADERS) {
|
|
148
|
+
const at = resetHeaderMs(headers.get(name), now);
|
|
149
|
+
if (at !== undefined && at > now) {
|
|
150
|
+
result.resetAtMs = at;
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Under ten minutes the wait is a per-minute throttle whatever the body calls
|
|
160
|
+
* it, and calling a 40-second retry a "5-hour window" would be its own lie. */
|
|
161
|
+
const WINDOW_FLOOR_MS = 10 * 60_000;
|
|
162
|
+
/** Bucket edges, padded: a window's reset lands wherever inside it the first
|
|
163
|
+
* request fell, so a 5-hour window routinely reports four hours and change. */
|
|
164
|
+
const FIVE_HOUR_MAX_MS = 5.5 * 3_600_000;
|
|
165
|
+
const WEEKLY_MAX_MS = 7.5 * 86_400_000;
|
|
166
|
+
|
|
167
|
+
/** A finite number off a JSON field, the string form a relay may stringify it
|
|
168
|
+
* into included. Bare `Number()` is the trap `numeric()` guards against one
|
|
169
|
+
* layer up: `Number(null)`, `Number("")`, `Number([])` and `Number(false)` are
|
|
170
|
+
* all 0, and 0 is finite and non-negative, so a relay that nulls out
|
|
171
|
+
* `resets_in_seconds` beside a real `resets_at` would win the branch below and
|
|
172
|
+
* report a three-day lockout as "retry now". */
|
|
173
|
+
function jsonSeconds(value: unknown): number | undefined {
|
|
174
|
+
const usable = typeof value === "number" || (typeof value === "string" && value.trim() !== "");
|
|
175
|
+
const n = usable ? Number(value) : NaN;
|
|
176
|
+
return Number.isFinite(n) ? n : undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* ChatGPT's codex backend puts the reset IN THE 429 BODY, not the headers:
|
|
181
|
+
* `{"error":{"type":"usage_limit_reached","resets_at":1788801754,"resets_in_seconds":2501465}}`.
|
|
182
|
+
* The window name is inferred from the wait itself, and only past the floor
|
|
183
|
+
* above — a sub-minute retry is a throttle, not a subscription window.
|
|
184
|
+
*/
|
|
185
|
+
export function parseUsageLimitBody(bodyText: string, now = Date.now()): RateLimitReset {
|
|
186
|
+
let body: unknown;
|
|
187
|
+
try {
|
|
188
|
+
body = JSON.parse(bodyText);
|
|
189
|
+
} catch {
|
|
190
|
+
return {};
|
|
191
|
+
}
|
|
192
|
+
if (typeof body !== "object" || body === null) return {};
|
|
193
|
+
// The fields sit under `error` on the documented shape and at the top level
|
|
194
|
+
// on some gateway relays of it. Both are read rather than guessed between.
|
|
195
|
+
const source = (body as Record<string, unknown>).error;
|
|
196
|
+
const error =
|
|
197
|
+
typeof source === "object" && source !== null
|
|
198
|
+
? (source as Record<string, unknown>)
|
|
199
|
+
: (body as Record<string, unknown>);
|
|
200
|
+
|
|
201
|
+
const inSeconds = jsonSeconds(error.resets_in_seconds);
|
|
202
|
+
const atSeconds = jsonSeconds(error.resets_at);
|
|
203
|
+
const waitMs =
|
|
204
|
+
inSeconds !== undefined && inSeconds >= 0
|
|
205
|
+
? inSeconds * 1000
|
|
206
|
+
: atSeconds !== undefined && atSeconds > 0
|
|
207
|
+
? Math.max(0, atSeconds * 1000 - now)
|
|
208
|
+
: undefined;
|
|
209
|
+
if (waitMs === undefined) return {};
|
|
210
|
+
|
|
211
|
+
const result: RateLimitReset = { retryAfterMs: waitMs, resetAtMs: now + waitMs };
|
|
212
|
+
if (waitMs > WINDOW_FLOOR_MS) {
|
|
213
|
+
result.window =
|
|
214
|
+
waitMs <= FIVE_HOUR_MAX_MS ? "5h" : waitMs <= WEEKLY_MAX_MS ? "weekly" : "monthly";
|
|
215
|
+
}
|
|
216
|
+
return result;
|
|
217
|
+
}
|