@providerkit/core 0.17.2 → 0.21.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 (69) hide show
  1. package/README.md +12 -4
  2. package/dist/auth/device.d.ts +34 -0
  3. package/dist/auth/device.d.ts.map +1 -0
  4. package/dist/auth/device.js +85 -0
  5. package/dist/auth/device.js.map +1 -0
  6. package/dist/auth/flows.d.ts +36 -0
  7. package/dist/auth/flows.d.ts.map +1 -0
  8. package/dist/auth/flows.js +392 -0
  9. package/dist/auth/flows.js.map +1 -0
  10. package/dist/auth/index.d.ts +5 -0
  11. package/dist/auth/index.d.ts.map +1 -0
  12. package/dist/auth/index.js +8 -0
  13. package/dist/auth/index.js.map +1 -0
  14. package/dist/auth/oauth.d.ts +92 -0
  15. package/dist/auth/oauth.d.ts.map +1 -0
  16. package/dist/auth/oauth.js +148 -0
  17. package/dist/auth/oauth.js.map +1 -0
  18. package/dist/auth/token-source.d.ts +30 -0
  19. package/dist/auth/token-source.d.ts.map +1 -0
  20. package/dist/auth/token-source.js +61 -0
  21. package/dist/auth/token-source.js.map +1 -0
  22. package/dist/context.d.ts.map +1 -1
  23. package/dist/context.js +17 -1
  24. package/dist/context.js.map +1 -1
  25. package/dist/errors.d.ts.map +1 -1
  26. package/dist/errors.js +3 -0
  27. package/dist/errors.js.map +1 -1
  28. package/dist/presets.d.ts +78 -23
  29. package/dist/presets.d.ts.map +1 -1
  30. package/dist/presets.js +61 -22
  31. package/dist/presets.js.map +1 -1
  32. package/dist/providers/anthropic.d.ts +3 -0
  33. package/dist/providers/anthropic.d.ts.map +1 -1
  34. package/dist/providers/anthropic.js +10 -1
  35. package/dist/providers/anthropic.js.map +1 -1
  36. package/dist/providers/factory.d.ts +8 -2
  37. package/dist/providers/factory.d.ts.map +1 -1
  38. package/dist/providers/factory.js +46 -9
  39. package/dist/providers/factory.js.map +1 -1
  40. package/dist/providers/gemini.d.ts.map +1 -1
  41. package/dist/providers/gemini.js +3 -0
  42. package/dist/providers/gemini.js.map +1 -1
  43. package/dist/providers/openai.d.ts +8 -2
  44. package/dist/providers/openai.d.ts.map +1 -1
  45. package/dist/providers/openai.js +41 -1
  46. package/dist/providers/openai.js.map +1 -1
  47. package/dist/providers/responses.d.ts +7 -1
  48. package/dist/providers/responses.d.ts.map +1 -1
  49. package/dist/providers/responses.js +48 -6
  50. package/dist/providers/responses.js.map +1 -1
  51. package/dist/types.d.ts +26 -0
  52. package/dist/types.d.ts.map +1 -1
  53. package/dist/types.js +13 -5
  54. package/dist/types.js.map +1 -1
  55. package/package.json +5 -1
  56. package/src/auth/device.ts +137 -0
  57. package/src/auth/flows.ts +526 -0
  58. package/src/auth/index.ts +7 -0
  59. package/src/auth/oauth.ts +226 -0
  60. package/src/auth/token-source.ts +76 -0
  61. package/src/context.ts +16 -1
  62. package/src/errors.ts +3 -0
  63. package/src/presets.ts +90 -24
  64. package/src/providers/anthropic.ts +17 -1
  65. package/src/providers/factory.ts +76 -11
  66. package/src/providers/gemini.ts +7 -0
  67. package/src/providers/openai.ts +50 -2
  68. package/src/providers/responses.ts +60 -6
  69. package/src/types.ts +49 -5
@@ -10,6 +10,7 @@
10
10
  // `effort` passes through to the adapter unchanged — the per-endpoint
11
11
  // thinking dialects (Z.ai's explicit disabled marker, OpenRouter's floors)
12
12
  // are the adapters' knowledge, and this factory must not re-derive them.
