@providerkit/core 0.22.0 → 0.24.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 +29 -7
- 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 +32 -0
- package/dist/auth/with-token.d.ts.map +1 -0
- package/dist/auth/with-token.js +88 -0
- package/dist/auth/with-token.js.map +1 -0
- package/dist/context.d.ts +39 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +119 -0
- package/dist/context.js.map +1 -1
- package/dist/errors.d.ts +12 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +44 -5
- 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/gate.d.ts +29 -0
- package/dist/gate.d.ts.map +1 -0
- package/dist/gate.js +96 -0
- package/dist/gate.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/key-pool.d.ts +21 -4
- package/dist/key-pool.d.ts.map +1 -1
- package/dist/key-pool.js +43 -12
- package/dist/key-pool.js.map +1 -1
- package/dist/presets.d.ts +7 -2
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +12 -2
- package/dist/presets.js.map +1 -1
- package/dist/providers/anthropic.d.ts +7 -0
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +83 -26
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/factory.d.ts +26 -0
- package/dist/providers/factory.d.ts.map +1 -1
- package/dist/providers/factory.js +41 -9
- package/dist/providers/factory.js.map +1 -1
- package/dist/providers/gemini.d.ts +6 -0
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +102 -27
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/openai.d.ts +7 -0
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +66 -15
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/responses.d.ts +11 -0
- package/dist/providers/responses.d.ts.map +1 -1
- package/dist/providers/responses.js +101 -53
- package/dist/providers/responses.js.map +1 -1
- package/dist/rate-limit.d.ts +10 -0
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +7 -1
- package/dist/rate-limit.js.map +1 -1
- package/dist/registry.d.ts +105 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +291 -0
- package/dist/registry.js.map +1 -0
- package/dist/retry.d.ts +11 -0
- package/dist/retry.d.ts.map +1 -1
- package/dist/retry.js +25 -12
- package/dist/retry.js.map +1 -1
- package/dist/tool-args.d.ts +22 -0
- package/dist/tool-args.d.ts.map +1 -1
- package/dist/tool-args.js +28 -0
- package/dist/tool-args.js.map +1 -1
- package/dist/tools.d.ts +8 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +2 -2
- package/dist/tools.js.map +1 -1
- package/dist/transport.d.ts +34 -4
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +77 -16
- package/dist/transport.js.map +1 -1
- package/dist/turn.d.ts +55 -0
- package/dist/turn.d.ts.map +1 -0
- package/dist/turn.js +125 -0
- package/dist/turn.js.map +1 -0
- package/dist/types.d.ts +21 -22
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +0 -53
- package/dist/types.js.map +1 -1
- package/dist/watchdog.d.ts +2 -0
- package/dist/watchdog.d.ts.map +1 -1
- package/dist/watchdog.js +25 -6
- package/dist/watchdog.js.map +1 -1
- package/package.json +1 -1
- 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 +100 -0
- package/src/context.ts +137 -2
- package/src/errors.ts +46 -6
- package/src/fallback.ts +173 -75
- package/src/gate.ts +152 -0
- package/src/index.ts +3 -0
- package/src/key-pool.ts +45 -13
- package/src/presets.ts +12 -2
- package/src/providers/anthropic.ts +93 -25
- package/src/providers/factory.ts +66 -21
- package/src/providers/gemini.ts +102 -28
- package/src/providers/openai.ts +71 -14
- package/src/providers/responses.ts +116 -51
- package/src/rate-limit.ts +31 -3
- package/src/registry.ts +420 -0
- package/src/retry.ts +41 -10
- package/src/tool-args.ts +44 -0
- package/src/tools.ts +10 -2
- package/src/transport.ts +97 -17
- package/src/turn.ts +166 -0
- package/src/types.ts +21 -62
- package/src/watchdog.ts +25 -6
package/src/providers/gemini.ts
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
// its own chain of thought on the next turn. Written straight against the REST
|
|
6
6
|
// wire rather than @google/genai, because this package ships zero dependencies
|
|
7
7
|
// and the SDK is a Node-shaped one.
|
|
8
|
-
import {
|
|
8
|
+
import { repairHistory } from "../context.ts";
|
|
9
|
+
import { ProviderError, streamCut, streamError, type ErrorKind } from "../errors.ts";
|
|
9
10
|
import { schemaPrompt, toGeminiToolSchema } from "../schema.ts";
|
|
10
|
-
import { parseToolArgs } from "../tool-args.ts";
|
|
11
|
+
import { parseToolArgs, settleToolCalls } from "../tool-args.ts";
|
|
11
12
|
import { streamSse, apiUrl } from "../transport.ts";
|
|
12
13
|
import { withConfiguredFallbacks, type ProviderFallbackConfig } from "../fallback.ts";
|
|
13
14
|
import type {
|
|
@@ -133,6 +134,12 @@ function toResponseObject(content: string): Record<string, unknown> {
|
|
|
133
134
|
* and carry tool calls as `functionCall` parts with the thought signature
|
|
134
135
|
* beside them; tool results are role `user` with a `functionResponse` part
|
|
135
136
|
* naming the FUNCTION, since Gemini pairs a result to its call by name.
|
|
137
|
+
*
|
|
138
|
+
* A run of results is ONE user turn: Gemini answers a model turn's parallel
|
|
139
|
+
* calls with every response together, and spread over several turns it 400s
|
|
140
|
+
* that the response count does not match the call count. The responses go
|
|
141
|
+
* first, in call order, and the run's images follow them all, the way the
|
|
142
|
+
* OpenAI dialect places them.
|
|
136
143
|
*/
|
|
137
144
|
export function toGeminiContents(messages: readonly ChatMessage[]): {
|
|
138
145
|
system?: string;
|
|
@@ -140,8 +147,11 @@ export function toGeminiContents(messages: readonly ChatMessage[]): {
|
|
|
140
147
|
} {
|
|
141
148
|
let system = "";
|
|
142
149
|
const contents: GeminiContent[] = [];
|
|
150
|
+
// The open run of tool results: its parts, and how many lead with a response.
|
|
151
|
+
let results: { parts: unknown[]; responses: number } | undefined;
|
|
143
152
|
|
|
144
153
|
for (const message of messages) {
|
|
154
|
+
if (message.role === "user" || message.role === "assistant") results = undefined;
|
|
145
155
|
switch (message.role) {
|
|
146
156
|
case "system":
|
|
147
157
|
system = system ? `${system}\n\n${message.content}` : message.content;
|
|
@@ -181,23 +191,24 @@ export function toGeminiContents(messages: readonly ChatMessage[]): {
|
|
|
181
191
|
break;
|
|
182
192
|
}
|
|
183
193
|
|
|
184
|
-
case "tool":
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
parts:
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
...(message.images ?? []).map((image) => ({
|
|
196
|
-
inlineData: { mimeType: image.mimeType, data: image.data },
|
|
197
|
-
})),
|
|
198
|
-
],
|
|
194
|
+
case "tool": {
|
|
195
|
+
if (!results) {
|
|
196
|
+
results = { parts: [], responses: 0 };
|
|
197
|
+
contents.push({ role: "user", parts: results.parts });
|
|
198
|
+
}
|
|
199
|
+
results.parts.splice(results.responses, 0, {
|
|
200
|
+
functionResponse: {
|
|
201
|
+
id: message.toolCallId,
|
|
202
|
+
name: message.name,
|
|
203
|
+
response: toResponseObject(message.content),
|
|
204
|
+
},
|
|
199
205
|
});
|
|
206
|
+
results.responses += 1;
|
|
207
|
+
for (const image of message.images ?? []) {
|
|
208
|
+
results.parts.push({ inlineData: { mimeType: image.mimeType, data: image.data } });
|
|
209
|
+
}
|
|
200
210
|
break;
|
|
211
|
+
}
|
|
201
212
|
}
|
|
202
213
|
}
|
|
203
214
|
|
|
@@ -216,15 +227,66 @@ function toToolConfig(choice: ToolChoice | undefined): unknown {
|
|
|
216
227
|
return { functionCallingConfig: { mode: "ANY", allowedFunctionNames: [choice.name] } };
|
|
217
228
|
}
|
|
218
229
|
|
|
219
|
-
|
|
230
|
+
/**
|
|
231
|
+
* Every finish reason Gemini documents (the generate-content reference, read
|
|
232
|
+
* 2026-10-19), decided one by one. Anything this switch did not name used to
|
|
233
|
+
* read as `stop`, so a call the model botched (`MALFORMED_FUNCTION_CALL`) or a
|
|
234
|
+
* turn that ended for no stated reason (`OTHER`) came back as a finished, empty
|
|
235
|
+
* answer. After a tool result, `requireContent` takes an empty `stop` as the
|
|
236
|
+
* model choosing to say nothing, so nothing retried it.
|
|
237
|
+
*/
|
|
238
|
+
function mapFinishReason(provider: string, reason: string): FinishReason {
|
|
239
|
+
const fail = (kind: ErrorKind, why: string): never => {
|
|
240
|
+
throw new ProviderError(provider, kind, `${provider} ended the turn on ${reason}: ${why}`, {
|
|
241
|
+
code: reason,
|
|
242
|
+
});
|
|
243
|
+
};
|
|
220
244
|
switch (reason) {
|
|
245
|
+
case "STOP":
|
|
246
|
+
return "stop";
|
|
247
|
+
// The output cap. CONTINUATION is the same cap with a token to carry on,
|
|
248
|
+
// which this adapter never sends back.
|
|
221
249
|
case "MAX_TOKENS":
|
|
250
|
+
case "CONTINUATION":
|
|
222
251
|
return "length";
|
|
252
|
+
// A filter on what was said or drawn. The text so far stands and the
|
|
253
|
+
// finish says why it ended, as `content_filter` does on every wire. A
|
|
254
|
+
// retry would mostly meet the same filter.
|
|
223
255
|
case "SAFETY":
|
|
224
256
|
case "PROHIBITED_CONTENT":
|
|
257
|
+
case "BLOCKLIST":
|
|
258
|
+
case "SPII":
|
|
259
|
+
case "RECITATION":
|
|
260
|
+
case "LANGUAGE":
|
|
261
|
+
case "ESCALATION":
|
|
262
|
+
case "IMAGE_SAFETY":
|
|
263
|
+
case "IMAGE_PROHIBITED_CONTENT":
|
|
264
|
+
case "IMAGE_RECITATION":
|
|
225
265
|
return "content_filter";
|
|
266
|
+
// The request lost a thought signature, and the same request loses it
|
|
267
|
+
// again. A bug in what was sent, so nothing retries it.
|
|
268
|
+
case "MISSING_THOUGHT_SIGNATURE":
|
|
269
|
+
return fail("invalid", "the request is missing a thought signature");
|
|
270
|
+
// The account was limited for a policy violation. Benched like a refused
|
|
271
|
+
// key, so a key pool moves to another project.
|
|
272
|
+
case "PUP_LIMITED_DISABLED":
|
|
273
|
+
return fail("auth", "the account is limited or disabled");
|
|
274
|
+
// The model broke its own turn: a call it could not form, one with no tool
|
|
275
|
+
// offered, too many in a row, a malformed reply, an image it never made.
|
|
276
|
+
// The same request usually works on the next try.
|
|
277
|
+
case "MALFORMED_FUNCTION_CALL":
|
|
278
|
+
case "UNEXPECTED_TOOL_CALL":
|
|
279
|
+
case "TOO_MANY_TOOL_CALLS":
|
|
280
|
+
case "MALFORMED_RESPONSE":
|
|
281
|
+
case "NO_IMAGE":
|
|
282
|
+
return fail("overload", "the model broke its own turn");
|
|
283
|
+
// These say nothing about why, and a reason added after this list says
|
|
284
|
+
// nothing we can read. Neither proves the answer is whole.
|
|
285
|
+
case "OTHER":
|
|
286
|
+
case "IMAGE_OTHER":
|
|
287
|
+
case "FINISH_REASON_UNSPECIFIED":
|
|
226
288
|
default:
|
|
227
|
-
return "
|
|
289
|
+
return fail("overload", "it gave no reason that shows the answer is complete");
|
|
228
290
|
}
|
|
229
291
|
}
|
|
230
292
|
|
|
@@ -277,7 +339,9 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
|
|
|
277
339
|
): AsyncIterable<ProviderChunk> {
|
|
278
340
|
const effort = opts.effort ?? config.effort;
|
|
279
341
|
const maxTokens = opts.maxTokens ?? config.maxTokens;
|
|
280
|
-
|
|
342
|
+
// Repaired first: a result with no call, or a call with no result, is a
|
|
343
|
+
// 400 that loses the turn. See repairHistory.
|
|
344
|
+
const { system, contents } = toGeminiContents(repairHistory(messages).messages);
|
|
281
345
|
|
|
282
346
|
// A model id copied out of Gemini's docs is often already `models/…`, and
|
|
283
347
|
// the doubled segment 404s as "model not found" — a confusing way to
|
|
@@ -385,6 +449,11 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
|
|
|
385
449
|
...(opts.signal ? { signal: opts.signal } : {}),
|
|
386
450
|
...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
|
|
387
451
|
...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
|
|
452
|
+
...(opts.onRateLimit ? { onRateLimit: opts.onRateLimit } : {}),
|
|
453
|
+
// This wire has no terminal event to stop at, so the finish reason is
|
|
454
|
+
// the end: a read that dies after it can cost a late usage record, never
|
|
455
|
+
// the answer.
|
|
456
|
+
finished: () => finished,
|
|
388
457
|
})) {
|
|
389
458
|
let chunk: GeminiResponse;
|
|
390
459
|
try {
|
|
@@ -442,12 +511,14 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
|
|
|
442
511
|
const call = part.functionCall;
|
|
443
512
|
if (!call) continue;
|
|
444
513
|
const index = callIndex++;
|
|
514
|
+
const identity = { index, id: call.id ?? "", name: call.name ?? "" };
|
|
515
|
+
// The part is the whole call, so it is settled here: one with no
|
|
516
|
+
// name can't run, and one with no id gets one the way the other
|
|
517
|
+
// three wires mint it. Gemini omits the id on a single-call turn, and
|
|
518
|
+
// the seam's consumers pair a result back to its call by id.
|
|
519
|
+
settleToolCalls(id, [identity]);
|
|
445
520
|
toolCalls.push({
|
|
446
|
-
|
|
447
|
-
// Gemini omits the id on a single-call turn, and the seam's
|
|
448
|
-
// consumers pair a result back to its call by id.
|
|
449
|
-
id: call.id ?? `call_${index}`,
|
|
450
|
-
...(call.name ? { name: call.name } : {}),
|
|
521
|
+
...identity,
|
|
451
522
|
// Whole and already assembled — unlike the OpenAI shape, Gemini
|
|
452
523
|
// never fragments an argument object across chunks.
|
|
453
524
|
arguments: JSON.stringify(call.args ?? {}),
|
|
@@ -463,13 +534,16 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
|
|
|
463
534
|
}
|
|
464
535
|
|
|
465
536
|
if (candidate.finishReason) {
|
|
537
|
+
// Mapped first: a reason that throws must not be dressed as tool_calls.
|
|
538
|
+
const finishReason = mapFinishReason(id, candidate.finishReason);
|
|
466
539
|
finished = true;
|
|
467
540
|
yield {
|
|
468
541
|
type: "finish",
|
|
469
|
-
//
|
|
470
|
-
// candidate says — Gemini routinely reports STOP there, and a
|
|
542
|
+
// Gemini routinely reports STOP on a turn that called tools, and a
|
|
471
543
|
// caller reading that as "done" drops the tool round entirely.
|
|
472
|
-
|
|
544
|
+
// Only STOP is promoted, as on the other three wires: a length cut
|
|
545
|
+
// or a filter may be what broke the call (invariant 10).
|
|
546
|
+
finishReason: sawFunctionCalls && finishReason === "stop" ? "tool_calls" : finishReason,
|
|
473
547
|
};
|
|
474
548
|
}
|
|
475
549
|
}
|
package/src/providers/openai.ts
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
// This is the dialect most gateways speak, so one adapter serves OpenAI,
|
|
4
4
|
// OpenRouter, DeepSeek, GLM, Kimi, Groq, Together, vLLM, Ollama and LM Studio.
|
|
5
5
|
// Their divergences are small and named where they appear.
|
|
6
|
+
import { repairHistory } from "../context.ts";
|
|
6
7
|
import { fileRefused, ProviderError, streamCut, streamError } from "../errors.ts";
|
|
7
|
-
import { streamSse, apiUrl } from "../transport.ts";
|
|
8
|
+
import { streamSse, apiUrl, parseFrame } from "../transport.ts";
|
|
9
|
+
import { settleToolCalls, type ToolCallIdentity } from "../tool-args.ts";
|
|
8
10
|
import { attributionHeaders } from "../attribution.ts";
|
|
9
11
|
import type {
|
|
10
12
|
ChatMessage,
|
|
@@ -105,6 +107,13 @@ export interface OpenAIConfig extends ProviderFallbackConfig {
|
|
|
105
107
|
* no sessionId, the provider's own per-instance id rides instead, so the
|
|
106
108
|
* gateway never sees a call without one. */
|
|
107
109
|
sessionHeader?: string;
|
|
110
|
+
/** The id `sessionHeader` carries when a call has no `sessionId`. Absent, the
|
|
111
|
+
* adapter mints one when it is built. Pass one when something rebuilds the
|
|
112
|
+
* adapter (`withKeyPool` and `withToken` build one per attempt): each build
|
|
113
|
+
* mints a new id, and a gateway that pins a session to one upstream host
|
|
114
|
+
* then treats every call as a new session, re-routes it and misses its
|
|
115
|
+
* prompt cache. Ignored without `sessionHeader`. */
|
|
116
|
+
instanceSessionId?: string;
|
|
108
117
|
/** Send `X-Initiator`: `user` when the last message is the user's, else
|
|
109
118
|
* `agent`. GitHub Copilot bills a premium request per `user` turn and lets
|
|
110
119
|
* the agent's own follow-ups (tool results) ride free. */
|
|
@@ -328,19 +337,43 @@ function dialectFor(id: string): EffortDialect {
|
|
|
328
337
|
return "openai";
|
|
329
338
|
}
|
|
330
339
|
|
|
331
|
-
|
|
340
|
+
/**
|
|
341
|
+
* A stated finish, or a throw for one this dialect does not document. An
|
|
342
|
+
* unknown reason used to end the stream as a clean success with no finish at
|
|
343
|
+
* all, so DeepSeek's `insufficient_system_resource`, Z.ai's `network_error` and
|
|
344
|
+
* OpenRouter's `error` each read as a finished answer. Nothing says the answer
|
|
345
|
+
* ended on purpose, so it is `overload`: theirs, and worth a retry (cc-proxy
|
|
346
|
+
* answers it the same way).
|
|
347
|
+
*/
|
|
348
|
+
function mapFinishReason(provider: string, reason: string): FinishReason {
|
|
332
349
|
switch (reason) {
|
|
350
|
+
// A natural end under another name, from servers on this wire: Together's
|
|
351
|
+
// `eos`, Hugging Face TGI's `eos_token` and `stop_sequence`. Thrown as
|
|
352
|
+
// unknown, each would fail an answer that was already whole.
|
|
333
353
|
case "stop":
|
|
354
|
+
case "eos":
|
|
355
|
+
case "eos_token":
|
|
356
|
+
case "stop_sequence":
|
|
334
357
|
return "stop";
|
|
358
|
+
// Z.ai's `model_context_window_exceeded` and Mistral's `model_length`: the
|
|
359
|
+
// context window filled before the answer did. Cut short, and a retry fills
|
|
360
|
+
// it the same way.
|
|
335
361
|
case "length":
|
|
362
|
+
case "model_context_window_exceeded":
|
|
363
|
+
case "model_length":
|
|
336
364
|
return "length";
|
|
337
365
|
case "tool_calls":
|
|
338
366
|
case "function_call":
|
|
339
367
|
return "tool_calls";
|
|
368
|
+
// `sensitive` is Z.ai's name for its safety stop. A retry meets the same
|
|
369
|
+
// filter.
|
|
340
370
|
case "content_filter":
|
|
371
|
+
case "sensitive":
|
|
341
372
|
return "content_filter";
|
|
342
373
|
default:
|
|
343
|
-
|
|
374
|
+
throw new ProviderError(provider, "overload", `${provider} ended the turn on "${reason}"`, {
|
|
375
|
+
code: reason,
|
|
376
|
+
});
|
|
344
377
|
}
|
|
345
378
|
}
|
|
346
379
|
|
|
@@ -505,7 +538,9 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
|
505
538
|
// `usage.cost` is its field in its unit. Another gateway that sends a
|
|
506
539
|
// `cost` has not said what it means.
|
|
507
540
|
const reportsCost = isOpenRouter(baseUrl);
|
|
508
|
-
const instanceSessionId = config.sessionHeader
|
|
541
|
+
const instanceSessionId = config.sessionHeader
|
|
542
|
+
? (config.instanceSessionId ?? crypto.randomUUID())
|
|
543
|
+
: undefined;
|
|
509
544
|
|
|
510
545
|
const provider: Provider = {
|
|
511
546
|
id,
|
|
@@ -519,7 +554,9 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
|
519
554
|
const effort = opts.effort ?? config.effort;
|
|
520
555
|
const model = opts.model ?? config.model;
|
|
521
556
|
|
|
522
|
-
|
|
557
|
+
// Repaired first: a result with no call, or a call with no result, is a
|
|
558
|
+
// 400 that loses the turn. See repairHistory.
|
|
559
|
+
const body = toOpenAIMessages(repairHistory(messages).messages, id);
|
|
523
560
|
const request: Record<string, unknown> = {
|
|
524
561
|
model,
|
|
525
562
|
messages: body,
|
|
@@ -624,6 +661,9 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
|
624
661
|
: "/v1/chat/completions";
|
|
625
662
|
|
|
626
663
|
let ended = false;
|
|
664
|
+
// Each call as its deltas named it, by index. The finish checks them
|
|
665
|
+
// before it goes out, and they are what says the turn called a tool.
|
|
666
|
+
const calls = new Map<number, ToolCallIdentity>();
|
|
627
667
|
for await (const data of streamSse({
|
|
628
668
|
url: apiUrl(baseUrl, config.path ?? defaultPath),
|
|
629
669
|
headers: {
|
|
@@ -642,16 +682,16 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
|
642
682
|
...(opts.signal ? { signal: opts.signal } : {}),
|
|
643
683
|
...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
|
|
644
684
|
...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
|
|
685
|
+
...(opts.onRateLimit ? { onRateLimit: opts.onRateLimit } : {}),
|
|
645
686
|
onDone: () => {
|
|
646
687
|
ended = true;
|
|
647
688
|
},
|
|
689
|
+
// Usage comes AFTER the finish on this shape, so the reader keeps going
|
|
690
|
+
// to `[DONE]` (invariant 5). But a read that dies in between costs only
|
|
691
|
+
// the usage frame: the answer is whole once its finish is stated.
|
|
692
|
+
finished: () => ended,
|
|
648
693
|
})) {
|
|
649
|
-
|
|
650
|
-
try {
|
|
651
|
-
chunk = JSON.parse(data) as OpenAIChunk;
|
|
652
|
-
} catch {
|
|
653
|
-
continue;
|
|
654
|
-
}
|
|
694
|
+
const chunk = parseFrame<OpenAIChunk>(data, id);
|
|
655
695
|
|
|
656
696
|
// A failure the backend reports after its headers went out. The
|
|
657
697
|
// gateways speaking this dialect — OpenRouter above all — report a
|
|
@@ -723,18 +763,35 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
|
723
763
|
? { arguments: call.function.arguments }
|
|
724
764
|
: {}),
|
|
725
765
|
}));
|
|
766
|
+
for (const call of out.toolCalls) {
|
|
767
|
+
const seen = calls.get(call.index) ?? { index: call.index, id: "", name: "" };
|
|
768
|
+
if (call.id) seen.id = call.id;
|
|
769
|
+
if (call.name) seen.name = call.name;
|
|
770
|
+
calls.set(call.index, seen);
|
|
771
|
+
}
|
|
726
772
|
has = true;
|
|
727
773
|
}
|
|
728
774
|
if (has) yield out;
|
|
729
775
|
}
|
|
730
776
|
|
|
731
|
-
if (choice.finish_reason)
|
|
732
|
-
|
|
733
|
-
|
|
777
|
+
if (choice.finish_reason) {
|
|
778
|
+
const stated = mapFinishReason(id, choice.finish_reason);
|
|
779
|
+
const minted = settleToolCalls(id, calls.values());
|
|
780
|
+
if (minted.length > 0) yield { type: "delta", toolCalls: minted };
|
|
781
|
+
ended = true;
|
|
782
|
+
// Several gateways end a turn that called a tool on `stop`, and a
|
|
783
|
+
// caller reading that as done never runs the tool. Only `stop` is
|
|
784
|
+
// promoted: a length cut or a filter may be what broke the call.
|
|
785
|
+
const finishReason = stated === "stop" && calls.size > 0 ? "tool_calls" : stated;
|
|
786
|
+
yield { type: "finish", finishReason };
|
|
787
|
+
}
|
|
734
788
|
}
|
|
735
789
|
// Either proof will do: some gateways send `[DONE]` with no finish, and
|
|
736
790
|
// some a finish with no `[DONE]`. Neither means the socket closed early.
|
|
737
791
|
if (!ended) throw streamCut(id);
|
|
792
|
+
// A `[DONE]` with no finish stated still owes its calls the same check.
|
|
793
|
+
const minted = settleToolCalls(id, calls.values());
|
|
794
|
+
if (minted.length > 0) yield { type: "delta", toolCalls: minted };
|
|
738
795
|
},
|
|
739
796
|
};
|
|
740
797
|
|
|
@@ -10,8 +10,11 @@
|
|
|
10
10
|
// The event names arrive on the SSE `event:` line and are repeated inside each
|
|
11
11
|
// payload's own `type`. The transport yields only `data:` payloads, so this
|
|
12
12
|
// adapter reads `type` — which is what survives, and what gateways agree on.
|
|
13
|
+
import { repairHistory } from "../context.ts";
|
|
13
14
|
import { fileRefused, ProviderError, streamCut, streamError } from "../errors.ts";
|
|
14
|
-
import { streamSse, apiUrl } from "../transport.ts";
|
|
15
|
+
import { streamSse, apiUrl, parseFrame } from "../transport.ts";
|
|
16
|
+
import { parseRateLimitReset } from "../rate-limit.ts";
|
|
17
|
+
import { settleToolCalls } from "../tool-args.ts";
|
|
15
18
|
import type {
|
|
16
19
|
ChatMessage,
|
|
17
20
|
ContentPart,
|
|
@@ -45,6 +48,17 @@ export interface ResponsesConfig extends ProviderFallbackConfig {
|
|
|
45
48
|
/** Header that carries `StreamOptions.sessionId`. When set and a call has no
|
|
46
49
|
* sessionId, one stable id for this provider instance rides instead. */
|
|
47
50
|
sessionHeader?: string;
|
|
51
|
+
/** The id `sessionHeader` carries when a call has no `sessionId`. Absent, the
|
|
52
|
+
* adapter mints one when it is built. Pass one when something rebuilds the
|
|
53
|
+
* adapter (`withKeyPool` and `withToken` build one per attempt): each build
|
|
54
|
+
* mints a new id, and a gateway that pins a session to one upstream host
|
|
55
|
+
* then treats every call as a new session, re-routes it and misses its
|
|
56
|
+
* prompt cache. Ignored without `sessionHeader`.
|
|
57
|
+
*
|
|
58
|
+
* It is not sent as `prompt_cache_key`, which stays the per-call
|
|
59
|
+
* `sessionId`'s: this id names the adapter, so it is shared by every
|
|
60
|
+
* conversation the adapter serves, and a minted one never was a cache key. */
|
|
61
|
+
instanceSessionId?: string;
|
|
48
62
|
/**
|
|
49
63
|
* Where this backend serves the endpoint, when it is not `/v1/responses`.
|
|
50
64
|
* The ChatGPT subscription surface serves it at `/backend-api/codex/responses`
|
|
@@ -82,13 +96,26 @@ function reasoningEffort(model: string, effort: Effort): string {
|
|
|
82
96
|
}
|
|
83
97
|
|
|
84
98
|
/**
|
|
85
|
-
* `
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
99
|
+
* The finish an `incomplete` turn stands for. This shape documents two
|
|
100
|
+
* reasons, and each has its own finish: `max_output_tokens` is the output cap
|
|
101
|
+
* (`length`) and `content_filter` the filter.
|
|
102
|
+
*
|
|
103
|
+
* Any other reason, or none, says only that the turn did not end on purpose.
|
|
104
|
+
* It used to read as `length`, so an empty turn was told to raise a maxTokens
|
|
105
|
+
* it never hit. It is thrown as `overload` instead, the transient fault
|
|
106
|
+
* cc-proxy answers it with (a 503). Nothing about the request is wrong, so it
|
|
107
|
+
* is retried if nothing was emitted yet; if something was, the error reaches
|
|
108
|
+
* the caller after it (invariant 1). It is read before reasoning and usage go
|
|
109
|
+
* out, like a failed terminal, so that retry stays open.
|
|
89
110
|
*/
|
|
90
|
-
function
|
|
91
|
-
|
|
111
|
+
function incompleteFinish(provider: string, reason: string | undefined): FinishReason {
|
|
112
|
+
if (reason === "max_output_tokens") return "length";
|
|
113
|
+
if (reason === "content_filter") return "content_filter";
|
|
114
|
+
throw new ProviderError(
|
|
115
|
+
provider,
|
|
116
|
+
"overload",
|
|
117
|
+
`${provider}: the turn stopped early, reason: ${reason ?? "none given"}`,
|
|
118
|
+
);
|
|
92
119
|
}
|
|
93
120
|
|
|
94
121
|
// ── input items ───────────────────────────────────────────────────────────
|
|
@@ -282,8 +309,11 @@ interface ResponsesEvent {
|
|
|
282
309
|
headers?: Record<string, unknown>;
|
|
283
310
|
}
|
|
284
311
|
|
|
312
|
+
/** One window of the snapshot: the `x-codex-<slot>-*` header numbers as JSON. */
|
|
285
313
|
interface CodexWindow {
|
|
286
314
|
used_percent?: number;
|
|
315
|
+
window_minutes?: number;
|
|
316
|
+
reset_at?: number;
|
|
287
317
|
reset_after_seconds?: number;
|
|
288
318
|
}
|
|
289
319
|
|
|
@@ -301,24 +331,42 @@ interface CodexRateLimits {
|
|
|
301
331
|
* A full window the account's credits cover, or that the backend still
|
|
302
332
|
* `allowed`, lets the turn proceed. It is only the reason when the stream then
|
|
303
333
|
* closes without a response: that close is a spent window, not a dropped
|
|
304
|
-
* socket, and retrying it walks every backup into the same wall.
|
|
305
|
-
*
|
|
306
|
-
*
|
|
334
|
+
* socket, and retrying it walks every backup into the same wall.
|
|
335
|
+
*
|
|
336
|
+
* Which window, and when it lifts, comes from the same parser as the headers
|
|
337
|
+
* the snapshot mirrors: the fuller window binds, and the later reset when both
|
|
338
|
+
* are full. cc-proxy took the primary's clock first and told a weekly wall to
|
|
339
|
+
* come back in a few hours. The window is named so a caller can say "weekly"
|
|
340
|
+
* instead of "the limit", which reads as the 5-hour one.
|
|
307
341
|
*/
|
|
308
342
|
function quotaWall(event: ResponsesEvent, provider: string): ProviderError | undefined {
|
|
309
343
|
const limits = event.rate_limits;
|
|
310
344
|
if (!limits?.limit_reached || limits.allowed) return undefined;
|
|
311
345
|
if (event.credits?.has_credits || event.credits?.unlimited) return undefined;
|
|
312
|
-
const
|
|
313
|
-
const
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
346
|
+
const now = Date.now();
|
|
347
|
+
const { window, resetAtMs } = parseRateLimitReset(codexHeaders(limits), now);
|
|
348
|
+
const named = window ? ` (${window} window)` : "";
|
|
349
|
+
const message = `${provider}: the usage limit has been reached${named}`;
|
|
350
|
+
return new ProviderError(provider, "quota", message, {
|
|
351
|
+
...(resetAtMs !== undefined ? { retryAfterMs: resetAtMs - now, resetAtMs } : {}),
|
|
352
|
+
window,
|
|
319
353
|
});
|
|
320
354
|
}
|
|
321
355
|
|
|
356
|
+
/** The snapshot as the `x-codex-*` headers it mirrors, rebuilt the way
|
|
357
|
+
* cc-proxy's `event_headers` does, so one parser reads both. */
|
|
358
|
+
function codexHeaders(limits: CodexRateLimits): Headers {
|
|
359
|
+
const headers = new Headers();
|
|
360
|
+
for (const slot of ["primary", "secondary"] as const) {
|
|
361
|
+
for (const [field, value] of Object.entries(limits[slot] ?? {})) {
|
|
362
|
+
if (typeof value === "number") {
|
|
363
|
+
headers.set(`x-codex-${slot}-${field.replaceAll("_", "-")}`, String(value));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return headers;
|
|
368
|
+
}
|
|
369
|
+
|
|
322
370
|
function usageChunk(usage: ResponsesUsage): ProviderChunk {
|
|
323
371
|
return {
|
|
324
372
|
type: "usage",
|
|
@@ -358,7 +406,9 @@ function unsentTail(snapshot: string, streamed: string): string {
|
|
|
358
406
|
export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
359
407
|
const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
|
|
360
408
|
const id = config.id ?? "openai-responses";
|
|
361
|
-
const instanceSessionId = config.sessionHeader
|
|
409
|
+
const instanceSessionId = config.sessionHeader
|
|
410
|
+
? (config.instanceSessionId ?? crypto.randomUUID())
|
|
411
|
+
: undefined;
|
|
362
412
|
|
|
363
413
|
const provider: Provider = {
|
|
364
414
|
id,
|
|
@@ -373,8 +423,11 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
|
373
423
|
const model = opts.model ?? config.model;
|
|
374
424
|
const museContributor = isMuseSparkContributor(model);
|
|
375
425
|
const requestTools = museContributor && opts.toolChoice === "none" ? [] : tools;
|
|
426
|
+
// Repaired before the images are gated: an orphaned result becomes a user
|
|
427
|
+
// note that keeps its images, and those must meet the limits too.
|
|
428
|
+
const sent = repairHistory(messages).messages;
|
|
376
429
|
const { instructions, input } = toResponsesInput(
|
|
377
|
-
config.imageLimits ? gateImages(
|
|
430
|
+
config.imageLimits ? gateImages(sent, config.imageLimits) : sent,
|
|
378
431
|
id,
|
|
379
432
|
);
|
|
380
433
|
|
|
@@ -457,9 +510,10 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
|
457
510
|
const pendingFor = (itemId?: string, callId?: string) =>
|
|
458
511
|
(itemId ? pending.get(itemId) : undefined) ?? (callId ? pending.get(callId) : undefined);
|
|
459
512
|
let nextIndex = 0;
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
|
|
513
|
+
// Every call the turn opened, kept past its `.done`. This shape never
|
|
514
|
+
// states a stop reason on a clean finish, so it is inferred from whether
|
|
515
|
+
// there are any, and the terminal event checks each one can run.
|
|
516
|
+
const calls: PendingCall[] = [];
|
|
463
517
|
let wall: ProviderError | undefined;
|
|
464
518
|
let produced = false;
|
|
465
519
|
const reasoningItems: unknown[] = [];
|
|
@@ -482,13 +536,9 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
|
482
536
|
...(opts.signal ? { signal: opts.signal } : {}),
|
|
483
537
|
...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
|
|
484
538
|
...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
|
|
539
|
+
...(opts.onRateLimit ? { onRateLimit: opts.onRateLimit } : {}),
|
|
485
540
|
})) {
|
|
486
|
-
|
|
487
|
-
try {
|
|
488
|
-
event = JSON.parse(data) as ResponsesEvent;
|
|
489
|
-
} catch {
|
|
490
|
-
continue; // a keep-alive or a frame we do not model
|
|
491
|
-
}
|
|
541
|
+
const event = parseFrame<ResponsesEvent>(data, id);
|
|
492
542
|
if (event.type?.endsWith(".delta") || event.type === "response.output_item.added") {
|
|
493
543
|
produced = true;
|
|
494
544
|
}
|
|
@@ -515,7 +565,6 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
|
515
565
|
// turn came back as a nameless call with empty arguments.
|
|
516
566
|
const key = item.id ?? item.call_id;
|
|
517
567
|
if (!key) break;
|
|
518
|
-
sawToolCall = true;
|
|
519
568
|
const index = nextIndex++;
|
|
520
569
|
// Normally empty here, but a backend that already has the whole
|
|
521
570
|
// call sends it in the skeleton.
|
|
@@ -526,6 +575,7 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
|
526
575
|
name: item.name ?? "",
|
|
527
576
|
streamed: seeded,
|
|
528
577
|
};
|
|
578
|
+
calls.push(call);
|
|
529
579
|
pending.set(key, call);
|
|
530
580
|
if (item.call_id) pending.set(item.call_id, call);
|
|
531
581
|
// Only the fields the skeleton actually states. An empty `id` here
|
|
@@ -582,20 +632,24 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
|
582
632
|
|
|
583
633
|
if (!known) {
|
|
584
634
|
// A backend that emits neither the skeleton nor the deltas — the
|
|
585
|
-
// whole call arrives here or not at all.
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
635
|
+
// whole call arrives here or not at all. A call with no `call_id`
|
|
636
|
+
// used to be dropped, because the caller's `function_call_output`
|
|
637
|
+
// would quote `""` and take a 400 one turn later. Dropped, the
|
|
638
|
+
// model's call silently never happened. It gets an id at the
|
|
639
|
+
// terminal event instead (`settleToolCalls`).
|
|
640
|
+
const call: PendingCall = {
|
|
641
|
+
index: nextIndex++,
|
|
642
|
+
id: item.call_id ?? "",
|
|
643
|
+
name: item.name ?? "",
|
|
644
|
+
streamed: snapshot,
|
|
645
|
+
};
|
|
646
|
+
calls.push(call);
|
|
593
647
|
yield {
|
|
594
648
|
type: "delta",
|
|
595
649
|
toolCalls: [
|
|
596
650
|
{
|
|
597
|
-
index:
|
|
598
|
-
id: item.call_id,
|
|
651
|
+
index: call.index,
|
|
652
|
+
...(item.call_id ? { id: item.call_id } : {}),
|
|
599
653
|
...(item.name ? { name: item.name } : {}),
|
|
600
654
|
arguments: snapshot,
|
|
601
655
|
},
|
|
@@ -617,6 +671,8 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
|
617
671
|
...(item.name && item.name !== known.name ? { name: item.name } : {}),
|
|
618
672
|
...(tail ? { arguments: tail } : {}),
|
|
619
673
|
};
|
|
674
|
+
known.id = item.call_id || known.id;
|
|
675
|
+
known.name = item.name || known.name;
|
|
620
676
|
if (Object.keys(restated).length > 0) {
|
|
621
677
|
yield { type: "delta", toolCalls: [{ index: known.index, ...restated }] };
|
|
622
678
|
}
|
|
@@ -635,16 +691,27 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
|
635
691
|
if (response?.status === "failed" || response?.error) {
|
|
636
692
|
throw streamError(id, response.error ?? { message: "response failed" });
|
|
637
693
|
}
|
|
694
|
+
// Any other status (`cancelled`, `queued`, one added later) says
|
|
695
|
+
// nothing about the answer being whole. Read as a clean stop, it
|
|
696
|
+
// was a finished turn nobody retried. Theirs, so `overload`.
|
|
697
|
+
const status = response?.status;
|
|
698
|
+
if (status !== undefined && status !== "completed" && status !== "incomplete") {
|
|
699
|
+
throw new ProviderError(id, "overload", `${id} ended the turn as "${status}"`, {
|
|
700
|
+
code: status,
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
let finishReason: FinishReason = calls.length > 0 ? "tool_calls" : "stop";
|
|
704
|
+
if (status === "incomplete") {
|
|
705
|
+
finishReason = incompleteFinish(id, response?.incomplete_details?.reason);
|
|
706
|
+
} else {
|
|
707
|
+
// Checked on a whole turn only: a cut one may have lost a call's
|
|
708
|
+
// name to the cut, and nobody runs a cut turn's calls.
|
|
709
|
+
const minted = settleToolCalls(id, calls);
|
|
710
|
+
if (minted.length > 0) yield { type: "delta", toolCalls: minted };
|
|
711
|
+
}
|
|
638
712
|
yield* reasoningChunk();
|
|
639
713
|
if (response?.usage) yield usageChunk(response.usage);
|
|
640
|
-
|
|
641
|
-
yield {
|
|
642
|
-
type: "finish",
|
|
643
|
-
finishReason: mapIncompleteReason(response.incomplete_details?.reason),
|
|
644
|
-
};
|
|
645
|
-
return;
|
|
646
|
-
}
|
|
647
|
-
yield { type: "finish", finishReason: sawToolCall ? "tool_calls" : "stop" };
|
|
714
|
+
yield { type: "finish", finishReason };
|
|
648
715
|
return;
|
|
649
716
|
}
|
|
650
717
|
|
|
@@ -655,13 +722,11 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
|
|
|
655
722
|
case "response.incomplete": {
|
|
656
723
|
// A turn that ran out of output tokens still billed for its input,
|
|
657
724
|
// and this event carries usage in the same shape as `completed`.
|
|
725
|
+
const finishReason = incompleteFinish(id, event.response?.incomplete_details?.reason);
|
|
658
726
|
yield* reasoningChunk();
|
|
659
727
|
const usage = event.response?.usage;
|
|
660
728
|
if (usage) yield usageChunk(usage);
|
|
661
|
-
yield {
|
|
662
|
-
type: "finish",
|
|
663
|
-
finishReason: mapIncompleteReason(event.response?.incomplete_details?.reason),
|
|
664
|
-
};
|
|
729
|
+
yield { type: "finish", finishReason };
|
|
665
730
|
return;
|
|
666
731
|
}
|
|
667
732
|
|