@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.
- package/README.md +12 -4
- package/dist/auth/device.d.ts +34 -0
- package/dist/auth/device.d.ts.map +1 -0
- package/dist/auth/device.js +85 -0
- package/dist/auth/device.js.map +1 -0
- package/dist/auth/flows.d.ts +36 -0
- package/dist/auth/flows.d.ts.map +1 -0
- package/dist/auth/flows.js +392 -0
- package/dist/auth/flows.js.map +1 -0
- package/dist/auth/index.d.ts +5 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +8 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/oauth.d.ts +92 -0
- package/dist/auth/oauth.d.ts.map +1 -0
- package/dist/auth/oauth.js +148 -0
- package/dist/auth/oauth.js.map +1 -0
- package/dist/auth/token-source.d.ts +30 -0
- package/dist/auth/token-source.d.ts.map +1 -0
- package/dist/auth/token-source.js +61 -0
- package/dist/auth/token-source.js.map +1 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +17 -1
- package/dist/context.js.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +3 -0
- package/dist/errors.js.map +1 -1
- package/dist/presets.d.ts +78 -23
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +61 -22
- package/dist/presets.js.map +1 -1
- package/dist/providers/anthropic.d.ts +3 -0
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +10 -1
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/factory.d.ts +8 -2
- package/dist/providers/factory.d.ts.map +1 -1
- package/dist/providers/factory.js +46 -9
- package/dist/providers/factory.js.map +1 -1
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +3 -0
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/openai.d.ts +8 -2
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +41 -1
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/responses.d.ts +7 -1
- package/dist/providers/responses.d.ts.map +1 -1
- package/dist/providers/responses.js +48 -6
- package/dist/providers/responses.js.map +1 -1
- package/dist/types.d.ts +26 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +13 -5
- package/dist/types.js.map +1 -1
- package/package.json +5 -1
- package/src/auth/device.ts +137 -0
- package/src/auth/flows.ts +526 -0
- package/src/auth/index.ts +7 -0
- package/src/auth/oauth.ts +226 -0
- package/src/auth/token-source.ts +76 -0
- package/src/context.ts +16 -1
- package/src/errors.ts +3 -0
- package/src/presets.ts +90 -24
- package/src/providers/anthropic.ts +17 -1
- package/src/providers/factory.ts +76 -11
- package/src/providers/gemini.ts +7 -0
- package/src/providers/openai.ts +50 -2
- package/src/providers/responses.ts +60 -6
- package/src/types.ts +49 -5
package/src/providers/factory.ts
CHANGED
|
@@ -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.
|
|
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 (
|
|
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
|
|
127
|
+
baseUrl,
|
|
111
128
|
id,
|
|
112
129
|
headers,
|
|
113
|
-
bearer:
|
|
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
|
|
129
|
-
...(
|
|
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
|
|
144
|
-
...(
|
|
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
|
|
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(
|
|
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
|
}
|
package/src/providers/gemini.ts
CHANGED
|
@@ -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
|
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, 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
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
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 (
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
}
|