@providerkit/core 0.15.1 → 0.16.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.
Files changed (55) hide show
  1. package/dist/errors.d.ts +17 -0
  2. package/dist/errors.d.ts.map +1 -1
  3. package/dist/errors.js +55 -6
  4. package/dist/errors.js.map +1 -1
  5. package/dist/presets.d.ts +37 -2
  6. package/dist/presets.d.ts.map +1 -1
  7. package/dist/presets.js +37 -2
  8. package/dist/presets.js.map +1 -1
  9. package/dist/providers/anthropic.d.ts.map +1 -1
  10. package/dist/providers/anthropic.js +46 -17
  11. package/dist/providers/anthropic.js.map +1 -1
  12. package/dist/providers/gemini.d.ts.map +1 -1
  13. package/dist/providers/gemini.js +25 -1
  14. package/dist/providers/gemini.js.map +1 -1
  15. package/dist/providers/openai.d.ts.map +1 -1
  16. package/dist/providers/openai.js +44 -16
  17. package/dist/providers/openai.js.map +1 -1
  18. package/dist/providers/responses.d.ts.map +1 -1
  19. package/dist/providers/responses.js +131 -24
  20. package/dist/providers/responses.js.map +1 -1
  21. package/dist/rate-limit.d.ts.map +1 -1
  22. package/dist/rate-limit.js +53 -22
  23. package/dist/rate-limit.js.map +1 -1
  24. package/dist/schema.d.ts +10 -0
  25. package/dist/schema.d.ts.map +1 -1
  26. package/dist/schema.js +49 -0
  27. package/dist/schema.js.map +1 -1
  28. package/dist/transport.d.ts +30 -3
  29. package/dist/transport.d.ts.map +1 -1
  30. package/dist/transport.js +67 -28
  31. package/dist/transport.js.map +1 -1
  32. package/dist/types.d.ts +26 -2
  33. package/dist/types.d.ts.map +1 -1
  34. package/dist/types.js.map +1 -1
  35. package/dist/usage.d.ts +5 -4
  36. package/dist/usage.d.ts.map +1 -1
  37. package/dist/usage.js +14 -5
  38. package/dist/usage.js.map +1 -1
  39. package/dist/watchdog.d.ts +22 -8
  40. package/dist/watchdog.d.ts.map +1 -1
  41. package/dist/watchdog.js +77 -27
  42. package/dist/watchdog.js.map +1 -1
  43. package/package.json +3 -2
  44. package/src/errors.ts +60 -5
  45. package/src/presets.ts +37 -2
  46. package/src/providers/anthropic.ts +50 -17
  47. package/src/providers/gemini.ts +30 -1
  48. package/src/providers/openai.ts +42 -16
  49. package/src/providers/responses.ts +158 -26
  50. package/src/rate-limit.ts +55 -24
  51. package/src/schema.ts +51 -0
  52. package/src/transport.ts +95 -24
  53. package/src/types.ts +26 -2
  54. package/src/usage.ts +14 -5
  55. package/src/watchdog.ts +89 -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
- /** The Codex backend behind a ChatGPT sign-in — Responses wire, no public
455
- * model list, so these ids are the list. */
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
- if (opts.toolChoice && opts.toolChoice !== "auto" && !unforced) {
391
- request.tool_choice =
392
- opts.toolChoice === "none"
393
- ? { type: "none" }
394
- : opts.toolChoice === "required"
395
- ? { type: "any" }
396
- : { type: "tool", name: opts.toolChoice.name };
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
- yield {
540
- type: "usage",
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);
@@ -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
 
@@ -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 its own user message, which is the only way the model ever
333
- // sees it. Dropped instead, the turn reads as a tool that returned
334
- // words about a picture nobody was shown.
335
- if (message.images?.length) {
336
- out.push({
337
- role: "user",
338
- content: message.images.map((image) => ({
339
- type: "image_url",
340
- image_url: { url: toDataUri(image) },
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
- // Two spellings for the same subset. DeepSeek's native endpoint uses
598
- // its own field, and reading only the standard one bills every cached
599
- // token at the full input rate — on an agent loop, where the re-sent
600
- // prefix is overwhelmingly hits, that overstates a run by up to 10×.
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