@byokit/accounts 0.3.0 → 0.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ ## 0.4.0
6
+
7
+ - Each catalogue row carries its billing (`subscription`, `api`); show `billingWords(p)` next to every provider you list.
8
+ - FIX: OpenRouter (API billing) is no longer offered by default on computers; `offered()` without keys returns subscription rows only. Name it explicitly (`offer: ['openrouter']`) to keep offering it.
9
+ - FIX: a locked-keychain read (iOS returns "User interaction is not allowed" while the phone is locked) no longer signs the person out: `keepFresh` treats it as unknown and tries later, only a refused refresh fires `onExpired`.
10
+ - `secureStore(secure, name, options?)` passes `options` (e.g. `{ keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY }`) to every keychain get, set and delete; the default is unchanged.
11
+ ## 0.3.1
12
+
13
+ - `respond(member, { instructions, input, onText })` asks ChatGPT with the member's own sign-in, the answer streaming in over an injected `fetch` (whole answer at once when the fetch can't stream); limits and lapsed sign-ins are acted on as `failed()` does.
14
+ - FIX: a plain HTTP 429 or an undated `rate_limit_exceeded` is a temporary rate limit, not "plan doesn't include this"; a streamed error keeps its code.
15
+ - FIX: a refresh that fails on the network before asking is reported as network trouble; a refused refresh signs the account out.
16
+
17
+ ## 0.3.0
18
+
19
+ - FIX: `@byokit/accounts/testing` `decoy()` now requires a caller-owned root; migrate from `decoy()` to `decoy(root)` and clean up that root when done.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @byokit/accounts
2
2
 
