@providerkit/core 0.10.0 → 0.11.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.
@@ -5,6 +5,7 @@
5
5
  // Their divergences are small and named where they appear.
6
6
  import { streamError } from "../errors.ts";
7
7
  import { streamSse, apiUrl } from "../transport.ts";
8
+ import { attributionHeaders } from "../attribution.ts";
8
9
  import type {
9
10
  ChatMessage,
10
11
  ContentPart,
@@ -75,6 +76,17 @@ export interface OpenAIConfig extends ProviderFallbackConfig {
75
76
  maxTokens?: number;
76
77
  fetchImpl?: typeof fetch;
77
78
  headers?: Record<string, string>;
79
+ /**
80
+ * App attribution for OpenRouter's rankings — the site URL rides as
81
+ * `HTTP-Referer`, the app name as `X-Title`. Public, not secret. Sent
82
+ * whenever set (other vendors ignore unknown headers); an explicit entry
83
+ * in `headers` always wins.
84
+ */
85
+ siteUrl?: string;
86
+ /**
87
+ * App name for OpenRouter's rankings, sent as `X-Title`. See `siteUrl`.
88
+ */
89
+ siteName?: string;
78
90
  /**
79
91
  * Pin OpenRouter to preferred upstream hosts so the PROMPT CACHE stays warm
80
92
  * across rounds. The cache lives on the upstream host's account and default
@@ -142,23 +154,20 @@ export type EffortDialect = "openai" | "openrouter" | "deepseek" | "off";
142
154
  *
143
155
  * One knob, three incompatible spellings, and the differences are not cosmetic:
144
156
  *
145
- * - **On OpenRouter, a model that must think refuses BOTH off switches.**
146
- * `reasoning.enabled: false` is refused by GLM 5.3 Flash with
147
- * `400 "Reasoning is mandatory for this endpoint and cannot be disabled."`,
148
- * which cost one app its onboarding read, and is why nothing here ever emits
149
- * that field. That was then read as "OpenRouter has no off switch" and `none`
150
- * was floored to `low` on the strength of it — wrong, because `none` is a
151
- * member of OpenRouter's own effort enum and the floor bought a thinking pass
152
- * on every turn that asked for none. But the correction over-reached: `none`
153
- * is NOT universally safe either. Re-measured 2026-09-09, the same model
154
- * answers the same 400 to `effort: "none"`. An unsupported level is mapped to
155
- * the nearest only where the model CAN vary its effort; where thinking is
156
- * mandatory, `none` is refused outright and the whole call is lost.
157
- * The honest way to ask for the least thinking on this dialect is to pass no
158
- * effort at all — see `effortParams`, where the numbers live.
157
+ * - **On OpenRouter, GLM's off switch is the ABSENT field.** Not setting
158
+ * `reasoning` at all disables thinking for GLM 5.3 Flash (measured live
159
+ * 2026-09-14). Naming a level raises it — the accepted values are
160
+ * `low`, `high` and `max` (`medium` and `xhigh` are accepted too, but our
161
+ * vocabulary has no use for them). `none` is NOT sendable there: the model
162
+ * answers
163
+ * `400 "Reasoning is mandatory for this endpoint and cannot be disabled."`
164
+ * to `reasoning.effort: "none"` exactly as it does to
165
+ * `reasoning.enabled: false` — so "do not think" on this dialect is said by
166
+ * omitting the field, never by naming a level. See `effortParams`, where the
167
+ * numbers live.
159
168
  * - **DeepSeek V4 defaults thinking ON**, so `none` has to be an explicit
160
- * refusal. That is the case proving OpenRouter's floor is a constraint and
161
- * not a preference for always thinking.
169
+ * refusal. That is the case proving OpenRouter's absent-field off switch is
170
+ * a real dialect fact and not a preference for always thinking.
162
171
  * - **OpenAI** takes `reasoning_effort`, and `none` is one of its values — not
163
172
  * the absence of the field. GPT-5.1 both accepted `none` and made it the
164
173
  * default; everything from GPT-5 back still defaults to `medium`. So sending
@@ -168,8 +177,10 @@ export type EffortDialect = "openai" | "openrouter" | "deepseek" | "off";
168
177
  * empty with `finish_reason: "length"`.
169
178
  *
170
179
  * An absent effort sends nothing on every dialect: the seam's rule is that a
171
- * knob the caller never touched is a knob the provider still owns. `none` is
172
- * the caller touching it, and the two must not produce the same request.
180
+ * knob the caller never touched is a knob the provider still owns. On
181
+ * OpenRouter that makes `none` and "never asked" the same request — on this
182
+ * dialect, not thinking IS the default state, so there is no third spelling
183
+ * to invent.
173
184
  */
174
185
  export function effortParams(
175
186
  dialect: EffortDialect,
@@ -189,31 +200,19 @@ export function effortParams(
189
200
  ? { thinking: { type: "enabled" } }
190
201
  : { thinking: { type: "enabled" }, reasoning_effort: level };
191
202
  case "openrouter":
192
- // Its own enum runs xhigh > high > medium > low > minimal > none, so `max`
193
- // has a real tier here and clamping it to `high` throws the top one away —
194
- // 0.95 of the budget against 0.8.
195
- //
196
- // CAUTION, measured 2026-09-09 against z-ai/glm-5.3-flash: the claim that
197
- // "OpenRouter maps an unsupported level to its nearest rather than
198
- // refusing" does NOT hold for `none`. That model answers
199
- // `400 "Reasoning is mandatory for this endpoint and cannot be disabled."`
200
- // to `reasoning.effort: "none"` exactly as it does to
201
- // `reasoning.enabled: false` — so on a model that must think, `none` is a
202
- // refusal and not a floor. Callers that mean "as little as possible"
203
- // should pass no effort at all rather than `none`, which is why the
204
- // absent-effort branch above stays load-bearing.
205
- //
206
- // Same measurement, same model, one structured-output call three ways:
207
- //
208
- // no reasoning field 4,1s · 1 reasoning token
209
- // effort "minimal" 46,6s · 474 reasoning tokens
210
- // max_tokens 2000 30,3s · 484 reasoning tokens
203
+ // GLM's off switch here is the ABSENT field (measured live 2026-09-14:
204
+ // omit `reasoning` → thinking disabled for glm-5.3-flash), so `none`
205
+ // sends nothing — the same request as a caller who never asked, which on
206
+ // this dialect is the same state. Naming a level RAISES it: the accepted
207
+ // values are low, high and max (`medium` and `xhigh` exist on the wire
208
+ // The refusal that once looked like "OpenRouter cannot be told not to
209
+ // think" was `reasoning.enabled: false` — a different field, never sent
210
+ // (pinned by the test below).
211
211
  //
212
- // Its default sat below every level on offer, so naming one RAISED the
213
- // thinking — an effort is a request, never a cap. Nearly all of the extra
214
- // time was spent before the first token arrived, so it does not show up as
215
- // slow streaming; it shows up as a call that looks hung.
216
- return { reasoning: { effort: effort === "max" ? "xhigh" : (level ?? "none") } };
212
+ // `max` rides verbatim here — the enum clamp below is DeepSeek/OpenAI's
213
+ // shape (DeepSeek auto-bumps past its own top, OpenAI's top IS high);
214
+ // OpenRouter takes `max` as named (measured live 2026-09-14).
215
+ return effort === "none" ? {} : { reasoning: { effort } };
217
216
  case "openai":
218
217
  return { reasoning_effort: level ?? "none" };
219
218
  case "off":
@@ -464,7 +463,11 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
464
463
 
465
464
  for await (const data of streamSse({
466
465
  url: apiUrl(baseUrl, config.path ?? defaultPath),
467
- headers: { authorization: `Bearer ${config.apiKey}`, ...config.headers },
466
+ headers: {
467
+ authorization: `Bearer ${config.apiKey}`,
468
+ ...attributionHeaders(config, config.headers),
469
+ ...config.headers,
470
+ },
468
471
  body: request,
469
472
  provider: id,
470
473
  ...(opts.signal ? { signal: opts.signal } : {}),
package/src/retry.ts CHANGED
@@ -79,9 +79,10 @@ export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
79
79
  function delayFor(err: unknown, attempt: number, opts: RetryOptions): number | null {
80
80
  const asked = parseRetryAfterMs(err);
81
81
  const cap = opts.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
82
- const resetWait = err instanceof ProviderError && err.resetAtMs !== undefined
83
- ? Math.max(0, err.resetAtMs - Date.now())
84
- : undefined;
82
+ const resetWait =
83
+ err instanceof ProviderError && err.resetAtMs !== undefined
84
+ ? Math.max(0, err.resetAtMs - Date.now())
85
+ : undefined;
85
86
  if (asked !== undefined || resetWait !== undefined) {
86
87
  const wait = Math.max(asked ?? 0, resetWait ?? 0);
87
88
  return wait > cap ? null : wait;