@providerkit/core 0.17.1 → 0.20.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 (68) hide show
  1. package/README.md +1 -0
  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 +43 -5
  29. package/dist/presets.d.ts.map +1 -1
  30. package/dist/presets.js +44 -4
  31. package/dist/presets.js.map +1 -1
  32. package/dist/providers/anthropic.d.ts.map +1 -1
  33. package/dist/providers/anthropic.js +6 -1
  34. package/dist/providers/anthropic.js.map +1 -1
  35. package/dist/providers/factory.d.ts +8 -2
  36. package/dist/providers/factory.d.ts.map +1 -1
  37. package/dist/providers/factory.js +11 -4
  38. package/dist/providers/factory.js.map +1 -1
  39. package/dist/providers/gemini.d.ts.map +1 -1
  40. package/dist/providers/gemini.js +3 -0
  41. package/dist/providers/gemini.js.map +1 -1
  42. package/dist/providers/openai.d.ts +7 -1
  43. package/dist/providers/openai.d.ts.map +1 -1
  44. package/dist/providers/openai.js +6 -0
  45. package/dist/providers/openai.js.map +1 -1
  46. package/dist/providers/responses.d.ts +7 -1
  47. package/dist/providers/responses.d.ts.map +1 -1
  48. package/dist/providers/responses.js +48 -6
  49. package/dist/providers/responses.js.map +1 -1
  50. package/dist/types.d.ts +26 -0
  51. package/dist/types.d.ts.map +1 -1
  52. package/dist/types.js +13 -5
  53. package/dist/types.js.map +1 -1
  54. package/package.json +5 -1
  55. package/src/auth/device.ts +137 -0
  56. package/src/auth/flows.ts +526 -0
  57. package/src/auth/index.ts +7 -0
  58. package/src/auth/oauth.ts +226 -0
  59. package/src/auth/token-source.ts +76 -0
  60. package/src/context.ts +16 -1
  61. package/src/errors.ts +3 -0
  62. package/src/presets.ts +53 -6
  63. package/src/providers/anthropic.ts +10 -1
  64. package/src/providers/factory.ts +21 -6
  65. package/src/providers/gemini.ts +7 -0
  66. package/src/providers/openai.ts +12 -0
  67. package/src/providers/responses.ts +60 -6
  68. package/src/types.ts +49 -5