3
- Sign in with the AI plan you already pay for (ChatGPT on every platform; OpenRouter on computers; Grok and GitHub
4
- Copilot hidden by default), inside your own app, into your app's own store: on a computer (Node, Electron), in a browser
3
+ Sign in with the AI plan you already pay for (ChatGPT on every platform; OpenRouter on computers when an app offers it
4
+ (API billing, never by default); Grok and GitHub Copilot hidden by default), inside your own app, into your app's own store: on a computer (Node, Electron), in a browser
5
5
  (a PWA, Electron's renderer) and on a
6
6
  phone (React Native and Expo, iOS and Android). One import; your bundler picks the platform's side
7
7
  (`package.json`'s `react-native` and `browser` conditions).
@@ -25,13 +25,22 @@ Node), into the phone's secure storage or the browser's IndexedDB:
25
25
 
26
26
  ```ts
27
27
  import * as SecureStore from 'expo-secure-store';
28
+ import { fetch as streamingFetch } from 'expo/fetch'; // optional: React Native's fetch returns the answer at once
28
29
  import { Accounts, secureStore } from '@byokit/accounts';
29
30
 
30
- const accounts = new Accounts({ store: (member) => secureStore(SecureStore, `byokit.${member}`) });
31
+ const accounts = new Accounts({ store: (member) => secureStore(SecureStore, `byokit.${member}`), fetch: streamingFetch as unknown as typeof fetch });
31
32
  const shown = await accounts.login(1, 'chatgpt'); // { state: 'waiting', via: 'code', code, url }: open url, show code
32
33
  ```
33
34
 
34
- Examples: [`examples/expo`](../../examples/expo) (iOS and Android bundles; Android emulator sign-in) and
35
+ Then ask ChatGPT with that sign-in, showing streamed pieces while it runs and the returned final answer when it finishes
36
+ (the completion can correct earlier pieces):
37
+
38
+ ```ts
39
+ const answer = await accounts.respond(1, { instructions: 'Answer briefly.', input: 'Plan my day', onText: (d) => show(d) });
40
+ show(answer);
41
+ ```
42
+
43
+ Examples: [`examples/expo`](../../examples/expo) (iOS and Android bundles; Android emulator sign-in, asking, pairing) and
35
44
  [`examples/pwa`](../../examples/pwa) (browser sign-in).
36
45
 
37
46
  ## Which sign-in works where
@@ -39,7 +48,7 @@ Examples: [`examples/expo`](../../examples/expo) (iOS and Android bundles; Andro
39
48
  | | Computer (Node, Electron main) | Browser (PWA, Electron renderer) | Phone (React Native: iOS, Android) |
40
49
  |---|---|---|---|
41
50
  | ChatGPT | Its own page, straight back to this computer (port 1455); a code when asked or stuck | Device code | Device code |
42
- | OpenRouter | Its own page, back to this computer (Pi's flow) | Not yet | Not yet |
51
+ | OpenRouter | Its own page, back to this computer (Pi's flow), when an app offers it (API billing, never by default) | Not yet | Not yet |
43
52
  | Grok, Copilot (hidden) | Pi's flows | No | No |
44
53
  | Where sign-ins are kept | `fileStore(path)`, sealed with Electron's `safeStorage` when given | `browserStore(name)` (IndexedDB) | `secureStore(SecureStore, name)` (Keychain, Keystore) |
45
54
 
@@ -48,10 +57,12 @@ listener on the computer the browser runs on, so it is desktop only: ChatGPT sen
48
57
  `localhost:1455`, fixed for the client this signs in as. A web page can't call ChatGPT's model endpoint itself (it
49
58
  doesn't answer other web pages), so a PWA's model calls go through the app's own server or relay.
50
59
 
51
- - **Catalogue** (`catalogue.json`): each provider with its terms status (`allowed`, `grey`, `partner`), a one-line reason
52
- and a source. The kit labels; your app decides what to offer (`new Accounts({ offer: ['chatgpt'] })`). Without an
53
- explicit `offer`, only sign-ins supported on this platform are shown; an explicit list is not platform-filtered, so
54
- choose from the table above. Claude plan sign-in is never offered: Anthropic reserves it for its own apps.
60
+ - **Catalogue** (`catalogue.json`): each provider with its billing (`subscription`, `api`) and terms status (`allowed`,
61
+ `grey`, `partner`), a one-line reason and a source. The kit labels; your app decides what to offer
62
+ (`new Accounts({ offer: ['chatgpt'] })`). Without an explicit `offer`, only subscription sign-ins supported on this
63
+ platform are shown: OpenRouter is API-billed and never offered by default. An explicit list is not
64
+ platform-filtered, so choose from the table above. Show `billingWords(p)` next to every provider you list.
65
+ Claude plan sign-in is never offered: Anthropic reserves it for its own apps.
55
66
  - **Sign-in**: on computers, the provider's own page by default. For ChatGPT, whose page returns to this computer's
56
67
  port 1455, the kit listens there itself, so the tab shows your app's words (`new Accounts({ app: 'My App' })`) and only once they are
57
68
  true. A code takes over when asked ("Having trouble?"), when the page never comes back, or when the port is taken by
@@ -63,11 +74,22 @@ doesn't answer other web pages), so a PWA's model calls go through the app's own
63
74
  sign-out uses its rotated token. If a cancelled sign-in finishes late, `onSignOutError` reports a failed revoke of its
64
75
  discarded credential (or it is logged when no handler is set).
65
76
  - **One person, one store**: `memoryStore()`, `fileStore(path)` (0600, the same shape as Pi's `auth.json`),
66
- `secureStore(SecureStore, name)` or `browserStore(name)`; any other storage with `recordStore(load, save)`. Writes are
77
+ `secureStore(SecureStore, name, options?)` or `browserStore(name)`; any other storage with `recordStore(load, save)`. Writes are
67
78
  serialized within a store instance; `browserStore` also uses Web Locks across tabs for the same provider when available.
68
- Never a shared fallback. Browser storage is readable by scripts on your page: avoid untrusted scripts. Using another
79
+ Never a shared fallback. Browser storage is readable by scripts on your page: avoid untrusted scripts. On a phone, pass
80
+ `{ keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY }` as `options` (to every get, set and delete) so
81
+ tokens never migrate to a new device through an iCloud/iTunes backup; without it Expo's default (`WHEN_UNLOCKED`)
82
+ applies. iOS Keychain items survive an app reinstall under the same bundle id (Android data is gone): apps that must
83
+ forget on reinstall keep a first-run marker outside the Keychain (e.g. `expo-file-system` or `AsyncStorage`) and, when
84
+ it is missing, call `accounts.logout(member, key)` for each offered key before first use, which revokes and wipes. Using another
69
85
  engine with the same seam (Pi's coding-agent `ModelRuntime`)? Override `open(member)` with an engine whose
70
86
  `credentialStore` is made with `boundStore(member, engineStore)` and whose `readCredential(id)` reads that store.
87
+ - **Asking**: `respond(member, { instructions, input, model?, onText?, signal? })` asks ChatGPT's own answers endpoint
88
+ with the member's sign-in, refreshed first when due, and returns the whole text (`onText` gets each piece as it
89
+ streams; the returned completion is authoritative). A limit or a lapsed sign-in is acted on as `failed()` does, then
90
+ thrown as a `ResponseError` with the words to show and the kind acted on. Rules: [conformance fixtures](../../fixtures/README.md).
91
+ A web page can't call this endpoint itself (it answers no other web page): ask from the app's own server or over
92
+ `@byokit/link`.
71
93
  - **Limits**: `failed(member, key, error)` rests an account until the provider said (or a default), marks a plan that
72
94
  doesn't include this use, and signs out only a sign-in that no longer refreshes. `ladder()` picks the next usable
73
95
  account; `keepFresh()` refreshes ahead of expiry. Limits come from errors only; no undocumented usage endpoint is read.
@@ -75,5 +97,6 @@ doesn't answer other web pages), so a PWA's model calls go through the app's own
75
97
  `@byokit/accounts/testing` has the decoy-HOME harness and fs tracer to prove it in your own tests. `decoy(root)`
76
98
  writes only under the caller-supplied root; the caller owns its creation and cleanup.
77
99
  - **A stand-in OpenAI**: `mockOpenAI()` from `@byokit/accounts/testing` (or `node .../testing/mock-openai.ts [port]`)
78
- answers device code, its page where a person types the code, token exchange, refresh and revoke, so tests and demos
79
- sign in end to end with no account. Point the kit at it with `new Accounts({ authBase })`.
100
+ answers device code, its page where a person types the code, token exchange, refresh, revoke and streamed answers
101
+ (echoing the question), so tests and demos sign in and ask end to end with no account. Point the kit at it with
102
+ `new Accounts({ authBase, apiBase })`.
@@ -1,6 +1,7 @@
1
1
  import type { CredentialStore, Models } from '@earendil-works/pi-ai';
2
2
  import { type Provider } from './catalogue.ts';
3
3
  import { type Kind } from './limits.ts';
4
+ import { ResponseError, type Ask } from './responses.ts';
4
5
  import { type EndingStore } from './stores.ts';
5
6
  import { type Why } from './words.ts';
6
7
  /** What signing in needs from an engine: Pi's `Models`, or anything shaped like it (the coding agent's `ModelRuntime`). */
@@ -62,6 +63,10 @@ export type AccountsOptions<M extends Member = Member> = {
62
63
  * Phones and browsers sign in and sign out there; on a computer Pi's engine always calls OpenAI, and only sign-out's
63
64
  * revoke goes here. */
64
65
  authBase?: string;
66
+ /** Where ChatGPT answers `respond`, for a stand-in in tests and demos. */
67
+ apiBase?: string;
68
+ /** The fetch `respond` asks with: one that streams on a phone (Expo's `expo/fetch`). Default: the platform's. */
69
+ fetch?: typeof fetch;
65
70
  };
66
71
  /** The ChatGPT plan behind a sign-in, from its own token: a work plan (Business, Enterprise, Edu) follows the employer's rules. */
67
72
  export declare function planOf(access: string): {
@@ -123,7 +128,11 @@ export declare class Accounts<R extends AuthHost = AuthHost, M extends Member =
123
128
  * use is marked so. A refusal is checked: a sign-in that no longer refreshes is signed out for real, one that still
124
129
  * does was a passing refusal and rests a few minutes (kind `overloaded`) rather than loop. Returns the kind acted on,
125
130
  * or null for an error that is not about the account; `network` changes nothing. */
126
- failed(member: M, key: string, error: string): Promise<{
131
+ /** Ask ChatGPT with this member's own sign-in, the answer streaming into `onText`; refreshed first when due. A
132
+ * failure about the account (a limit, a lapsed sign-in) is acted on as `failed()` does, then thrown as a
133
+ * ResponseError with the words to show. */
134
+ respond(member: M, ask: Ask): Promise<string>;
135
+ failed(member: M, key: string, error: string | ResponseError): Promise<{
127
136
  kind: Kind;
128
137
  until: number;
129
138
  } | null>;
package/dist/accounts.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { offered, provider } from "./catalogue.js";
2
2
  import { claims, PORTABLE, portableEngine } from "./engine.js";
3
3
  import { classify, REST_MS } from "./limits.js";
4
+ import { respond, ResponseError } from "./responses.js";
4
5
  import { memoryStore } from "./stores.js";
5
6
  import { callbackPage, clock, failure, say, signInError } from "./words.js";
6
7
  /** Phones and browsers: ChatGPT by device code, no listener. */
@@ -219,8 +220,42 @@ export class Accounts {
219
220
  * use is marked so. A refusal is checked: a sign-in that no longer refreshes is signed out for real, one that still
220
221
  * does was a passing refusal and rests a few minutes (kind `overloaded`) rather than loop. Returns the kind acted on,
221
222
  * or null for an error that is not about the account; `network` changes nothing. */
223
+ /** Ask ChatGPT with this member's own sign-in, the answer streaming into `onText`; refreshed first when due. A
224
+ * failure about the account (a limit, a lapsed sign-in) is acted on as `failed()` does, then thrown as a
225
+ * ResponseError with the words to show. */
226
+ async respond(member, ask) {
227
+ const key = 'chatgpt';
228
+ const p = this.offer(key);
229
+ const rt = await this.runtime(member);
230
+ let access;
231
+ try {
232
+ access = (await rt.getAuth(p.pi))?.auth?.apiKey;
233
+ }
234
+ catch (e) {
235
+ if ([400, 401, 403].includes(e?.status)) {
236
+ await rt.credentialStore.delete(p.pi);
237
+ this.forget(member, key);
238
+ throw new ResponseError(say('status.needsAgain', { name: p.name }), 'signed_out');
239
+ }
240
+ throw new ResponseError('ChatGPT could not refresh its sign-in. Try again when the network is back.', 'network');
241
+ }
242
+ const c = await rt.readCredential(p.pi).catch(() => undefined);
243
+ if (!access || c?.type !== 'oauth')
244
+ throw new ResponseError(say('status.signedOut', { name: p.name }), 'signed_out');
245
+ try {
246
+ return await respond({ ...ask, access, accountId: String(c.accountId ?? ''), model: ask.model ?? p.models.strong, base: this.opts.apiBase, fetch: this.opts.fetch });
247
+ }
248
+ catch (e) {
249
+ if (e instanceof ResponseError && e.kind && e.kind !== 'network') {
250
+ const acted = await this.failed(member, key, e);
251
+ if (acted && acted.kind !== e.kind)
252
+ throw new ResponseError(e.message, acted.kind, acted.until);
253
+ }
254
+ throw e;
255
+ }
256
+ }
222
257
  async failed(member, key, error) {
223
- const c = classify(error);
258
+ const c = error instanceof ResponseError ? error.kind && { kind: error.kind, until: error.until } : classify(error);
224
259
  if (!c || c.kind === 'network')
225
260
  return c;
226
261
  if (c.kind === 'signed_out' && await this.recheck(member, key))
@@ -420,10 +455,14 @@ export class Accounts {
420
455
  return (await this.runtime(member)).getAuth(pi, { minOAuthValidityMs }).then(Boolean, async (e) => {
421
456
  if (offline(e))
422
457
  return true;
423
- if (e?.message !== `OAuth refresh returned a token that expires too soon for ${pi}`)
424
- return false;
425
- const c = await this.store(member).read(pi);
426
- return c?.type === 'oauth' && c.expires > Date.now();
458
+ if (e?.message === `OAuth refresh returned a token that expires too soon for ${pi}`) {
459
+ const c = await this.store(member).read(pi);
460
+ return c?.type === 'oauth' && c.expires > Date.now();
461
+ }
462
+ // Only the provider refusing (400-403) counts as expiry. Anything else (a locked keychain read, storage failing)
463
+ // is unknown: try later, never sign the person out.
464
+ const status = e?.status;
465
+ return typeof status !== 'number' || status < 400 || status > 403;
427
466
  });
428
467
  }
429
468
  /** Refresh every signed-in account an hour ahead of expiry (call it now and then), so a sign-in never lapses while
@@ -1,4 +1,6 @@
1
1
  export type Terms = 'allowed' | 'grey' | 'partner' | 'forbidden';
2
+ /** How the person pays: their plan (`subscription`), or per-use charges to their account (`api`, never offered by default). */
3
+ export type Billing = 'subscription' | 'api';
2
4
  /** `callbackPort`: where the provider sends the browser back after its own sign-in page, fixed for the client Pi signs in as.
3
5
  * `revoke`: where signing out ends the sign-in on the provider's side too, for the client `clientId`. */
4
6
  export type Provider = {
@@ -13,6 +15,7 @@ export type Provider = {
13
15
  callbackPort?: number;
14
16
  clientId?: string;
15
17
  revoke?: string;
18
+ billing: Billing;
16
19
  terms: Terms;
17
20
  hidden: boolean;
18
21
  why: string;
@@ -20,5 +23,6 @@ export type Provider = {
20
23
  };
21
24
  export declare const PROVIDERS: Record<string, Provider>;
22
25
  export declare function provider(key: string): Provider;
23
- /** What an app offers: the keys it names, in its order, or every provider not hidden by default. */
26
+ /** What an app offers: the keys it names, in its order, or every subscription provider not hidden by default.
27
+ * API-billed rows are never in the default: an app offers them only by naming them. */
24
28
  export declare const offered: (keys?: readonly string[]) => Provider[];
package/dist/catalogue.js CHANGED
@@ -1,7 +1,8 @@
1
- // The AI accounts a person can bring, by the name they know, with each provider's terms status as data (catalogue.json,
2
- // readable from Kotlin too). The kit labels; the app decides what to offer. Claude plan sign-in is absent on purpose:
3
- // Anthropic reserves it for its own apps, so no app can wire it by mistake. Meta and Kimi are left out too (a
4
- // competitor by the owner's choice; Kimi refuses anything but coding agents).
1
+ // The AI accounts a person can bring, by the name they know, with each provider's billing and terms status as data
2
+ // (catalogue.json, readable from Kotlin too). The kit labels; the app decides what to offer. API-billed rows are never
3
+ // offered by default; an app names them explicitly. Claude plan sign-in is absent on purpose: Anthropic reserves it for
4
+ // its own apps, so no app can wire it by mistake. Meta and Kimi are left out too (a competitor by the owner's choice;
5
+ // Kimi refuses anything but coding agents).
5
6
  import CATALOGUE from './catalogue.json' with { type: 'json' };
6
7
  export const PROVIDERS = Object.fromEntries(Object.entries(CATALOGUE).map(([key, p]) => [key, { key, ...p }]));
7
8
  export function provider(key) {
@@ -10,5 +11,6 @@ export function provider(key) {
10
11
  throw Object.assign(new Error('no such AI account'), { status: 404 });
11
12
  return p;
12
13
  }
13
- /** What an app offers: the keys it names, in its order, or every provider not hidden by default. */
14
- export const offered = (keys) => keys ? keys.map(provider) : Object.values(PROVIDERS).filter((p) => !p.hidden);
14
+ /** What an app offers: the keys it names, in its order, or every subscription provider not hidden by default.
15
+ * API-billed rows are never in the default: an app offers them only by naming them. */
16
+ export const offered = (keys) => keys ? keys.map(provider) : Object.values(PROVIDERS).filter((p) => !p.hidden && p.billing === 'subscription');
@@ -10,6 +10,7 @@
10
10
  "callbackPort": 1455,
11
11
  "clientId": "app_EMoamEEZ73f0CkXaXp7hrann",
12
12
  "revoke": "https://auth.openai.com/oauth/revoke",
13
+ "billing": "subscription",
13
14
  "terms": "grey",
14
15
  "hidden": false,
15
16
  "why": "Signs in through Codex's own sign-in. OpenAI documents it for Codex, not for other apps, and has endorsed one other app using it.",
@@ -22,6 +23,7 @@
22
23
  "models": {
23
24
  "strong": "moonshotai/kimi-k2.6"
24
25
  },
26
+ "billing": "api",
25
27
  "terms": "allowed",
26
28
  "hidden": false,
27
29
  "why": "Documented sign-in for any app, no registration. Pay-as-you-go credits, not a subscription.",
@@ -34,6 +36,7 @@
34
36
  "models": {
35
37
  "strong": "grok-4.7"
36
38
  },
39
+ "billing": "subscription",
37
40
  "terms": "partner",
38
41
  "hidden": true,
39
42
  "why": "xAI allows plan sign-in only in apps it has partnered with.",
@@ -46,6 +49,7 @@
46
49
  "models": {
47
50
  "strong": "gpt-5.4"
48
51
  },
52
+ "billing": "subscription",
49
53
  "terms": "partner",
50
54
  "hidden": true,
51
55
  "why": "GitHub allows Copilot sign-in only in apps it has partnered with; this uses VS Code's client.",
package/dist/engine.js CHANGED
@@ -82,7 +82,7 @@ export function portableEngine(credentials, { base = 'https://auth.openai.com' }
82
82
  };
83
83
  const tokens = async (what, r) => {
84
84
  if (r.status < 200 || r.status > 299)
85
- throw new Error(`OpenAI Codex token ${what} failed (${r.status})`);
85
+ throw Object.assign(new Error(`OpenAI Codex token ${what} failed (${r.status})`), { status: r.status });
86
86
  return credentialOf(json(r.body));
87
87
  };
88
88
  const refresh = async (c) => {
@@ -92,7 +92,7 @@ export function portableEngine(credentials, { base = 'https://auth.openai.com' }
92
92
  return await tokens('refresh', await post('/oauth/token', { grant_type: 'refresh_token', refresh_token: c.refresh, client_id: CLIENT_ID }, true, stop.signal));
93
93
  }
94
94
  catch (e) {
95
- throw new Error(`OAuth refresh failed for openai-codex: ${e?.message ?? e}`);
95
+ throw Object.assign(new Error(`OAuth refresh failed for openai-codex: ${e?.message ?? e}`), { status: e?.status });
96
96
  }
97
97
  finally {
98
98
  clearTimeout(t);
package/dist/limits.js CHANGED
@@ -4,9 +4,7 @@ export const REST_MS = { rate_limit: 60 * 60_000, overloaded: 5 * 60_000, signed
4
4
  export function classify(error) {
5
5
  const m = /try again in ~?(\d+)\s*(min|h)/i.exec(error);
6
6
  const until = m ? Date.now() + Number(m[1]) * (m[2].toLowerCase() === 'h' ? 3_600_000 : 60_000) : 0;
7
- // ChatGPT words "your plan doesn't include this" (usage_not_included) like a limit, but with no time to come back.
8
- // ponytail: told apart by the missing "try again"; a real limit always says when it resets.
9
- if (/usage limit/i.test(error) && !m)
7
+ if (/your plan doesn't include/i.test(error))
10
8
  return { kind: 'not_included', until };
11
9
  if (/usage limit|rate.?limit|quota|too many requests|\b429\b/i.test(error))
12
10
  return { kind: 'rate_limit', until };
@@ -1,6 +1,7 @@
1
1
  export { Accounts, planOf, portable, type AccountsOptions, type AuthHost, type Loopback, type Member, type Platform, type SignIn, type Status } from './accounts.ts';
2
- export { PROVIDERS, offered, provider, type Provider, type Terms } from './catalogue.ts';
2
+ export { PROVIDERS, offered, provider, type Billing, type Provider, type Terms } from './catalogue.ts';
3
3
  export { PORTABLE, claims, credentialOf, devicePoll, deviceStart, portableEngine, type EngineOptions, type Poll } from './engine.ts';
4
4
  export { REST_MS, classify, type Kind } from './limits.ts';
5
+ export { ResponseError, limitResponse, respond, sseReader, type Ask } from './responses.ts';
5
6
  export { browserStore, memoryStore, recordStore, secureStore, type SecureStoreLike } from './stores.ts';
6
- export { WORDS, callbackPage, clock, failure, say, signInError, type WordKey, type Why } from './words.ts';
7
+ export { WORDS, billingWords, callbackPage, clock, failure, say, signInError, type WordKey, type Why } from './words.ts';
package/dist/portable.js CHANGED
@@ -4,5 +4,6 @@ export { Accounts, planOf, portable } from "./accounts.js";
4
4
  export { PROVIDERS, offered, provider } from "./catalogue.js";
5
5
  export { PORTABLE, claims, credentialOf, devicePoll, deviceStart, portableEngine } from "./engine.js";
6
6
  export { REST_MS, classify } from "./limits.js";
7
+ export { ResponseError, limitResponse, respond, sseReader } from "./responses.js";
7
8
  export { browserStore, memoryStore, recordStore, secureStore } from "./stores.js";
8
- export { WORDS, callbackPage, clock, failure, say, signInError } from "./words.js";
9
+ export { WORDS, billingWords, callbackPage, clock, failure, say, signInError } from "./words.js";
@@ -0,0 +1,39 @@
1
+ import { type Kind } from './limits.ts';
2
+ /** A failed answer: the words to show, and the kind an app acts on (null for one that isn't about the account). */
3
+ export declare class ResponseError extends Error {
4
+ kind: Kind | null;
5
+ /** When the provider said to come back (epoch ms), or 0. */
6
+ until: number;
7
+ constructor(message: string, kind: Kind | null, until?: number);
8
+ }
9
+ /** A ChatGPT HTTP error as the kind, when to come back, and the message (fixtures/conformance/limit-responses.json). */
10
+ export declare function limitResponse(status: number, body: string, now?: number): {
11
+ kind: Kind | null;
12
+ until: number | null;
13
+ message: string;
14
+ };
15
+ /** Reads a streamed answer (fixtures/conformance/sse.json): `push` each piece as it arrives, `end` for the whole text.
16
+ * An error event throws a ResponseError. */
17
+ export declare function sseReader(onText?: (delta: string) => void): {
18
+ push(chunk: string): void;
19
+ end(): string;
20
+ };
21
+ export type Ask = {
22
+ /** What the model is told to be. */
23
+ instructions: string;
24
+ /** The person's words. */
25
+ input: string;
26
+ /** Default: the provider's strong model in the catalogue. */
27
+ model?: string;
28
+ /** Each piece of the answer as it streams. */
29
+ onText?: (delta: string) => void;
30
+ signal?: AbortSignal;
31
+ };
32
+ /** Ask ChatGPT with a signed-in token. `fetch`: pass one that streams (Expo's `expo/fetch`); any fetch works. */
33
+ export declare function respond(o: Ask & {
34
+ access: string;
35
+ accountId: string;
36
+ model: string;
37
+ base?: string;
38
+ fetch?: typeof fetch;
39
+ }): Promise<string>;
@@ -0,0 +1,114 @@
1
+ // One question to ChatGPT, answered as it streams, with fetch alone: what a phone or browser app asks the model with
2
+ // the sign-in it holds. Rules are the shared fixtures (sse.json, limit-responses.json). A fetch that can't stream (React
3
+ // Native's own) still works: the whole answer arrives at once. Expo's `fetch` from 'expo/fetch' streams.
4
+ import { classify } from "./limits.js";
5
+ const limitKind = (code) => code === 'usage_not_included' ? 'not_included'
6
+ : /^(usage_limit_reached|rate_limit_exceeded)$/.test(code) ? 'rate_limit' : null;
7
+ /** A failed answer: the words to show, and the kind an app acts on (null for one that isn't about the account). */
8
+ export class ResponseError extends Error {
9
+ kind;
10
+ /** When the provider said to come back (epoch ms), or 0. */
11
+ until;
12
+ constructor(message, kind, until = 0) { super(message); this.kind = kind; this.until = until; }
13
+ }
14
+ /** A ChatGPT HTTP error as the kind, when to come back, and the message (fixtures/conformance/limit-responses.json). */
15
+ export function limitResponse(status, body, now = Date.now()) {
16
+ let err = {};
17
+ try {
18
+ err = JSON.parse(body)?.error ?? {};
19
+ }
20
+ catch { }
21
+ const code = String(err.code || err.type || '');
22
+ if (limitKind(code) || status === 429) {
23
+ const resets = typeof err.resets_at === 'number' ? err.resets_at * 1000 : null;
24
+ const message = 'You have hit your ChatGPT usage limit' + (err.plan_type ? ` (${String(err.plan_type).toLowerCase()} plan)` : '') + '.' +
25
+ (resets ? ` Try again in ~${Math.max(0, Math.round((resets - now) / 60_000))} min.` : '');
26
+ return { kind: limitKind(code) ?? 'rate_limit', until: resets, message };
27
+ }
28
+ const kind = status === 401 || status === 403 ? 'signed_out' : [500, 502, 503, 504].includes(status) ? 'overloaded' : null;
29
+ return { kind, until: null, message: (typeof err.message === 'string' && err.message) || body || 'Request failed' };
30
+ }
31
+ /** Reads a streamed answer (fixtures/conformance/sse.json): `push` each piece as it arrives, `end` for the whole text.
32
+ * An error event throws a ResponseError. */
33
+ export function sseReader(onText) {
34
+ let buffer = '', text = '', completed;
35
+ let done = false;
36
+ const event = (block) => {
37
+ const data = block.split('\n').filter((l) => l.startsWith('data:')).map((l) => l.slice(5).replace(/^ /, '')).join('\n');
38
+ if (!data || data === '[DONE]')
39
+ return;
40
+ let e;
41
+ try {
42
+ e = JSON.parse(data);
43
+ }
44
+ catch {
45
+ return;
46
+ }
47
+ if (e.type === 'response.output_text.delta' && typeof e.delta === 'string') {
48
+ text += e.delta;
49
+ onText?.(e.delta);
50
+ }
51
+ if (e.type === 'response.completed') {
52
+ done = true;
53
+ if (Array.isArray(e.response?.output))
54
+ completed = e.response.output.flatMap((o) => o?.content ?? []).filter((c) => c?.type === 'output_text').map((c) => c.text ?? '').join('');
55
+ }
56
+ const failed = e.type === 'error' ? e : e.type === 'response.failed' ? e.response?.error : undefined;
57
+ if (failed) {
58
+ const message = String(failed.message ?? 'Request failed');
59
+ const c = classify(message);
60
+ throw new ResponseError(message, limitKind(String(failed.code ?? failed.type ?? '')) ?? c?.kind ?? null, c?.until ?? 0);
61
+ }
62
+ };
63
+ const drain = (final) => {
64
+ const blocks = buffer.replace(/\r\n/g, '\n').split('\n\n');
65
+ buffer = final ? '' : blocks.pop();
66
+ for (const b of blocks)
67
+ event(b);
68
+ };
69
+ return {
70
+ push(chunk) { buffer += chunk; drain(false); },
71
+ end() {
72
+ drain(true);
73
+ if (!done)
74
+ throw new ResponseError('ChatGPT stopped before completing its answer.', 'network');
75
+ if (completed !== undefined && completed !== text) {
76
+ if (completed.startsWith(text))
77
+ onText?.(completed.slice(text.length));
78
+ return completed;
79
+ }
80
+ return text;
81
+ },
82
+ };
83
+ }
84
+ /** Ask ChatGPT with a signed-in token. `fetch`: pass one that streams (Expo's `expo/fetch`); any fetch works. */
85
+ export async function respond(o) {
86
+ const res = await (o.fetch ?? fetch)(`${o.base ?? 'https://chatgpt.com/backend-api'}/codex/responses`, {
87
+ method: 'POST', signal: o.signal,
88
+ headers: {
89
+ 'content-type': 'application/json', accept: 'text/event-stream', authorization: `Bearer ${o.access}`,
90
+ 'chatgpt-account-id': o.accountId, 'OpenAI-Beta': 'responses=experimental', originator: 'byokit',
91
+ },
92
+ body: JSON.stringify({
93
+ model: o.model, store: false, stream: true, instructions: o.instructions,
94
+ input: [{ role: 'user', content: [{ type: 'input_text', text: o.input }] }],
95
+ text: { verbosity: 'low' }, reasoning: { effort: 'none' },
96
+ }),
97
+ });
98
+ if (!res.ok) {
99
+ const e = limitResponse(res.status, await res.text().catch(() => ''));
100
+ throw new ResponseError(e.message, e.kind, e.until ?? 0);
101
+ }
102
+ const reader = sseReader(o.onText);
103
+ const body = res.body;
104
+ if (body?.getReader && typeof TextDecoder !== 'undefined') {
105
+ const r = body.getReader();
106
+ const decoder = new TextDecoder();
107
+ for (let c = await r.read(); !c.done; c = await r.read())
108
+ reader.push(typeof c.value === 'string' ? c.value : decoder.decode(c.value, { stream: true }));
109
+ }
110
+ else {
111
+ reader.push(await res.text()); // a fetch that can't stream: the whole answer at once
112
+ }
113
+ return reader.end();
114
+ }
package/dist/stores.d.ts CHANGED
@@ -9,16 +9,19 @@ export type EndingStore = CredentialStore & {
9
9
  * re-reads first, so a sign-in that took minutes never overwrites a provider that changed meanwhile. */
10
10
  export declare function recordStore(load: () => Promise<Record>, save: (data: Record) => Promise<void>): EndingStore;
11
11
  export declare function memoryStore(): CredentialStore;
12
- /** The parts of `expo-secure-store` this uses (Keychain on iOS, Keystore-encrypted on Android); pass the module itself. */
12
+ /** The parts of `expo-secure-store` this uses (Keychain on iOS, Keystore-encrypted on Android); pass the module itself.
13
+ * Every method takes the same optional `options` (e.g. `{ keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY }`). */
13
14
  export type SecureStoreLike = {
14
- getItemAsync(key: string): Promise<string | null>;
15
- setItemAsync(key: string, value: string): Promise<void>;
16
- deleteItemAsync(key: string): Promise<void>;
15
+ getItemAsync(key: string, options?: object): Promise<string | null>;
16
+ setItemAsync(key: string, value: string, options?: object): Promise<void>;
17
+ deleteItemAsync(key: string, options?: object): Promise<void>;
17
18
  };
18
19
  /** One person's sign-ins in the phone's secure storage: `secureStore(SecureStore, 'byokit.1')`. Keys may hold letters,
19
20
  * digits, `.`, `-` and `_`. The record is split into pieces under the 2048 bytes expo-secure-store warns about, written
20
- * as a new generation, then `name` is pointed at it: a crash mid-write leaves the old sign-ins whole. */
21
- export declare function secureStore(secure: SecureStoreLike, name: string): CredentialStore;
21
+ * as a new generation, then `name` is pointed at it: a crash mid-write leaves the old sign-ins whole. `options` (e.g.
22
+ * `{ keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY }`) is passed to every get, set and delete;
23
+ * without it Expo's default (`WHEN_UNLOCKED`) applies. */
24
+ export declare function secureStore(secure: SecureStoreLike, name: string, options?: object): CredentialStore;
22
25
  /** One person's sign-ins in the browser's IndexedDB (a PWA, or Electron's renderer), under `name`. A browser has no
23
26
  * keychain: anything running on this page could read them, so keep the page free of scripts you don't control. */
24
27
  export declare function browserStore(name: string, db?: string): EndingStore;
package/dist/stores.js CHANGED
@@ -40,14 +40,16 @@ export function memoryStore() {
40
40
  }
41
41
  /** One person's sign-ins in the phone's secure storage: `secureStore(SecureStore, 'byokit.1')`. Keys may hold letters,
42
42
  * digits, `.`, `-` and `_`. The record is split into pieces under the 2048 bytes expo-secure-store warns about, written
43
- * as a new generation, then `name` is pointed at it: a crash mid-write leaves the old sign-ins whole. */
44
- export function secureStore(secure, name) {
45
- const head = async () => { const [gen = '0', n = '0'] = (await secure.getItemAsync(name))?.split(':') ?? []; return { gen: Number(gen), n: Number(n) }; };
43
+ * as a new generation, then `name` is pointed at it: a crash mid-write leaves the old sign-ins whole. `options` (e.g.
44
+ * `{ keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY }`) is passed to every get, set and delete;
45
+ * without it Expo's default (`WHEN_UNLOCKED`) applies. */
46
+ export function secureStore(secure, name, options) {
47
+ const head = async () => { const [gen = '0', n = '0'] = (await secure.getItemAsync(name, options))?.split(':') ?? []; return { gen: Number(gen), n: Number(n) }; };
46
48
  const load = async () => {
47
49
  const { gen, n } = await head();
48
50
  let text = '';
49
51
  for (let i = 0; i < n; i++)
50
- text += (await secure.getItemAsync(`${name}.${gen}.${i}`)) ?? '';
52
+ text += (await secure.getItemAsync(`${name}.${gen}.${i}`, options)) ?? '';
51
53
  return text ? JSON.parse(text) : {};
52
54
  };
53
55
  const save = async (data) => {
@@ -55,13 +57,13 @@ export function secureStore(secure, name) {
55
57
  const gen = old.gen + 1;
56
58
  const parts = (Object.keys(data).length ? JSON.stringify(data) : '').match(/[\s\S]{1,1800}/g) ?? [];
57
59
  for (const [i, part] of parts.entries())
58
- await secure.setItemAsync(`${name}.${gen}.${i}`, part);
59
- await secure.setItemAsync(name, `${gen}:${parts.length}`);
60
+ await secure.setItemAsync(`${name}.${gen}.${i}`, part, options);
61
+ await secure.setItemAsync(name, `${gen}:${parts.length}`, options);
60
62
  for (let i = 0; i < old.n; i++)
61
- await secure.deleteItemAsync(`${name}.${old.gen}.${i}`);
63
+ await secure.deleteItemAsync(`${name}.${old.gen}.${i}`, options);
62
64
  // Leftovers of a write that crashed at this generation before.
63
- for (let i = parts.length; (await secure.getItemAsync(`${name}.${gen}.${i}`)) !== null; i++)
64
- await secure.deleteItemAsync(`${name}.${gen}.${i}`);
65
+ for (let i = parts.length; (await secure.getItemAsync(`${name}.${gen}.${i}`, options)) !== null; i++)
66
+ await secure.deleteItemAsync(`${name}.${gen}.${i}`, options);
65
67
  };
66
68
  return recordStore(load, save);
67
69
  }
@@ -23,6 +23,11 @@ export declare function mockOpenAI({ port, host, plan, email, expiresIn, log }?:
23
23
  expiresIn: number;
24
24
  /** Drop this many device-code polls on the floor, as a phone does to a backgrounded app. */
25
25
  dropPolls: number;
26
+ /** Answer the next question with this HTTP error instead (a limit, a lapsed sign-in), then answer normally. */
27
+ fail: {
28
+ status: number;
29
+ body: string;
30
+ } | undefined;
26
31
  };
27
32
  approve: (userCode: string, deny?: boolean) => boolean;
28
33
  /** The code most recently handed out. */
@@ -1,5 +1,6 @@
1
1
  // OpenAI's sign-in, stood in for: device code, its page where a person types the code, token exchange and refresh
2
- // (rotating), revoke, and the same CORS answer the real endpoints give, so a web page, a phone app or a test signs in
2
+ // (rotating), revoke, ChatGPT's streamed answers (`/codex/responses`, echoing the question), and the same CORS answer the
3
+ // real sign-in endpoints give (the answers endpoint, like the real one, answers no web page), so a web page, a phone app or a test signs in
3
4
  // end to end with no account and no real network. Run it alone for a demo or an emulator:
4
5
  // node packages/accounts/src/testing/mock-openai.ts [port] (21455 by default, never ChatGPT's own 1455)
5
6
  import { createServer } from 'node:http';
@@ -23,11 +24,15 @@ export async function mockOpenAI({ port = 0, host = '127.0.0.1', plan = 'plus',
23
24
  expiresIn,
24
25
  /** Drop this many device-code polls on the floor, as a phone does to a backgrounded app. */
25
26
  dropPolls: 0,
27
+ /** Answer the next question with this HTTP error instead (a limit, a lapsed sign-in), then answer normally. */
28
+ fail: undefined,
26
29
  };
30
+ const accessOf = new Map(); // refresh token → the access token issued with it
27
31
  const issue = () => {
28
32
  const refresh = `rt_${++issued}`;
29
33
  state.live.add(refresh);
30
- return { access_token: mockJwt(plan, email, issued), refresh_token: refresh, expires_in: state.expiresIn, id_token: 'x' };
34
+ accessOf.set(refresh, mockJwt(plan, email, issued));
35
+ return { access_token: accessOf.get(refresh), refresh_token: refresh, expires_in: state.expiresIn, id_token: 'x' };
31
36
  };
32
37
  const approve = (userCode, deny = false) => {
33
38
  const c = codes.get(userCode.trim().toUpperCase());
@@ -86,6 +91,23 @@ export async function mockOpenAI({ port = 0, host = '127.0.0.1', plan = 'plus',
86
91
  if (state.refuse || !state.live.delete(form.get('refresh_token') ?? ''))
87
92
  return send(401, { error: { code: 'refresh_token_reused', message: 'invalid_grant' } });
88
93
  return send(200, issue());
94
+ case '/codex/responses': {
95
+ const bearer = req.headers.authorization?.replace(/^Bearer /, '');
96
+ if (state.fail) {
97
+ const f = state.fail;
98
+ state.fail = undefined;
99
+ return send(f.status, f.body);
100
+ }
101
+ if (![...state.live].some((r) => accessOf.get(r) === bearer) || req.headers['chatgpt-account-id'] !== 'acct-1')
102
+ return send(401, { error: { message: 'Provided authentication token is expired. Please try signing in again.' } });
103
+ const text = `You said: ${json().input?.[0]?.content?.[0]?.text ?? ''}`;
104
+ res.writeHead(200, { 'content-type': 'text/event-stream' });
105
+ for (const delta of text.match(/[\s\S]{1,4}/g) ?? []) {
106
+ res.write(`event: response.output_text.delta\ndata: ${JSON.stringify({ type: 'response.output_text.delta', delta })}\n\n`);
107
+ await new Promise((r) => setTimeout(r, 5));
108
+ }
109
+ return res.end('event: response.completed\ndata: {"type":"response.completed","response":{"status":"completed"}}\n\ndata: [DONE]\n\n');
110
+ }
89
111
  case '/oauth/revoke':
90
112
  state.live.delete(json().token);
91
113
  return send(200, {});
package/dist/words.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import WORDS from './words.json';
2
+ import type { Provider } from './catalogue.ts';
2
3
  export type WordKey = keyof typeof WORDS;
3
4
  export { WORDS };
4
5
  /** A sentence with its `{slots}` filled. */
@@ -8,6 +9,8 @@ export declare const clock: (t: number) => string;
8
9
  /** Why a sign-in failed, from the engine's own error. */
9
10
  export type Why = 'expired' | 'declined' | 'offline' | 'deviceCodeOff' | 'busy' | 'tooLong' | 'failed';
10
11
  export declare function failure(error: string): Why;
12
+ /** What the person pays, in one plain sentence to show next to the provider. */
13
+ export declare const billingWords: (p: Provider) => string;
11
14
  /** A failed sign-in in one plain sentence with one next step. */
12
15
  export declare const signInError: (name: string, error: string) => string;
13
16
  /** The app's own page for the browser tab a provider sends back: it says how it really went, never "success" before it is. */
package/dist/words.js CHANGED
@@ -20,6 +20,8 @@ export function failure(error) {
20
20
  return 'deviceCodeOff';
21
21
  return 'failed';
22
22
  }
23
+ /** What the person pays, in one plain sentence to show next to the provider. */
24
+ export const billingWords = (p) => say(`billing.${p.billing}`, { name: p.name, company: p.company });
23
25
  /** A failed sign-in in one plain sentence with one next step. */
24
26
  export const signInError = (name, error) => say(`signIn.${failure(error)}`, { name });
25
27
  /** The app's own page for the browser tab a provider sends back: it says how it really went, never "success" before it is. */
package/dist/words.json CHANGED
@@ -18,6 +18,8 @@
18
18
  "status.signedOut": "{name} isn't signed in yet.",
19
19
  "status.needsAgain": "{name} needs you to sign in again.",
20
20
  "terms.grey": "Uses your {name} plan. {company} may change this at any time.",
21
+ "billing.subscription": "Uses your {name} plan.",
22
+ "billing.api": "Charged per use to your {company} account, not a plan.",
21
23
  "status.notIncluded": "Your {name} plan doesn't include this yet.",
22
24
  "callback.done": "You're signed in. You can go back to {app} now.",
23
25
  "callback.declined": "No problem. Nothing was changed. You can go back to {app}.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/accounts",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Sign in with the AI plan you already pay for, into your app's own store.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -35,7 +35,8 @@
35
35
  }
36
36
  },
37
37
  "files": [
38
- "dist"
38
+ "dist",
39
+ "CHANGELOG.md"
39
40
  ],
40
41
  "scripts": {
41
42
  "prepack": "tsc -b"