@adeildo/pi-providers 5.2.0 → 5.4.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.
@@ -1,6 +1,7 @@
1
- // A provider with accounts is re-registered with its stream wrapped: each request resolves the
2
- // credential of the active account and injects it, so one provider keeps one model list. The auth
3
- // methods of the native provider do the work, so this file never speaks OAuth.
1
+ // A provider with accounts is re-registered so the accounts own it: every way pi asks for auth answers
2
+ // with the account in use, and each stream injects it again so a failure can move to the next one. One
3
+ // provider keeps one model list. The auth methods of the native provider do the work, so this file
4
+ // never speaks OAuth.
4
5
  import {
5
6
  defaultProviderAuthContext,
6
7
  lazyStream,
@@ -16,6 +17,7 @@ import {
16
17
  type OAuthCredential,
17
18
  type Provider,
18
19
  type ProviderAuth,
20
+ type ProviderAuthInteraction,
19
21
  type ProviderEnv,
20
22
  type ProviderHeaders,
21
23
  type SimpleStreamOptions,
@@ -23,15 +25,41 @@ import {
23
25
  type TranscriptContext,
24
26
  } from "@earendil-works/pi-ai";
25
27
 
28
+ import { classifyFailure, type Failure } from "../errors.ts";
29
+ import { needsRefresh, refreshAccount, type Renewal } from "./refresh.ts";
30
+
31
+ /** An account as a request sees it. */
32
+ export interface AccountCredential {
33
+ id: string;
34
+ credential: Credential;
35
+ }
36
+
26
37
  /** The account the next request uses, and where a refreshed token is kept. */
27
38
  export interface AccountSession {
28
- resolve(): { id: string; credential: Credential } | undefined;
39
+ /** The account in use, or undefined once the provider has none left. */
40
+ resolve(): AccountCredential | undefined;
41
+ /** A subscription account, for when pi's copy needs a refresh and the one in use is a key. */
42
+ subscription?(): AccountCredential | undefined;
29
43
  save(id: string, credential: Credential): void;
30
44
  /** The account to try after this one hit a limit, or undefined to surface the error. */
31
- afterLimit(
32
- currentId: string,
33
- detail: string,
34
- ): Promise<{ id: string; credential: Credential } | undefined>;
45
+ afterLimit(currentId: string, detail: string): Promise<AccountCredential | undefined>;
46
+ /** The credential was refused, so sign in again or try another account. */
47
+ afterAuthFailure?(currentId: string, detail: string): Promise<AccountCredential | undefined>;
48
+ /** Pi's own login just produced a credential; the feature keeps it under a name. */
49
+ adopt?(credential: Credential): Promise<void>;
50
+ /** A framed screen for a login, when the caller can draw one. */
51
+ loginScreen?(
52
+ label: string,
53
+ signal: AbortSignal,
54
+ ): Promise<{ interaction: ProviderAuthInteraction; close(): void } | undefined>;
55
+ /** The credential on disk now, in case another pi refreshed it since we read ours. */
56
+ freshen?(id: string): Credential | undefined;
57
+ /** The file the refresh takes across processes, when the caller shares one. */
58
+ lockPath?(): string;
59
+ /** The quota headers of a response, kept for the account that served it. */
60
+ noteUsage?(id: string, headers: Record<string, string>): void;
61
+ /** The account served a request, so a limit it had is over. */
62
+ served?(id: string): void;
35
63
  }
36
64
 
37
65
  /** The native provider behind a lifted one, so lifting twice wraps the same object. */
@@ -60,7 +88,7 @@ export function liftProvider(provider: Provider, session: AccountSession): Provi
60
88
  name: provider.name,
61
89
  ...(provider.baseUrl === undefined ? {} : { baseUrl: provider.baseUrl }),
62
90
  ...(provider.headers === undefined ? {} : { headers: provider.headers }),
63
- auth: liftedAuth(provider, session),
91
+ auth: liftedAuth(provider, session, refreshing),
64
92
  getModels: () => provider.getModels(),
65
93
  ...(getAllModels === undefined ? {} : { getAllModels: () => getAllModels() }),
66
94
  ...(refreshModels === undefined ? {} : { refreshModels: (context) => refreshModels(context) }),
@@ -109,20 +137,15 @@ function accountStream(
109
137
  options?: Options,
110
138
  ): AssistantMessageEventStream {
111
139
  const first = session.resolve();
112
- if (first === undefined) return call(provider, kind, model, context, options ?? {});
113
-
114
140
  const settled = options ?? {};
115
141
  return lazyStream(model, async () =>
116
142
  attempts(provider, session, refreshing, kind, model, context, settled, first),
117
143
  );
118
144
  }
119
145
 
120
- /** One account may serve a limit that another one does not have. */
146
+ /** One account may serve a limit that another one does not have. A re-login costs an attempt too. */
121
147
  const MAX_ATTEMPTS = 3;
122
148
 
123
- // A quota or a rate limit belongs to the account, so another one can serve. A busy server cannot.
124
- const LIMIT_PATTERN = /quota|rate[ _-]?limit|too many requests|usage limit|429/i;
125
-
126
149
  async function* attempts(
127
150
  provider: Provider,
128
151
  session: AccountSession,
@@ -131,16 +154,11 @@ async function* attempts(
131
154
  model: Model<Api>,
132
155
  context: TranscriptContext,
133
156
  options: Options,
134
- first: { id: string; credential: Credential },
157
+ first: AccountCredential | undefined,
135
158
  ): AsyncGenerator<AssistantMessageEvent> {
136
159
  let account = first;
137
160
 
138
161
  for (let attempt = 1; ; attempt++) {
139
- // oxlint-disable-next-line no-await-in-loop -- an attempt starts only after the one before it.
140
- const resolved = await resolveAuth(provider, session, refreshing, account, options.signal);
141
- const withAuth = applyAuth(resolved, model, options);
142
- const source = call(provider, kind, withAuth.model, context, withAuth.options);
143
-
144
162
  // Hold the events until something is visible: a retry on another account must not repeat a
145
163
  // message the caller already saw.
146
164
  const buffered: AssistantMessageEvent[] = [];
@@ -148,6 +166,15 @@ async function* attempts(
148
166
  let failure: AssistantMessage | undefined;
149
167
  let thrown: unknown;
150
168
  try {
169
+ // The auth resolution is inside the try too: a refresh that fails is an auth failure, and it
170
+ // must be able to move to the next account like any other.
171
+ // oxlint-disable-next-line no-await-in-loop -- an attempt starts only after the one before it.
172
+ const withAuth = await authFor(provider, session, refreshing, account, model, options);
173
+ const source = call(provider, kind, withAuth.model, context, {
174
+ ...withAuth.options,
175
+ onResponse: noteUsage(withAuth.options.onResponse, session, account?.id),
176
+ });
177
+
151
178
  // oxlint-disable-next-line no-await-in-loop -- a stream is read one event at a time.
152
179
  for await (const event of source) {
153
180
  if (isContent(event)) {
@@ -167,15 +194,15 @@ async function* attempts(
167
194
  thrown = error;
168
195
  }
169
196
 
170
- if (!output && attempt < MAX_ATTEMPTS && failure !== undefined) {
171
- const text = failure.errorMessage;
172
- if (text !== undefined && LIMIT_PATTERN.test(text)) {
173
- // oxlint-disable-next-line no-await-in-loop -- the answer comes from the user.
174
- const next = await session.afterLimit(account.id, text);
175
- if (next !== undefined) {
176
- account = next;
177
- continue;
178
- }
197
+ if (failure === undefined && thrown === undefined && account !== undefined)
198
+ session.served?.(account.id);
199
+
200
+ if (!output && attempt < MAX_ATTEMPTS) {
201
+ // oxlint-disable-next-line no-await-in-loop -- the answer comes from the user.
202
+ const next = await moveOn(session, account?.id, failureOf(failure, thrown, model.provider));
203
+ if (next !== undefined) {
204
+ account = next;
205
+ continue;
179
206
  }
180
207
  }
181
208
 
@@ -185,10 +212,59 @@ async function* attempts(
185
212
  }
186
213
  }
187
214
 
215
+ /** The classified failure behind an attempt: the provider's own error, or what it threw. */
216
+ function failureOf(
217
+ failed: AssistantMessage | undefined,
218
+ thrown: unknown,
219
+ provider: string,
220
+ ): Failure | undefined {
221
+ if (failed?.errorMessage !== undefined) return classifyFailure(failed.errorMessage, provider);
222
+ return thrown === undefined ? undefined : classifyFailure(thrown, provider);
223
+ }
224
+
225
+ /** What the policy says after a failure: another account, a sign-in, or nothing. */
226
+ async function moveOn(
227
+ session: AccountSession,
228
+ accountId: string | undefined,
229
+ failure: Failure | undefined,
230
+ ): Promise<AccountCredential | undefined> {
231
+ // Without an account there is nothing to move from, and the error is the provider's to show.
232
+ if (accountId === undefined) return undefined;
233
+ if (failure?.kind === "usage") return await session.afterLimit(accountId, failure.message);
234
+ if (failure?.kind === "auth") return await session.afterAuthFailure?.(accountId, failure.message);
235
+ return undefined;
236
+ }
237
+
188
238
  function isContent(event: AssistantMessageEvent): boolean {
189
239
  return event.type !== "start" && event.type !== "done" && event.type !== "error";
190
240
  }
191
241
 
242
+ /** Pi hands the response headers here, so the account that served it keeps its quota current. */
243
+ function noteUsage(
244
+ next: Options["onResponse"],
245
+ session: AccountSession,
246
+ accountId: string | undefined,
247
+ ): Options["onResponse"] {
248
+ return async (response, model) => {
249
+ if (accountId !== undefined) session.noteUsage?.(accountId, response.headers);
250
+ await next?.(response, model);
251
+ };
252
+ }
253
+
254
+ /** The request auth for an attempt: the account's, or untouched once the provider has none. */
255
+ async function authFor(
256
+ provider: Provider,
257
+ session: AccountSession,
258
+ refreshing: Map<string, Promise<OAuthCredential>>,
259
+ account: AccountCredential | undefined,
260
+ model: Model<Api>,
261
+ options: Options,
262
+ ): Promise<{ model: Model<Api>; options: Options }> {
263
+ if (account === undefined) return { model, options };
264
+ const resolved = await resolveAuth(provider, session, refreshing, account, options.signal);
265
+ return applyAuth(resolved, model, options);
266
+ }
267
+
192
268
  function call(
193
269
  provider: Provider,
194
270
  kind: "stream" | "streamSimple",
@@ -210,7 +286,7 @@ async function resolveAuth(
210
286
  provider: Provider,
211
287
  session: AccountSession,
212
288
  refreshing: Map<string, Promise<OAuthCredential>>,
213
- account: { id: string; credential: Credential },
289
+ account: AccountCredential,
214
290
  signal: AbortSignal | undefined,
215
291
  ): Promise<ResolvedAuth> {
216
292
  const abort = signal ?? new AbortController().signal;
@@ -238,10 +314,7 @@ async function resolveAuth(
238
314
  return resolved === undefined ? { auth: {} } : { auth: resolved.auth, env: resolved.env };
239
315
  }
240
316
 
241
- // Pi refreshes its own token under the store lock, five minutes early. Ours is ours to refresh, and
242
- // one refresh per account is shared, so two requests cannot rotate the token twice.
243
- const REFRESH_MARGIN_MS = 5 * 60 * 1000;
244
-
317
+ // One refresh per account is shared, so two requests cannot rotate the token twice.
245
318
  async function refreshToken(
246
319
  oauth: OAuthAuth,
247
320
  session: AccountSession,
@@ -250,15 +323,16 @@ async function refreshToken(
250
323
  credential: OAuthCredential,
251
324
  signal: AbortSignal,
252
325
  ): Promise<OAuthCredential> {
253
- if (credential.expires > Date.now() + REFRESH_MARGIN_MS) return credential;
326
+ if (!needsRefresh(credential)) return credential;
254
327
 
255
328
  const inFlight = refreshing.get(accountId);
256
329
  if (inFlight !== undefined) return await inFlight;
257
330
 
258
- const task = oauth.refresh(credential, signal).then((fresh) => {
259
- session.save(accountId, fresh);
260
- return fresh;
261
- });
331
+ const renewal: Renewal = {
332
+ freshen: (id) => session.freshen?.(id),
333
+ save: (id, fresh) => session.save(id, fresh),
334
+ };
335
+ const task = refreshAccount(oauth, renewal, accountId, credential, signal, session.lockPath?.());
262
336
  refreshing.set(accountId, task);
263
337
  try {
264
338
  return await task;
@@ -306,26 +380,104 @@ function mergeHeaders(
306
380
  return merged;
307
381
  }
308
382
 
309
- // The runtime reads auth from the provider, so it has to report configured when an account exists,
310
- // even though the credential itself is injected into the request by `accountStream`. Reporting the
311
- // account's own type keeps `isUsingOAuth` honest, which is what bills a subscription request.
312
- function liftedAuth(provider: Provider, session: AccountSession): ProviderAuth {
383
+ // Pi resolves a request's auth from the credential it stored for the provider, before the stream runs.
384
+ // Every one of those paths answers with the account in use here, so a classifier, an image or a
385
+ // deferred call bills the same account a chat does, and pi never refreshes its own copy: when that
386
+ // copy expires, the refresh renews an account and hands pi the result. Pi keeps that copy only because
387
+ // it decides at startup, before any extension runs, which providers are logged in.
388
+ function liftedAuth(
389
+ provider: Provider,
390
+ session: AccountSession,
391
+ refreshing: Map<string, Promise<OAuthCredential>>,
392
+ ): ProviderAuth {
313
393
  const original = provider.auth.apiKey;
314
394
  const login = original?.login;
315
- return {
395
+ const oauth = provider.auth.oauth;
396
+ const auth: ProviderAuth = {
316
397
  apiKey: {
317
398
  name: original?.name ?? `${provider.name} accounts`,
318
- ...(login === undefined ? {} : { login: (interaction) => login(interaction) }),
399
+ ...(login === undefined
400
+ ? {}
401
+ : { login: (interaction) => adopted(login, interaction, session, provider.name) }),
402
+ // Reporting the account's own type keeps `isUsingOAuth` honest, which bills a subscription.
319
403
  async check(input) {
320
404
  const account = session.resolve();
321
405
  if (account !== undefined) return { type: account.credential.type, source: "account" };
322
406
  return original?.check?.(input);
323
407
  },
324
408
  async resolve(input) {
325
- if (session.resolve() !== undefined) return { auth: {}, source: "account" };
326
- return original?.resolve(input);
409
+ const resolved = await authInUse(provider, session, refreshing, input.signal);
410
+ if (resolved === undefined) return original?.resolve(input);
411
+ return { ...resolved, source: "account" };
327
412
  },
328
413
  },
329
- ...(provider.auth.oauth === undefined ? {} : { oauth: provider.auth.oauth }),
330
414
  };
415
+ if (oauth !== undefined) {
416
+ auth.oauth = {
417
+ name: oauth.name,
418
+ ...(oauth.isSubscription === undefined ? {} : { isSubscription: oauth.isSubscription }),
419
+ ...(oauth.loginLabel === undefined ? {} : { loginLabel: oauth.loginLabel }),
420
+ login: (interaction, options) =>
421
+ adopted((next) => oauth.login(next, options), interaction, session, provider.name),
422
+ async refresh(credential, signal) {
423
+ const account = subscriptionOf(session);
424
+ if (account === undefined) return await oauth.refresh(credential, signal);
425
+ return await refreshToken(
426
+ oauth,
427
+ session,
428
+ refreshing,
429
+ account.id,
430
+ account.credential,
431
+ signal,
432
+ );
433
+ },
434
+ async toAuth(credential) {
435
+ const signal = new AbortController().signal;
436
+ const resolved = await authInUse(provider, session, refreshing, signal);
437
+ return resolved?.auth ?? (await oauth.toAuth(credential));
438
+ },
439
+ };
440
+ }
441
+ return auth;
442
+ }
443
+
444
+ /** The auth of the account in use, or undefined once the provider has none. */
445
+ async function authInUse(
446
+ provider: Provider,
447
+ session: AccountSession,
448
+ refreshing: Map<string, Promise<OAuthCredential>>,
449
+ signal: AbortSignal,
450
+ ): Promise<ResolvedAuth | undefined> {
451
+ const account = session.resolve();
452
+ if (account === undefined) return undefined;
453
+ return await resolveAuth(provider, session, refreshing, account, signal);
454
+ }
455
+
456
+ /** The OAuth account pi's copy is renewed from: the one in use, else any subscription. */
457
+ function subscriptionOf(
458
+ session: AccountSession,
459
+ ): { id: string; credential: OAuthCredential } | undefined {
460
+ const candidates = [session.resolve(), session.subscription?.()];
461
+ for (const account of candidates) {
462
+ if (account?.credential.type === "oauth")
463
+ return { id: account.id, credential: account.credential };
464
+ }
465
+ return undefined;
466
+ }
467
+
468
+ /** Runs a login on our framed screen when there is one, then lets the feature keep the credential. */
469
+ async function adopted<T extends Credential>(
470
+ login: (interaction: ProviderAuthInteraction) => Promise<T>,
471
+ interaction: ProviderAuthInteraction,
472
+ session: AccountSession,
473
+ label: string,
474
+ ): Promise<T> {
475
+ const screen = await session.loginScreen?.(label, interaction.signal);
476
+ try {
477
+ const credential = await login(screen?.interaction ?? interaction);
478
+ await session.adopt?.(credential);
479
+ return credential;
480
+ } finally {
481
+ screen?.close();
482
+ }
331
483
  }
@@ -0,0 +1,54 @@
1
+ // Serializes the refresh across pi processes. A lock older than its deadline belonged to a process
2
+ // that died, and is taken over.
3
+ import { statSync, unlinkSync, writeFileSync } from "node:fs";
4
+
5
+ const STALE_MS = 60_000;
6
+ const RETRY_MS = 50;
7
+ const WAIT_MS = 5_000;
8
+
9
+ /** Runs `work` holding the lock, or without it when it cannot be taken in time. */
10
+ export async function withLock<T>(
11
+ path: string,
12
+ work: () => Promise<T>,
13
+ waitMs = WAIT_MS,
14
+ ): Promise<T> {
15
+ const deadline = Date.now() + waitMs;
16
+ while (!take(path)) {
17
+ if (Date.now() >= deadline) return await work();
18
+ // oxlint-disable-next-line no-await-in-loop -- the lock is retried until its deadline.
19
+ await new Promise((resolve) => setTimeout(resolve, RETRY_MS));
20
+ }
21
+ try {
22
+ return await work();
23
+ } finally {
24
+ release(path);
25
+ }
26
+ }
27
+
28
+ function take(path: string): boolean {
29
+ try {
30
+ // Opens, writes and closes in one call: handing writeSync's return value to closeSync closed
31
+ // the byte count, and for a 5 or 6 digit pid that count is a descriptor libuv owns.
32
+ writeFileSync(path, String(process.pid), { flag: "wx" });
33
+ return true;
34
+ } catch {
35
+ dropStale(path);
36
+ return false;
37
+ }
38
+ }
39
+
40
+ function dropStale(path: string): void {
41
+ try {
42
+ if (Date.now() - statSync(path).mtimeMs > STALE_MS) unlinkSync(path);
43
+ } catch {
44
+ // The holder released it between the open and the stat, which is the good case.
45
+ }
46
+ }
47
+
48
+ function release(path: string): void {
49
+ try {
50
+ unlinkSync(path);
51
+ } catch {
52
+ // Already gone, which is also the point.
53
+ }
54
+ }
@@ -4,6 +4,20 @@ import type {
4
4
  Provider,
5
5
  ProviderAuthInteraction,
6
6
  } from "@earendil-works/pi-ai";
7
+ import { readStoredCredential } from "@earendil-works/pi-coding-agent";
8
+
9
+ /**
10
+ * The credential pi's own `/login` wrote for a provider. Read once, when the provider gets its first
11
+ * account, so that login becomes an account too. After that the store is the only one consulted.
12
+ */
13
+ export function piLogin(providerId: string): Credential | undefined {
14
+ return readStoredCredential(providerId);
15
+ }
16
+
17
+ /** The word a person reads for a credential type. */
18
+ export function kindText(type: Credential["type"]): string {
19
+ return type === "oauth" ? "subscription" : "api key";
20
+ }
7
21
 
8
22
  /** One way a provider can log in, named the way its own login dialog names it. */
9
23
  export interface LoginMethod {
@@ -5,54 +5,39 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
5
 
6
6
  import { SESSION_ENTRY } from "./names.ts";
7
7
 
8
- /** The account pinned for a provider. `null` means the credential pi itself has. */
9
- export type Pins = Map<string, string | null>;
8
+ /** The account pinned for a provider. */
9
+ export type Pins = Map<string, string>;
10
10
 
11
- export type PinEntry =
12
- | { kind: "account"; provider: string; account: string }
13
- | { kind: "default"; provider: string };
14
-
15
- export function record(pi: ExtensionAPI, entry: PinEntry): void {
16
- pi.appendEntry(SESSION_ENTRY, entry);
11
+ export interface PinEntry {
12
+ kind: "account";
13
+ provider: string;
14
+ account: string;
17
15
  }
18
16
 
19
17
  /** Pins the account for the rest of the session, and writes it into the session file. */
20
- export function pin(
21
- pi: ExtensionAPI,
22
- pins: Pins,
23
- providerId: string,
24
- accountId: string | null,
25
- ): void {
18
+ export function pin(pi: ExtensionAPI, pins: Pins, providerId: string, accountId: string): void {
26
19
  pins.set(providerId, accountId);
27
- record(
28
- pi,
29
- accountId === null
30
- ? { kind: "default", provider: providerId }
31
- : { kind: "account", provider: providerId, account: accountId },
32
- );
20
+ pi.appendEntry(SESSION_ENTRY, {
21
+ kind: "account",
22
+ provider: providerId,
23
+ account: accountId,
24
+ } satisfies PinEntry);
33
25
  }
34
26
 
35
27
  /** The pins a branch recorded, newest winning. */
36
28
  export function replay(branch: readonly unknown[]): Pins {
37
29
  const pins: Pins = new Map();
38
30
  for (const item of branch) {
39
- const entry = pinEntry(item);
40
- if (entry === undefined) continue;
41
- if (entry.kind === "default") pins.set(entry.provider, null);
42
- else pins.set(entry.provider, entry.account);
31
+ if (!isObject(item) || item.type !== "custom" || item.customType !== SESSION_ENTRY) continue;
32
+ const data = item.data;
33
+ if (!isObject(data) || typeof data.provider !== "string") continue;
34
+ if (data.kind === "account" && typeof data.account === "string") {
35
+ pins.set(data.provider, data.account);
36
+ } else {
37
+ // Older sessions pinned pi's own credential, which is no account any more: the session goes
38
+ // back to the store's choice.
39
+ pins.delete(data.provider);
40
+ }
43
41
  }
44
42
  return pins;
45
43
  }
46
-
47
- function pinEntry(item: unknown): PinEntry | undefined {
48
- if (!isObject(item) || item.type !== "custom" || item.customType !== SESSION_ENTRY)
49
- return undefined;
50
- const data = item.data;
51
- if (!isObject(data)) return undefined;
52
- const provider = typeof data.provider === "string" ? data.provider : undefined;
53
- if (provider === undefined) return undefined;
54
- if (data.kind === "default") return { kind: "default", provider };
55
- if (data.kind === "account" && typeof data.account === "string")
56
- return { kind: "account", provider, account: data.account };
57
- return undefined;
58
- }