@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
|
@@ -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;
|
|
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
|
|
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
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
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: [
|
|
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 —
|
|
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,
|