@providerkit/core 0.22.0 → 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/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/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/key-pool.d.ts +10 -4
- package/dist/key-pool.d.ts.map +1 -1
- package/dist/key-pool.js +29 -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.map +1 -1
- package/dist/providers/anthropic.js +80 -25
- package/dist/providers/anthropic.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.map +1 -1
- package/dist/providers/openai.js +63 -14
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/responses.d.ts.map +1 -1
- package/dist/providers/responses.js +98 -52
- 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/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/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/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +0 -8
- 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 +97 -0
- package/src/context.ts +137 -2
- package/src/errors.ts +46 -6
- package/src/fallback.ts +173 -75
- package/src/key-pool.ts +30 -13
- package/src/presets.ts +12 -2
- package/src/providers/anthropic.ts +83 -24
- package/src/providers/gemini.ts +102 -28
- package/src/providers/openai.ts +61 -13
- package/src/providers/responses.ts +102 -50
- package/src/rate-limit.ts +31 -3
- package/src/retry.ts +41 -10
- package/src/tool-args.ts +44 -0
- package/src/transport.ts +97 -17
- package/src/types.ts +13 -0
- package/src/watchdog.ts +25 -6
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Anthropic-shape adapter — SSE from POST /v1/messages.
|
|
2
|
+
import { repairHistory } from "../context.ts";
|
|
2
3
|
import { fileRefused, ProviderError, streamCut, streamError } from "../errors.ts";
|
|
3
4
|
import { schemaPrompt, toAnthropicToolSchema } from "../schema.ts";
|
|
4
|
-
import { parseToolArgs } from "../tool-args.ts";
|
|
5
|
-
import { streamSse, apiUrl } from "../transport.ts";
|
|
5
|
+
import { parseToolArgs, settleToolCalls, type ToolCallIdentity } from "../tool-args.ts";
|
|
6
|
+
import { streamSse, apiUrl, parseFrame } from "../transport.ts";
|
|
6
7
|
import { attributionHeaders } from "../attribution.ts";
|
|
7
8
|
import { withConfiguredFallbacks, type ProviderFallbackConfig } from "../fallback.ts";
|
|
8
9
|
import type {
|
|
@@ -191,19 +192,32 @@ function continuesToolLoop(messages: readonly ChatMessage[]): boolean {
|
|
|
191
192
|
return false;
|
|
192
193
|
}
|
|
193
194
|
|
|
194
|
-
|
|
195
|
+
/**
|
|
196
|
+
* A stated stop, or a throw for one this adapter does not know. An unknown
|
|
197
|
+
* reason used to end the stream as a clean success with no finish at all.
|
|
198
|
+
* Nothing says the answer ended on purpose, so it is `overload`: theirs, and
|
|
199
|
+
* worth a retry (cc-proxy answers it the same way). `pause_turn` lands there
|
|
200
|
+
* too: it is a server-tool turn waiting to be sent back, which this adapter
|
|
201
|
+
* never asks for, and a half answer is not a finished one.
|
|
202
|
+
*/
|
|
203
|
+
function mapStopReason(provider: string, reason: string): FinishReason {
|
|
195
204
|
switch (reason) {
|
|
196
205
|
case "end_turn":
|
|
197
206
|
case "stop_sequence":
|
|
198
207
|
return "stop";
|
|
199
208
|
case "tool_use":
|
|
200
209
|
return "tool_calls";
|
|
210
|
+
// `model_context_window_exceeded`: the context window filled before the
|
|
211
|
+
// answer did. Cut short, and a retry fills it the same way.
|
|
201
212
|
case "max_tokens":
|
|
213
|
+
case "model_context_window_exceeded":
|
|
202
214
|
return "length";
|
|
203
215
|
case "refusal":
|
|
204
216
|
return "content_filter";
|
|
205
217
|
default:
|
|
206
|
-
|
|
218
|
+
throw new ProviderError(provider, "overload", `${provider} stopped the turn on "${reason}"`, {
|
|
219
|
+
code: reason,
|
|
220
|
+
});
|
|
207
221
|
}
|
|
208
222
|
}
|
|
209
223
|
|
|
@@ -270,6 +284,34 @@ function jsonBlock(json: JsonOutput): unknown {
|
|
|
270
284
|
return { type: "text", text: schemaPrompt(json.schema) };
|
|
271
285
|
}
|
|
272
286
|
|
|
287
|
+
/**
|
|
288
|
+
* Tool ids as this wire accepts them: `[A-Za-z0-9_-]` only. Kimi's
|
|
289
|
+
* `functions.search:0` is a 400 here, and an opencode-go rotation hands a
|
|
290
|
+
* history from chat completions to Messages mid-conversation, so a Kimi id
|
|
291
|
+
* does arrive. Each other character becomes `_`, and a result follows its call
|
|
292
|
+
* because both go through the same map.
|
|
293
|
+
*
|
|
294
|
+
* Collision-safe in one forward pass: an id whose cleaned form is already
|
|
295
|
+
* taken gets `_2`, `_3`…, judged only against ids seen before it, so an earlier
|
|
296
|
+
* id never changes because of a later one and the prompt cache keeps its
|
|
297
|
+
* prefix. A history whose ids are already clean comes out byte for byte.
|
|
298
|
+
*/
|
|
299
|
+
function anthropicIds(): (id: string) => string {
|
|
300
|
+
const sent = new Map<string, string>();
|
|
301
|
+
const taken = new Set<string>();
|
|
302
|
+
return (id) => {
|
|
303
|
+
let wire = sent.get(id);
|
|
304
|
+
if (wire === undefined) {
|
|
305
|
+
const clean = id.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
306
|
+
wire = clean;
|
|
307
|
+
for (let k = 2; taken.has(wire); k++) wire = `${clean}_${k}`;
|
|
308
|
+
sent.set(id, wire);
|
|
309
|
+
taken.add(wire);
|
|
310
|
+
}
|
|
311
|
+
return wire;
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
273
315
|
export function toAnthropicMessages(
|
|
274
316
|
messages: readonly ChatMessage[],
|
|
275
317
|
provider = "anthropic",
|
|
@@ -277,6 +319,7 @@ export function toAnthropicMessages(
|
|
|
277
319
|
system?: unknown[];
|
|
278
320
|
messages: unknown[];
|
|
279
321
|
} {
|
|
322
|
+
const wireId = anthropicIds();
|
|
280
323
|
const system = messages
|
|
281
324
|
.filter((m) => m.role === "system")
|
|
282
325
|
.map((m) => m.content)
|
|
@@ -299,7 +342,7 @@ export function toAnthropicMessages(
|
|
|
299
342
|
pushBlocks("user", [
|
|
300
343
|
{
|
|
301
344
|
type: "tool_result",
|
|
302
|
-
tool_use_id: message.toolCallId,
|
|
345
|
+
tool_use_id: wireId(message.toolCallId),
|
|
303
346
|
content: [
|
|
304
347
|
{ type: "text", text: message.content },
|
|
305
348
|
...(message.images ?? []).map((image) => ({
|
|
@@ -319,7 +362,7 @@ export function toAnthropicMessages(
|
|
|
319
362
|
for (const call of message.toolCalls ?? []) {
|
|
320
363
|
blocks.push({
|
|
321
364
|
type: "tool_use",
|
|
322
|
-
id: call.id,
|
|
365
|
+
id: wireId(call.id),
|
|
323
366
|
name: call.name,
|
|
324
367
|
input: parseToolArgs(call.arguments),
|
|
325
368
|
});
|
|
@@ -373,7 +416,10 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
|
373
416
|
const model = opts.model ?? config.model;
|
|
374
417
|
const maxTokens = opts.maxTokens ?? config.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
375
418
|
const effort = opts.effort ?? config.effort;
|
|
376
|
-
|
|
419
|
+
// Repaired first: a result with no call, a call with no result, or an id
|
|
420
|
+
// used twice is a 400 that loses the turn. See repairHistory.
|
|
421
|
+
const sent = repairHistory(messages).messages;
|
|
422
|
+
const { system, messages: body } = toAnthropicMessages(sent, id);
|
|
377
423
|
|
|
378
424
|
// Anthropic's own `service_tier` means auto or standard_only, not this.
|
|
379
425
|
if (opts.serviceTier) {
|
|
@@ -438,7 +484,7 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
|
438
484
|
// ponytail: every loop step after the first goes unthought on these
|
|
439
485
|
// models. The upgrade is replaying the signed thinking blocks.
|
|
440
486
|
const unthought =
|
|
441
|
-
!claude && model.startsWith("claude-") && (forcesTool || continuesToolLoop(
|
|
487
|
+
!claude && model.startsWith("claude-") && (forcesTool || continuesToolLoop(sent));
|
|
442
488
|
if (claude) {
|
|
443
489
|
// An effort the caller never set sends nothing: the model keeps its own
|
|
444
490
|
// default, which on most of these is thinking ON.
|
|
@@ -476,12 +522,12 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
|
476
522
|
let cachedInputTokens = 0;
|
|
477
523
|
let cacheWriteTokens = 0;
|
|
478
524
|
let outputTokens = 0;
|
|
479
|
-
let toolCall:
|
|
525
|
+
let toolCall: ToolCallIdentity | null = null;
|
|
526
|
+
let sawToolCall = false;
|
|
480
527
|
let blockIndex = -1;
|
|
481
528
|
// What proves the turn ended rather than the socket: a stated stop
|
|
482
529
|
// reason, or `message_stop`. See the check after the loop.
|
|
483
530
|
let stopped = false;
|
|
484
|
-
let closed = false;
|
|
485
531
|
const usageChunk = (): ProviderChunk => ({
|
|
486
532
|
type: "usage",
|
|
487
533
|
usage: {
|
|
@@ -510,13 +556,12 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
|
510
556
|
...(opts.signal ? { signal: opts.signal } : {}),
|
|
511
557
|
...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
|
|
512
558
|
...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
|
|
559
|
+
...(opts.onRateLimit ? { onRateLimit: opts.onRateLimit } : {}),
|
|
560
|
+
// The stop reason rides in `message_delta` with the final usage, so a
|
|
561
|
+
// read that dies after it loses nothing but `message_stop`.
|
|
562
|
+
finished: () => stopped,
|
|
513
563
|
})) {
|
|
514
|
-
|
|
515
|
-
try {
|
|
516
|
-
event = JSON.parse(data) as AnthropicEvent;
|
|
517
|
-
} catch {
|
|
518
|
-
continue; // a keep-alive or a frame we do not model
|
|
519
|
-
}
|
|
564
|
+
const event = parseFrame<AnthropicEvent>(data, id);
|
|
520
565
|
|
|
521
566
|
switch (event.type) {
|
|
522
567
|
// A failure the backend reports after its headers went out. Classified
|
|
@@ -542,6 +587,10 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
|
542
587
|
id: event.content_block.id ?? "",
|
|
543
588
|
name: event.content_block.name ?? "",
|
|
544
589
|
};
|
|
590
|
+
// Checked here, because this block is the only place this shape
|
|
591
|
+
// states a call's id and name.
|
|
592
|
+
settleToolCalls(id, [toolCall]);
|
|
593
|
+
sawToolCall = true;
|
|
545
594
|
yield {
|
|
546
595
|
type: "delta",
|
|
547
596
|
toolCalls: [{ index: toolCall.index, id: toolCall.id, name: toolCall.name }],
|
|
@@ -577,21 +626,31 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
|
577
626
|
cachedInputTokens = event.usage?.cache_read_input_tokens ?? cachedInputTokens;
|
|
578
627
|
cacheWriteTokens = event.usage?.cache_creation_input_tokens ?? cacheWriteTokens;
|
|
579
628
|
outputTokens = event.usage?.output_tokens ?? outputTokens;
|
|
580
|
-
if (event.delta?.stop_reason)
|
|
581
|
-
|
|
582
|
-
|
|
629
|
+
if (event.delta?.stop_reason) {
|
|
630
|
+
const stated = mapStopReason(id, event.delta.stop_reason);
|
|
631
|
+
stopped = true;
|
|
632
|
+
// Anthropic-compatible gateways can end a tool turn on
|
|
633
|
+
// `end_turn`, and a caller reading that as done never runs the
|
|
634
|
+
// tool. Only `stop` is promoted: a length cut may be what broke
|
|
635
|
+
// the call.
|
|
636
|
+
const finishReason = stated === "stop" && sawToolCall ? "tool_calls" : stated;
|
|
637
|
+
yield { type: "finish", finishReason };
|
|
638
|
+
}
|
|
583
639
|
break;
|
|
584
640
|
}
|
|
585
641
|
|
|
642
|
+
// The turn's last event, so the reader stops here. Some gateways
|
|
643
|
+
// append a `[DONE]` or other bytes after it, and a read that died on
|
|
644
|
+
// those failed an answer that was already whole (Kimi, GLM and
|
|
645
|
+
// OpenCode Go, measured by cc-proxy).
|
|
586
646
|
case "message_stop":
|
|
587
|
-
closed = true;
|
|
588
647
|
yield usageChunk();
|
|
589
|
-
|
|
648
|
+
return;
|
|
590
649
|
}
|
|
591
650
|
}
|
|
592
|
-
|
|
593
|
-
//
|
|
594
|
-
//
|
|
651
|
+
// A gateway that states its stop reason and then skips `message_stop`, or
|
|
652
|
+
// whose socket dies after the reason, still finished the turn, and still
|
|
653
|
+
// billed it.
|
|
595
654
|
if (stopped) {
|
|
596
655
|
yield usageChunk();
|
|
597
656
|
return;
|
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,
|
|
@@ -328,19 +330,43 @@ function dialectFor(id: string): EffortDialect {
|
|
|
328
330
|
return "openai";
|
|
329
331
|
}
|
|
330
332
|
|
|
331
|
-
|
|
333
|
+
/**
|
|
334
|
+
* A stated finish, or a throw for one this dialect does not document. An
|
|
335
|
+
* unknown reason used to end the stream as a clean success with no finish at
|
|
336
|
+
* all, so DeepSeek's `insufficient_system_resource`, Z.ai's `network_error` and
|
|
337
|
+
* OpenRouter's `error` each read as a finished answer. Nothing says the answer
|
|
338
|
+
* ended on purpose, so it is `overload`: theirs, and worth a retry (cc-proxy
|
|
339
|
+
* answers it the same way).
|
|
340
|
+
*/
|
|
341
|
+
function mapFinishReason(provider: string, reason: string): FinishReason {
|
|
332
342
|
switch (reason) {
|
|
343
|
+
// A natural end under another name, from servers on this wire: Together's
|
|
344
|
+
// `eos`, Hugging Face TGI's `eos_token` and `stop_sequence`. Thrown as
|
|
345
|
+
// unknown, each would fail an answer that was already whole.
|
|
333
346
|
case "stop":
|
|
347
|
+
case "eos":
|
|
348
|
+
case "eos_token":
|
|
349
|
+
case "stop_sequence":
|
|
334
350
|
return "stop";
|
|
351
|
+
// Z.ai's `model_context_window_exceeded` and Mistral's `model_length`: the
|
|
352
|
+
// context window filled before the answer did. Cut short, and a retry fills
|
|
353
|
+
// it the same way.
|
|
335
354
|
case "length":
|
|
355
|
+
case "model_context_window_exceeded":
|
|
356
|
+
case "model_length":
|
|
336
357
|
return "length";
|
|
337
358
|
case "tool_calls":
|
|
338
359
|
case "function_call":
|
|
339
360
|
return "tool_calls";
|
|
361
|
+
// `sensitive` is Z.ai's name for its safety stop. A retry meets the same
|
|
362
|
+
// filter.
|
|
340
363
|
case "content_filter":
|
|
364
|
+
case "sensitive":
|
|
341
365
|
return "content_filter";
|
|
342
366
|
default:
|
|
343
|
-
|
|
367
|
+
throw new ProviderError(provider, "overload", `${provider} ended the turn on "${reason}"`, {
|
|
368
|
+
code: reason,
|
|
369
|
+
});
|
|
344
370
|
}
|
|
345
371
|
}
|
|
346
372
|
|
|
@@ -519,7 +545,9 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
|
519
545
|
const effort = opts.effort ?? config.effort;
|
|
520
546
|
const model = opts.model ?? config.model;
|
|
521
547
|
|
|
522
|
-
|
|
548
|
+
// Repaired first: a result with no call, or a call with no result, is a
|
|
549
|
+
// 400 that loses the turn. See repairHistory.
|
|
550
|
+
const body = toOpenAIMessages(repairHistory(messages).messages, id);
|
|
523
551
|
const request: Record<string, unknown> = {
|
|
524
552
|
model,
|
|
525
553
|
messages: body,
|
|
@@ -624,6 +652,9 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
|
624
652
|
: "/v1/chat/completions";
|
|
625
653
|
|
|
626
654
|
let ended = false;
|
|
655
|
+
// Each call as its deltas named it, by index. The finish checks them
|
|
656
|
+
// before it goes out, and they are what says the turn called a tool.
|
|
657
|
+
const calls = new Map<number, ToolCallIdentity>();
|
|
627
658
|
for await (const data of streamSse({
|
|
628
659
|
url: apiUrl(baseUrl, config.path ?? defaultPath),
|
|
629
660
|
headers: {
|
|
@@ -642,16 +673,16 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
|
642
673
|
...(opts.signal ? { signal: opts.signal } : {}),
|
|
643
674
|
...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
|
|
644
675
|
...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
|
|
676
|
+
...(opts.onRateLimit ? { onRateLimit: opts.onRateLimit } : {}),
|
|
645
677
|
onDone: () => {
|
|
646
678
|
ended = true;
|
|
647
679
|
},
|
|
680
|
+
// Usage comes AFTER the finish on this shape, so the reader keeps going
|
|
681
|
+
// to `[DONE]` (invariant 5). But a read that dies in between costs only
|
|
682
|
+
// the usage frame: the answer is whole once its finish is stated.
|
|
683
|
+
finished: () => ended,
|
|
648
684
|
})) {
|
|
649
|
-
|
|
650
|
-
try {
|
|
651
|
-
chunk = JSON.parse(data) as OpenAIChunk;
|
|
652
|
-
} catch {
|
|
653
|
-
continue;
|
|
654
|
-
}
|
|
685
|
+
const chunk = parseFrame<OpenAIChunk>(data, id);
|
|
655
686
|
|
|
656
687
|
// A failure the backend reports after its headers went out. The
|
|
657
688
|
// gateways speaking this dialect — OpenRouter above all — report a
|
|
@@ -723,18 +754,35 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
|
723
754
|
? { arguments: call.function.arguments }
|
|
724
755
|
: {}),
|
|
725
756
|
}));
|
|
757
|
+
for (const call of out.toolCalls) {
|
|
758
|
+
const seen = calls.get(call.index) ?? { index: call.index, id: "", name: "" };
|
|
759
|
+
if (call.id) seen.id = call.id;
|
|
760
|
+
if (call.name) seen.name = call.name;
|
|
761
|
+
calls.set(call.index, seen);
|
|
762
|
+
}
|
|
726
763
|
has = true;
|
|
727
764
|
}
|
|
728
765
|
if (has) yield out;
|
|
729
766
|
}
|
|
730
767
|
|
|
731
|
-
if (choice.finish_reason)
|
|
732
|
-
|
|
733
|
-
|
|
768
|
+
if (choice.finish_reason) {
|
|
769
|
+
const stated = mapFinishReason(id, choice.finish_reason);
|
|
770
|
+
const minted = settleToolCalls(id, calls.values());
|
|
771
|
+
if (minted.length > 0) yield { type: "delta", toolCalls: minted };
|
|
772
|
+
ended = true;
|
|
773
|
+
// Several gateways end a turn that called a tool on `stop`, and a
|
|
774
|
+
// caller reading that as done never runs the tool. Only `stop` is
|
|
775
|
+
// promoted: a length cut or a filter may be what broke the call.
|
|
776
|
+
const finishReason = stated === "stop" && calls.size > 0 ? "tool_calls" : stated;
|
|
777
|
+
yield { type: "finish", finishReason };
|
|
778
|
+
}
|
|
734
779
|
}
|
|
735
780
|
// Either proof will do: some gateways send `[DONE]` with no finish, and
|
|
736
781
|
// some a finish with no `[DONE]`. Neither means the socket closed early.
|
|
737
782
|
if (!ended) throw streamCut(id);
|
|
783
|
+
// A `[DONE]` with no finish stated still owes its calls the same check.
|
|
784
|
+
const minted = settleToolCalls(id, calls.values());
|
|
785
|
+
if (minted.length > 0) yield { type: "delta", toolCalls: minted };
|
|
738
786
|
},
|
|
739
787
|
};
|
|
740
788
|
|