13
+ import { ProviderError } from "../errors.ts";
13
14
  import { createAnthropicProvider } from "./anthropic.ts";
14
15
  import { createGeminiProvider } from "./gemini.ts";
15
16
  import { createOpenAIProvider } from "./openai.ts";
@@ -17,15 +18,17 @@ import { createResponsesProvider } from "./responses.ts";
17
18
  import {
18
19
  PRESET_IDS,
19
20
  PROVIDER_PRESETS,
21
+ type PresetAuth,
22
+ type PresetShape,
20
23
  type ProviderPreset,
21
24
  type ProviderPresetId,
22
25
  } from "../presets.ts";
23
- import type { Effort, Provider } from "../types.ts";
26
+ import type { Effort, Provider, ServiceTier } from "../types.ts";
24
27
  import { withConfiguredFallbacks, type ProviderFallbackConfig } from "../fallback.ts";
25
28
 
26
29
  export interface PresetProviderConfig extends ProviderFallbackConfig {
27
30
  /** The credential: an API key for `key`/`bearer` presets, an ACCESS TOKEN
28
- * for `oauth` ones. Acquiring the token is the caller's job. */
31
+ * for `oauth` ones. `@providerkit/core/auth` signs in and renews it. */
29
32
  apiKey: string;
30
33
  /** Which model to run. Absent = the preset's `defaultModel`; a preset
31
34
  * without either throws rather than sending a request nobody chose. */
@@ -34,6 +37,9 @@ export interface PresetProviderConfig extends ProviderFallbackConfig {
34
37
  * take over in order when it fails (quota, rate, outage). Wins over `model`
35
38
  * and the preset's `rotation`; `fallbacks` still run after the chain. */
36
39
  models?: readonly string[];
40
+ /** Replaces the preset's API root. For a vendor that gives each account its
41
+ * own host (Copilot: `credential.baseUrl` from the sign-in). */
42
+ baseUrl?: string;
37
43
  effort?: Effort;
38
44
  /** Output ceiling. The Anthropic shape requires one; adapters default it. */
39
45
  maxTokens?: number;
@@ -57,6 +63,9 @@ export interface PresetProviderConfig extends ProviderFallbackConfig {
57
63
  * between rounds — pinning keeps a conversation's rounds (and their cache)
58
64
  * on one host. Empty/absent = default routing. */
59
65
  providerOrder?: string[];
66
+ /** Default `service_tier` for every call on the OpenAI and Responses shapes;
67
+ * `StreamOptions.serviceTier` wins. Anthropic and Gemini presets refuse it. */
68
+ serviceTier?: ServiceTier;
60
69
  fetchImpl?: typeof fetch;
61
70
  }
62
71
 
@@ -85,7 +94,15 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
85
94
  if (!model) {
86
95
  throw new Error(`[providerkit] Preset "${id}" has no defaultModel — pass a model.`);
87
96
  }
97
+ const wire = wireFor(preset, model);
98
+ if (config.serviceTier && (wire.shape === "anthropic" || wire.shape === "gemini")) {
99
+ throw new Error(
100
+ `[providerkit] Preset "${id}" speaks the ${wire.shape} shape, which has no service tier. ` +
101
+ "Remove serviceTier.",
102
+ );
103
+ }
88
104
  const headers = { ...preset.headers, ...config.headers };
105
+ const baseUrl = config.baseUrl ?? wire.baseUrl;
89
106
  const maxTokens = config.maxTokens ?? preset.maxTokens;
90
107
 
91
108
  const {
@@ -97,7 +114,7 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
97
114
  } = config;
98
115
 
99
116
  let provider: Provider;
100
- switch (preset.shape) {
117
+ switch (wire.shape) {
101
118
  case "anthropic":
102
119
  // `key` rides Anthropic's native x-api-key; coding plans and OAuth
103
120
  // tokens read Bearer (measured across the family's coding endpoints).
@@ -107,10 +124,11 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
107
124
  model,
108
125
  effort: config.effort,
109
126
  maxTokens,
110
- baseUrl: preset.baseUrl,
127
+ baseUrl,
111
128
  id,
112
129
  headers,
113
- bearer: preset.auth !== "key",
130
+ bearer: wire.auth !== "key",
131
+ ...(preset.sessionHeader ? { sessionHeader: preset.sessionHeader } : {}),
114
132
  // The endpoint's thinking dialect rides the table (zai: silence means
115
133
  // the model default, which is ON — "none" must be said out loud).
116
134
  ...(preset.explicitNone ? { explicitNone: true } : {}),
@@ -125,11 +143,12 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
125
143
  model,
126
144
  effort: config.effort,
127
145
  maxTokens,
128
- baseUrl: preset.baseUrl,
129
- ...(preset.path ? { path: preset.path } : {}),
146
+ baseUrl,
147
+ ...(wire.path ? { path: wire.path } : {}),
130
148
  headers,
131
149
  ...(config.providerOrder ? { providerOrder: config.providerOrder } : {}),
132
150
  ...(preset.sessionHeader ? { sessionHeader: preset.sessionHeader } : {}),
151
+ ...(preset.initiatorHeader ? { initiatorHeader: true } : {}),
133
152
  fetchImpl: config.fetchImpl,
134
153
  });
135
154
  break;
@@ -140,12 +159,13 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
140
159
  model,
141
160
  effort: config.effort,
142
161
  maxTokens,
143
- baseUrl: preset.baseUrl,
144
- ...(preset.path ? { path: preset.path } : {}),
162
+ baseUrl,
163
+ ...(wire.path ? { path: wire.path } : {}),
145
164
  id,
146
165
  headers,
147
166
  ...(preset.sessionHeader ? { sessionHeader: preset.sessionHeader } : {}),
148
167
  ...(preset.imageLimits ? { imageLimits: preset.imageLimits } : {}),
168
+ ...(preset.replayReasoning ? { replayReasoning: true } : {}),
149
169
  fetchImpl: config.fetchImpl,
150
170
  });