@@ -0,0 +1,226 @@
1
+ // The OAuth plumbing every signed-in provider shares: PKCE, the token POST, the
2
+ // credential it builds, and the errors a sign-in can end in. Fetch only, no
3
+ // Node built-ins. What differs per vendor stays in flows.ts.
4
+ import { classifyHttp, ProviderError } from "../errors.ts";
5
+
6
+ /** What the host app supplies. Opening a browser and catching a redirect are
7
+ * the two things a fetch-only package can't do. */
8
+ export interface AuthHost {
9
+ /** Names the app on the wire: Copilot's `Editor-Version`, xAI's `referrer`.
10
+ * Use `name/version`, like `TabRunner/1.4.0`. */
11
+ appName: string;
12
+ /** Opens a page for the user. Device-code flows call it with the approval page. */
13
+ openUrl(url: string): void | Promise<void>;
14
+ /**
15
+ * Opens `authorizeUrl`, waits for the browser to land on `redirectUri`, and
16
+ * returns the `code` from that URL. Reject with a `SignInError` when the user
17
+ * denies (`denied`), closes the page (`cancelled`) or takes too long
18
+ * (`expired`). Check `state` against the one in the URL when it is set.
19
+ */
20
+ captureRedirect(request: {
21
+ authorizeUrl: string;
22
+ redirectUri: string;
23
+ state?: string;
24
+ signal: AbortSignal;
25
+ }): Promise<string>;
26
+ /** Replaces `fetch`, for tests. */
27
+ fetchImpl?: typeof fetch;
28
+ }
29
+
30
+ /** The stored result of a sign-in. */
31
+ export interface Credential {
32
+ accessToken: string;
33
+ /** What renews `accessToken`. Copilot keeps the GitHub token here and Meta
34
+ * its identity token. Empty for a key that never expires (OpenRouter). */
35
+ refreshToken: string;
36
+ /** Epoch ms, already moved earlier by {@link REFRESH_SKEW_MS}: refresh at or
37
+ * after this, it is not the server's own expiry. */
38
+ expiresAt: number;
39
+ /** For display: the account the token belongs to. */
40
+ account?: string;
41
+ /** ChatGPT only: send it as the `ChatGPT-Account-Id` header. */
42
+ chatgptAccountId?: string;
43
+ /** Set when the vendor gives each account its own host (Copilot). */
44
+ baseUrl?: string;
45
+ }
46
+
47
+ export type SignInFailure = "denied" | "expired" | "cancelled";
48
+
49
+ /** How a sign-in ended without a credential. The reason picks the wording. */
50
+ export class SignInError extends Error {
51
+ constructor(readonly reason: SignInFailure) {
52
+ super(reason);
53
+ this.name = "SignInError";
54
+ }
55
+ }
56
+
57
+ /** The coded errors a sign-in or a refresh throws, as `ProviderError.code`. */
58
+ export type AuthErrorCode =
59
+ | "token_refused"
60
+ | "token_incomplete"
61
+ | "device_response_invalid"
62
+ | "setup_required"
63
+ | "refresh_dead";
64
+
65
+ export function authError(
66
+ code: AuthErrorCode,
67
+ message: string,
68
+ opts: { status?: number; body?: string } = {},
69
+ ): ProviderError {
70
+ const body = opts.body ?? "";
71
+ // `invalid_grant` and a 401 classify as `auth`, a 429 as `rate`, a 5xx as an outage.
72
+ const kind = opts.status === undefined ? "auth" : classifyHttp(opts.status, body);
73
+ return new ProviderError("auth", kind, message, { ...opts, code });
74
+ }
75
+
76
+ /** Refresh this long before the server's own expiry. */
77
+ export const REFRESH_SKEW_MS = 5 * 60 * 1000;
78
+
79
+ export const str = (value: unknown): string | undefined =>
80
+ typeof value === "string" && value.length > 0 ? value : undefined;
81
+
82
+ export const num = (value: unknown): number | undefined =>
83
+ typeof value === "number" && Number.isFinite(value) ? value : undefined;
84
+
85
+ export const asRecord = (value: unknown): Record<string, unknown> =>
86
+ typeof value === "object" && value !== null ? Object.fromEntries(Object.entries(value)) : {};
87
+
88
+ /** RFC 7636 code verifier and S256 challenge. */
89
+ export async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
90
+ const verifier = toBase64Url(crypto.getRandomValues(new Uint8Array(64)));
91
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
92
+ return { verifier, challenge: toBase64Url(new Uint8Array(digest)) };
93
+ }
94
+
95
+ /** Opaque CSRF state: 128 bits, hex. */
96
+ export function randomState(): string {
97
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
98
+ return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
99
+ }
100
+
101
+ function toBase64Url(bytes: Uint8Array): string {
102
+ let binary = "";
103
+ for (const b of bytes) binary += String.fromCharCode(b);
104
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
105
+ }
106
+
107
+ /**
108
+ * POST to a token endpoint and return the parsed body.
109
+ *
110
+ * A token in the body wins over the status: a vendor can return a usable
111
+ * credential next to a non-2xx (Anthropic answers 429 for a plan over its usage
112
+ * limit). Only a body without one is an error. The device-code poll sets
113
+ * `allowErrorBody`, because it gets a 4xx for most of the wait.
114
+ */
115
+ export async function postToken(
116
+ host: AuthHost,
117
+ url: string,
118
+ params: Record<string, string>,
119
+ opts: { encode: "json" | "form"; allowErrorBody?: boolean },
120
+ ): Promise<Record<string, unknown>> {
121
+ const json = opts.encode === "json";
122
+ const res = await (host.fetchImpl ?? fetch)(url, {
123
+ method: "POST",
124
+ headers: {
125
+ "Content-Type": json ? "application/json" : "application/x-www-form-urlencoded",
126
+ Accept: "application/json",
127
+ },
128
+ body: json ? JSON.stringify(params) : new URLSearchParams(params).toString(),
129
+ });
130
+ const text = await res.text().catch(() => "");
131
+ const record = asRecord(safeJson(text));
132
+
133
+ if (isTokenPair(record)) return record;
134
+ if (res.ok || (opts.allowErrorBody && res.status < 500)) return record;
135
+
136
+ const detail = errorDetail(record) ?? String(res.status);
137
+ throw authError("token_refused", `The sign-in server refused the request: ${detail}`, {
138
+ status: res.status,
139
+ body: text.slice(0, 2_000),
140
+ });
141
+ }
142
+
143
+ function safeJson(text: string): unknown {
144
+ try {
145
+ return JSON.parse(text);
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ function isTokenPair(body: Record<string, unknown>): boolean {
152
+ return (
153
+ str(body.access_token) !== undefined &&
154
+ str(body.refresh_token) !== undefined &&
155
+ num(body.expires_in) !== undefined
156
+ );
157
+ }
158
+
159
+ /** A reason out of an OAuth error body: RFC 6749's `error_description` and
160
+ * `error`, or the nested `error.message` Anthropic and OpenAI use. */
161
+ export function errorDetail(record: Record<string, unknown>): string | undefined {
162
+ const description = str(record.error_description);
163
+ if (description) return description;
164
+ const error = record.error;
165
+ if (typeof error === "string") return error;
166
+ const nested = asRecord(error);
167
+ return str(nested.message) ?? str(nested.type);
168
+ }
169
+
170
+ /** The credential a token response describes. Naming the account is the
171
+ * caller's job: every vendor puts it under a different claim. */
172
+ export function toCredential(body: Record<string, unknown>, fallbackRefresh?: string): Credential {
173
+ const accessToken = str(body.access_token);
174
+ // A refresh response may leave out the refresh token. Keep the old one then.
175
+ const refreshToken = str(body.refresh_token) ?? fallbackRefresh;
176
+ const expiresIn = num(body.expires_in);
177
+ if (!accessToken || !refreshToken || expiresIn === undefined) {
178
+ throw authError(
179
+ "token_incomplete",
180
+ "The sign-in server's reply is missing a token or its expiry.",
181
+ );
182
+ }
183
+ return { accessToken, refreshToken, expiresAt: Date.now() + expiresIn * 1000 - REFRESH_SKEW_MS };
184
+ }
185
+
186
+ /** A JWT's payload, read WITHOUT checking the signature. Safe here because
187
+ * nothing is authorized on it: it only names the account for display. */
188
+ export function jwtClaims(token?: string): Record<string, unknown> | undefined {
189
+ const payload = token?.split(".")[1];
190
+ if (!payload) return undefined;
191
+ try {
192
+ return asRecord(JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/"))));
193
+ } catch {
194
+ return undefined;
195
+ }
196
+ }
197
+
198
+ /** The account a token names, trying the claims in order. An email wins and is
199
+ * lowercased, because one mailbox written two ways is one account. */
200
+ export function accountFromToken(
201
+ token: string | undefined,
202
+ ...claims: string[]
203
+ ): string | undefined {
204
+ const payload = jwtClaims(token);
205
+ if (!payload) return undefined;
206
+ for (const claim of claims) {
207
+ const value = str(payload[claim]);
208
+ if (value) return claim === "email" ? value.toLowerCase() : value;
209
+ }
210
+ return undefined;
211
+ }
212
+
213
+ /** The URL if it is http(s), else undefined. It comes off the network and ends
214
+ * up in `openUrl`, so a `javascript:` or `data:` URL must not get through. */
215
+ export function webUrl(
216
+ value: string | undefined,
217
+ protocols = ["https:", "http:"],
218
+ ): string | undefined {
219
+ if (!value) return undefined;
220
+ try {
221
+ const url = new URL(value);
222
+ return protocols.includes(url.protocol) ? url.href : undefined;
223
+ } catch {
224
+ return undefined;
225
+ }
226
+ }
@@ -0,0 +1,76 @@
1
+ // One place that hands out a token that works: refreshes it once however many
2
+ // callers ask, saves the new pair before anyone uses it, and recovers when a
3
+ // second tab or process spent the refresh token first.
4
+ import { isTransportFailure, ProviderError } from "../errors.ts";
5
+ import { authError, type Credential } from "./oauth.ts";
6
+
7
+ export interface TokenSourceOptions<C extends Credential = Credential> {
8
+ /** Read the stored credential. Read it on every call, never cache it: the
9
+ * refresh token rotates, and a cached copy would spend one already used. */
10
+ load(): Promise<C | undefined>;
11
+ /** Persist a refreshed credential. Awaited before the new token is used. */
12
+ save(credential: C): Promise<void>;
13
+ /** Trade the credential for a fresh one: `createAuthFlow(id, host).refresh`. */
14
+ refresh(credential: C): Promise<C>;
15
+ }
16
+
17
+ export interface TokenSource<C extends Credential = Credential> {
18
+ /** A credential whose `accessToken` is good now. Throws a `ProviderError`
19
+ * coded `refresh_dead` when the user must sign in again. */
20
+ getToken(): Promise<C>;
21
+ }
22
+
23
+ /**
24
+ * Refresh when the stored credential has expired, and only then.
25
+ *
26
+ * - Concurrent calls share one refresh and the result is saved before use.
27
+ * - When the server rejects the refresh, the stored credential is reloaded. If
28
+ * another context saved a newer one meanwhile, that one is used. Otherwise
29
+ * the session is dead and the error says so.
30
+ * - A network failure keeps the credential and rethrows, so a user on bad wifi
31
+ * isn't told to sign in again.
32
+ *
33
+ * ponytail: two contexts can both lose a refresh before either one saves, and
34
+ * then both fail. Closing that needs a lock on the storage.
35
+ */
36
+ export function tokenSource<C extends Credential>(options: TokenSourceOptions<C>): TokenSource<C> {
37
+ let inFlight: Promise<C> | undefined;
38
+
39
+ async function refreshStored(stale: C): Promise<C> {
40
+ try {
41
+ const fresh = await options.refresh(stale);
42
+ await options.save(fresh);
43
+ return fresh;
44
+ } catch (error) {
45
+ if (!isRejected(error)) throw error;
46
+ const stored = await options.load();
47
+ if (stored && isNewer(stored, stale) && Date.now() < stored.expiresAt) return stored;
48
+ throw authError("refresh_dead", "The sign-in expired and can't be renewed. Sign in again.");
49
+ }
50
+ }
51
+
52
+ return {
53
+ async getToken() {
54
+ const credential = await options.load();
55
+ if (!credential) {
56
+ throw authError("refresh_dead", "Not signed in. Sign in first.");
57
+ }
58
+ if (Date.now() < credential.expiresAt) return credential;
59
+ inFlight ??= refreshStored(credential).finally(() => {
60
+ inFlight = undefined;
61
+ });
62
+ return inFlight;
63
+ },
64
+ };
65
+ }
66
+
67
+ /** A refresh the server turned down for good, as opposed to one that never
68
+ * reached it or came back unreadable. */
69
+ function isRejected(error: unknown): error is ProviderError {
70
+ if (isTransportFailure(error) || !(error instanceof ProviderError)) return false;
71
+ return error.status === 400 || error.status === 401 || error.status === 403;
72
+ }
73
+
74
+ function isNewer(stored: Credential, stale: Credential): boolean {
75
+ return stored.refreshToken !== stale.refreshToken || stored.expiresAt > stale.expiresAt;
76
+ }
package/src/context.ts CHANGED
@@ -41,7 +41,22 @@ export function messageTokens(message: ChatMessage): number {
41
41
  ? (message.reasoning ?? "") +
42
42
  (message.toolCalls?.map((call) => call.name + call.arguments).join("") ?? "")
43
43
  : "";
44
- return estimateTokens(textOf(message) + extra);
44
+ return estimateTokens(textOf(message) + extra) + reasoningItemTokens(message);
45
+ }
46
+
47
+ /**
48
+ * Encrypted reasoning is base64 of the real thing, so its length says little:
49
+ * three bytes per four characters, less ~650 bytes of envelope, then four bytes
50
+ * a token (cc-proxy's estimate).
51
+ */
52
+ function reasoningItemTokens(message: ChatMessage): number {
53
+ if (message.role !== "assistant" || !message.reasoningItems) return 0;
54
+ let tokens = 0;
55
+ for (const item of message.reasoningItems.items) {
56
+ const blob = (item as { encrypted_content?: unknown } | null)?.encrypted_content;
57
+ if (typeof blob === "string") tokens += Math.max(0, ((blob.length * 3) / 4 - 650) / 4);
58
+ }
59
+ return Math.ceil(tokens);
45
60
  }
46
61
 
47
62
  export function conversationTokens(messages: readonly ChatMessage[]): number {
package/src/errors.ts CHANGED
@@ -423,6 +423,9 @@ const AUTH_PATTERNS: readonly RegExp[] = [
423
423
  /account (?:disabled|suspended|deactivated|banned)/i,
424
424
  /unrecognizedclient|unauthorizedexception|accessdeniedexception/i,
425
425
  /expired[_\s]?token/i,
426
+ // OAuth's answer to a refresh token that was revoked, spent or expired. It
427
+ // arrives as a 400, which on status alone reads as `invalid`.
428
+ /\binvalid_grant\b/i,
426
429
  /OAuth token has been revoked/i,
427
430
  // Cloudflare's code 7003, for an account id that names no account. It
428
431
  // arrives as a 404, which on status alone reads as a missing model — but
package/src/presets.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * they drift on a vendor's schedule and a wrong number in a library is a
8
8
  * wrong number in everyone's ledger), icons, colors, marketing names, and
9
9
  * sign-in flows (an `oauth` preset expects the CALLER to hand over an access
10
- * token; acquiring one stays app-side).
10
+ * token; `@providerkit/core/auth` is how to get and renew one).
11
11
  *
12
12
  * Sources: the coding-plan endpoints and auth quirks are measured
13
13
  * (tabrunner's extension + this repo's adopters, 2026-09); the model lists
@@ -31,7 +31,7 @@ export type PresetShape = "anthropic" | "openai" | "responses" | "gemini";
31
31
  * coding-plan gateways' mode on the Anthropic wire (measured: Z.ai's coding
32
32
  * endpoint ignores `x-api-key` and reads Bearer).
33
33
  * - `oauth`: same wire as `bearer`, but the token is a short-lived access
34
- * token from a sign-in flow the caller owns. The preset may carry the beta
34
+ * token from a sign-in flow (see `@providerkit/core/auth`). The preset may carry the beta
35
35
  * headers that switch the endpoint into OAuth mode.
36
36
  */
37
37
  export type PresetAuth = "key" | "bearer" | "oauth";
@@ -76,6 +76,13 @@ export interface ProviderPreset {
76
76
  /** Responses shape: what the backend accepts as an image, for backends that
77
77
  * fail a whole request over one image they won't take. */
78
78
  imageLimits?: ImageLimits;
79
+ /** Responses shape: ask for the encrypted reasoning items, and send them back
80
+ * on the next turn. Only for backends that take `include`; `chatgpt` is the
81
+ * one that does, and Go and Grok may refuse it. */
82
+ replayReasoning?: boolean;
83
+ /** OpenAI shape: send `X-Initiator` (`user` or `agent`) on every call. GitHub
84
+ * Copilot bills a premium request per `user` turn. */
85
+ initiatorHeader?: boolean;
79
86
  }
80
87
 
81
88
  export const PROVIDER_PRESETS = {
@@ -445,7 +452,7 @@ export const PROVIDER_PRESETS = {
445
452
  auth: "bearer",
446
453
  },
447
454
 
448
- // ── OAuth — the caller owns the sign-in flow and hands over the token ───
455
+ // ── OAuth — sign in with `@providerkit/core/auth`, hand over the token ──
449
456
  claude: {
450
457
  shape: "anthropic",
451
458
  baseUrl: "https://api.anthropic.com",
@@ -466,6 +473,15 @@ export const PROVIDER_PRESETS = {
466
473
  * model id was wrong. `session-id` is the header the official Codex client
467
474
  * sends; the backend keys its prompt cache on it (cc-proxy, in daily use
468
475
  * against this backend, 2026-09).
476
+ *
477
+ * The backend retires models without notice, and models.dev doesn't list
478
+ * this backend, so `refresh-presets` can't catch the drift. By 2026-10-01 the
479
+ * default here (gpt-5.3-codex) and two of the other three listed ids
480
+ * answered 400 "not supported when using Codex with a ChatGPT account": a
481
+ * caller who took the default failed on every call. This list is what the
482
+ * backend accepted that day. It checks the model before the quota, so an
483
+ * account with its window spent still tells the two apart: 400 for a
484
+ * rejected model, 429 for an accepted one.
469
485
  */
470
486
  chatgpt: {
471
487
  shape: "responses",
@@ -473,8 +489,18 @@ export const PROVIDER_PRESETS = {
473
489
  path: "/responses",
474
490
  sessionHeader: "session-id",
475
491
  auth: "oauth",
476
- defaultModel: "gpt-5.3-codex",
477
- models: ["gpt-5.3-codex", "gpt-5.3-codex-spark", "gpt-5.5", "gpt-5.4-mini"],
492
+ defaultModel: "gpt-6-sol",
493
+ models: [
494
+ "gpt-6-sol",
495
+ "gpt-6.1-sol",
496
+ "gpt-6-astra",
497
+ "gpt-6-luna",
498
+ "gpt-5.6-sol",
499
+ "gpt-5.6-terra",
500
+ "gpt-5.6-luna",
501
+ "gpt-5.5",
502
+ ],
503
+ replayReasoning: true,
478
504
  },
479
505
  /**
480
506
  * A Grok subscription (SuperGrok, X Premium) — the backend the Grok CLI
@@ -512,12 +538,33 @@ export const PROVIDER_PRESETS = {
512
538
  auth: "oauth",
513
539
  defaultModel: "kimi-for-coding",
514
540
  },
541
+ /**
542
+ * GitHub Copilot, on an individual seat's host. Other plans have their own
543
+ * host, which the sign-in returns as `credential.baseUrl`: pass it as
544
+ * `baseUrl`. Copilot serves chat at `/chat/completions` with no `/v1`, and the
545
+ * default path would have added one and 404'd.
546
+ *
547
+ * The headers are the ones its gate checks on every call. Pass
548
+ * `copilotHeaders(appName, "user")` from `@providerkit/core/auth` in
549
+ * `headers` to name your app in `Editor-Version`. `X-Initiator` is set per
550
+ * call, because it decides what Copilot bills.
551
+ */
515
552
  "github-copilot": {
516
553
  shape: "openai",
517
- baseUrl: "https://api.githubcopilot.com",
554
+ baseUrl: "https://api.individual.githubcopilot.com",
555
+ path: "/chat/completions",
518
556
  auth: "oauth",
519
557
  defaultModel: "gpt-5.6-sol",
520
558
  models: ["gpt-5.6-sol", "claude-opus-5", "claude-sonnet-5", "gpt-6-astra"],
559
+ headers: {
560
+ "Copilot-Integration-Id": "vscode-chat",
561
+ "Editor-Version": "providerkit",
562
+ "Editor-Plugin-Version": "providerkit",
563
+ "X-GitHub-Api-Version": "2026-06-01",
564
+ "Openai-Intent": "conversation-edits",
565
+ "Copilot-Vision-Request": "true",
566
+ },
567
+ initiatorHeader: true,
521
568
  },
522
569
  } as const satisfies Record<string, ProviderPreset>;
523
570
 
@@ -1,5 +1,5 @@
1
1
  // Anthropic-shape adapter — SSE from POST /v1/messages.
2
- import { fileRefused, streamCut, streamError } from "../errors.ts";
2
+ import { fileRefused, ProviderError, 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";
@@ -371,6 +371,15 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
371
371
  const effort = opts.effort ?? config.effort;
372
372
  const { system, messages: body } = toAnthropicMessages(messages, id);
373
373
 
374
+ // Anthropic's own `service_tier` means auto or standard_only, not this.
375
+ if (opts.serviceTier) {
376
+ throw new ProviderError(
377
+ id,
378
+ "invalid",
379
+ `${id}: Anthropic has no priority or flex tier (its service_tier field means something else). ` +
380
+ "Leave serviceTier unset for Claude models.",
381
+ );
382
+ }
374
383
  const request: Record<string, unknown> = {
375
384
  model,
376
385
  max_tokens: maxTokens,
@@ -20,12 +20,12 @@ import {
20
20
  type ProviderPreset,
21
21
  type ProviderPresetId,
22
22
  } from "../presets.ts";
23
- import type { Effort, Provider } from "../types.ts";
23
+ import type { Effort, Provider, ServiceTier } from "../types.ts";
24
24
  import { withConfiguredFallbacks, type ProviderFallbackConfig } from "../fallback.ts";
25
25
 
26
26
  export interface PresetProviderConfig extends ProviderFallbackConfig {
27
27
  /** The credential: an API key for `key`/`bearer` presets, an ACCESS TOKEN
28
- * for `oauth` ones. Acquiring the token is the caller's job. */
28
+ * for `oauth` ones. `@providerkit/core/auth` signs in and renews it. */
29
29
  apiKey: string;
30
30
  /** Which model to run. Absent = the preset's `defaultModel`; a preset
31
31
  * without either throws rather than sending a request nobody chose. */
@@ -34,6 +34,9 @@ export interface PresetProviderConfig extends ProviderFallbackConfig {
34
34
  * take over in order when it fails (quota, rate, outage). Wins over `model`
35
35
  * and the preset's `rotation`; `fallbacks` still run after the chain. */
36
36
  models?: readonly string[];
37
+ /** Replaces the preset's API root. For a vendor that gives each account its
38
+ * own host (Copilot: `credential.baseUrl` from the sign-in). */
39
+ baseUrl?: string;
37
40
  effort?: Effort;
38
41
  /** Output ceiling. The Anthropic shape requires one; adapters default it. */
39
42
  maxTokens?: number;
@@ -57,6 +60,9 @@ export interface PresetProviderConfig extends ProviderFallbackConfig {
57
60
  * between rounds — pinning keeps a conversation's rounds (and their cache)
58
61
  * on one host. Empty/absent = default routing. */
59
62
  providerOrder?: string[];
63
+ /** Default `service_tier` for every call on the OpenAI and Responses shapes;
64
+ * `StreamOptions.serviceTier` wins. Anthropic and Gemini presets refuse it. */
65
+ serviceTier?: ServiceTier;
60
66
  fetchImpl?: typeof fetch;
61
67
  }
62
68
 
@@ -85,7 +91,14 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
85
91
  if (!model) {
86
92
  throw new Error(`[providerkit] Preset "${id}" has no defaultModel — pass a model.`);
87
93
  }
94
+ if (config.serviceTier && (preset.shape === "anthropic" || preset.shape === "gemini")) {
95
+ throw new Error(
96
+ `[providerkit] Preset "${id}" speaks the ${preset.shape} shape, which has no service tier. ` +
97
+ "Remove serviceTier.",
98
+ );
99
+ }
88
100
  const headers = { ...preset.headers, ...config.headers };
101
+ const baseUrl = config.baseUrl ?? preset.baseUrl;
89
102
  const maxTokens = config.maxTokens ?? preset.maxTokens;
90
103
 
91
104
  const {
@@ -107,7 +120,7 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
107
120
  model,
108
121
  effort: config.effort,
109
122
  maxTokens,
110
- baseUrl: preset.baseUrl,
123
+ baseUrl,
111
124
  id,
112
125
  headers,
113
126
  bearer: preset.auth !== "key",
@@ -125,11 +138,12 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
125
138
  model,
126
139
  effort: config.effort,
127
140
  maxTokens,
128
- baseUrl: preset.baseUrl,
141
+ baseUrl,
129
142
  ...(preset.path ? { path: preset.path } : {}),
130
143
  headers,
131
144
  ...(config.providerOrder ? { providerOrder: config.providerOrder } : {}),
132
145
  ...(preset.sessionHeader ? { sessionHeader: preset.sessionHeader } : {}),
146
+ ...(preset.initiatorHeader ? { initiatorHeader: true } : {}),
133
147
  fetchImpl: config.fetchImpl,
134
148
  });
135
149
  break;
@@ -140,12 +154,13 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
140
154
  model,
141
155
  effort: config.effort,
142
156
  maxTokens,
143
- baseUrl: preset.baseUrl,
157
+ baseUrl,
144
158
  ...(preset.path ? { path: preset.path } : {}),
145
159
  id,
146
160
  headers,
147
161
  ...(preset.sessionHeader ? { sessionHeader: preset.sessionHeader } : {}),
148
162
  ...(preset.imageLimits ? { imageLimits: preset.imageLimits } : {}),
163
+ ...(preset.replayReasoning ? { replayReasoning: true } : {}),
149
164
  fetchImpl: config.fetchImpl,
150
165
  });
151
166
  break;
@@ -155,7 +170,7 @@ export function createPresetProvider(id: ProviderPresetId, config: PresetProvide
155
170
  apiKey: config.apiKey,
156
171
  model,
157
172
  effort: config.effort,
158
- baseUrl: preset.baseUrl,
173
+ baseUrl,
159
174
  id,
160
175
  headers,
161
176
  fetchImpl: config.fetchImpl,
@@ -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
@@ -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";
@@ -487,6 +494,8 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
487
494
  if (opts.temperature !== undefined) request.temperature = opts.temperature;
488
495
  if (opts.topP !== undefined) request.top_p = opts.topP;
489
496
  if (opts.stopSequences?.length) request.stop = opts.stopSequences;
497
+ const serviceTier = opts.serviceTier ?? config.serviceTier;
498
+ if (serviceTier) request.service_tier = serviceTier;
490
499
  Object.assign(request, effortParams(config.effortDialect ?? dialectFor(id), effort, model));
491
500
  if (tools.length > 0) {
492
501
  request.tools = tools.map((tool) => ({
@@ -582,6 +591,9 @@ export function createOpenAIProvider(config: OpenAIConfig): Provider {
582
591
  ...(config.sessionHeader
583
592
  ? { [config.sessionHeader]: opts.sessionId ?? instanceSessionId }
584
593
  : {}),
594
+ ...(config.initiatorHeader
595
+ ? { "X-Initiator": messages.at(-1)?.role === "user" ? "user" : "agent" }
596
+ : {}),
585
597
  ...config.headers,
586
598
  },
587
599
  body: request,