@providerkit/core 0.17.2 → 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.
- package/README.md +1 -0
- 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 +32 -3
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +24 -2
- package/dist/presets.js.map +1 -1
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +6 -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 +11 -4
- 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 +7 -1
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +6 -0
- 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 +33 -4
- package/src/providers/anthropic.ts +10 -1
- package/src/providers/factory.ts +21 -6
- package/src/providers/gemini.ts +7 -0
- package/src/providers/openai.ts +12 -0
- 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,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
|
|
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 —
|
|
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",
|
|
@@ -493,6 +500,7 @@ export const PROVIDER_PRESETS = {
|
|
|
493
500
|
"gpt-5.6-luna",
|
|
494
501
|
"gpt-5.5",
|
|
495
502
|
],
|
|
503
|
+
replayReasoning: true,
|
|
496
504
|
},
|
|
497
505
|
/**
|
|
498
506
|
* A Grok subscription (SuperGrok, X Premium) — the backend the Grok CLI
|
|
@@ -530,12 +538,33 @@ export const PROVIDER_PRESETS = {
|
|
|
530
538
|
auth: "oauth",
|
|
531
539
|
defaultModel: "kimi-for-coding",
|
|
532
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
|
+
*/
|
|
533
552
|
"github-copilot": {
|
|
534
553
|
shape: "openai",
|
|
535
|
-
baseUrl: "https://api.githubcopilot.com",
|
|
554
|
+
baseUrl: "https://api.individual.githubcopilot.com",
|
|
555
|
+
path: "/chat/completions",
|
|
536
556
|
auth: "oauth",
|
|
537
557
|
defaultModel: "gpt-5.6-sol",
|
|
538
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,
|
|
539
568
|
},
|
|
540
569
|
} as const satisfies Record<string, ProviderPreset>;
|
|
541
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,
|
package/src/providers/factory.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
173
|
+
baseUrl,
|
|
159
174
|
id,
|
|
160
175
|
headers,
|
|
161
176
|
fetchImpl: config.fetchImpl,
|
package/src/providers/gemini.ts
CHANGED
|
@@ -320,6 +320,13 @@ export function createGeminiProvider(config: GeminiConfig): Provider {
|
|
|
320
320
|
generationConfig.responseJsonSchema = toGeminiToolSchema(opts.json.schema);
|
|
321
321
|
}
|
|
322
322
|
|
|
323
|
+
if (opts.serviceTier) {
|
|
324
|
+
throw new ProviderError(
|
|
325
|
+
id,
|
|
326
|
+
"invalid",
|
|
327
|
+
`${id}: Gemini has no service tier switch. Leave serviceTier unset for Gemini models.`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
323
330
|
const request: Record<string, unknown> = { contents, generationConfig };
|
|
324
331
|
// A Content, not the bare string the SDK accepts — REST rejects a string.
|
|
325
332
|
// The prompt-carried schema rides as a second part, after the caller's
|
package/src/providers/openai.ts
CHANGED
|
@@ -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,
|