@providerkit/core 0.15.1 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/errors.d.ts +17 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +55 -6
- package/dist/errors.js.map +1 -1
- package/dist/presets.d.ts +37 -2
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +37 -2
- package/dist/presets.js.map +1 -1
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +46 -17
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +25 -1
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +44 -16
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/responses.d.ts.map +1 -1
- package/dist/providers/responses.js +131 -24
- package/dist/providers/responses.js.map +1 -1
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +53 -22
- package/dist/rate-limit.js.map +1 -1
- package/dist/schema.d.ts +10 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +49 -0
- package/dist/schema.js.map +1 -1
- package/dist/transport.d.ts +30 -3
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +67 -28
- package/dist/transport.js.map +1 -1
- package/dist/types.d.ts +26 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/usage.d.ts +5 -4
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +14 -5
- package/dist/usage.js.map +1 -1
- package/dist/watchdog.d.ts +22 -8
- package/dist/watchdog.d.ts.map +1 -1
- package/dist/watchdog.js +81 -27
- package/dist/watchdog.js.map +1 -1
- package/package.json +3 -2
- package/src/errors.ts +60 -5
- package/src/presets.ts +37 -2
- package/src/providers/anthropic.ts +50 -17
- package/src/providers/gemini.ts +30 -1
- package/src/providers/openai.ts +42 -16
- package/src/providers/responses.ts +158 -26
- package/src/rate-limit.ts +55 -24
- package/src/schema.ts +51 -0
- package/src/transport.ts +95 -24
- package/src/types.ts +26 -2
- package/src/usage.ts +14 -5
- package/src/watchdog.ts +93 -32
package/src/presets.ts
CHANGED
|
@@ -451,15 +451,50 @@ export const PROVIDER_PRESETS = {
|
|
|
451
451
|
// by tabrunner's Claude plan sign-in).
|
|
452
452
|
headers: { "anthropic-beta": "claude-code-20250219,oauth-2025-04-20" },
|
|
453
453
|
},
|
|
454
|
-
/**
|
|
455
|
-
*
|
|
454
|
+
/**
|
|
455
|
+
* The Codex backend behind a ChatGPT sign-in — Responses wire, no public
|
|
456
|
+
* model list, so these ids are the list.
|
|
457
|
+
*
|
|
458
|
+
* The endpoint is `/backend-api/codex/responses`, with no version segment.
|
|
459
|
+
* Without the `path` the adapter appends `/v1/responses`, the POST 404s, and
|
|
460
|
+
* a 404 classifies as "model": every call failed telling the user their
|
|
461
|
+
* model id was wrong. `session-id` is the header the official Codex client
|
|
462
|
+
* sends; the backend keys its prompt cache on it (cc-proxy, in daily use
|
|
463
|
+
* against this backend, 2026-09).
|
|
464
|
+
*/
|
|
456
465
|
chatgpt: {
|
|
457
466
|
shape: "responses",
|
|
458
467
|
baseUrl: "https://chatgpt.com/backend-api/codex",
|
|
468
|
+
path: "/responses",
|
|
469
|
+
sessionHeader: "session-id",
|
|
459
470
|
auth: "oauth",
|
|
460
471
|
defaultModel: "gpt-5.3-codex",
|
|
461
472
|
models: ["gpt-5.3-codex", "gpt-5.3-codex-spark", "gpt-5.5", "gpt-5.4-mini"],
|
|
462
473
|
},
|
|
474
|
+
/**
|
|
475
|
+
* A Grok subscription (SuperGrok, X Premium) — the backend the Grok CLI
|
|
476
|
+
* talks to, Responses wire. Distinct from `xai`, which is the keyed API and
|
|
477
|
+
* bills per token.
|
|
478
|
+
*
|
|
479
|
+
* The two `x-…` headers are what switch the backend into CLI-token mode;
|
|
480
|
+
* without them the subscription token is refused. The version header is the
|
|
481
|
+
* Grok CLI release cc-proxy sends by default (2026-09); pass your own in
|
|
482
|
+
* `headers` when the backend starts asking for a newer one. Tool calls on
|
|
483
|
+
* this backend are keyed by `call_id` alone, which the adapter follows.
|
|
484
|
+
*/
|
|
485
|
+
grok: {
|
|
486
|
+
shape: "responses",
|
|
487
|
+
baseUrl: "https://cli-chat-proxy.grok.com/v1",
|
|
488
|
+
path: "/responses",
|
|
489
|
+
auth: "oauth",
|
|
490
|
+
defaultModel: "grok-4.7",
|
|
491
|
+
models: ["grok-4.7", "grok-4.6", "grok-4.5", "grok-composer-2.5-fast"],
|
|
492
|
+
headers: {
|
|
493
|
+
"x-xai-token-auth": "xai-grok-cli",
|
|
494
|
+
"x-grok-client-identifier": "grok-shell",
|
|
495
|
+
"x-grok-client-version": "0.2.93",
|
|
496
|
+
},
|
|
497
|
+
},
|
|
463
498
|
/** Kimi's coding endpoint reached with a subscription token instead of a
|
|
464
499
|
* key — Kimi bills the two separately. */
|
|
465
500
|
"kimi-plan": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Anthropic-shape adapter — SSE from POST /v1/messages.
|
|
2
|
-
import { fileRefused, streamError } from "../errors.ts";
|
|
2
|
+
import { fileRefused, streamCut, streamError } from "../errors.ts";
|
|
3
3
|
import { schemaPrompt, toAnthropicToolSchema } from "../schema.ts";
|
|
4
4
|
import { parseToolArgs } from "../tool-args.ts";
|
|
5
5
|
import { streamSse, apiUrl } from "../transport.ts";
|
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
Provider,
|
|
15
15
|
ProviderChunk,
|
|
16
16
|
StreamOptions,
|
|
17
|
+
ToolChoice,
|
|
17
18
|
ToolDefinition,
|
|
18
19
|
} from "../types.ts";
|
|
19
20
|
|
|
@@ -203,6 +204,18 @@ function mapStopReason(reason: string | undefined): FinishReason | undefined {
|
|
|
203
204
|
}
|
|
204
205
|
}
|
|
205
206
|
|
|
207
|
+
interface AnthropicToolChoice {
|
|
208
|
+
type: "auto" | "any" | "none" | "tool";
|
|
209
|
+
name?: string;
|
|
210
|
+
disable_parallel_tool_use?: boolean;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function toAnthropicToolChoice(choice: Exclude<ToolChoice, "auto">): AnthropicToolChoice {
|
|
214
|
+
if (choice === "none") return { type: "none" };
|
|
215
|
+
if (choice === "required") return { type: "any" };
|
|
216
|
+
return { type: "tool", name: choice.name };
|
|
217
|
+
}
|
|
218
|
+
|
|
206
219
|
function partsToAnthropic(content: string | ContentPart[], provider: string): unknown[] {
|
|
207
220
|
if (typeof content === "string") return [{ type: "text", text: content }];
|
|
208
221
|
return content.map((part) => {
|
|
@@ -387,13 +400,17 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
|
387
400
|
input_schema: toAnthropicToolSchema(tool.inputSchema),
|
|
388
401
|
}));
|
|
389
402
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
opts.toolChoice
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
403
|
+
const choice =
|
|
404
|
+
opts.toolChoice && opts.toolChoice !== "auto" && !unforced
|
|
405
|
+
? toAnthropicToolChoice(opts.toolChoice)
|
|
406
|
+
: undefined;
|
|
407
|
+
// This wire says "one call at a time" inside the tool choice, so asking
|
|
408
|
+
// for it needs a choice to ride on — `auto` when the caller named none.
|
|
409
|
+
// `none` calls nothing, so there is nothing to serialize.
|
|
410
|
+
if (opts.parallelToolCalls === false && offered.length > 0 && choice?.type !== "none") {
|
|
411
|
+
request.tool_choice = { ...(choice ?? { type: "auto" }), disable_parallel_tool_use: true };
|
|
412
|
+
} else if (choice) {
|
|
413
|
+
request.tool_choice = choice;
|
|
397
414
|
}
|
|
398
415
|
// Extended mode, which is all Claude 4.5 and older have, can't think on
|
|
399
416
|
// two kinds of request this adapter sends, so those run without thinking.
|
|
@@ -448,6 +465,19 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
|
448
465
|
let outputTokens = 0;
|
|
449
466
|
let toolCall: { index: number; id: string; name: string } | null = null;
|
|
450
467
|
let blockIndex = -1;
|
|
468
|
+
// What proves the turn ended rather than the socket: a stated stop
|
|
469
|
+
// reason, or `message_stop`. See the check after the loop.
|
|
470
|
+
let stopped = false;
|
|
471
|
+
let closed = false;
|
|
472
|
+
const usageChunk = (): ProviderChunk => ({
|
|
473
|
+
type: "usage",
|
|
474
|
+
usage: {
|
|
475
|
+
inputTokens: freshInputTokens + cachedInputTokens + cacheWriteTokens,
|
|
476
|
+
cachedInputTokens,
|
|
477
|
+
cacheWriteTokens,
|
|
478
|
+
outputTokens,
|
|
479
|
+
},
|
|
480
|
+
});
|
|
451
481
|
|
|
452
482
|
for await (const data of streamSse({
|
|
453
483
|
url: apiUrl(baseUrl, "/v1/messages"),
|
|
@@ -463,6 +493,7 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
|
463
493
|
provider: id,
|
|
464
494
|
...(opts.signal ? { signal: opts.signal } : {}),
|
|
465
495
|
...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
|
|
496
|
+
...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
|
|
466
497
|
})) {
|
|
467
498
|
let event: AnthropicEvent;
|
|
468
499
|
try {
|
|
@@ -530,24 +561,26 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
|
|
|
530
561
|
cachedInputTokens = event.usage?.cache_read_input_tokens ?? cachedInputTokens;
|
|
531
562
|
cacheWriteTokens = event.usage?.cache_creation_input_tokens ?? cacheWriteTokens;
|
|
532
563
|
outputTokens = event.usage?.output_tokens ?? outputTokens;
|
|
564
|
+
if (event.delta?.stop_reason) stopped = true;
|
|
533
565
|
const finishReason = mapStopReason(event.delta?.stop_reason);
|
|
534
566
|
if (finishReason) yield { type: "finish", finishReason };
|
|
535
567
|
break;
|
|
536
568
|
}
|
|
537
569
|
|
|
538
570
|
case "message_stop":
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
usage: {
|
|
542
|
-
inputTokens: freshInputTokens + cachedInputTokens + cacheWriteTokens,
|
|
543
|
-
cachedInputTokens,
|
|
544
|
-
cacheWriteTokens,
|
|
545
|
-
outputTokens,
|
|
546
|
-
},
|
|
547
|
-
};
|
|
571
|
+
closed = true;
|
|
572
|
+
yield usageChunk();
|
|
548
573
|
break;
|
|
549
574
|
}
|
|
550
575
|
}
|
|
576
|
+
if (closed) return;
|
|
577
|
+
// A gateway that states its stop reason and then skips `message_stop`
|
|
578
|
+
// still finished the turn — and still billed it.
|
|
579
|
+
if (stopped) {
|
|
580
|
+
yield usageChunk();
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
throw streamCut(id);
|
|
551
584
|
},
|
|
552
585
|
};
|
|
553
586
|
return withConfiguredFallbacks(provider, config);
|
package/src/providers/gemini.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
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 { streamError } from "../errors.ts";
|
|
8
|
+
import { ProviderError, streamCut, streamError } from "../errors.ts";
|
|
9
9
|
import { schemaPrompt, toGeminiToolSchema } from "../schema.ts";
|
|
10
10
|
import { parseToolArgs } from "../tool-args.ts";
|
|
11
11
|
import { streamSse, apiUrl } from "../transport.ts";
|
|
@@ -257,6 +257,8 @@ interface GeminiResponse {
|
|
|
257
257
|
};
|
|
258
258
|
/** Present only on the in-band failure below — never on a real candidate. */
|
|
259
259
|
error?: GeminiStatus;
|
|
260
|
+
/** A prompt Gemini refused outright: no candidate at all, just the reason. */
|
|
261
|
+
promptFeedback?: { blockReason?: string };
|
|
260
262
|
}
|
|
261
263
|
|
|
262
264
|
export function createGeminiProvider(config: GeminiConfig): Provider {
|
|
@@ -329,6 +331,16 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
|
|
|
329
331
|
];
|
|
330
332
|
if (systemParts.length > 0) request.systemInstruction = { parts: systemParts };
|
|
331
333
|
if (tools.length > 0) {
|
|
334
|
+
// Gemini has no switch for one call per turn. Sending the request
|
|
335
|
+
// anyway would hand back the parallel calls the caller ruled out.
|
|
336
|
+
if (opts.parallelToolCalls === false) {
|
|
337
|
+
throw new ProviderError(
|
|
338
|
+
id,
|
|
339
|
+
"invalid",
|
|
340
|
+
`${id}: Gemini can't limit a turn to one tool call. Leave parallelToolCalls unset ` +
|
|
341
|
+
"for Gemini models, or run the calls it returns one at a time.",
|
|
342
|
+
);
|
|
343
|
+
}
|
|
332
344
|
request.tools = [
|
|
333
345
|
{
|
|
334
346
|
functionDeclarations: tools.map((tool) => ({
|
|
@@ -351,6 +363,7 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
|
|
|
351
363
|
// remembered rather than read off the final chunk.
|
|
352
364
|
let sawFunctionCalls = false;
|
|
353
365
|
let callIndex = 0;
|
|
366
|
+
let finished = false;
|
|
354
367
|
|
|
355
368
|
for await (const data of streamSse({
|
|
356
369
|
// Without `?alt=sse` the response is one long JSON array that only
|
|
@@ -361,6 +374,7 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
|
|
|
361
374
|
provider: id,
|
|
362
375
|
...(opts.signal ? { signal: opts.signal } : {}),
|
|
363
376
|
...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
|
|
377
|
+
...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
|
|
364
378
|
})) {
|
|
365
379
|
let chunk: GeminiResponse;
|
|
366
380
|
try {
|
|
@@ -373,6 +387,14 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
|
|
|
373
387
|
// throttle or an overload landing after that arrives here as a
|
|
374
388
|
// google.rpc.Status in the body rather than as a status line.
|
|
375
389
|
if (chunk.error) throw streamError(id, chunk.error);
|
|
390
|
+
// A blocked PROMPT has no candidate to finish, so it reads as a cut
|
|
391
|
+
// stream — retried, into the same block. It is a content failure.
|
|
392
|
+
const blocked = chunk.promptFeedback?.blockReason;
|
|
393
|
+
if (blocked) {
|
|
394
|
+
throw new ProviderError(id, "content", `${id}: the prompt was blocked (${blocked})`, {
|
|
395
|
+
code: blocked,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
376
398
|
|
|
377
399
|
if (chunk.usageMetadata) {
|
|
378
400
|
const usage = chunk.usageMetadata;
|
|
@@ -385,6 +407,9 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
|
|
|
385
407
|
// turn by most of what it cost.
|
|
386
408
|
outputTokens: (usage.candidatesTokenCount ?? 0) + (usage.thoughtsTokenCount ?? 0),
|
|
387
409
|
cachedInputTokens: usage.cachedContentTokenCount ?? 0,
|
|
410
|
+
...(usage.thoughtsTokenCount !== undefined
|
|
411
|
+
? { reasoningTokens: usage.thoughtsTokenCount }
|
|
412
|
+
: {}),
|
|
388
413
|
},
|
|
389
414
|
};
|
|
390
415
|
}
|
|
@@ -428,6 +453,7 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
|
|
|
428
453
|
}
|
|
429
454
|
|
|
430
455
|
if (candidate.finishReason) {
|
|
456
|
+
finished = true;
|
|
431
457
|
yield {
|
|
432
458
|
type: "finish",
|
|
433
459
|
// A turn that called tools finishes as tool_calls whatever the
|
|
@@ -437,6 +463,9 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
|
|
|
437
463
|
};
|
|
438
464
|
}
|
|
439
465
|
}
|
|
466
|
+
// Gemini states a finish reason on the last candidate of every turn it
|
|
467
|
+
// completes; a stream that closes without one was cut.
|
|
468
|
+
if (!finished) throw streamCut(id);
|
|
440
469
|
},
|
|
441
470
|
};
|
|
442
471
|
|
package/src/providers/openai.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
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 { fileRefused, streamError } from "../errors.ts";
|
|
6
|
+
import { fileRefused, streamCut, streamError } from "../errors.ts";
|
|
7
7
|
import { streamSse, apiUrl } from "../transport.ts";
|
|
8
8
|
import { attributionHeaders } from "../attribution.ts";
|
|
9
9
|
import type {
|
|
@@ -315,7 +315,14 @@ function partsToOpenAI(content: string | ContentPart[], provider: string): unkno
|
|
|
315
315
|
*/
|
|
316
316
|
export function toOpenAIMessages(messages: readonly ChatMessage[], provider = "openai"): unknown[] {
|
|
317
317
|
const out: unknown[] = [];
|
|
318
|
+
// Images from a run of tool results, held until the run ends. See "tool".
|
|
319
|
+
let toolImages: unknown[] = [];
|
|
320
|
+
const flushToolImages = () => {
|
|
321
|
+
if (toolImages.length > 0) out.push({ role: "user", content: toolImages });
|
|
322
|
+
toolImages = [];
|
|
323
|
+
};
|
|
318
324
|
for (const message of messages) {
|
|
325
|
+
if (message.role !== "tool") flushToolImages();
|
|
319
326
|
switch (message.role) {
|
|
320
327
|
case "system":
|
|
321
328
|
out.push({ role: "system", content: message.content });
|
|
@@ -329,17 +336,16 @@ export function toOpenAIMessages(messages: readonly ChatMessage[], provider = "o
|
|
|
329
336
|
out.push({ role: "tool", tool_call_id: message.toolCallId, content: message.content });
|
|
330
337
|
// This dialect has no image slot on a tool message — a `tool` role takes
|
|
331
338
|
// text and nothing else. A screenshot a tool hands back therefore
|
|
332
|
-
// follows as
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
});
|
|
339
|
+
// follows as a user message, which is the only way the model ever sees
|
|
340
|
+
// it. Dropped instead, the turn reads as a tool that returned words
|
|
341
|
+
// about a picture nobody was shown.
|
|
342
|
+
//
|
|
343
|
+
// It follows the whole RUN of tool results, not this one: an assistant
|
|
344
|
+
// turn that made parallel calls needs every answer before any user
|
|
345
|
+
// message, and an image wedged between two of them is a 400 that
|
|
346
|
+
// classifies as "invalid" — never retried, never walked to a backup.
|
|
347
|
+
for (const image of message.images ?? []) {
|
|
348
|
+
toolImages.push({ type: "image_url", image_url: { url: toDataUri(image) } });
|
|
343
349
|
}
|
|
344
350
|
break;
|
|
345
351
|
|
|
@@ -369,6 +375,7 @@ export function toOpenAIMessages(messages: readonly ChatMessage[], provider = "o
|
|
|
369
375
|
}
|
|
370
376
|
}
|
|
371
377
|
}
|
|
378
|
+
flushToolImages();
|
|
372
379
|
return out;
|
|
373
380
|
}
|
|
374
381
|
|
|
@@ -392,9 +399,12 @@ interface OpenAIChunk {
|
|
|
392
399
|
prompt_tokens?: number;
|
|
393
400
|
completion_tokens?: number;
|
|
394
401
|
prompt_tokens_details?: { cached_tokens?: number };
|
|
402
|
+
completion_tokens_details?: { reasoning_tokens?: number };
|
|
395
403
|
/** DeepSeek's native API reports the cache-hit count here instead of in
|
|
396
404
|
* `prompt_tokens_details`, and it is absent from every OpenAI SDK type. */
|
|
397
405
|
prompt_cache_hit_tokens?: number;
|
|
406
|
+
/** Kimi (Moonshot) puts it at the top level of `usage`. */
|
|
407
|
+
cached_tokens?: number;
|
|
398
408
|
/** OpenRouter only, from here down: what the call cost, in its credits,
|
|
399
409
|
* which are US dollars. */
|
|
400
410
|
cost?: unknown;
|
|
@@ -487,6 +497,8 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
|
487
497
|
parameters: tool.inputSchema,
|
|
488
498
|
},
|
|
489
499
|
}));
|
|
500
|
+
// Only beside tools: OpenAI refuses the field on a request without any.
|
|
501
|
+
if (opts.parallelToolCalls === false) request.parallel_tool_calls = false;
|
|
490
502
|
}
|
|
491
503
|
if (opts.toolChoice && opts.toolChoice !== "auto") {
|
|
492
504
|
request.tool_choice =
|
|
@@ -561,6 +573,7 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
|
561
573
|
? "/chat/completions"
|
|
562
574
|
: "/v1/chat/completions";
|
|
563
575
|
|
|
576
|
+
let ended = false;
|
|
564
577
|
for await (const data of streamSse({
|
|
565
578
|
url: apiUrl(baseUrl, config.path ?? defaultPath),
|
|
566
579
|
headers: {
|
|
@@ -575,6 +588,10 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
|
575
588
|
provider: id,
|
|
576
589
|
...(opts.signal ? { signal: opts.signal } : {}),
|
|
577
590
|
...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
|
|
591
|
+
...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
|
|
592
|
+
onDone: () => {
|
|
593
|
+
ended = true;
|
|
594
|
+
},
|
|
578
595
|
})) {
|
|
579
596
|
let chunk: OpenAIChunk;
|
|
580
597
|
try {
|
|
@@ -594,13 +611,15 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
|
594
611
|
// A usage-only frame carries no choices — this shape sends it last.
|
|
595
612
|
if (chunk.usage) {
|
|
596
613
|
const input = chunk.usage.prompt_tokens ?? 0;
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
// token at the full input rate — on an agent loop,
|
|
600
|
-
// prefix is overwhelmingly hits, that overstates a
|
|
614
|
+
// Three spellings for the same subset. DeepSeek's native endpoint and
|
|
615
|
+
// Kimi each use their own field, and reading only the standard one
|
|
616
|
+
// bills every cached token at the full input rate — on an agent loop,
|
|
617
|
+
// where the re-sent prefix is overwhelmingly hits, that overstates a
|
|
618
|
+
// run by up to 10×.
|
|
601
619
|
const cached =
|
|
602
620
|
chunk.usage.prompt_tokens_details?.cached_tokens ??
|
|
603
621
|
chunk.usage.prompt_cache_hit_tokens ??
|
|
622
|
+
chunk.usage.cached_tokens ??
|
|
604
623
|
0;
|
|
605
624
|
const reportedCostUsd = reportsCost ? openRouterCostUsd(chunk.usage) : undefined;
|
|
606
625
|
yield {
|
|
@@ -611,6 +630,9 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
|
611
630
|
// Anthropic's, which excludes them. No reconciling to do.
|
|
612
631
|
cachedInputTokens: cached,
|
|
613
632
|
outputTokens: chunk.usage.completion_tokens ?? 0,
|
|
633
|
+
...(chunk.usage.completion_tokens_details?.reasoning_tokens !== undefined
|
|
634
|
+
? { reasoningTokens: chunk.usage.completion_tokens_details.reasoning_tokens }
|
|
635
|
+
: {}),
|
|
614
636
|
...(reportedCostUsd !== undefined ? { reportedCostUsd } : {}),
|
|
615
637
|
},
|
|
616
638
|
};
|
|
@@ -653,9 +675,13 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
|
|
|
653
675
|
if (has) yield out;
|
|
654
676
|
}
|
|
655
677
|
|
|
678
|
+
if (choice.finish_reason) ended = true;
|
|
656
679
|
const finishReason = mapFinishReason(choice.finish_reason);
|
|
657
680
|
if (finishReason) yield { type: "finish", finishReason };
|
|
658
681
|
}
|
|
682
|
+
// Either proof will do: some gateways send `[DONE]` with no finish, and
|
|
683
|
+
// some a finish with no `[DONE]`. Neither means the socket closed early.
|
|
684
|
+
if (!ended) throw streamCut(id);
|
|
659
685
|
},
|
|
660
686
|
};
|
|
661
687
|
|