@byokit/accounts 0.10.0 → 0.12.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/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.12.0 (2026-09-30)
6
+
7
+
8
+
9
+ - FIX: fileStore verifies optional sealing-adapter upgrades and atomically replaces authenticated ciphertext on read, allowing opt-in dual-wrap migration without losing the original store on interruption.
10
+ - Claude Pro/Max subscription PKCE sign-in, direct Messages and single-flight refresh with on-device credentials.
11
+
12
+ - Anthropic Messages with an app-passed API key (billed per use), explicit model and opt-in; typed native requests, streamed text/tools/thinking/message events, usage/raw results and the shared IncompleteError contract for max_tokens/refusal (including with tools) on every platform.
13
+ - Expose fresh subscription access to host-side capabilities using the app’s own sign-in.
14
+
15
+ ## 0.11.0 (2026-09-30)
16
+
17
+
18
+
19
+ - Add optional `parallelToolCalls` to `respond` and `Accounts.respond`, passed through to ChatGPT as `parallel_tool_calls`; omitted keeps the provider default.
20
+
5
21
  ## 0.10.0 (2026-09-30)
6
22
 
7
23
 
package/NOTICE ADDED
@@ -0,0 +1,30 @@
1
+ BYOKit accounts implements the Claude manual OAuth protocol independently, informed by:
2
+
3
+ Hermes Agent, Nous Research, copyright (c) 2025 Nous Research.
4
+ https://github.com/NousResearch/hermes-agent/tree/57a22675ef9f7761111feba9d24e5c366db3134b
5
+ agent/anthropic_credentials.py and agent/anthropic_adapter.py.
6
+
7
+ oh-my-pi, copyright (c) 2025 Mario Zechner; 2025-2026 Can Bölük; 2026 Stencil Labs.
8
+ https://github.com/can1357/oh-my-pi/tree/2b023d1b80133c523d66412602d99b5427408395
9
+ packages/catalog/src/compat/rules/auth/anthropic.kdl, packages/ai/src/registry/engine/refresh.ts.
10
+
11
+ These references are MIT licensed. No reference project's installed credential store or CLI is used.
12
+ The following MIT notice is retained for protocol adaptations, including constants and request construction:
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in
22
+ all copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
30
+ THE SOFTWARE.
package/README.md CHANGED
@@ -8,8 +8,8 @@
8
8
  </p>
9
9
 
10
10
  <p align="center"><strong>Sign in with the AI plan you already pay for, inside your own app.</strong><br/>
11
- ChatGPT on every platform; OpenRouter on computers when an app offers it (API billing, never by default); Grok and
12
- GitHub Copilot hidden by default. Sign-ins go into your app's own store: on a computer (Node, Electron), in a browser
11
+ ChatGPT and Claude Pro/Max on every platform (Claude needs Web Crypto); OpenRouter on computers when an app offers it (API billing, never by default); Grok and
12
+ GitHub Copilot hidden by default. Anthropic uses an app-passed API key (billed per use), explicitly opted in. Sign-ins go into your app's own store: on a computer (Node, Electron), in a browser
13
13
  (a PWA, Electron's renderer) and on a phone (React Native and Expo, iOS and Android). One import; your bundler picks
14
14
  the platform's side (`package.json`'s `react-native` and `browser` conditions).</p>
15
15
 
@@ -135,6 +135,8 @@ and [`examples/pwa`](../../examples/pwa) (browser sign-in).
135
135
  | | Computer (Node, Electron main) | Browser (PWA, Electron renderer) | Phone (React Native: iOS, Android) |
136
136
  |---|---|---|---|
137
137
  | ChatGPT (subscription) | Its own page, straight back to this computer (port 1455); a code when asked or stuck | Device code | Device code |