151
171
  break;
@@ -155,7 +175,7 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
155
175
  apiKey: config.apiKey,
156
176
  model,
157
177
  effort: config.effort,
158
- baseUrl: preset.baseUrl,
178
+ baseUrl,
159
179
  id,
160
180
  headers,
161
181
  fetchImpl: config.fetchImpl,
@@ -163,5 +183,50 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
163
183
  break;
164
184
  }
165
185
 
166
- return withConfiguredFallbacks(provider, config);
186
+ return withConfiguredFallbacks(
187
+ preset.routes ? refuseOtherWires(provider, preset, wire) : provider,
188
+ config,
189
+ );
190
+ }
191
+
192
+ /** The endpoint a model is served from: its route when the preset has one for
193
+ * it, else the preset's own. */
194
+ function wireFor(preset: ProviderPreset, model: string): Wire {
195
+ const route = preset.routes?.find((r) => r.prefixes.some((prefix) => model.startsWith(prefix)));
196
+ return route
197
+ ? { ...route, auth: route.auth ?? preset.auth }
198
+ : { shape: preset.shape, baseUrl: preset.baseUrl, path: preset.path, auth: preset.auth };
199
+ }
200
+
201
+ interface Wire {
202
+ shape: PresetShape;
203
+ baseUrl: string;
204
+ path?: string;
205
+ auth: PresetAuth;
206
+ }
207
+
208
+ const sameWire = (a: Wire, b: Wire) =>
209
+ a.shape === b.shape && a.baseUrl === b.baseUrl && a.path === b.path;
210
+
211
+ /**
212
+ * A per-call `model` that lives on another wire than the one this provider was
213
+ * built for would be posted to an endpoint that doesn't serve it, and the 404
214
+ * would read as an unknown model. Refuse it before any request goes out. To use
215
+ * a model on another wire, build a provider for it (a `models` chain does).
216
+ */
217
+ function refuseOtherWires(provider: Provider, preset: ProviderPreset, bound: Wire): Provider {
218
+ return {
219
+ ...provider,
220
+ async *createStream(messages, tools, opts) {
221
+ if (opts?.model && !sameWire(wireFor(preset, opts.model), bound)) {
222
+ throw new ProviderError(
223
+ provider.id,
224
+ "invalid",
225
+ `${provider.id}: ${opts.model} is served on a different endpoint than ${provider.model}. ` +
226
+ `Build a provider for ${opts.model}, or put both in a models chain.`,
227
+ );
228
+ }
229
+ yield* provider.createStream(messages, tools, opts);
230
+ },
231
+ };
167
232
  }
