@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
@@ -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,11 +31,26 @@ 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";
38
38
 
39
+ /**
40
+ * One wire a preset serves besides its own. A gateway that puts different
41
+ * models on different wires (OpenCode Go) lists the exceptions here, and the
42
+ * preset's own `shape` and `baseUrl` serve every other model.
43
+ */
44
+ export interface PresetRoute {
45
+ /** The model ids this route serves, matched by prefix. */
46
+ prefixes: readonly string[];
47
+ shape: PresetShape;
48
+ baseUrl: string;
49
+ path?: string;
50
+ /** Defaults to the preset's `auth`. */
51
+ auth?: PresetAuth;
52
+ }
53
+
39
54
  export interface ProviderPreset {
40
55
  /** Adapter wire format. */
41
56
  shape: PresetShape;
@@ -70,12 +85,24 @@ export interface ProviderPreset {
70
85
  * and cache per conversation — and refuse a call without it (OpenCode Go:
71
86
  * `MissingSessionID`, measured 2026-09-27). */
72
87
  sessionHeader?: string;
88
+ /** Wires for model ids that don't use the preset's own `shape`. The first
89
+ * route with a matching prefix serves the model. A model on a different wire
90
+ * than the one the provider was built for is refused with `invalid`, so a
91
+ * per-call `model` can't send a request to an endpoint that would 404 it. */
92
+ routes?: readonly PresetRoute[];
73
93
  /** Default model chain when the caller names neither `model` nor `models`:
74
94
  * the first answers, the rest take over (same key) when it fails. */
75
95
  rotation?: readonly string[];
76
96
  /** Responses shape: what the backend accepts as an image, for backends that
77
97
  * fail a whole request over one image they won't take. */
78
98
  imageLimits?: ImageLimits;
99
+ /** Responses shape: ask for the encrypted reasoning items, and send them back
100
+ * on the next turn. Only for backends that take `include`; `chatgpt` is the
101
+ * one that does, and Go and Grok may refuse it. */
102
+ replayReasoning?: boolean;
103
+ /** OpenAI shape: send `X-Initiator` (`user` or `agent`) on every call. GitHub
104
+ * Copilot bills a premium request per `user` turn. */
105
+ initiatorHeader?: boolean;
79
106
  }
80
107
 
81
108
  export const PROVIDER_PRESETS = {
@@ -329,22 +356,6 @@ export const PROVIDER_PRESETS = {
329
356
  defaultModel: "glm-5.3-flash",
330
357
  models: ["glm-5.3-flash", "glm-5.3", "glm-5.2"],
331
358
  },
332
- /**
333
- * OpenCode Go's Responses route. Muse Contributor measured 2026-09-28:
334
- * text, images, strict JSON Schema, parallel tools and tool-result round trips
335
- * all passed. It requires reasoning (`none` becomes `minimal`) and accepts
336
- * only automatic tool choice (`none` is encoded by sending no tools).
337
- * Contributor requests may be used to improve Meta products; the workspace
338
- * must explicitly allow those endpoints.
339
- */
340
- "opencode-go-responses": {
341
- shape: "responses",
342
- baseUrl: "https://opencode.ai/zen/go",
343
- auth: "bearer",
344
- defaultModel: "muse-spark-1.3-contributor",
345
- models: ["muse-spark-1.3-contributor"],
346
- sessionHeader: "x-opencode-session",
347
- },
348
359
  /**
349
360
  * OpenCode Go — one $10/month key across many open models, each with its own
350
361
  * monthly dollar limit (5h = 20%, week = 50%). Chain several models as
@@ -355,17 +366,50 @@ export const PROVIDER_PRESETS = {
355
366
  * `reasoning_effort: "none"`, tool calls and json_schema; `glm-5.3-flash`
356
367
  * 400s on a tool without a description and on a `thinking` field.
357
368
  *
358
- * This preset speaks chat completions only. Go serves GPT/Grok/Muse Spark on
359
- * /responses and some Qwen/MiniMax ids on Anthropic /messages — those ids
360
- * fail here.
369
+ * Go serves three wires, and `routes` picks one from the model id (cc-proxy's
370
+ * table, from Go's own docs): `minimax-` and `qwen` on Anthropic /messages
371
+ * (key in `x-api-key`), `gpt-`, `grok-` and `muse-spark-` on /responses, and
372
+ * everything else on chat completions. Each model in a chain resolves its
373
+ * own route, so one preset serves a chain that crosses wires.
374
+ *
375
+ * Muse Contributor, measured 2026-09-28 on /responses: text, images, strict
376
+ * JSON Schema, parallel tools and tool-result round trips all passed. It
377
+ * requires reasoning (`none` becomes `minimal`) and accepts only automatic
378
+ * tool choice (`none` is encoded by sending no tools). Contributor requests
379
+ * may be used to improve Meta products; the workspace must explicitly allow
380
+ * those endpoints.
381
+ *
382
+ * Reasoning effort is spelled per model on chat completions (see
383
+ * `effortParams`): GLM takes only `high` and `max`, DeepSeek V4 and MiMo
384
+ * take graded levels.
361
385
  */
362
386
  "opencode-go": {
363
387
  shape: "openai",
364
388
  baseUrl: "https://opencode.ai/zen/go/v1",
365
389
  auth: "bearer",
366
390
  defaultModel: "mimo-v2.6-flash",
367
- models: ["mimo-v2.6-flash", "mimo-v2.5", "glm-5.3-flash", "qwen3.8-flash", "longcat-2.0"],
391
+ models: [
392
+ "mimo-v2.6-flash",
393
+ "mimo-v2.5",
394
+ "glm-5.3-flash",
395
+ "qwen3.8-flash",
396
+ "longcat-2.0",
397
+ "muse-spark-1.3-contributor",
398
+ ],
368
399
  sessionHeader: "x-opencode-session",
400
+ routes: [
401
+ {
402
+ prefixes: ["minimax-", "qwen"],
403
+ shape: "anthropic",
404
+ baseUrl: "https://opencode.ai/zen/go",
405
+ auth: "key",
406
+ },
407
+ {
408
+ prefixes: ["gpt-", "grok-", "muse-spark-"],
409
+ shape: "responses",
410
+ baseUrl: "https://opencode.ai/zen/go",
411
+ },
412
+ ],
369
413
  // Cheapest first, each with its own monthly limit ($60, qwen3.8-flash $30).
370
414
  // All five passed plain text, a described tool call and json_schema on
371
415
  // 2026-09-27; longcat-2.0 ignores reasoning "none", so it goes last.
@@ -445,7 +489,7 @@ export const PROVIDER_PRESETS = {
445
489
  auth: "bearer",
446
490
  },
447
491
 
448
- // ── OAuth — the caller owns the sign-in flow and hands over the token ───
492
+ // ── OAuth — sign in with `@providerkit/core/auth`, hand over the token ──
449
493
  claude: {
450
494
  shape: "anthropic",
451
495
  baseUrl: "https://api.anthropic.com",
@@ -493,6 +537,7 @@ export const PROVIDER_PRESETS = {
493
537
  "gpt-5.6-luna",
494
538
  "gpt-5.5",
495
539
  ],
540
+ replayReasoning: true,
496
541
  },
497
542
  /**
498
543
  * A Grok subscription (SuperGrok, X Premium) — the backend the Grok CLI
@@ -530,12 +575,33 @@ export const PROVIDER_PRESETS = {
530
575
  auth: "oauth",
531
576
  defaultModel: "kimi-for-coding",
532
577
  },
578
+ /**
579
+ * GitHub Copilot, on an individual seat's host. Other plans have their own
580
+ * host, which the sign-in returns as `credential.baseUrl`: pass it as
581
+ * `baseUrl`. Copilot serves chat at `/chat/completions` with no `/v1`, and the
582
+ * default path would have added one and 404'd.
583
+ *
584
+ * The headers are the ones its gate checks on every call. Pass
585
+ * `copilotHeaders(appName, "user")` from `@providerkit/core/auth` in
586
+ * `headers` to name your app in `Editor-Version`. `X-Initiator` is set per
587
+ * call, because it decides what Copilot bills.
588
+ */
533
589
  "github-copilot": {
534
590
  shape: "openai",
535
- baseUrl: "https://api.githubcopilot.com",
591
+ baseUrl: "https://api.individual.githubcopilot.com",
592
+ path: "/chat/completions",
536
593
  auth: "oauth",
537
594
  defaultModel: "gpt-5.6-sol",
538
595
  models: ["gpt-5.6-sol", "claude-opus-5", "claude-sonnet-5", "gpt-6-astra"],
596
+ headers: {
597
+ "Copilot-Integration-Id": "vscode-chat",
598
+ "Editor-Version": "providerkit",
599
+ "Editor-Plugin-Version": "providerkit",
600
+ "X-GitHub-Api-Version": "2026-06-01",
601
+ "Openai-Intent": "conversation-edits",
602
+ "Copilot-Vision-Request": "true",
603
+ },
604
+ initiatorHeader: true,
539
605
  },
540
606
  } as const satisfies Record<string, ProviderPreset>;
541
607
 
@@ -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";
@@ -51,6 +51,9 @@ export interface AnthropicConfig extends ProviderFallbackConfig {
51
51
  /** Send the key as a Bearer instead of `x-api-key` — what a subscription
52
52
  * access token needs. */
53
53
  bearer?: boolean;
54
+ /** Header that carries `StreamOptions.sessionId`. When set and a call has no
55
+ * sessionId, one stable id for this provider instance rides instead. */
56
+ sessionHeader?: string;
54
57
  /** Say "no thinking" with an explicit `thinking: { type: "disabled" }` when
55
58
  * effort resolves to none, instead of omitting the field. For endpoints
56
59
  * where an absent field means the MODEL's default, not off — Z.ai's coding
@@ -356,6 +359,7 @@ interface AnthropicEvent {
356
359
  export function createAnthropicProvider(config: AnthropicConfig): Provider {
357
360
  const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
358
361
  const id = config.id ?? "anthropic";
362
+ const instanceSessionId = config.sessionHeader ? crypto.randomUUID() : undefined;
359
363
 
360
364
  const provider: Provider = {
361
365
  id,
@@ -371,6 +375,15 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
371
375
  const effort = opts.effort ?? config.effort;
372
376
  const { system, messages: body } = toAnthropicMessages(messages, id);
373
377
 
378
+ // Anthropic's own `service_tier` means auto or standard_only, not this.
379
+ if (opts.serviceTier) {
380
+ throw new ProviderError(
381
+ id,
382
+ "invalid",
383
+ `${id}: Anthropic has no priority or flex tier (its service_tier field means something else). ` +
384
+ "Leave serviceTier unset for Claude models.",
385
+ );
386
+ }
374
387
  const request: Record<string, unknown> = {
375
388
  model,
376
389
  max_tokens: maxTokens,
@@ -487,6 +500,9 @@ export function createAnthropicProvider(config: AnthropicConfig): Provider {
487
500
  ? { authorization: `Bearer ${config.apiKey}` }
488
501
  : { "x-api-key": config.apiKey }),
489
502
  ...attributionHeaders(config, config.headers),
503
+ ...(config.sessionHeader
504
+ ? { [config.sessionHeader]: opts.sessionId ?? instanceSessionId }
505
+ : {}),
490
506
  ...config.headers,
491
507
  },
492
508
  body: request,