@jameslovespancakes/pi-plus 1.0.15 → 1.0.17
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 +34 -4
- package/package.json +5 -5
- package/src/core/accounts/oauth-pool.ts +4 -2
- package/src/core/accounts/registry.ts +7 -2
- package/src/core/anthropic/catalog.ts +139 -0
- package/src/core/anthropic/client-identity.ts +24 -5
- package/src/core/anthropic/models.ts +111 -26
- package/src/core/config.ts +1 -1
- package/src/core/gemini/LICENSE.md +21 -0
- package/src/core/gemini/client.ts +188 -0
- package/src/core/gemini/convert.ts +239 -0
- package/src/core/gemini/credentials.ts +56 -0
- package/src/core/gemini/models.ts +362 -0
- package/src/core/gemini/oauth.ts +240 -0
- package/src/core/gemini/request.ts +243 -0
- package/src/core/gemini/schema.ts +142 -0
- package/src/core/gemini/stream.ts +557 -0
- package/src/core/oauth/callback-server.ts +110 -0
- package/src/core/policy/policy.ts +3 -1
- package/src/domains/models/catalog-tool.ts +1 -1
- package/src/domains/subscriptions/accounts.ts +2 -2
- package/src/domains/subscriptions/footer.ts +1 -1
- package/src/domains/subscriptions/index.ts +3 -1
- package/src/domains/subscriptions/provider.ts +66 -7
- package/src/domains/subscriptions/providers/builtin.ts +19 -0
- package/src/domains/subscriptions/providers/codex.ts +2 -2
- package/src/domains/subscriptions/providers/gemini.ts +111 -0
- package/src/domains/subscriptions/providers/hosted.ts +3 -4
- package/src/domains/subscriptions/providers/oauth-pool.ts +35 -9
|
@@ -42,8 +42,8 @@ function bridge(pi: ExtensionAPI, ctx: any): AccountContext {
|
|
|
42
42
|
hasUI: ctx.hasUI,
|
|
43
43
|
signal: ctx.signal,
|
|
44
44
|
ui: {
|
|
45
|
-
input: (title, placeholder) => ctx.ui.input(title, placeholder),
|
|
46
|
-
select: (title, options) => ctx.ui.select(title, options),
|
|
45
|
+
input: (title, placeholder, options) => ctx.ui.input(title, placeholder, options),
|
|
46
|
+
select: (title, options, dialog) => ctx.ui.select(title, options, dialog),
|
|
47
47
|
confirm: (title, message) => ctx.ui.confirm(title, message),
|
|
48
48
|
notify: (message, type) => ctx.ui.notify(message, type ?? "info"),
|
|
49
49
|
},
|
|
@@ -6,7 +6,7 @@ import { formatTokens, sanitize } from "../../ui/format.ts";
|
|
|
6
6
|
|
|
7
7
|
/** Compact session footer with shared subscription usage bars. */
|
|
8
8
|
|
|
9
|
-
const SUBSCRIPTION_PROVIDERS = new Set(["anthropic", "openai-codex", "kimi-coding", "xai"]);
|
|
9
|
+
const SUBSCRIPTION_PROVIDERS = new Set(["anthropic", "openai-codex", "gemini", "kimi-coding", "xai"]);
|
|
10
10
|
|
|
11
11
|
interface SessionTotals {
|
|
12
12
|
input: number;
|
|
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { registerAccountProvider } from "../../core/accounts/registry.ts";
|
|
3
3
|
import { anthropicAccounts } from "./providers/anthropic.ts";
|
|
4
4
|
import { codexAccounts, CODEX_SPEC } from "./providers/codex.ts";
|
|
5
|
+
import { geminiAccounts, GEMINI_SPEC } from "./providers/gemini.ts";
|
|
5
6
|
import { kimiAccounts, KIMI_SPEC, xaiAccounts, XAI_SPEC } from "./providers/hosted.ts";
|
|
6
7
|
import { registerPooledOAuthProvider } from "./providers/oauth-pool.ts";
|
|
7
8
|
import { registerAnthropicProvider } from "./provider.ts";
|
|
@@ -11,12 +12,13 @@ import { registerFooter } from "./footer.ts";
|
|
|
11
12
|
|
|
12
13
|
/** Registers subscription providers, pooled accounts, routing, and usage UI. */
|
|
13
14
|
export default function subscriptions(pi: ExtensionAPI) {
|
|
14
|
-
for (const provider of [anthropicAccounts, codexAccounts, kimiAccounts, xaiAccounts]) {
|
|
15
|
+
for (const provider of [anthropicAccounts, codexAccounts, geminiAccounts, kimiAccounts, xaiAccounts]) {
|
|
15
16
|
registerAccountProvider(provider);
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
registerAnthropicProvider(pi);
|
|
19
20
|
registerPooledOAuthProvider(pi, CODEX_SPEC);
|
|
21
|
+
registerPooledOAuthProvider(pi, GEMINI_SPEC);
|
|
20
22
|
registerPooledOAuthProvider(pi, KIMI_SPEC);
|
|
21
23
|
registerPooledOAuthProvider(pi, XAI_SPEC);
|
|
22
24
|
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { authorize, exchange, refreshToken } from "../../core/anthropic/oauth.ts";
|
|
3
3
|
import {
|
|
4
|
-
billingHeader, clientIdentityHeaders, firstUserText, prependPromptBlock, signRequestBody,
|
|
4
|
+
billingHeader, clientIdentityHeaders, firstUserText, identityBetas, prependPromptBlock, signRequestBody,
|
|
5
|
+
splitSystemPrompt,
|
|
5
6
|
} from "../../core/anthropic/client-identity.ts";
|
|
6
7
|
import {
|
|
7
8
|
anthropicAccountIdentity,
|
|
8
9
|
cachedAnthropicAccountIdentity,
|
|
9
10
|
} from "../../core/anthropic/identity.ts";
|
|
10
|
-
import {
|
|
11
|
+
import { catalogIsStale, refreshAnthropicCatalog } from "../../core/anthropic/catalog.ts";
|
|
12
|
+
import { ANTHROPIC_MODELS, buildAnthropicModels, type ModelSpec } from "../../core/anthropic/models.ts";
|
|
11
13
|
import {
|
|
12
14
|
ACCESS_REFRESH_INTERVAL_MS,
|
|
13
15
|
applyQuotaHeaders,
|
|
@@ -17,6 +19,7 @@ import {
|
|
|
17
19
|
MAIN_ACCOUNT_ID, familyForModel, selectAccount, type Candidate,
|
|
18
20
|
} from "../../core/anthropic/routing.ts";
|
|
19
21
|
import { getRoutingMode, loadAccounts, saveAccount } from "../../core/anthropic/store.ts";
|
|
22
|
+
import { refreshAbortSignal } from "../../core/accounts/routing.ts";
|
|
20
23
|
|
|
21
24
|
/**
|
|
22
25
|
* Anthropic uses pi's Messages API client with per-request account routing.
|
|
@@ -31,9 +34,12 @@ function isAnthropicMessagesPayload(payload: any): boolean {
|
|
|
31
34
|
if ("instructions" in payload || "input" in payload) return false;
|
|
32
35
|
if (!Array.isArray(payload.messages)) return false;
|
|
33
36
|
const model = typeof payload.model === "string" ? payload.model.toLowerCase() : "";
|
|
34
|
-
return model.startsWith("claude") ||
|
|
37
|
+
return model.startsWith("claude") || registeredModels.some((m) => m.id === payload.model);
|
|
35
38
|
}
|
|
36
39
|
|
|
40
|
+
/** The catalogue currently registered; replaced when discovery finds a new model. */
|
|
41
|
+
let registeredModels: ModelSpec[] = ANTHROPIC_MODELS;
|
|
42
|
+
|
|
37
43
|
let lastSelected: { id: string; at: number } | undefined;
|
|
38
44
|
const accountLastUsed = new Map<string, number>();
|
|
39
45
|
|
|
@@ -114,12 +120,40 @@ async function login(callbacks: any) {
|
|
|
114
120
|
return { access: result.access, refresh: result.refresh, expires: result.expires };
|
|
115
121
|
}
|
|
116
122
|
|
|
117
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Asks Anthropic which models this subscription can actually use.
|
|
125
|
+
*
|
|
126
|
+
* pi's catalogue is generated at build time, so a newly shipped model is
|
|
127
|
+
* missing until pi is upgraded. Discovery is best-effort and off the request
|
|
128
|
+
* path: a failure leaves the bundled catalogue in place, which is exactly the
|
|
129
|
+
* behaviour without this function.
|
|
130
|
+
*/
|
|
131
|
+
async function discoverModels(pi: ExtensionAPI, ctx: any, force = false): Promise<void> {
|
|
132
|
+
if (!force && !catalogIsStale()) return;
|
|
133
|
+
try {
|
|
134
|
+
const resolved = await ctx.modelRegistry?.getProviderAuth?.("anthropic");
|
|
135
|
+
if (!resolved?.auth) return;
|
|
136
|
+
|
|
137
|
+
const added = await refreshAnthropicCatalog(
|
|
138
|
+
{ ...resolved.auth, source: resolved.source },
|
|
139
|
+
refreshAbortSignal(ctx.signal),
|
|
140
|
+
);
|
|
141
|
+
if (added.length === 0) return;
|
|
142
|
+
|
|
143
|
+
registeredModels = buildAnthropicModels();
|
|
144
|
+
registerProvider(pi);
|
|
145
|
+
ctx.ui?.notify?.(`New Anthropic models available: ${added.join(", ")}`, "info");
|
|
146
|
+
} catch {
|
|
147
|
+
// Offline, rate limited, or an expired credential: keep what we have.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function registerProvider(pi: ExtensionAPI): void {
|
|
118
152
|
pi.registerProvider("anthropic", {
|
|
119
153
|
name: "Anthropic",
|
|
120
154
|
baseUrl: "https://api.anthropic.com",
|
|
121
155
|
api: "anthropic-messages",
|
|
122
|
-
models:
|
|
156
|
+
models: registeredModels,
|
|
123
157
|
// Required for subscription billing. See client-identity.ts.
|
|
124
158
|
headers: clientIdentityHeaders(),
|
|
125
159
|
oauth: {
|
|
@@ -133,6 +167,26 @@ export function registerAnthropicProvider(pi: ExtensionAPI): void {
|
|
|
133
167
|
getApiKey: (credentials: any) => routeAccessToken(credentials.access),
|
|
134
168
|
},
|
|
135
169
|
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function registerAnthropicProvider(pi: ExtensionAPI): void {
|
|
173
|
+
registerProvider(pi);
|
|
174
|
+
|
|
175
|
+
pi.registerCommand("models-refresh", {
|
|
176
|
+
description: "Re-ask subscription providers which models your accounts can use",
|
|
177
|
+
handler: async (_args, ctx: any) => {
|
|
178
|
+
await discoverModels(pi, ctx, true);
|
|
179
|
+
// Live catalogues (Gemini's, pi's remote ones) answer pi's own
|
|
180
|
+
// refresh; `force` bypasses their freshness windows.
|
|
181
|
+
const result = await ctx.modelRegistry?.refresh?.({ force: true }).catch(() => undefined);
|
|
182
|
+
const failed = [...(result?.errors?.keys?.() ?? [])];
|
|
183
|
+
ctx.ui.notify(
|
|
184
|
+
`${registeredModels.length} Anthropic models available.`
|
|
185
|
+
+ (failed.length > 0 ? ` Could not refresh: ${failed.join(", ")}.` : " Other catalogues refreshed."),
|
|
186
|
+
failed.length > 0 ? "warning" : "info",
|
|
187
|
+
);
|
|
188
|
+
},
|
|
189
|
+
});
|
|
136
190
|
|
|
137
191
|
/** Rebuilds the Anthropic prompt, then signs the final body. */
|
|
138
192
|
pi.on("before_provider_request", async (event: any) => {
|
|
@@ -154,7 +208,10 @@ export function registerAnthropicProvider(pi: ExtensionAPI): void {
|
|
|
154
208
|
...(split.systemText ? [{ type: "text", text: split.systemText }] : []),
|
|
155
209
|
];
|
|
156
210
|
|
|
157
|
-
|
|
211
|
+
// Union, not replacement: pi's betas authorise fields pi itself emits.
|
|
212
|
+
const betas = identityBetas(payload, Array.isArray(payload.betas) ? payload.betas : []);
|
|
213
|
+
|
|
214
|
+
const signed = await signRequestBody(JSON.stringify({ ...payload, betas, system, messages }));
|
|
158
215
|
return JSON.parse(signed);
|
|
159
216
|
});
|
|
160
217
|
|
|
@@ -190,9 +247,11 @@ export function registerAnthropicProvider(pi: ExtensionAPI): void {
|
|
|
190
247
|
void refreshAllQuota().catch(() => {});
|
|
191
248
|
});
|
|
192
249
|
|
|
193
|
-
pi.on("session_start", async () => {
|
|
250
|
+
pi.on("session_start", async (_event: any, ctx: any) => {
|
|
194
251
|
startRefreshLoop();
|
|
195
252
|
void refreshAllQuota().catch(() => {});
|
|
253
|
+
// Off the request path and TTL-gated, so this is one call a day at most.
|
|
254
|
+
void discoverModels(pi, ctx).catch(() => {});
|
|
196
255
|
});
|
|
197
256
|
|
|
198
257
|
pi.on("session_shutdown", async () => {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Api, Provider } from "@earendil-works/pi-ai";
|
|
2
|
+
import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One of pi's own providers, from the host's copy of pi-ai.
|
|
6
|
+
*
|
|
7
|
+
* pi hands extensions its pi-ai through a fixed set of entry points (the
|
|
8
|
+
* package root, `compat`, `oauth` and `providers/all`) and installs packages
|
|
9
|
+
* without their peers. A deep import such as `pi-ai/providers/openai-codex`
|
|
10
|
+
* therefore has no copy to resolve against on a clean install, and wherever a
|
|
11
|
+
* stray copy does exist it is a different version from the host — the exact
|
|
12
|
+
* way stale provider definitions have dropped tools before.
|
|
13
|
+
* `providers/all` is always the host's, so providers come from here.
|
|
14
|
+
*/
|
|
15
|
+
export function builtinProvider<TApi extends Api>(id: string): Provider<TApi> {
|
|
16
|
+
const provider = builtinProviders().find((candidate) => candidate.id === id);
|
|
17
|
+
if (!provider) throw new Error(`This version of pi has no built-in "${id}" provider.`);
|
|
18
|
+
return provider as unknown as Provider<TApi>;
|
|
19
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { OAuthCredential } from "@earendil-works/pi-ai";
|
|
2
|
-
import { openaiCodexProvider } from "@earendil-works/pi-ai/providers/openai-codex";
|
|
3
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
3
|
import type { RoutingMode } from "../../../core/accounts/registry.ts";
|
|
5
4
|
import { normalizeRoutingMode, type AccountQuotaState } from "../../../core/accounts/routing.ts";
|
|
@@ -13,6 +12,7 @@ import {
|
|
|
13
12
|
saveCodexAccounts,
|
|
14
13
|
type CodexAccount,
|
|
15
14
|
} from "../../../core/codex/store.ts";
|
|
15
|
+
import { builtinProvider } from "./builtin.ts";
|
|
16
16
|
import {
|
|
17
17
|
chooseCredential,
|
|
18
18
|
createPooledOAuthAdapter,
|
|
@@ -146,7 +146,7 @@ function markCodexRateLimited(accountId: string, headers: Record<string, string>
|
|
|
146
146
|
export const CODEX_SPEC: PooledOAuthProviderSpec<"openai-codex-responses"> = {
|
|
147
147
|
id: "openai-codex",
|
|
148
148
|
label: "Codex",
|
|
149
|
-
createProvider:
|
|
149
|
+
createProvider: () => builtinProvider("openai-codex"),
|
|
150
150
|
store: CODEX_STORE,
|
|
151
151
|
addPrompt: "Sign in with a DIFFERENT ChatGPT account in the browser. Continue?",
|
|
152
152
|
describeAccount: (account) => describePlan(account as PooledOAuthAccount & CodexAccount),
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createProvider, type Api, type Model, type Provider, type RefreshModelsContext } from "@earendil-works/pi-ai";
|
|
2
|
+
import { GEMINI_ENDPOINT, fetchAvailableModels } from "../../../core/gemini/client.ts";
|
|
3
|
+
import { credentialEmail, decodeApiKey } from "../../../core/gemini/credentials.ts";
|
|
4
|
+
import {
|
|
5
|
+
GEMINI_API,
|
|
6
|
+
GEMINI_PROVIDER,
|
|
7
|
+
STATIC_MODELS,
|
|
8
|
+
buildCatalog,
|
|
9
|
+
withStaticModels,
|
|
10
|
+
type GeminiModel,
|
|
11
|
+
} from "../../../core/gemini/models.ts";
|
|
12
|
+
import { geminiOAuth, requestProjectId } from "../../../core/gemini/oauth.ts";
|
|
13
|
+
import { geminiApi } from "../../../core/gemini/stream.ts";
|
|
14
|
+
import { createPooledOAuthAdapter, type PooledOAuthProviderSpec } from "./oauth-pool.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Gemini adapter.
|
|
18
|
+
*
|
|
19
|
+
* pi has no provider for Google's Antigravity backend, so this one is built
|
|
20
|
+
* with pi's own `createProvider` and joins the same pooled serving path as
|
|
21
|
+
* every other subscription: one `routedAuth`, one bounded refresh, one quota
|
|
22
|
+
* observer, and `/accounts` management for free.
|
|
23
|
+
*
|
|
24
|
+
* The catalogue is live. pi already refreshes provider catalogues at startup,
|
|
25
|
+
* on login and when the model picker opens; this answers those refreshes from
|
|
26
|
+
* `fetchAvailableModels`, so a newly enabled model becomes selectable without
|
|
27
|
+
* a pi-plus release. The wrapper mirrors pi's own `withRemoteCatalog`:
|
|
28
|
+
* restore the stored list, honour a freshness window, and never lose the
|
|
29
|
+
* last known catalogue to a failed fetch.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** pi refreshes whenever the picker opens; the backend's list changes far less often. */
|
|
33
|
+
export const CATALOG_TTL_MS = 4 * 60 * 60_000;
|
|
34
|
+
|
|
35
|
+
async function discover(context: RefreshModelsContext): Promise<GeminiModel[] | undefined> {
|
|
36
|
+
const credential = context.credential;
|
|
37
|
+
if (credential?.type !== "oauth" || !credential.access) return undefined;
|
|
38
|
+
const runtimeModels = await fetchAvailableModels(credential.access, requestProjectId(credential), context.signal);
|
|
39
|
+
return Object.keys(runtimeModels).length > 0 ? buildCatalog(runtimeModels) : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function withLiveCatalog(base: Provider<typeof GEMINI_API>): Provider<typeof GEMINI_API> {
|
|
43
|
+
let catalog: readonly GeminiModel[] = base.getModels();
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
...base,
|
|
47
|
+
getModels: () => catalog,
|
|
48
|
+
|
|
49
|
+
async refreshModels(context) {
|
|
50
|
+
const stored = context.stored;
|
|
51
|
+
const restored = withStaticModels((stored?.models ?? []) as Model<Api>[]);
|
|
52
|
+
if (!(await context.publish({ update: () => { catalog = restored; } }))) return;
|
|
53
|
+
|
|
54
|
+
if (!context.allowNetwork || context.signal.aborted) return;
|
|
55
|
+
const checkedAt = stored?.checkedAt;
|
|
56
|
+
if (!context.force && checkedAt !== undefined && Date.now() - checkedAt < CATALOG_TTL_MS) return;
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const discovered = await discover(context);
|
|
60
|
+
if (!discovered || context.signal.aborted) return;
|
|
61
|
+
await context.publish({
|
|
62
|
+
persist: { models: discovered, checkedAt: Date.now() },
|
|
63
|
+
update: () => { catalog = discovered; },
|
|
64
|
+
});
|
|
65
|
+
} catch (error) {
|
|
66
|
+
// The last known catalogue stays in place. Only an explicit refresh
|
|
67
|
+
// reports the failure; a background one is not worth interrupting for.
|
|
68
|
+
if (context.force) throw error;
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createGeminiProvider(): Provider<typeof GEMINI_API> {
|
|
75
|
+
return withLiveCatalog(createProvider({
|
|
76
|
+
id: GEMINI_PROVIDER,
|
|
77
|
+
name: "Gemini",
|
|
78
|
+
baseUrl: GEMINI_ENDPOINT,
|
|
79
|
+
auth: { oauth: geminiOAuth },
|
|
80
|
+
models: STATIC_MODELS,
|
|
81
|
+
api: geminiApi(),
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const GEMINI_SPEC: PooledOAuthProviderSpec<typeof GEMINI_API> = {
|
|
86
|
+
id: GEMINI_PROVIDER,
|
|
87
|
+
label: "Gemini",
|
|
88
|
+
createProvider: createGeminiProvider,
|
|
89
|
+
addPrompt: "Sign in with a DIFFERENT Google account in the browser. Continue?",
|
|
90
|
+
// Google issues opaque `ya29.` tokens with no readable claims, so the
|
|
91
|
+
// email discovered at login is what recognises a duplicate sign-in.
|
|
92
|
+
identityOfCredential: (credential) => {
|
|
93
|
+
const email = credentialEmail(credential);
|
|
94
|
+
return email ? `email:${email.toLowerCase()}` : undefined;
|
|
95
|
+
},
|
|
96
|
+
describeAccount: (account) => {
|
|
97
|
+
const email = credentialEmail(account);
|
|
98
|
+
const name = account.label || account.id.slice(0, 8);
|
|
99
|
+
return email ? `${name} (${email})` : name;
|
|
100
|
+
},
|
|
101
|
+
accessTokenOf: (apiKey) => {
|
|
102
|
+
try {
|
|
103
|
+
return decodeApiKey(apiKey).token;
|
|
104
|
+
} catch {
|
|
105
|
+
// Quota attribution is telemetry; an unreadable key is not an error here.
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const geminiAccounts = createPooledOAuthAdapter(GEMINI_SPEC);
|
|
@@ -1,17 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { xaiProvider } from "@earendil-works/pi-ai/providers/xai";
|
|
1
|
+
import { builtinProvider } from "./builtin.ts";
|
|
3
2
|
import { createPooledOAuthAdapter, type PooledOAuthProviderSpec } from "./oauth-pool.ts";
|
|
4
3
|
|
|
5
4
|
export const KIMI_SPEC: PooledOAuthProviderSpec<"anthropic-messages"> = {
|
|
6
5
|
id: "kimi-coding",
|
|
7
6
|
label: "Kimi",
|
|
8
|
-
createProvider:
|
|
7
|
+
createProvider: () => builtinProvider("kimi-coding"),
|
|
9
8
|
};
|
|
10
9
|
|
|
11
10
|
export const XAI_SPEC: PooledOAuthProviderSpec<"openai-responses"> = {
|
|
12
11
|
id: "xai",
|
|
13
12
|
label: "Grok",
|
|
14
|
-
createProvider:
|
|
13
|
+
createProvider: () => builtinProvider("xai"),
|
|
15
14
|
};
|
|
16
15
|
|
|
17
16
|
export const kimiAccounts = createPooledOAuthAdapter(KIMI_SPEC);
|
|
@@ -27,6 +27,20 @@ export interface PooledOAuthProviderSpec<TApi extends Api> {
|
|
|
27
27
|
describeAccount?(account: PooledOAuthAccount): string;
|
|
28
28
|
/** Stable identity used to reject a duplicate login. Defaults to JWT claims. */
|
|
29
29
|
identityOf?(access: string): string | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* Identity read from the whole credential rather than the access token.
|
|
32
|
+
* Providers that issue opaque tokens (Google hands out `ya29.` strings with
|
|
33
|
+
* no readable claims) have no identity inside the token at all, so a stable
|
|
34
|
+
* value discovered at login — an email, say — is stored alongside it and
|
|
35
|
+
* read back here. Takes precedence over `identityOf` when present.
|
|
36
|
+
*/
|
|
37
|
+
identityOfCredential?(credential: OAuthCredential): string | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* Recovers the access token from what `toAuth()` put in `options.apiKey`,
|
|
40
|
+
* for providers that encode more than the token there. Without it, quota
|
|
41
|
+
* observed on a response cannot be attributed to the account that served it.
|
|
42
|
+
*/
|
|
43
|
+
accessTokenOf?(apiKey: string): string | undefined;
|
|
30
44
|
/**
|
|
31
45
|
* Records a response's quota signal against an account. Defaults to the
|
|
32
46
|
* generic `x-ratelimit-*` reader; providers with their own headers (Codex
|
|
@@ -72,15 +86,17 @@ async function authenticate<TApi extends Api>(spec: PooledOAuthProviderSpec<TApi
|
|
|
72
86
|
return oauthFor(spec).login({
|
|
73
87
|
signal,
|
|
74
88
|
notify: (event) => { void notifyAuthEvent(ctx, event); },
|
|
89
|
+
// `prompt.signal` lets a flow retract a prompt, e.g. the paste box once
|
|
90
|
+
// the browser callback has already answered it.
|
|
75
91
|
prompt: async (prompt) => {
|
|
76
92
|
if (prompt.type === "select") {
|
|
77
93
|
const labels = prompt.options.map((option) => option.label);
|
|
78
|
-
const selected = await ctx.ui.select(prompt.message, labels);
|
|
94
|
+
const selected = await ctx.ui.select(prompt.message, labels, { signal: prompt.signal });
|
|
79
95
|
const index = selected ? labels.indexOf(selected) : -1;
|
|
80
96
|
if (index < 0) throw new Error("Login cancelled.");
|
|
81
97
|
return prompt.options[index].id;
|
|
82
98
|
}
|
|
83
|
-
const value = await ctx.ui.input(prompt.message, prompt.placeholder);
|
|
99
|
+
const value = await ctx.ui.input(prompt.message, prompt.placeholder, { signal: prompt.signal });
|
|
84
100
|
if (!value) throw new Error("Login cancelled.");
|
|
85
101
|
return value;
|
|
86
102
|
},
|
|
@@ -95,12 +111,20 @@ function identityFor<TApi extends Api>(spec: PooledOAuthProviderSpec<TApi>, acce
|
|
|
95
111
|
return (spec.identityOf ?? oauthIdentity)(access);
|
|
96
112
|
}
|
|
97
113
|
|
|
114
|
+
/** Credential-wide identity where the provider has one, else the token's. */
|
|
115
|
+
function credentialIdentity<TApi extends Api>(
|
|
116
|
+
spec: PooledOAuthProviderSpec<TApi>,
|
|
117
|
+
credential: OAuthCredential,
|
|
118
|
+
): string | undefined {
|
|
119
|
+
return spec.identityOfCredential?.(credential) ?? identityFor(spec, credential.access);
|
|
120
|
+
}
|
|
121
|
+
|
|
98
122
|
function duplicateAccount<TApi extends Api>(
|
|
99
123
|
spec: PooledOAuthProviderSpec<TApi>,
|
|
100
124
|
credential: OAuthCredential,
|
|
101
125
|
excludeId?: string,
|
|
102
126
|
): PooledOAuthAccount | undefined {
|
|
103
|
-
const identity =
|
|
127
|
+
const identity = credentialIdentity(spec, credential);
|
|
104
128
|
return storeFor(spec).load().accounts.find((account) =>
|
|
105
129
|
account.id !== excludeId && (identity ? account.identity === identity : account.access === credential.access));
|
|
106
130
|
}
|
|
@@ -114,7 +138,7 @@ export function createPooledOAuthAdapter<TApi extends Api>(spec: PooledOAuthProv
|
|
|
114
138
|
|
|
115
139
|
async list(): Promise<ManagedAccount[]> {
|
|
116
140
|
return store().load().accounts.map((account) => {
|
|
117
|
-
const identity = account.identity ??
|
|
141
|
+
const identity = account.identity ?? credentialIdentity(spec, account);
|
|
118
142
|
return {
|
|
119
143
|
id: account.id,
|
|
120
144
|
label: accountLabel(spec, account),
|
|
@@ -140,7 +164,7 @@ export function createPooledOAuthAdapter<TApi extends Api>(spec: PooledOAuthProv
|
|
|
140
164
|
id: randomUUID(),
|
|
141
165
|
label,
|
|
142
166
|
enabled: true,
|
|
143
|
-
identity:
|
|
167
|
+
identity: credentialIdentity(spec, credential),
|
|
144
168
|
addedAt: Date.now(),
|
|
145
169
|
});
|
|
146
170
|
return label;
|
|
@@ -158,7 +182,7 @@ export function createPooledOAuthAdapter<TApi extends Api>(spec: PooledOAuthProv
|
|
|
158
182
|
store().saveAccount({
|
|
159
183
|
...account,
|
|
160
184
|
...credential,
|
|
161
|
-
identity:
|
|
185
|
+
identity: credentialIdentity(spec, credential),
|
|
162
186
|
});
|
|
163
187
|
return account.label;
|
|
164
188
|
},
|
|
@@ -264,7 +288,7 @@ export function registerPooledOAuthProvider<TApi extends Api>(pi: ExtensionAPI,
|
|
|
264
288
|
...options,
|
|
265
289
|
onResponse: async (response: { status: number; headers: Record<string, string> }, model: unknown) => {
|
|
266
290
|
try {
|
|
267
|
-
recordQuotaResponse(spec, requestAccessToken(options), response.status, response.headers);
|
|
291
|
+
recordQuotaResponse(spec, requestAccessToken(spec, options), response.status, response.headers);
|
|
268
292
|
} catch {
|
|
269
293
|
// Quota accounting is telemetry; it must never fail the response.
|
|
270
294
|
}
|
|
@@ -286,8 +310,10 @@ export function registerPooledOAuthProvider<TApi extends Api>(pi: ExtensionAPI,
|
|
|
286
310
|
});
|
|
287
311
|
}
|
|
288
312
|
|
|
289
|
-
function requestAccessToken(options: any): string | undefined {
|
|
290
|
-
if (typeof options?.apiKey === "string")
|
|
313
|
+
function requestAccessToken<TApi extends Api>(spec: PooledOAuthProviderSpec<TApi>, options: any): string | undefined {
|
|
314
|
+
if (typeof options?.apiKey === "string") {
|
|
315
|
+
return spec.accessTokenOf ? spec.accessTokenOf(options.apiKey) : options.apiKey;
|
|
316
|
+
}
|
|
291
317
|
const headers = options?.headers;
|
|
292
318
|
if (!headers || typeof headers !== "object") return undefined;
|
|
293
319
|
const authorization = Object.entries(headers).find(([key]) => key.toLowerCase() === "authorization")?.[1];
|