138
+ | Claude Pro/Max (subscription) | Provider page, paste its code back | Same PKCE flow; token endpoint CORS required | Same PKCE flow; app supplies Web Crypto |
139
+ | Anthropic (API key, billed per use) | App passes its own key, explicitly | Same fetch-only Messages provider | Same fetch-only Messages provider |
138
140
  | OpenRouter (API billing) | Its own page, back to this computer (Pi's flow), when an app offers it (never by default) | Not yet | Not yet |
139
141
  | Grok, Copilot (hidden) | Pi's flows | No | No |
140
142
  | Where sign-ins are kept | `fileStore(path, safeStorage)`, sealing required | `browserStore(name)` (IndexedDB) | `secureStore(SecureStore, name)` (Keychain, Keystore) |
@@ -152,7 +154,7 @@ doesn't answer other web pages), so a PWA's model calls go through the app's own
152
154
  platform are shown: OpenRouter is API-billed and never offered by default. An explicit list is not platform-filtered,
153
155
  so choose from the table above. Show `billingWords(p)` next to every provider you list.
154
156
 
155
- Claude plan sign-in is never offered: Anthropic reserves it for its own apps.
157
+ Anthropic Messages uses an app-passed API key (billed per use), with explicit opt-in; its authentication is separate from the Messages request.
156
158
 
157
159
  ```ts
158
160
  import { Accounts, billingWords, offered } from '@byokit/accounts';
@@ -162,7 +164,7 @@ for (const p of offered(['chatgpt', 'openrouter'])) console.log(`${p.name}: ${bi
162
164
  ```
163
165
 
164
166
  ```text
165
- [ 'chatgpt' ]
167
+ [ 'chatgpt', 'claude' ]
166
168
  ChatGPT: Uses your ChatGPT plan.
167
169
  OpenRouter: Charged per use to your OpenRouter account, not a plan.
168
170
  ```
@@ -260,6 +262,11 @@ already have shown partial words. This covers `response.incomplete` events and `
260
262
  whether fetch streams SSE, buffers it, or returns JSON. The account stays signed in and is not put to rest.
261
263
  Successful return values are unchanged.
262
264
 
265
+ Set `parallelToolCalls: false` to request one tool call at a time; `true` allows parallel calls. Both pass through as
266
+ `parallel_tool_calls`; omitting it sends nothing and keeps the provider default. `respond` supports only the ChatGPT
267
+ subscription route; OpenRouter, Grok and Copilot are credential routes without `respond` support, and there is no
268
+ Anthropic response route.
269
+
263
270
  The whole question passes through: `input` takes the turns so far (messages, with `input_image` where the person
264
271
  attached a picture), `tools` and `tool_choice` take the app's own function tools and built-ins (including
265
272
  `image_generation`), `reasoning.effort` how hard the model thinks, and `text` how long the answer is with the shape it
@@ -344,6 +351,10 @@ and the Linux `basic_text` backend. Other adapters must protect their keys outsi
344
351
  file and provide authenticated encryption. For a Node service, use a host-owned keystore through
345
352
  `recordStore(load, save)`, or provide an equivalent sealing adapter; the kit never discovers a key
346
353
  or invokes an OS keyring itself. Use `memoryStore()` for temporary sign-ins.
354
+ If the adapter exposes optional `upgrade(bytes)`, a read validates the decrypted record, verifies
355
+ that upgraded ciphertext decrypts to identical text and atomically replaces the original. This
356
+ supports opt-in dual-wrap migration through `@byokit/secrets`; a failed replacement leaves the
357
+ original envelope usable. Hold the host writer lock for read upgrades as well as ordinary writes.
347
358
 
348
359
  Use an app-owned directory: the immediate folder must be a real 0700 directory and credential
349
360
  files must be private regular files. Reuse one store instance for each path; a host lock is required
@@ -361,3 +372,120 @@ in N min/hours", and zero otherwise. For resting kinds, use
361
372
  `failure.until || nowMs + REST_MS[failure.kind]`; signed-out, not-included and network
362
373
  failures have no fallback rest. `classify` remains an alias. Pass a clock for deterministic
363
374
  classification; the default uses `Date.now()`. It stores and logs no error text.
375
+
376
+ For a capability running where the sign-in lives, `await accounts.access(member, signal)` returns fresh `{ access, accountId }` from the app’s own ChatGPT store. Keep these credentials in that process; proxy signaling or relay media for another device. This uses the same refresh and signed-out behavior as `respond`.
377
+
378
+ ## Anthropic Messages: API key (billed per use)
379
+
380
+ This route is never a default or a subscription fallback. The app supplies the key and an explicit model.
381
+ The kit never reads keys from environment variables, files or another tool's sign-in.
382
+
383
+ ```ts
384
+ import { anthropic, AnthropicIncompleteError } from '@byokit/accounts';
385
+
386
+ export async function askClaude(appKey: string, show: (text: string) => void) {
387
+ const claude = anthropic({ key: appKey });
388
+ try {
389
+ const answer = await claude.respond({
390
+ model: 'claude-opus-5-5', max_tokens: 1024,
391
+ system: 'Be brief.', messages: [{ role: 'user', content: 'Hello' }],
392
+ result: true, onText: (delta) => show(delta),
393
+ });
394
+ // answer.text, answer.output, answer.usage, answer.raw
395
+ } catch (error) {
396
+ if (error instanceof AnthropicIncompleteError) {
397
+ show(error.reason); // error.result carries partial text/output, usage and native raw response
398
+ } else throw error;
399
+ }
400
+ }
401
+ ```
402
+
403
+ Native `system`, `messages` (images, thinking, tool calls/results), `tools`, `tool_choice`, `thinking`,
404
+ `max_tokens`, `stop_sequences`, `metadata`, sampling and `output_config` pass through unchanged.
405
+ `onEvent` carries text deltas, tool JSON deltas, completed tools/content blocks, message events and an
406
+ incomplete event for `max_tokens` or `refusal`. A stream without `message_stop` fails. With `tools` or
407
+ `result: true`, the result carries metadata; without either, it returns text. Incomplete answers always
408
+ throw the shared `IncompleteError` contract (the `AnthropicIncompleteError` subtype retains typed native
409
+ metadata), including with tools or `result: true`. Call `isFunctionCall` on normalized `output` items; use native `raw.content` for the next Messages turn.
410
+
411
+ The catalogue's `label` is exactly `API key (billed per use)`; show it when presenting the explicit key route.
412
+ `billingWords` also supplies the existing plain sentence about per-use charges.
413
+
414
+ An `Accounts` instance can also use this route, with `offer: ['anthropic']` and
415
+ `accounts.respond(member, { provider: 'anthropic', key: appKey, model, max_tokens, messages, result: true })`.
416
+ The key is used for that request, never stored by the kit; `login` does not launch a plan flow for this route.
417
+
418
+ The existing `@byokit/decide` seam accepts it without a new dependency:
419
+
420
+ ```ts
421
+ import { anthropic } from '@byokit/accounts';
422
+ import { answerer } from '@byokit/decide';
423
+
424
+ export function claudeBackend(appKey: string) {
425
+ const claude = anthropic({ key: appKey });
426
+ return answerer({ name: 'anthropic', leaves: true,
427
+ ask: (prompt, signal) => claude.respond({
428
+ model: 'claude-opus-5-5', max_tokens: 2048, signal,
429
+ messages: [{ role: 'user', content: prompt }],
430
+ }),
431
+ });
432
+ }
433
+ ```
434
+
435
+ An incomplete answer throws through this text seam, so decide abstains rather than parsing a partial answer.
436
+ Browser apps should keep the API key in an app-owned server proxy (`base`); React Native can pass a streaming
437
+ fetch such as Expo's. `betas` explicitly opts into native beta headers.
438
+
439
+ ## Claude Pro/Max subscription
440
+
441
+ Anthropic's [developer guidance](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use) prohibits third-party Claude.ai login without approval; BYOKit has no approval, and this route may stop working or lead to account restrictions (the separately billed API-key route is the documented alternative).
442
+
443
+ Claude is available by default. Open its page, then paste the returned `code#state` into the app:
444
+
445
+ ```ts
446
+ import { Accounts, memoryStore } from '@byokit/accounts';
447
+
448
+ const member = 1;
449
+ const credentials = memoryStore(); // replace with device-owned persistent storage in your app
450
+ const accounts = new Accounts({ store: () => credentials });
451
+ const pastedCode = 'code#state'; // collect the actual returned value from the person
452
+ const pending = await accounts.login(member, 'claude');
453
+ // Open pending.url in the person's browser, then collect the code from that page.
454
+ accounts.paste(member, 'claude', pastedCode);
455
+ await accounts.finished(member, 'claude');
456
+ const text = await accounts.respond(member, {
457
+ provider: 'claude', model: 'claude-opus-5-5', max_tokens: 1024,
458
+ messages: [{ role: 'user', content: 'Hello' }],
459
+ });
460
+ ```
461
+
462
+ The manual HTTPS callback works without a local listener or an installed CLI. It uses PKCE (secure random verifier,
463
+ SHA-256 challenge), independent state, strict `code#state` validation, and direct JSON exchange/refresh at
464
+ `platform.claude.com/v1/oauth/token`. Refresh is single-flight for one store, saves an attempt before sending and commits the rotated credentials before
465
+ returning access. An omitted replacement requires sign-in again. `ClaudePlanExpiredError` means sign in again, including after a refused or uncertain rotation or a
466
+ failed durable save; a persisted attempt prevents replay after restart. A custom store must provide the refresh transaction seam (use
467
+ `recordStore(load, save)`); a host lock is required for multiple processes. Local logout removes only this app's credentials; it does not promise server
468
+ revocation. A Messages authentication refusal requests re-authentication without replaying the request or switching
469
+ billing to an API key.
470
+
471
+ Tokens belong in one device-owned `CredentialStore` per member. `keystoreStore(hostKeystore, 'member.1')` adapts
472
+ `@byokit/secrets` without importing Node into the portable entry. Electron can pass safeStorage to `fileStore`;
473
+ its sealed file writes atomically with mode 0600; a sealing adapter is required. Use one process per file (or a host-supplied cross-process lock).
474
+ Phones use `secureStore` with device-only accessibility. PWA `browserStore` uses IndexedDB and Web Locks; page
475
+ scripts can read its credentials. Tokens are never collected by a BYOKit server or logged. The default is memory-only.
476
+
477
+ React Native hosts pass `claudePlan: { crypto: webCrypto }` when global Web Crypto is unavailable;
478
+ `ClaudePlanPlatformError` reports missing secure randomness/SHA-256. Browsers require the provider token endpoint's
479
+ CORS support; a network/CORS failure stays on-device and does not trigger a proxy. Browser User-Agent restrictions
480
+ can affect compatibility. Tests replay protocol-shaped captures offline; no live OAuth, CORS, or current inference
481
+ compatibility is claimed. This implementation supplies the manual-flow headers and Claude Code identity prelude,
482
+ without relying on Pi's provider or executing an installed Claude CLI.
483
+
484
+ Protocol references: [Hermes credentials at 57a22675, PKCE/exchange](https://github.com/NousResearch/hermes-agent/blob/57a22675ef9f7761111feba9d24e5c366db3134b/agent/anthropic_credentials.py#L747),
485
+ [Hermes headers/identity](https://github.com/NousResearch/hermes-agent/blob/57a22675ef9f7761111feba9d24e5c366db3134b/agent/anthropic_adapter.py#L219),
486
+ [oh-my-pi auth rule at 2b023d1b](https://github.com/can1357/oh-my-pi/blob/2b023d1b80133c523d66412602d99b5427408395/packages/catalog/src/compat/rules/auth/anthropic.kdl#L1),
487
+ and [oh-my-pi refresh](https://github.com/can1357/oh-my-pi/blob/2b023d1b80133c523d66412602d99b5427408395/packages/ai/src/registry/engine/refresh.ts#L62).
488
+ The common route follows Hermes's platform token host, three scopes and `axios/1.7.9` token User-Agent,
489
+ with inference `claude-code/2.1.74 (external, cli)`, `x-app: cli`, bearer authorization, Messages version
490
+ `2023-06-01` and betas `claude-code-20250219,oauth-2025-04-20`. The implementation is independent;
491
+ [NOTICE](NOTICE) records the MIT protocol references.
@@ -1,4 +1,6 @@
1
1
  import type { CredentialStore, Models } from '@earendil-works/pi-ai';
2
+ import { type ClaudePlanOptions } from './claude-plan.ts';
3
+ import { type AnthropicAsk, type AnthropicResult, type AnthropicTool } from './anthropic.ts';
2
4
  import { type Provider } from './catalogue.ts';
3
5
  import { ResponseError, type Ask, type ResponseResult, type ResponseTool } from './responses.ts';
4
6
  import { type EndingStore, type RefreshStore } from './stores.ts';
@@ -45,8 +47,15 @@ export type Platform = {
45
47
  };
46
48
  /** Phones and browsers: ChatGPT by device code, no listener. */
47
49
  export declare const portable: Platform;
50
+ export type ClaudePlanAsk = AnthropicAsk & {
51
+ provider: 'claude';
52
+ };
53
+ export type AnthropicAccountAsk = AnthropicAsk & {
54
+ provider: 'anthropic';
55
+ key: string;
56
+ };
48
57
  export type AccountsOptions<M extends Member = Member> = {
49
- /** The accounts this app offers, in order. Default: every provider not hidden (ChatGPT, OpenRouter). */
58
+ /** The accounts this app offers, in order. Default: supported subscription providers not hidden. */
50
59
  offer?: readonly string[];
51
60
  /** Each member's own store. Default: in memory. */
52
61
  store?: (member: M) => CredentialStore;
@@ -64,6 +73,10 @@ export type AccountsOptions<M extends Member = Member> = {
64
73
  authBase?: string;
65
74
  /** Where ChatGPT answers `respond`, for a stand-in in tests and demos. */
66
75
  apiBase?: string;
76
+ /** Anthropic Messages origin: an app-owned proxy or stand-in. */
77
+ anthropicBase?: string;
78
+ /** Claude PKCE transport and Web Crypto supplied by the app (React Native). */
79
+ claudePlan?: ClaudePlanOptions;
67
80
  /** The fetch `respond` asks with: one that streams on a phone (Expo's `expo/fetch`). Default: the platform's. */
68
81
  fetch?: typeof fetch;
69
82
  /** The originator header `respond` sends. Default: 'byokit'. */
@@ -80,6 +93,7 @@ export declare class Accounts<R extends AuthHost = AuthHost, M extends Member =
80
93
  private opts;
81
94
  private runtimes;
82
95
  private stores;
96
+ private baseStores;
83
97
  private generations;
84
98
  private signals;
85
99
  private chains;
@@ -125,21 +139,48 @@ export declare class Accounts<R extends AuthHost = AuthHost, M extends Member =
125
139
  forget(member: M, key: string): void;
126
140
  /** 0 when the account is available; otherwise when it stops resting. */
127
141
  restingUntil(member: M, key: string): number;
128
- /** An account's error, acted on. A limit or overload rests it (until when it said, or a default). A plan without this
129
- * use is marked so. A refusal is checked: a sign-in that no longer refreshes is signed out for real, one that still
130
- * does was a passing refusal and rests a few minutes (kind `overloaded`) rather than loop. Returns the kind acted on,
131
- * or null for an error that is not about the account; `network` changes nothing. */
142
+ /** Fresh ChatGPT access for a host-side capability. Never send this to another device. */
143
+ access(member: M, signal?: AbortSignal): Promise<{
144
+ access: string;
145
+ accountId: string;
146
+ }>;
132
147
  /** Ask ChatGPT with this member's own sign-in, the answer streaming into `onText`; refreshed first when due. A
133
148
  * failure about the account (a limit, a lapsed sign-in) is acted on as `failed()` does, then thrown as a
134
149
  * ResponseError with the words to show. Without `tools` the answer is the plain text, as before: pass `input` as
135
150
  * words or as turns (messages with `input_image`, then the `function_call` with its `function_call_output`). With
136
151
  * `tools` it is the text with every output item, and `onEvent` sees each tool call as it lands. */
152
+ respond(member: M, ask: ClaudePlanAsk & {
153
+ result: true;
154
+ }): Promise<AnthropicResult>;
155
+ respond(member: M, ask: ClaudePlanAsk & {
156
+ tools: AnthropicTool[];
157
+ }): Promise<AnthropicResult>;
158
+ respond(member: M, ask: ClaudePlanAsk & {
159
+ tools?: undefined;
160
+ result?: false;
161
+ }): Promise<string>;
162
+ respond(member: M, ask: ClaudePlanAsk): Promise<string | AnthropicResult>;
163
+ respond(member: M, ask: AnthropicAccountAsk & {
164
+ result: true;
165
+ }): Promise<AnthropicResult>;
166
+ respond(member: M, ask: AnthropicAccountAsk & {
167
+ tools: AnthropicTool[];
168
+ }): Promise<AnthropicResult>;
169
+ respond(member: M, ask: AnthropicAccountAsk & {
170
+ tools?: undefined;
171
+ result?: false;
172
+ }): Promise<string>;
173
+ respond(member: M, ask: AnthropicAccountAsk): Promise<string | AnthropicResult>;
137
174
  respond(member: M, ask: Ask & {
138
175
  tools?: undefined;
139
176
  }): Promise<string>;
140
177
  respond(member: M, ask: Ask & {
141
178
  tools: ResponseTool[];
142
179
  }): Promise<ResponseResult>;
180
+ /** An account's error, acted on. A limit or overload rests it (until when it said, or a default). A plan without this
181
+ * use is marked so. A refusal is checked: a sign-in that no longer refreshes is signed out for real, one that still
182
+ * does was a passing refusal and rests a few minutes (kind `overloaded`) rather than loop. Returns the kind acted on,
183
+ * or null for an error that is not about the account; `network` changes nothing. */
143
184
  failed(member: M, key: string, error: string | ResponseError): Promise<import("./limits.ts").Failure | null>;
144
185
  /** The first choice whose account is neither resting nor known to be unusable: the fallback ladder. */
145
186
  ladder<T>(member: M, choices: readonly T[], key?: (c: T) => string): T | undefined;
package/dist/accounts.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { CLAUDE_PLAN_ID, ClaudePlanExpiredError, claudePlanMessages, withClaudePlan } from "./claude-plan.js";
2
+ import { anthropic } from "./anthropic.js";
1
3
  import { offered, provider } from "./catalogue.js";
2
4
  import { claims, PORTABLE, portableEngine } from "./engine.js";
3
5
  import { classify, REST_MS } from "./limits.js";
@@ -5,7 +7,7 @@ import { respond, ResponseError } from "./responses.js";
5
7
  import { memoryStore, refreshCredential } from "./stores.js";
6
8
  import { callbackPage, clock, failure, say, signInError } from "./words.js";
7
9
  /** Phones and browsers: ChatGPT by device code, no listener. */
8
- export const portable = { engine: (c, base) => portableEngine(c, { base }), signsIn: (pi) => PORTABLE.includes(pi) };
10
+ export const portable = { engine: (c, base) => portableEngine(c, { base }), signsIn: (pi) => pi === CLAUDE_PLAN_ID || PORTABLE.includes(pi) };
9
11
  /** The ChatGPT plan behind a sign-in, from its own token: a work plan (Business, Enterprise, Edu) follows the employer's rules. */
10
12
  export function planOf(access) {
11
13
  let c = {};
@@ -30,6 +32,7 @@ export class Accounts {
30
32
  opts;
31
33
  runtimes = new Map();
32
34
  stores = new Map();
35
+ baseStores = new Map();
33
36
  generations = new Map();
34
37
  signals = new WeakMap();
35
38
  chains = new Map();
@@ -59,6 +62,7 @@ export class Accounts {
59
62
  let s = this.stores.get(String(member));
60
63
  if (!s) {
61
64
  const base = (this.opts.store ?? memoryStore)(member);
65
+ this.baseStores.set(String(member), base);
62
66
  let chain = Promise.resolve();
63
67
  const serial = (fn) => { const result = chain.then(fn); chain = result.catch(() => { }); return result; };
64
68
  s = {
@@ -172,7 +176,7 @@ export class Accounts {
172
176
  }
173
177
  engine(member, raw) {
174
178
  const credentials = this.boundStore(member, raw);
175
- return Promise.resolve(Object.assign(this.platform.engine(credentials, this.opts.authBase), {
179
+ return Promise.resolve(Object.assign(withClaudePlan(this.platform.engine(credentials, this.opts.authBase), credentials, this.baseStores.get(String(member)) ?? raw, { ...this.opts.claudePlan, fetch: this.opts.claudePlan?.fetch ?? this.opts.fetch }), {
176
180
  credentialStore: credentials, readCredential: (id) => credentials.read(id),
177
181
  }));
178
182
  }
@@ -228,7 +232,9 @@ export class Accounts {
228
232
  const r = this.rests.get(`${member}:${key}`);
229
233
  return r && r.until > Date.now() ? r.until : 0;
230
234
  }
231
- async respond(member, ask) {
235
+ /** Fresh ChatGPT access for a host-side capability. Never send this to another device. */
236
+ async access(member, signal) {
237
+ signal?.throwIfAborted();
232
238
  const key = 'chatgpt';
233
239
  const p = this.offer(key);
234
240
  const rt = await this.runtime(member);
@@ -247,8 +253,53 @@ export class Accounts {
247
253
  const c = await rt.readCredential(p.pi).catch(() => undefined);
248
254
  if (!access || c?.type !== 'oauth')
249
255
  throw new ResponseError(say('status.signedOut', { name: p.name }), 'signed_out');
256
+ signal?.throwIfAborted();
257
+ return { access, accountId: String(c.accountId ?? '') };
258
+ }
259
+ async respond(member, query) {
260
+ const ask = query;
261
+ if ('provider' in query && query.provider === 'claude') {
262
+ this.offer('claude');
263
+ const rt = await this.runtime(member);
264
+ const { provider: _provider, ...request } = query;
265
+ let access;
266
+ try {
267
+ access = (await rt.getAuth(CLAUDE_PLAN_ID))?.auth?.apiKey;
268
+ }
269
+ catch (e) {
270
+ if (e instanceof ClaudePlanExpiredError) {
271
+ this.forget(member, 'claude');
272
+ this.onExpired?.(member, 'claude');
273
+ }
274
+ throw e;
275
+ }
276
+ if (!access)
277
+ throw new ClaudePlanExpiredError();
278
+ try {
279
+ return await claudePlanMessages(access, { fetch: this.opts.fetch }).respond(request);
280
+ }
281
+ catch (e) {
282
+ if (e instanceof ResponseError && e.kind === 'signed_out') {
283
+ await this.logout(member, 'claude').catch(() => { });
284
+ this.forget(member, 'claude');
285
+ this.onExpired?.(member, 'claude');
286
+ throw new ClaudePlanExpiredError();
287
+ }
288
+ if (e instanceof ResponseError && e.kind)
289
+ await this.failed(member, 'claude', e);
290
+ throw e;
291
+ }
292
+ }
293
+ if ('provider' in query && query.provider === 'anthropic') {
294
+ this.offer('anthropic');
295
+ const { provider: _provider, key, ...request } = query;
296
+ return anthropic({ key, fetch: this.opts.fetch, base: this.opts.anthropicBase }).respond(request);
297
+ }
298
+ const key = 'chatgpt';
299
+ const p = this.offer(key);
300
+ const { access, accountId } = await this.access(member);
250
301
  try {
251
- const base = { ...ask, access, accountId: String(c.accountId ?? ''), model: ask.model ?? p.models.strong, base: this.opts.apiBase, fetch: this.opts.fetch, originator: ask.originator ?? this.opts.originator };
302
+ const base = { ...ask, access, accountId, model: ask.model ?? p.models.strong, base: this.opts.apiBase, fetch: this.opts.fetch, originator: ask.originator ?? this.opts.originator };
252
303
  return ask.tools ? await respond({ ...base, tools: ask.tools }) : await respond({ ...base, tools: undefined });
253
304
  }
254
305
  catch (e) {
@@ -260,6 +311,10 @@ export class Accounts {
260
311
  throw e;
261
312
  }
262
313
  }
314
+ /** An account's error, acted on. A limit or overload rests it (until when it said, or a default). A plan without this
315
+ * use is marked so. A refusal is checked: a sign-in that no longer refreshes is signed out for real, one that still
316
+ * does was a passing refusal and rests a few minutes (kind `overloaded`) rather than loop. Returns the kind acted on,
317
+ * or null for an error that is not about the account; `network` changes nothing. */
263
318
  async failed(member, key, error) {
264
319
  const c = error instanceof ResponseError ? error.kind && { kind: error.kind, until: error.until } : classify(error);
265
320
  if (!c || c.kind === 'network')
@@ -300,7 +355,9 @@ export class Accounts {
300
355
  * stalls times out; nothing is kept unless the engine then sees a working sign-in; every failure ends in one plain
301
356
  * sentence. Returns as soon as there is a page to open or a code to show (or it is over); the rest carries on by itself. */
302
357
  async login(member, key, body = {}) {
303
- this.offer(key);
358
+ const p = this.offer(key);
359
+ if (p.auth === 'api-key')
360
+ throw new Error('Use the app-provided API key (billed per use) to ask this provider.');
304
361
  const id = `${member}:${key}`;
305
362
  const pending = this.signingOut.get(id);
306
363
  if (pending)
@@ -366,7 +423,7 @@ export class Accounts {
366
423
  },
367
424
  });
368
425
  const timer = setTimeout(() => { flow.timedOut = true; flow.abort.abort(); }, this.opts.signInMs ?? 15 * 60_000);
369
- const stuck = setTimeout(() => this.toCode(flow), this.opts.redirectMs ?? 3 * 60_000);
426
+ const stuck = p.key === 'claude' ? undefined : setTimeout(() => this.toCode(flow), this.opts.redirectMs ?? 3 * 60_000);
370
427
  // Listen where the provider sends the browser back (the engine then finds the port taken and waits to be handed the address).
371
428
  const port = p.callbackPort && (this.opts.callbackPort ?? p.callbackPort);
372
429
  const catcher = port && body.via !== 'code' && this.platform.loopback ? await this.catchRedirect(this.platform.loopback, flow, p.name, port).catch(() => null) : undefined;