@@ -320,6 +320,13 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
320
320
  generationConfig.responseJsonSchema = toGeminiToolSchema(opts.json.schema);
321
321
  }
322
322
 
323
+ if (opts.serviceTier) {
324
+ throw new ProviderError(
325
+ id,
326
+ "invalid",
327
+ `${id}: Gemini has no service tier switch. Leave serviceTier unset for Gemini models.`,
328
+ );
329
+ }
323
330
  const request: Record<string, unknown> = { contents, generationConfig };
324
331
  // A Content, not the bare string the SDK accepts — REST rejects a string.
325
332
  // The prompt-carried schema rides as a second part, after the caller's
@@ -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, streamCut, streamError } from "../errors.ts";
6
+ import { fileRefused, ProviderError, streamCut, streamError } from "../errors.ts";
7
7
  import { streamSse, apiUrl } from "../transport.ts";
8
8
  import { attributionHeaders } from "../attribution.ts";
9
9
  import type {
@@ -14,6 +14,7 @@ import type {
14
14
  JsonWithTools,
15
15
  Provider,
16
16
  ProviderChunk,
17
+ ServiceTier,
17
18
  StreamOptions,
18
19
  ToolDefinition,
19
20
  } from "../types.ts";
@@ -104,6 +105,12 @@ export interface OpenAIConfig extends ProviderFallbackConfig {
104
105
  * no sessionId, the provider's own per-instance id rides instead, so the
105
106
  * gateway never sees a call without one. */
106
107
  sessionHeader?: string;
108
+ /** Send `X-Initiator`: `user` when the last message is the user's, else
109
+ * `agent`. GitHub Copilot bills a premium request per `user` turn and lets
110
+ * the agent's own follow-ups (tool results) ride free. */
111
+ initiatorHeader?: boolean;
112
+ /** Default `service_tier` for every call; `StreamOptions.serviceTier` wins. */
113
+ serviceTier?: ServiceTier;
107
114
  }
108
115
 
109
116
  const DEFAULT_BASE_URL = "https://api.openai.com";
@@ -153,7 +160,7 @@ export function openRouterHostFor(baseUrl: string, model: string): string | unde
153
160
 
154
161
  /** The spellings of "think this hard" across the dialects that share this
155
162
  * adapter. `off` sends nothing and leaves the model on its own default. */
156
- export type EffortDialect = "openai" | "openrouter" | "deepseek" | "off";
163
+ export type EffortDialect = "openai" | "openrouter" | "deepseek" | "opencode-go" | "off";
157
164
 
158
165
  /**
159
166
  * How `none` is said on OpenRouter, per model id: the `reasoning.effort` that
@@ -267,6 +274,8 @@ export function effortParams(
267
274
  return model !== undefined && OPENROUTER_NONE.has(model)
268
275
  ? { reasoning: { effort: OPENROUTER_NONE.get(model) } }
269
276
  : {};
277
+ case "opencode-go":
278
+ return goEffortParams(effort, model);
270
279
  case "openai":
271
280
  return { reasoning_effort: level ?? "none" };
272
281
  case "off":
@@ -274,11 +283,45 @@ export function effortParams(
274
283
  }
275
284
  }
276
285
 
286
+ /**
287
+ * OpenCode Go's chat models each take a different set of `reasoning_effort`
288
+ * values (cc-proxy's table, measured on live traffic). A level a model can't
289
+ * take is refused here, rather than sent as a 400 or quietly changed into a
290
+ * level the caller didn't ask for.
291
+ *
292
+ * - GLM 5.2 and 5.3 take only `high` and `max`, with no way to turn thinking
293
+ * off. `none` gets `high`, the least they offer.
294
+ * - DeepSeek V4 takes `low` to `max`. `none` gets `low`, its least.
295
+ * - MiMo takes `low`, `medium` and `high`, and `none` (measured 2026-09-27 on
296
+ * `mimo-v2.6-flash`; cc-proxy maps it to `low` instead).
297
+ * - Every other Go chat model gets the OpenAI spelling, as before.
298
+ */
299
+ function goEffortParams(effort: Effort, model = ""): Record<string, unknown> {
300
+ const id = model.toLowerCase();
301
+ const refuse = (allowed: string): never => {
302
+ throw new ProviderError(
303
+ "opencode-go",
304
+ "invalid",
305
+ `opencode-go: ${model} can't take reasoning effort "${effort}". Use ${allowed}.`,
306
+ );
307
+ };
308
+ const level = (value: string) => ({ reasoning_effort: value });
309
+ if (/glm-5[-.p]?[23]/.test(id)) {
310
+ if (effort === "none" || effort === "high") return level("high");
311
+ return effort === "max" ? level("max") : refuse("high or max");
312
+ }
313
+ if (id.includes("deepseek-v4")) return level(effort === "none" ? "low" : effort);
314
+ if (id.includes("mimo"))
315
+ return effort === "max" ? refuse("none, low, medium or high") : level(effort);
316
+ return level(effort === "max" ? "high" : effort);
317
+ }
318
+
277
319
  /** Gateways named after their dialect get it for free; everything else keeps
278
320
  * the dialect this adapter is named for. */
279
321
  function dialectFor(id: string): EffortDialect {
280
322
  if (id === "openrouter") return "openrouter";
281
323
  if (id === "deepseek") return "deepseek";
324
+ if (id === "opencode-go") return "opencode-go";
282
325
  return "openai";
283
326
  }
284
327
 
@@ -487,6 +530,8 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
487
530
  if (opts.temperature !== undefined) request.temperature = opts.temperature;
488
531
  if (opts.topP !== undefined) request.top_p = opts.topP;
489
532
  if (opts.stopSequences?.length) request.stop = opts.stopSequences;
533
+ const serviceTier = opts.serviceTier ?? config.serviceTier;
534
+ if (serviceTier) request.service_tier = serviceTier;
490
535
  Object.assign(request, effortParams(config.effortDialect ?? dialectFor(id), effort, model));
491
536
  if (tools.length > 0) {
492
537
  request.tools = tools.map((tool) => ({
@@ -582,6 +627,9 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
582
627
  ...(config.sessionHeader
583
628
  ? { [config.sessionHeader]: opts.sessionId ?? instanceSessionId }
584
629
  : {}),
630
+ ...(config.initiatorHeader
631
+ ? { "X-Initiator": messages.at(-1)?.role === "user" ? "user" : "agent" }
632
+ : {}),
585
633
  ...config.headers,
586
634
  },
587
635
  body: request,
@@ -20,6 +20,7 @@ import type {
20
20
  ImagePart,
21
21
  Provider,
22
22
  ProviderChunk,
23
+ ServiceTier,
23
24
  StreamOptions,
24
25
  ToolDefinition,
25
26
  } from "../types.ts";
@@ -56,6 +57,12 @@ export interface ResponsesConfig extends ProviderFallbackConfig {
56
57
  /** What this backend accepts as an image. Each image it would refuse is
57
58
  * replaced by a note naming the reason, instead of failing the request. */
58
59
  imageLimits?: ImageLimits;
60
+ /** Ask for the encrypted reasoning items and replay them on the next turn,
61
+ * so the model keeps its chain of thought across a stateless turn. Only for
62
+ * backends that accept `include: ["reasoning.encrypted_content"]`. */
63
+ replayReasoning?: boolean;
64
+ /** Default `service_tier` for every call; `StreamOptions.serviceTier` wins. */
65
+ serviceTier?: ServiceTier;
59
66
  }
60
67
 
61
68
  const DEFAULT_BASE_URL = "https://api.openai.com";
@@ -91,7 +98,33 @@ type ResponsesContentPart =
91
98
  | { type: "output_text"; text: string }
92
99
  | { type: "input_image"; image_url: string };
93
100
 
101
+ /** A reasoning item the backend made, sent back as it came. */
102
+ type ReplayedReasoning = { type: "reasoning" } & Record<string, unknown>;
103
+
104
+ function isReplayedReasoning(item: unknown): item is ReplayedReasoning {
105
+ return typeof item === "object" && item !== null && "type" in item && item.type === "reasoning";
106
+ }
107
+
108
+ /** The most encrypted reasoning one request carries back. Newest turns win. */
109
+ const MAX_REPLAY_BYTES = 8 * 1024 * 1024;
110
+
111
+ /** Which assistant messages get their reasoning replayed: this provider's own,
112
+ * newest first, until the cap. An older turn's reasoning is the cheapest to lose. */
113
+ function replayable(messages: readonly ChatMessage[], provider: string): Set<ChatMessage> {
114
+ const keep = new Set<ChatMessage>();
115
+ let bytes = 0;
116
+ for (let i = messages.length - 1; i >= 0; i--) {
117
+ const message = messages[i]!;
118
+ if (message.role !== "assistant" || message.reasoningItems?.provider !== provider) continue;
119
+ bytes += JSON.stringify(message.reasoningItems.items).length;
120
+ if (bytes > MAX_REPLAY_BYTES) break;
121
+ keep.add(message);
122
+ }
123
+ return keep;
124
+ }
125
+
94
126
  type ResponsesInputItem =
127
+ | ReplayedReasoning
95
128
  | { type: "message"; role: "user" | "assistant"; content: ResponsesContentPart[] }
96
129
  | { type: "function_call"; call_id: string; name: string; arguments: string }
97
130
  | { type: "function_call_output"; call_id: string; output: string | ResponsesContentPart[] };
@@ -141,6 +174,7 @@ export function toResponsesInput(
141
174
  .map((message) => message.content)
142
175
  .join("\n\n");
143
176
 
177
+ const replayed = replayable(messages, provider);
144
178
  const input: ResponsesInputItem[] = [];
145
179
  for (const message of messages) {
146
180
  switch (message.role) {
@@ -167,12 +201,15 @@ export function toResponsesInput(
167
201
  break;
168
202
 
169
203
  case "assistant": {
170
- // Reasoning is deliberately NOT replayed. This shape wants the ORIGINAL
171
- // reasoning item back — its `rs_…` id, and under `store: false` its
172
- // `encrypted_content` blob — and the seam carries neither, only the
173
- // plain summary text a caller renders. A synthesized reasoning item is
174
- // rejected; omitting it costs only the model re-deriving its own chain
175
- // of thought, which is what every stateless caller already lives with.
204
+ // `reasoning` (the summary a caller renders) is never replayed: this
205
+ // shape wants the ORIGINAL item back, `rs_…` id and `encrypted_content`
206
+ // blob included, and a synthesized one is rejected. `reasoningItems`
207
+ // are those originals, so they go back first, but only to the provider
208
+ // that made them. Without them the model re-derives its own chain of
209
+ // thought, which is what every stateless caller already lives with.
210
+ if (replayed.has(message)) {
211
+ input.push(...message.reasoningItems!.items.filter(isReplayedReasoning));
212
+ }
176
213
  if (message.content) {
177
214
  input.push({
178
215
  type: "message",
@@ -213,6 +250,7 @@ interface ResponsesItem {
213
250
  call_id?: string;
214
251
  name?: string;
215
252
  arguments?: string;
253
+ encrypted_content?: string;
216
254
  }
217
255
 
218
256
  interface ResponsesEvent {
@@ -350,6 +388,7 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
350
388
  store: false,
351
389
  };
352
390
  if (instructions) request.instructions = instructions;
391
+ if (config.replayReasoning) request.include = ["reasoning.encrypted_content"];
353
392
  const maxTokens = opts.maxTokens ?? config.maxTokens;
354
393
  if (maxTokens !== undefined) request.max_output_tokens = maxTokens;
355
394
  if (opts.temperature !== undefined) request.temperature = opts.temperature;
@@ -386,6 +425,8 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
386
425
  // The cache affinity key. Without one the backend spreads a
387
426
  // conversation's turns across cache shards, and a re-sent prefix misses.
388
427
  if (opts.sessionId) request.prompt_cache_key = opts.sessionId;
428
+ const serviceTier = opts.serviceTier ?? config.serviceTier;
429
+ if (serviceTier) request.service_tier = serviceTier;
389
430
  // Muse Contributor accepts only `auto`. `none` has an exact wire-level
390
431
  // equivalent: send no tools. Required/named choices stay explicit and let
391
432
  // the endpoint refuse a promise it cannot keep.
@@ -421,6 +462,11 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
421
462
  let sawToolCall = false;
422
463
  let wall: ProviderError | undefined;
423
464
  let produced = false;
465
+ const reasoningItems: unknown[] = [];
466
+ const reasoningChunk = (): ProviderChunk[] =>
467
+ reasoningItems.length
468
+ ? [{ type: "delta", reasoningItems: { provider: id, items: reasoningItems } }]
469
+ : [];
424
470
 
425
471
  for await (const data of streamSse({
426
472
  url: apiUrl(baseUrl, config.path ?? DEFAULT_PATH),
@@ -522,6 +568,12 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
522
568
 
523
569
  case "response.output_item.done": {
524
570
  const item = event.item;
571
+ // Kept whole and emitted once, at the end of the turn: it is the
572
+ // backend's own record, and it only means something complete.
573
+ if (config.replayReasoning && item?.type === "reasoning") {
574
+ reasoningItems.push(item);
575
+ break;
576
+ }
525
577
  if (item?.type !== "function_call") break;
526
578
  const known = pendingFor(item.id, item.call_id);
527
579
  if (item.id) pending.delete(item.id);
@@ -583,6 +635,7 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
583
635
  if (response?.status === "failed" || response?.error) {
584
636
  throw streamError(id, response.error ?? { message: "response failed" });
585
637
  }
638
+ yield* reasoningChunk();
586
639
  if (response?.usage) yield usageChunk(response.usage);
587
640
  if (response?.status === "incomplete") {
588
641
  yield {
@@ -602,6 +655,7 @@ export function createResponsesProvider(config: ResponsesConfig): Provider {
602
655
  case "response.incomplete": {
603
656
  // A turn that ran out of output tokens still billed for its input,
604
657
  // and this event carries usage in the same shape as `completed`.
658
+ yield* reasoningChunk();
605
659
  const usage = event.response?.usage;
606
660
  if (usage) yield usageChunk(usage);
607
661
  yield {
package/src/types.ts CHANGED
@@ -108,6 +108,14 @@ export type ChatMessage =
108
108
  * continuity across a tool round. Absent on every other dialect.
109
109
  */
110
110
  reasoningDetails?: unknown[];
111
+ /**
112
+ * The ChatGPT backend's own reasoning items, encrypted, riding back
113
+ * UNMODIFIED on the next turn so the model keeps its chain of thought.
114
+ * Tagged with the provider that made them: only that provider replays
115
+ * them, because another vendor (after a fallback) would reject them.
116
+ * Opaque, like `reasoningDetails`.
117
+ */
118
+ reasoningItems?: ReasoningItems;
111
119
  toolCalls?: ToolCall[];
112
120
  }
113
121
  | {
@@ -119,6 +127,12 @@ export type ChatMessage =
119
127
  images?: ImagePart[];
120
128
  };
121
129
 
130
+ /** Provider-owned reasoning items and the id of the provider that made them. */
131
+ export interface ReasoningItems {
132
+ provider: string;
133
+ items: unknown[];
134
+ }
135
+
122
136
  /** JSON Schema for an object — what every provider's tool contract wants. */
123
137
  export interface JsonObjectSchema {
124
138
  type: "object";
@@ -192,6 +206,8 @@ export interface ProviderChunk {
192
206
  /** OpenRouter's normalized reasoning payload — hand it back on the next
193
207
  * turn's assistant message verbatim. See ChatMessage.reasoningDetails. */
194
208
  reasoningDetails?: unknown[];
209
+ /** Once per turn, where a provider replays its reasoning. See ChatMessage.reasoningItems. */
210
+ reasoningItems?: ReasoningItems;
195
211
  toolCalls?: ToolCallDelta[];
196
212
  usage?: TokenUsage;
197
213
  finishReason?: FinishReason;
@@ -387,8 +403,19 @@ export interface StreamOptions {
387
403
  * so it refuses `false` rather than silently ignore it.
388
404
  */
389
405
  parallelToolCalls?: boolean;
406
+ /**
407
+ * Which service tier to buy this call on: `priority` is faster and costs
408
+ * more, `flex` is slower and costs less. Sent as `service_tier` on the
409
+ * OpenAI and Responses shapes. Anthropic and Gemini refuse it with an
410
+ * `invalid` error: Anthropic's own `service_tier` means something else, and
411
+ * Gemini has no such switch. Overrides the provider's own setting.
412
+ */
413
+ serviceTier?: ServiceTier;
390
414
  }
391
415
 
416
+ // ponytail: Codex's `ultrafast` tier (gpt-6-astra only) is the next value to add.
417
+ export type ServiceTier = "priority" | "flex";
418
+
392
419
  export interface Provider {
393
420
  readonly id: string;
394
421
  readonly model: string;
@@ -406,6 +433,8 @@ export interface Completion {
406
433
  reasoning: string;
407
434
  /** Present only where the provider sent one. See ChatMessage.reasoningDetails. */
408
435
  reasoningDetails?: unknown[];
436
+ /** Present only where the provider sent them. See ChatMessage.reasoningItems. */
437
+ reasoningItems?: ReasoningItems;
409
438
  usage: TokenUsage;
410
439
  finishReason: FinishReason | null;
411
440
  model: string;
@@ -424,6 +453,7 @@ export async function drainStream(
424
453
  // a drained turn replays only half its own reasoning on the next round —
425
454
  // which is the failure `reasoningDetails` exists to prevent.
426
455
  let reasoningDetails: unknown[] | undefined;
456
+ let reasoningItems: ReasoningItems | undefined;
427
457
  let usage: TokenUsage = EMPTY_USAGE;
428
458
  let finishReason: FinishReason | null = null;
429
459
  let source: ProviderChunk["source"];
@@ -433,6 +463,7 @@ export async function drainStream(
433
463
  if (chunk.content) text += chunk.content;
434
464
  if (chunk.reasoning) reasoning += chunk.reasoning;
435
465
  if (chunk.reasoningDetails?.length) reasoningDetails = chunk.reasoningDetails;
466
+ if (chunk.reasoningItems?.items.length) reasoningItems = chunk.reasoningItems;
436
467
  } else if (chunk.type === "usage" && chunk.usage) {
437
468
  usage = chunk.usage;
438
469
  } else if (chunk.type === "finish" && chunk.finishReason) {
@@ -443,6 +474,7 @@ export async function drainStream(
443
474
  text,
444
475
  reasoning,
445
476
  ...(reasoningDetails ? { reasoningDetails } : {}),
477
+ ...(reasoningItems ? { reasoningItems } : {}),
446
478
  usage,
447
479
  finishReason,
448
480
  model: source?.model ?? model,
@@ -465,11 +497,23 @@ export async function drainStream(
465
497
  export function stripReasoning(messages: readonly ChatMessage[]): ChatMessage[] {
466
498
  return messages.map((message) => {
467
499
  if (message.role !== "assistant") return message;
468
- if (message.reasoning === undefined && message.reasoningDetails === undefined) return message;
469
- // Both halves go. `reasoningDetails` is the same chain of thought in the
470
- // provider's own words, so leaving it behind carries into a thinking-off
471
- // turn exactly what stripping `reasoning` was meant to keep out.
472
- const { reasoning: _text, reasoningDetails: _payload, ...rest } = message;
500
+ if (
501
+ message.reasoning === undefined &&
502
+ message.reasoningDetails === undefined &&
503
+ message.reasoningItems === undefined
504
+ ) {
505
+ return message;
506
+ }
507
+ // Every half goes. `reasoningDetails` and `reasoningItems` are the same
508
+ // chain of thought in the provider's own words, so leaving them behind
509
+ // carries into a thinking-off turn exactly what stripping `reasoning` was
510
+ // meant to keep out.
511
+ const {
512
+ reasoning: _text,
513
+ reasoningDetails: _payload,
514
+ reasoningItems: _items,
515
+ ...rest
516
+ } = message;
473
517
  return rest;
474
518
  });
475
519
  }