@byokit/accounts 0.13.0 → 0.15.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 +18 -0
- package/README.md +92 -24
- package/SECURITY.md +1 -1
- package/dist/accounts.d.ts +11 -2
- package/dist/accounts.js +8 -2
- package/dist/catalogue.d.ts +2 -6
- package/dist/catalogue.js +2 -6
- package/dist/catalogue.json +62 -18
- package/dist/chatgpt-plan.d.ts +8 -0
- package/dist/chatgpt-plan.js +1 -1
- package/dist/cli.d.ts +65 -0
- package/dist/cli.js +337 -0
- package/dist/portable.d.ts +3 -3
- package/dist/responses.d.ts +15 -1
- package/dist/responses.js +5 -2
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/mock-openai.d.ts +12 -1
- package/dist/testing/mock-openai.js +16 -4
- package/dist/words.json +6 -1
- package/package.json +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.15.0 (2026-10-01)
|
|
6
|
+
|
|
7
|
+
- Dependency update: pins @byokit/usage 0.5.0.
|
|
8
|
+
|
|
9
|
+
- FIX: ChatGPT subscription respond() now retains reported token usage with result: true or tools, including partial results for incomplete answers.
|
|
10
|
+
- Bind an existing ChatGPT subscription login to a member-bound chatgpt() handle without exposing credentials.
|
|
11
|
+
- Script mockOpenAI answers with string, regex or function prompt matchers and optional token usage.
|
|
12
|
+
|
|
13
|
+
## 0.14.0 (2026-10-01)
|
|
14
|
+
|
|
15
|
+
- Dependency update: pins @byokit/usage 0.4.0.
|
|
16
|
+
|
|
17
|
+
- SECURITY: Add a Node-only managed CLI account boundary for subscription sign-in: only app-owned folders and explicitly passed absolute binaries, no default login access, no credential-file reads, no token output, and launch environment credential shedding.
|
|
18
|
+
- Add managed CLI account creation, marker-gated sign-in, status, rename, cancellation, history links and removal, with legacy roster and terms compatibility.
|
|
19
|
+
- Share the portable chooser's AccountLike type and accept normalized subscription usage with millisecond reset times.
|
|
20
|
+
- Bound native status deadlines even when a passed CLI ignores termination; stdout is capped and only the owned child is terminated.
|
|
21
|
+
- Offer every subscription catalogue row by default on supported platforms, adding Kimi, Meta, Qwen and MiniMax labels; preserve Claude Pro/Max sign-in and remove terms and visibility gates. API key (billed per use) rows remain opt-in. Simplify the ChatGPT plan-use error words.
|
|
22
|
+
|
|
5
23
|
## 0.13.0 (2026-10-01)
|
|
6
24
|
|
|
7
25
|
|
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 and Claude Pro/Max on every platform (Claude needs Web Crypto);
|
|
12
|
-
|
|
11
|
+
ChatGPT and Claude Pro/Max on every platform (Claude needs Web Crypto); Grok, GitHub Copilot, Kimi and Meta on computers.
|
|
12
|
+
OpenRouter and Anthropic API keys are billed per use and require explicit app opt-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
|
|
|
@@ -75,16 +75,18 @@ On a computer it uses Pi's [`@earendil-works/pi-ai`](https://www.npmjs.com/packa
|
|
|
75
75
|
flows, pinned exactly:
|
|
76
76
|
|
|
77
77
|
```ts
|
|
78
|
+
import type { SafeStorageLike } from '@byokit/accounts';
|
|
78
79
|
import { isolate } from '@byokit/accounts/isolate'; // first, before any Pi import
|
|
79
80
|
isolate('/path/to/app/engine'); // scrub inherited Pi settings and provider keys
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const accounts = new Accounts({ store: (member) => fileStore(`/path/to/app/people/${member}/auth.json`, safeStorage) });
|
|
85
|
-
const shown = await accounts.login(1, 'chatgpt', { via: 'code' }); // { state: 'waiting', code, url }
|
|
86
|
-
// show shown.code and shown.url; the sign-in finishes by itself
|
|
87
|
-
|
|
81
|
+
|
|
82
|
+
// Your Electron main process waits for app.whenReady(), then passes its safeStorage here.
|
|
83
|
+
export async function connect(safeStorage: SafeStorageLike) {
|
|
84
|
+
const { Accounts, fileStore } = await import('@byokit/accounts');
|
|
85
|
+
const accounts = new Accounts({ store: (member) => fileStore(`/path/to/app/people/${member}/auth.json`, safeStorage) });
|
|
86
|
+
const shown = await accounts.login(1, 'chatgpt', { via: 'code' }); // { state: 'waiting', code, url }
|
|
87
|
+
// show shown.code and shown.url; the sign-in finishes by itself
|
|
88
|
+
return { shown, status: await accounts.status(1, 'chatgpt') };
|
|
89
|
+
}
|
|
88
90
|
```
|
|
89
91
|
|
|
90
92
|
### On a phone or in a browser
|
|
@@ -116,10 +118,10 @@ and [`examples/pwa`](../../examples/pwa) (browser sign-in).
|
|
|
116
118
|
|
|
117
119
|
| Export | What it does |
|
|
118
120
|
|---|---|
|
|
119
|
-
| `Accounts` | Sign-in, status, sign-out, asking and limits for each member: `login`, `finished`, `status`, `plan`, `logout`, `respond`, `failed`, `ladder`, `keepFresh` |
|
|
121
|
+
| `Accounts` | Sign-in, status, sign-out, asking and limits for each member: `login`, `finished`, `status`, `plan`, `logout`, `respond`, `chatgpt`, `failed`, `ladder`, `keepFresh` |
|
|
120
122
|
| `portable`, `computer`, `loopback` | The platform `Accounts` runs on: device code with `fetch` alone, or (Node entry only) Pi's flows and the loopback listener |
|
|
121
123
|
| `memoryStore`, `fileStore`, `secureStore`, `browserStore`, `recordStore` | One store per person: in memory, a sealed 0600 file (Node entry only), Keychain/Keystore, IndexedDB, or your own load and save |
|
|
122
|
-
| `offered`, `provider`, `PROVIDERS` | The catalogue: each provider's billing,
|
|
124
|
+
| `offered`, `provider`, `PROVIDERS` | The catalogue: each provider's billing, models and source |
|
|
123
125
|
| `billingWords`, `say`, `WORDS`, `signInError`, `failure`, `clock`, `callbackPage` | The plain sentences every app shows the same way (`words.json`), a time in words, and the page a browser sees after a sign-in |
|
|
124
126
|
| `respond`, `ResponseError`, `IncompleteError`, `sseReader`, `limitResponse`, `isFunctionCall` | Ask ChatGPT's answers endpoint with a sign-in, with tools, pictures, thinking effort and an answer shape; the error with the words to show and the kind acted on |
|
|
125
127
|
| `classifyFailure`, `classify`, `REST_MS` | An error's kind (limit, overload, plan without this use, lapsed sign-in, network) and default rest times |
|
|
@@ -138,7 +140,7 @@ and [`examples/pwa`](../../examples/pwa) (browser sign-in).
|
|
|
138
140
|
| 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
141
|
| Anthropic (API key, billed per use) | App passes its own key, explicitly | Same fetch-only Messages provider | Same fetch-only Messages provider |
|
|
140
142
|
| 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 |
|
|
141
|
-
| Grok, Copilot
|
|
143
|
+
| Grok, Copilot, Kimi, Meta | Pi's flows | No | No |
|
|
142
144
|
| Where sign-ins are kept | `fileStore(path, safeStorage)`, sealing required | `browserStore(name)` (IndexedDB) | `secureStore(SecureStore, name)` (Keychain, Keystore) |
|
|
143
145
|
|
|
144
146
|
Device code works everywhere: OpenAI's sign-in endpoints answer any web page. The page-straight-back sign-in needs a
|
|
@@ -148,13 +150,16 @@ doesn't answer other web pages), so a PWA's model calls go through the app's own
|
|
|
148
150
|
|
|
149
151
|
## Catalogue and billing
|
|
150
152
|
|
|
151
|
-
`catalogue.json` holds each provider
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
153
|
+
`catalogue.json` holds each provider's billing (`subscription`, `api`), models and source.
|
|
154
|
+
All subscription rows are offered by default on platforms that support their sign-in. API-billed rows
|
|
155
|
+
are offered only when the app names them. An explicit `offer` list is not platform-filtered.
|
|
156
|
+
The `Provider` shape no longer has `terms`, `hidden` or `why`, and `Terms` is no longer exported.
|
|
157
|
+
Qwen and MiniMax have subscription catalogue rows; their paste and portal sign-in flows follow in later work packages.
|
|
158
|
+
Each provider's own terms apply to how you use your plan.
|
|
159
|
+
Anthropic Messages uses an app-passed API key (billed per use); authentication is separate from the Messages request.
|
|
156
160
|
|
|
157
|
-
|
|
161
|
+
Native Claude CLI sign-in uses the managed-folder entry.
|
|
162
|
+
Approved exception: the `./cli` entry reads and runs only app-managed per-account folders under `stateDir` and the absolute CLI binaries the app passes; it never touches the person's default login; tokens never leave the device and are never logged.
|
|
158
163
|
|
|
159
164
|
```ts
|
|
160
165
|
import { Accounts, billingWords, offered } from '@byokit/accounts';
|
|
@@ -164,7 +169,7 @@ for (const p of offered(['chatgpt', 'openrouter'])) console.log(`${p.name}: ${bi
|
|
|
164
169
|
```
|
|
165
170
|
|
|
166
171
|
```text
|
|
167
|
-
[ 'chatgpt', 'claude' ]
|
|
172
|
+
[ 'chatgpt', 'grok', 'copilot', 'claude', 'kimi', 'meta', 'qwen', 'minimax' ]
|
|
168
173
|
ChatGPT: Uses your ChatGPT plan.
|
|
169
174
|
OpenRouter: Charged per use to your OpenRouter account, not a plan.
|
|
170
175
|
```
|
|
@@ -251,12 +256,15 @@ const accounts = new Accounts({
|
|
|
251
256
|
|
|
252
257
|
`respond(member, { instructions, input, model?, onText?, signal? })` asks ChatGPT's own answers endpoint with the
|
|
253
258
|
member's sign-in, refreshed first when due, and returns the whole text (`onText` gets each piece as it streams; the
|
|
254
|
-
returned completion is authoritative).
|
|
259
|
+
returned completion is authoritative). Pass `result: true` to receive `{ text, output, usage? }` even without tools.
|
|
260
|
+
`usage` retains provider-reported `input_tokens`, `output_tokens` and native details in the same shape as the
|
|
261
|
+
Messages route; absent usage stays absent. Subscription token counts do not imply an API-key charge.
|
|
262
|
+
A limit or a lapsed sign-in is acted on as `failed()` does, then thrown as a
|
|
255
263
|
`ResponseError` with the words to show and the kind acted on. Rules: [conformance fixtures](../../fixtures/README.md).
|
|
256
264
|
|
|
257
265
|
A cut-off answer always throws `IncompleteError` (a `ResponseError` with `kind: null`), with or without tools.
|
|
258
266
|
Its `reason` preserves the provider's `incomplete_details.reason`, including `max_output_tokens` and
|
|
259
|
-
`content_filter` (`unknown` when absent). Its `result` holds the partial `{ text, output }` for apps that want to
|
|
267
|
+
`content_filter` (`unknown` when absent). Its `result` holds the partial `{ text, output, usage? }` for apps that want to
|
|
260
268
|
show it as unfinished. `onEvent` also receives `{ type: 'incomplete', reason }` before rejection; `onText` may
|
|
261
269
|
already have shown partial words. This covers `response.incomplete` events and `status: 'incomplete'` envelopes,
|
|
262
270
|
whether fetch streams SSE, buffers it, or returns JSON. The account stays signed in and is not put to rest.
|
|
@@ -327,6 +335,14 @@ tests and demos sign in and ask end to end with no account. Point the kit at it
|
|
|
327
335
|
|
|
328
336
|
Apache-2.0. See [LICENSE](LICENSE) and [NOTICE](../../NOTICE).
|
|
329
337
|
|
|
338
|
+
## ChatGPT subscription handles
|
|
339
|
+
|
|
340
|
+
After `Accounts.login(member, 'chatgpt')` finishes, `accounts.chatgpt(member)` returns a member-bound
|
|
341
|
+
subscription handle for `openai({ auth: 'account', account: handle, model })` in `@byokit/decide`.
|
|
342
|
+
It routes requests through `Accounts.respond`, which keeps tokens in the app's store and refreshes them
|
|
343
|
+
before use. The handle contains no credentials, follows sign-out, and needs no additional sign-in or
|
|
344
|
+
API key (billed per use). ChatGPT remains offered by default. Use the handle only where the sign-in lives.
|
|
345
|
+
|
|
330
346
|
## Official ChatGPT plan token-sharing adapter
|
|
331
347
|
|
|
332
348
|
`chatgptPlan({ session })`, also exported from `@byokit/accounts/chatgpt-plan`, binds a single person's
|
|
@@ -438,8 +454,6 @@ fetch such as Expo's. `betas` explicitly opts into native beta headers.
|
|
|
438
454
|
|
|
439
455
|
## Claude Pro/Max subscription
|
|
440
456
|
|
|
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
457
|
Claude is available by default. Open its page, then paste the returned `code#state` into the app:
|
|
444
458
|
|
|
445
459
|
```ts
|
|
@@ -551,3 +565,57 @@ Identity and re-authentication stay with the host's canonical device store or en
|
|
|
551
565
|
scoped by member/provider proves identity; names and emails do not. The TypeScript identity fixture records
|
|
552
566
|
wrong-account, duplicate identity, changed-email, absent-identity, removal/refresh and extension-field boundaries
|
|
553
567
|
for runtime integration; the chooser consumes host-validated state and never adopts credentials itself.
|
|
568
|
+
|
|
569
|
+
## Managed CLI accounts (Node only)
|
|
570
|
+
|
|
571
|
+
`@byokit/accounts/cli` exports `cliAccounts`, `CliAccountError`, `CliProvider`, `CliAccount`, `CliOptions` and `SignInCommand`. Accounts use subscription billing. The portable entries and the `Accounts` class retain their existing sign-in flows.
|
|
572
|
+
|
|
573
|
+
`CliAccount` extends the portable chooser's `AccountLike`. Pass normalized usage through `@byokit/usage`'s `roomOf(reading, nowMs)` when selecting an account, preserving millisecond reset times and the original measurement time.
|
|
574
|
+
|
|
575
|
+
```ts
|
|
576
|
+
import { cliAccounts } from '@byokit/accounts/cli';
|
|
577
|
+
const accounts = cliAccounts({
|
|
578
|
+
stateDir: '/app/state/plans',
|
|
579
|
+
bins: { claude: '/app/bin/claude', codex: '/app/bin/codex' },
|
|
580
|
+
env: { HOME: '/app/home', PATH: '/app/bin:/usr/bin:/bin' },
|
|
581
|
+
historyFrom: { claude: '/app/history/projects', codex: '/app/history/sessions' },
|
|
582
|
+
prepare: async (folder, provider) => { /* app-owned setup, such as installing hooks */ },
|
|
583
|
+
});
|
|
584
|
+
const { account, signIn } = await accounts.add('claude');
|
|
585
|
+
// Run signIn.shell in the app's sign-in tab, or run signIn.argv with signIn.env
|
|
586
|
+
// and create signIn.completion privately only after that command succeeds.
|
|
587
|
+
const current = await accounts.status(account.id);
|
|
588
|
+
const { set, unset } = accounts.launchEnv(account.id);
|
|
589
|
+
// Apply unset to the launch environment, then apply set, before starting the agent.
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
`stateDir` must be an absolute app-owned directory with an existing parent. The kit creates it at 0700 and creates private `<provider>/<hex>` account folders below it. `bins` are absolute paths; PATH is never used to find the CLI. Status and login use an environment built from nothing plus the app's `env`, after removing provider credential overrides and adding the managed-folder variable. Pass proxy or temporary-directory settings explicitly if needed. `launchEnv` returns the folder variable in `set` and the provider overrides in `unset`; hosts must apply both so a stray API key (billed per use) cannot override the chosen subscription.
|
|
593
|
+
|
|
594
|
+
`add` returns a `signing` account and the native login command. `status` stays `signing` until the completion marker exists, including after a host restart, and does not run a second native client during pending sign-in. The shell uses a private per-folder lock and marks completion only after successful login. Stop the app's sign-in tab before `cancel` or `remove`; the kit does not supervise that tab. `signInAgain` reuses a pending command or starts a new completion cycle; if an account operation is already in flight it throws `prepare-failed`, so await that operation before retrying. Native CLIs own their refresh transactions; the kit neither copies credentials nor refreshes grants. A crashed sign-in shell can leave its lock; the host should stop that process and remove only that managed lock before retrying.
|
|
595
|
+
|
|
596
|
+
`list` probes managed folders concurrently; `status`, `rename`, `remove` and `cancel` serialize operations per account. Only signed-in state (`ready` or `signed_out`), email and plan come from native status output; no credential file is opened and raw CLI errors and output are discarded. `rename` accepts a trimmed name of 1–64 characters. `cancel` removes only folders added by this instance; for existing accounts it ends the pending completion cycle without deleting the account. `remove` deletes only a validated managed folder. `historyFrom` creates a history symlink inside that folder; it never creates or writes the target, even when it is missing. A throwing `prepare` rolls back the new folder.
|
|
597
|
+
|
|
598
|
+
Native status reads resolve within 15 seconds. Claude stdout is capped at 256 KB and identity JSON at 64 KB; the shared Codex client caps stdout at 64 KB. Timeout or excess output terminates only that owned child, with SIGTERM followed by SIGKILL after one second if it is still alive.
|
|
599
|
+
|
|
600
|
+
The existing `accounts-v1.json` `{version:1,accounts:[{id,provider,name,folder,found}]}` and `auto-terms-v1.json` `{acknowledged:true}` encodings remain unchanged, with 0600 files and atomic replacement. The kit preserves but excludes `found-*` and `found:true` rows, which belong to the host's default-login adapter. Legacy managed rows without kit completion sidecars retain their native signed-in status; new or re-signing rows require the completion marker. Symlinked account folders and records outside the provider/hex layout are refused.
|
|
601
|
+
|
|
602
|
+
`usageSource(id)` returns a Codex Source for `@byokit/usage`; Claude returns `undefined`, and its usage Source is `{provider:'claude', folder:set.CLAUDE_CONFIG_DIR, headers}` in a usage reader whose `stateDir` is the same managed root. `kinds` serves only the matching native agent (`claude` or `codex`); Pi is excluded until its folder mapping is verified. `resumeArgs` accepts an `id` conversation reference for these kinds. `termsAcknowledged` and `acknowledgeTerms` keep the host's existing terms bit; they do not gate sign-in. `suggestName` uses the first part of an email, falling back to the provider's name.
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
## Scripted sign-in stand-in
|
|
606
|
+
|
|
607
|
+
`mockOpenAI({ answers })` from `@byokit/accounts/testing` runs the normal device-code sign-in and
|
|
608
|
+
ChatGPT response path on loopback. Each script has `{ match, text, usage? }`: a string matches a
|
|
609
|
+
substring of joined input text, a regex tests it, and a function receives it and returns a boolean.
|
|
610
|
+
The first match wins, including on repeat requests; unmatched input retains echo/tool behavior.
|
|
611
|
+
Replace `mock.state.answers` between requests to change scripts. Counts are explicit fake data.
|
|
612
|
+
|
|
613
|
+
```ts
|
|
614
|
+
import { mockOpenAI } from '@byokit/accounts/testing';
|
|
615
|
+
|
|
616
|
+
const mock = await mockOpenAI({ email: 'umer@example.com', answers: [
|
|
617
|
+
{ match: 'Umer', text: 'Ready.', usage: { input_tokens: 12, output_tokens: 3 } },
|
|
618
|
+
{ match: /finished/i, text: 'Done.' },
|
|
619
|
+
{ match: (prompt) => prompt.includes('help'), text: 'I can help.' },
|
|
620
|
+
] });
|
|
621
|
+
```
|
package/SECURITY.md
CHANGED
|
@@ -66,7 +66,7 @@ Deleting local files alone does not revoke tokens, and deletion cannot erase old
|
|
|
66
66
|
|
|
67
67
|
Tests use fake providers, temporary homes, filesystem canaries and Node permissions;
|
|
68
68
|
`npm test` blocks outbound networking and checks the owner's existing setup byte for byte.
|
|
69
|
-
The kit never invokes the
|
|
69
|
+
The kit never borrows environment API keys. Its Node-only `./cli` exception invokes only app-passed absolute CLI binaries against app-managed folders; it never opens credential files or the person's default login. Native CLI credentials remain on the device under the CLI's own storage policy; they do not pass through `fileStore` sealing.
|
|
70
70
|
|
|
71
71
|
## Review record
|
|
72
72
|
|
package/dist/accounts.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { type ClaudePlanOptions } from './claude-plan.ts';
|
|
|
3
3
|
import { type AnthropicAsk, type AnthropicResult, type AnthropicTool } from './anthropic.ts';
|
|
4
4
|
import { type Provider } from './catalogue.ts';
|
|
5
5
|
import { ResponseError, type Ask, type ResponseResult, type ResponseTool } from './responses.ts';
|
|
6
|
+
import type { ChatGPTRespondAccount } from './chatgpt-plan.ts';
|
|
6
7
|
import { type EndingStore, type RefreshStore } from './stores.ts';
|
|
7
8
|
import { type Why } from './words.ts';
|
|
8
9
|
/** What signing in needs from an engine: Pi's `Models`, or anything shaped like it (the coding agent's `ModelRuntime`). */
|
|
@@ -55,7 +56,7 @@ export type AnthropicAccountAsk = AnthropicAsk & {
|
|
|
55
56
|
key: string;
|
|
56
57
|
};
|
|
57
58
|
export type AccountsOptions<M extends Member = Member> = {
|
|
58
|
-
/** The accounts this app offers, in order. Default:
|
|
59
|
+
/** The accounts this app offers, in order. Default: every subscription provider supported on this platform. */
|
|
59
60
|
offer?: readonly string[];
|
|
60
61
|
/** Each member's own store. Default: in memory. */
|
|
61
62
|
store?: (member: M) => CredentialStore;
|
|
@@ -112,7 +113,7 @@ export declare class Accounts<R extends AuthHost = AuthHost, M extends Member =
|
|
|
112
113
|
onExpired?: (member: M, key: string) => void;
|
|
113
114
|
onSignOutError?: (member: M, key: string, error: Error) => void;
|
|
114
115
|
private platform;
|
|
115
|
-
/** Offered: the providers named in `offer`, else every provider
|
|
116
|
+
/** Offered: the providers named in `offer`, else every subscription provider that this platform can sign in to. */
|
|
116
117
|
constructor(opts?: AccountsOptions<M>, platform?: Platform);
|
|
117
118
|
/** A member's own store. */
|
|
118
119
|
protected store(member: M): EndingStore;
|
|
@@ -171,12 +172,20 @@ export declare class Accounts<R extends AuthHost = AuthHost, M extends Member =
|
|
|
171
172
|
result?: false;
|
|
172
173
|
}): Promise<string>;
|
|
173
174
|
respond(member: M, ask: AnthropicAccountAsk): Promise<string | AnthropicResult>;
|
|
175
|
+
respond(member: M, ask: Ask & {
|
|
176
|
+
result: true;
|
|
177
|
+
}): Promise<ResponseResult>;
|
|
174
178
|
respond(member: M, ask: Ask & {
|
|
175
179
|
tools?: undefined;
|
|
180
|
+
result?: false;
|
|
176
181
|
}): Promise<string>;
|
|
177
182
|
respond(member: M, ask: Ask & {
|
|
178
183
|
tools: ResponseTool[];
|
|
179
184
|
}): Promise<ResponseResult>;
|
|
185
|
+
respond(member: M, ask: Ask): Promise<string | ResponseResult>;
|
|
186
|
+
/** Bind this member's existing ChatGPT subscription login for consumers such as decide. Each request uses
|
|
187
|
+
* the current sign-in and Accounts' refresh/limit/sign-out handling; the handle exposes no credentials. */
|
|
188
|
+
chatgpt(member: M): ChatGPTRespondAccount;
|
|
180
189
|
/** An account's error, acted on. A limit or overload rests it (until when it said, or a default). A plan without this
|
|
181
190
|
* use is marked so. A refusal is checked: a sign-in that no longer refreshes is signed out for real, one that still
|
|
182
191
|
* does was a passing refusal and rests a few minutes (kind `overloaded`) rather than loop. Returns the kind acted on,
|
package/dist/accounts.js
CHANGED
|
@@ -51,7 +51,7 @@ export class Accounts {
|
|
|
51
51
|
onExpired;
|
|
52
52
|
onSignOutError;
|
|
53
53
|
platform;
|
|
54
|
-
/** Offered: the providers named in `offer`, else every provider
|
|
54
|
+
/** Offered: the providers named in `offer`, else every subscription provider that this platform can sign in to. */
|
|
55
55
|
constructor(opts = {}, platform = portable) {
|
|
56
56
|
this.opts = opts;
|
|
57
57
|
this.platform = platform;
|
|
@@ -300,7 +300,7 @@ export class Accounts {
|
|
|
300
300
|
const { access, accountId } = await this.access(member);
|
|
301
301
|
try {
|
|
302
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 };
|
|
303
|
-
return
|
|
303
|
+
return await respond(base);
|
|
304
304
|
}
|
|
305
305
|
catch (e) {
|
|
306
306
|
if (e instanceof ResponseError && e.kind && e.kind !== 'network') {
|
|
@@ -311,6 +311,12 @@ export class Accounts {
|
|
|
311
311
|
throw e;
|
|
312
312
|
}
|
|
313
313
|
}
|
|
314
|
+
/** Bind this member's existing ChatGPT subscription login for consumers such as decide. Each request uses
|
|
315
|
+
* the current sign-in and Accounts' refresh/limit/sign-out handling; the handle exposes no credentials. */
|
|
316
|
+
chatgpt(member) {
|
|
317
|
+
this.offer('chatgpt');
|
|
318
|
+
return { billing: 'subscription', respond: (ask) => this.respond(member, ask) };
|
|
319
|
+
}
|
|
314
320
|
/** An account's error, acted on. A limit or overload rests it (until when it said, or a default). A plan without this
|
|
315
321
|
* use is marked so. A refusal is checked: a sign-in that no longer refreshes is signed out for real, one that still
|
|
316
322
|
* does was a passing refusal and rests a few minutes (kind `overloaded`) rather than loop. Returns the kind acted on,
|
package/dist/catalogue.d.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
export type Terms = 'allowed' | 'grey' | 'partner' | 'forbidden';
|
|
2
1
|
/** How the person pays: their plan (`subscription`), or per-use charges to their account (`api`, never offered by default). */
|
|
3
2
|
export type Billing = 'subscription' | 'api';
|
|
4
3
|
/** Terms assessment for choosing between several accounts; descriptive, never an eligibility gate. */
|
|
5
4
|
export type MultiAccountTerms = {
|
|
6
|
-
terms:
|
|
5
|
+
terms: 'allowed' | 'grey' | 'partner' | 'forbidden';
|
|
7
6
|
why: string;
|
|
8
7
|
source: string;
|
|
9
8
|
};
|
|
@@ -25,14 +24,11 @@ export type Provider = {
|
|
|
25
24
|
auth?: 'api-key' | 'oauth';
|
|
26
25
|
label?: string;
|
|
27
26
|
offer?: boolean;
|
|
28
|
-
terms: Terms;
|
|
29
|
-
hidden: boolean;
|
|
30
|
-
why: string;
|
|
31
27
|
source: string;
|
|
32
28
|
multiAccount: MultiAccountTerms;
|
|
33
29
|
};
|
|
34
30
|
export declare const PROVIDERS: Record<string, Provider>;
|
|
35
31
|
export declare function provider(key: string): Provider;
|
|
36
|
-
/** What an app offers: the keys it names, in its order, or every subscription provider
|
|
32
|
+
/** What an app offers: the keys it names, in its order, or every subscription provider by default.
|
|
37
33
|
* API-billed rows are never in the default: an app offers them only by naming them. */
|
|
38
34
|
export declare const offered: (keys?: readonly string[]) => Provider[];
|
package/dist/catalogue.js
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
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. Anthropic here is the app-passed API-key route; authentication
|
|
4
|
-
// is separate from Messages, allowing additional opt-in auth routes to be implemented independently.
|
|
5
1
|
import CATALOGUE from './catalogue.json' with { type: 'json' };
|
|
6
2
|
export const PROVIDERS = Object.fromEntries(Object.entries(CATALOGUE).map(([key, p]) => [key, { key, ...p }]));
|
|
7
3
|
export function provider(key) {
|
|
@@ -10,6 +6,6 @@ export function provider(key) {
|
|
|
10
6
|
throw Object.assign(new Error('no such AI account'), { status: 404 });
|
|
11
7
|
return p;
|
|
12
8
|
}
|
|
13
|
-
/** What an app offers: the keys it names, in its order, or every subscription provider
|
|
9
|
+
/** What an app offers: the keys it names, in its order, or every subscription provider by default.
|
|
14
10
|
* API-billed rows are never in the default: an app offers them only by naming them. */
|
|
15
|
-
export const offered = (keys) => keys ? keys.map(provider) : Object.values(PROVIDERS).filter((p) =>
|
|
11
|
+
export const offered = (keys) => keys ? keys.map(provider) : Object.values(PROVIDERS).filter((p) => p.billing === 'subscription');
|
package/dist/catalogue.json
CHANGED
|
@@ -11,9 +11,6 @@
|
|
|
11
11
|
"clientId": "app_EMoamEEZ73f0CkXaXp7hrann",
|
|
12
12
|
"revoke": "https://auth.openai.com/oauth/revoke",
|
|
13
13
|
"billing": "subscription",
|
|
14
|
-
"terms": "grey",
|
|
15
|
-
"hidden": false,
|
|
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.",
|
|
17
14
|
"source": "https://developers.openai.com/codex/auth",
|
|
18
15
|
"multiAccount": {
|
|
19
16
|
"terms": "grey",
|
|
@@ -29,9 +26,6 @@
|
|
|
29
26
|
"strong": "moonshotai/kimi-k2.6"
|
|
30
27
|
},
|
|
31
28
|
"billing": "api",
|
|
32
|
-
"terms": "allowed",
|
|
33
|
-
"hidden": false,
|
|
34
|
-
"why": "Documented sign-in for any app, no registration. Pay-as-you-go credits, not a subscription.",
|
|
35
29
|
"source": "https://openrouter.ai/docs/guides/overview/auth/oauth",
|
|
36
30
|
"multiAccount": {
|
|
37
31
|
"terms": "grey",
|
|
@@ -47,9 +41,6 @@
|
|
|
47
41
|
"strong": "grok-4.7"
|
|
48
42
|
},
|
|
49
43
|
"billing": "subscription",
|
|
50
|
-
"terms": "partner",
|
|
51
|
-
"hidden": true,
|
|
52
|
-
"why": "xAI allows plan sign-in only in apps it has partnered with.",
|
|
53
44
|
"source": "https://x.ai/news/grok-opencode",
|
|
54
45
|
"multiAccount": {
|
|
55
46
|
"terms": "grey",
|
|
@@ -65,9 +56,6 @@
|
|
|
65
56
|
"strong": "gpt-5.4"
|
|
66
57
|
},
|
|
67
58
|
"billing": "subscription",
|
|
68
|
-
"terms": "partner",
|
|
69
|
-
"hidden": true,
|
|
70
|
-
"why": "GitHub allows Copilot sign-in only in apps it has partnered with; this uses VS Code's client.",
|
|
71
59
|
"source": "https://github.blog/changelog/2026-01-16-github-copilot-now-supports-opencode",
|
|
72
60
|
"multiAccount": {
|
|
73
61
|
"terms": "grey",
|
|
@@ -83,12 +71,9 @@
|
|
|
83
71
|
"strong": "claude-opus-5-5"
|
|
84
72
|
},
|
|
85
73
|
"billing": "api",
|
|
86
|
-
"terms": "allowed",
|
|
87
|
-
"hidden": false,
|
|
88
74
|
"auth": "api-key",
|
|
89
75
|
"label": "API key (billed per use)",
|
|
90
76
|
"offer": false,
|
|
91
|
-
"why": "The app passes an Anthropic API key explicitly. API key (billed per use), never a default or subscription fallback.",
|
|
92
77
|
"source": "https://platform.claude.com/docs/en/api/overview",
|
|
93
78
|
"multiAccount": {
|
|
94
79
|
"terms": "grey",
|
|
@@ -107,14 +92,73 @@
|
|
|
107
92
|
"clientId": "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
|
108
93
|
"billing": "subscription",
|
|
109
94
|
"auth": "oauth",
|
|
110
|
-
"terms": "grey",
|
|
111
|
-
"hidden": false,
|
|
112
|
-
"why": "Anthropic prohibits third-party Claude.ai sign-in without approval; this route may stop working or lead to account restrictions.",
|
|
113
95
|
"source": "https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use",
|
|
114
96
|
"multiAccount": {
|
|
115
97
|
"terms": "grey",
|
|
116
98
|
"why": "This source does not explicitly document choosing between several accounts of this service in another app.",
|
|
117
99
|
"source": "https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use"
|
|
118
100
|
}
|
|
101
|
+
},
|
|
102
|
+
"kimi": {
|
|
103
|
+
"pi": "kimi-coding",
|
|
104
|
+
"name": "Kimi",
|
|
105
|
+
"company": "Moonshot AI",
|
|
106
|
+
"models": {
|
|
107
|
+
"strong": "k3",
|
|
108
|
+
"fast": "kimi-for-coding-highspeed"
|
|
109
|
+
},
|
|
110
|
+
"billing": "subscription",
|
|
111
|
+
"source": "https://www.kimi.com",
|
|
112
|
+
"multiAccount": {
|
|
113
|
+
"terms": "grey",
|
|
114
|
+
"why": "This source does not explicitly document choosing between several accounts of this service in another app.",
|
|
115
|
+
"source": "https://www.kimi.com"
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
"meta": {
|
|
119
|
+
"pi": "meta",
|
|
120
|
+
"name": "Meta",
|
|
121
|
+
"company": "Meta",
|
|
122
|
+
"models": {
|
|
123
|
+
"strong": "muse-spark-1.3"
|
|
124
|
+
},
|
|
125
|
+
"billing": "subscription",
|
|
126
|
+
"source": "https://www.meta.ai",
|
|
127
|
+
"multiAccount": {
|
|
128
|
+
"terms": "grey",
|
|
129
|
+
"why": "This source does not explicitly document choosing between several accounts of this service in another app.",
|
|
130
|
+
"source": "https://www.meta.ai"
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
"qwen": {
|
|
134
|
+
"pi": "qwen-portal",
|
|
135
|
+
"name": "Qwen",
|
|
136
|
+
"company": "Alibaba",
|
|
137
|
+
"models": {
|
|
138
|
+
"strong": "qwen3-coder-plus"
|
|
139
|
+
},
|
|
140
|
+
"billing": "subscription",
|
|
141
|
+
"source": "https://chat.qwen.ai",
|
|
142
|
+
"multiAccount": {
|
|
143
|
+
"terms": "grey",
|
|
144
|
+
"why": "This source does not explicitly document choosing between several accounts of this service in another app.",
|
|
145
|
+
"source": "https://chat.qwen.ai"
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
"minimax": {
|
|
149
|
+
"pi": "minimax",
|
|
150
|
+
"name": "MiniMax",
|
|
151
|
+
"company": "MiniMax",
|
|
152
|
+
"models": {
|
|
153
|
+
"strong": "MiniMax-M3",
|
|
154
|
+
"fast": "MiniMax-M2.7-highspeed"
|
|
155
|
+
},
|
|
156
|
+
"billing": "subscription",
|
|
157
|
+
"source": "https://www.minimax.io",
|
|
158
|
+
"multiAccount": {
|
|
159
|
+
"terms": "grey",
|
|
160
|
+
"why": "This source does not explicitly document choosing between several accounts of this service in another app.",
|
|
161
|
+
"source": "https://www.minimax.io"
|
|
162
|
+
}
|
|
119
163
|
}
|
|
120
164
|
}
|
package/dist/chatgpt-plan.d.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import type { Ask, ResponseResult } from './responses.ts';
|
|
2
|
+
/** A member-bound Accounts ChatGPT subscription handle. Credentials stay inside Accounts. */
|
|
3
|
+
export type ChatGPTRespondAccount = {
|
|
4
|
+
readonly billing: 'subscription';
|
|
5
|
+
respond(ask: Ask & {
|
|
6
|
+
result: true;
|
|
7
|
+
}): Promise<ResponseResult>;
|
|
8
|
+
};
|
|
1
9
|
/** A host-owned, validated Sign in with ChatGPT token-sharing session. The host completes the official
|
|
2
10
|
* OAuth flow (including ID-token verification), stores it per person, and refreshes it before returning it.
|
|
3
11
|
* This adapter never discovers credentials, starts a login, or substitutes a Codex credential. */
|
package/dist/chatgpt-plan.js
CHANGED
|
@@ -12,7 +12,7 @@ export function chatgptPlan(o) {
|
|
|
12
12
|
const session = await o.session(signal);
|
|
13
13
|
if (!session || !Array.isArray(session.scopes) || !session.scopes.includes('chatgpt.tokens.use.direct') ||
|
|
14
14
|
!session.scopes.includes('resource.invoke') || typeof session.accessToken !== 'string' || !session.accessToken.trim()) {
|
|
15
|
-
throw new UnsupportedAccountError('
|
|
15
|
+
throw new UnsupportedAccountError('This needs a ChatGPT sign-in that allows plan use.');
|
|
16
16
|
}
|
|
17
17
|
return session.accessToken;
|
|
18
18
|
},
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { AccountLike } from './multi.ts';
|
|
2
|
+
export type CliProvider = 'claude' | 'codex';
|
|
3
|
+
/** Shared selection surface; the CLI entry remains Node-only. */
|
|
4
|
+
export type CliAccount = AccountLike & {
|
|
5
|
+
provider: CliProvider;
|
|
6
|
+
billing: 'subscription';
|
|
7
|
+
email?: string;
|
|
8
|
+
plan?: string;
|
|
9
|
+
};
|
|
10
|
+
export type CliOptions = {
|
|
11
|
+
stateDir: string;
|
|
12
|
+
bins: Partial<Record<CliProvider, string>>;
|
|
13
|
+
env: {
|
|
14
|
+
PATH: string;
|
|
15
|
+
HOME: string;
|
|
16
|
+
} & Record<string, string>;
|
|
17
|
+
historyFrom?: Partial<Record<CliProvider, string>>;
|
|
18
|
+
prepare?: (folder: string, provider: CliProvider) => Promise<void>;
|
|
19
|
+
};
|
|
20
|
+
export type SignInCommand = {
|
|
21
|
+
argv: string[];
|
|
22
|
+
env: Record<string, string>;
|
|
23
|
+
completion: string;
|
|
24
|
+
shell: string;
|
|
25
|
+
};
|
|
26
|
+
export declare class CliAccountError extends Error {
|
|
27
|
+
name: string;
|
|
28
|
+
readonly code: 'unknown-account' | 'invalid-name' | 'kind-mismatch' | 'prepare-failed' | 'bad-option';
|
|
29
|
+
constructor(code: CliAccountError['code']);
|
|
30
|
+
}
|
|
31
|
+
/** Only app-managed folders and explicitly supplied absolute CLI binaries. */
|
|
32
|
+
export declare function cliAccounts(options: CliOptions): {
|
|
33
|
+
list: () => Promise<CliAccount[]>;
|
|
34
|
+
add(provider: CliProvider): Promise<{
|
|
35
|
+
account: CliAccount;
|
|
36
|
+
signIn: SignInCommand;
|
|
37
|
+
}>;
|
|
38
|
+
signInAgain: (id: string) => SignInCommand;
|
|
39
|
+
status: (id: string) => Promise<CliAccount>;
|
|
40
|
+
cancel(id: string): Promise<{
|
|
41
|
+
removed: boolean;
|
|
42
|
+
}>;
|
|
43
|
+
rename(id: string, name: string): Promise<CliAccount>;
|
|
44
|
+
remove: (id: string) => Promise<void>;
|
|
45
|
+
launchEnv: (id: string) => {
|
|
46
|
+
set: {
|
|
47
|
+
[x: string]: string;
|
|
48
|
+
};
|
|
49
|
+
unset: string[];
|
|
50
|
+
};
|
|
51
|
+
kinds: (provider: CliProvider) => string[];
|
|
52
|
+
resumeArgs(kind: string, ref: {
|
|
53
|
+
kind: "id" | "path";
|
|
54
|
+
value: string;
|
|
55
|
+
}): string[];
|
|
56
|
+
usageSource(id: string): {
|
|
57
|
+
provider: "codex";
|
|
58
|
+
bin: string;
|
|
59
|
+
home: string;
|
|
60
|
+
env: Record<string, string>;
|
|
61
|
+
} | undefined;
|
|
62
|
+
termsAcknowledged: () => boolean;
|
|
63
|
+
acknowledgeTerms: () => void;
|
|
64
|
+
suggestName: (email: string | undefined, provider: CliProvider) => string;
|
|
65
|
+
};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
3
|
+
import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
5
|
+
import { identity } from '@byokit/usage';
|
|
6
|
+
import { say } from "./words.js";
|
|
7
|
+
export class CliAccountError extends Error {
|
|
8
|
+
name = 'CliAccountError';
|
|
9
|
+
code;
|
|
10
|
+
constructor(code) {
|
|
11
|
+
super(say({ 'unknown-account': 'cli.unknownAccount', 'invalid-name': 'cli.invalidName', 'kind-mismatch': 'cli.kindMismatch', 'prepare-failed': 'cli.prepareFailed', 'bad-option': 'cli.badOption' }[code]));
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const record = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
16
|
+
const text = (v) => typeof v === 'string' && !v.includes('\0');
|
|
17
|
+
const providers = ['claude', 'codex'];
|
|
18
|
+
const shed = {
|
|
19
|
+
claude: ['CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', 'CLAUDE_CODE_USE_ANTHROPIC_AWS', 'CLAUDE_CODE_USE_MANTLE', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_PROFILE', 'ANTHROPIC_FEDERATION_RULE_ID'],
|
|
20
|
+
codex: ['OPENAI_API_KEY', 'CODEX_API_KEY'],
|
|
21
|
+
};
|
|
22
|
+
const folderVar = (p) => p === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME';
|
|
23
|
+
const quote = (v) => `'${v.replaceAll("'", "'\\''")}'`;
|
|
24
|
+
const complete = (r) => join(r.folder, '.byokit-signin-complete');
|
|
25
|
+
const pending = (r) => join(r.folder, '.byokit-signin-pending');
|
|
26
|
+
function marker(file) {
|
|
27
|
+
try {
|
|
28
|
+
const stat = lstatSync(file);
|
|
29
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
30
|
+
throw new CliAccountError('bad-option');
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error.code === 'ENOENT')
|
|
35
|
+
return false;
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function json(file) {
|
|
40
|
+
let fd;
|
|
41
|
+
try {
|
|
42
|
+
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
43
|
+
const stat = fstatSync(fd);
|
|
44
|
+
if (!stat.isFile() || stat.size > 256 * 1024)
|
|
45
|
+
return undefined;
|
|
46
|
+
const data = Buffer.alloc(256 * 1024 + 1);
|
|
47
|
+
let n = 0;
|
|
48
|
+
while (n < data.length) {
|
|
49
|
+
const count = readSync(fd, data, n, data.length - n, null);
|
|
50
|
+
if (!count)
|
|
51
|
+
break;
|
|
52
|
+
n += count;
|
|
53
|
+
}
|
|
54
|
+
return n < data.length ? JSON.parse(data.subarray(0, n).toString('utf8')) : undefined;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
if (fd !== undefined)
|
|
61
|
+
closeSync(fd);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function directory(path, create = false) {
|
|
65
|
+
try {
|
|
66
|
+
const s = lstatSync(path);
|
|
67
|
+
return s.isDirectory() && !s.isSymbolicLink();
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (create && error.code === 'ENOENT') {
|
|
71
|
+
mkdirSync(path, { mode: 0o700 });
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Only app-managed folders and explicitly supplied absolute CLI binaries. */
|
|
78
|
+
export function cliAccounts(options) {
|
|
79
|
+
if (!record(options) || !text(options.stateDir) || !isAbsolute(options.stateDir) || !record(options.bins) || !record(options.env) || !text(options.env.PATH) || !text(options.env.HOME) || !isAbsolute(options.env.HOME))
|
|
80
|
+
throw new CliAccountError('bad-option');
|
|
81
|
+
if (Object.entries(options.env).some(([k, v]) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(k) || !text(v)))
|
|
82
|
+
throw new CliAccountError('bad-option');
|
|
83
|
+
if (Object.entries(options.bins).some(([p, bin]) => !providers.includes(p) || !text(bin) || !isAbsolute(bin)))
|
|
84
|
+
throw new CliAccountError('bad-option');
|
|
85
|
+
if (options.historyFrom && (!record(options.historyFrom) || Object.entries(options.historyFrom).some(([p, path]) => !providers.includes(p) || !text(path) || !isAbsolute(path))))
|
|
86
|
+
throw new CliAccountError('bad-option');
|
|
87
|
+
const stateDir = resolve(options.stateDir);
|
|
88
|
+
const env = { ...options.env };
|
|
89
|
+
const bins = { ...options.bins };
|
|
90
|
+
const history = { ...options.historyFrom };
|
|
91
|
+
for (const path of ['.claude', '.codex', '.pi']) {
|
|
92
|
+
const own = join(resolve(env.HOME), path);
|
|
93
|
+
if (stateDir === own || stateDir.startsWith(own + '/'))
|
|
94
|
+
throw new CliAccountError('bad-option');
|
|
95
|
+
}
|
|
96
|
+
// Parents must already be app-owned. Never follow a state/provider/folder symlink.
|
|
97
|
+
try {
|
|
98
|
+
if (realpathSync(dirname(stateDir)) !== dirname(stateDir))
|
|
99
|
+
throw new CliAccountError('bad-option');
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
throw new CliAccountError('bad-option');
|
|
103
|
+
}
|
|
104
|
+
if (!directory(stateDir, true))
|
|
105
|
+
throw new CliAccountError('bad-option');
|
|
106
|
+
chmodSync(stateDir, 0o700);
|
|
107
|
+
const file = join(stateDir, 'accounts-v1.json');
|
|
108
|
+
const created = new Set();
|
|
109
|
+
const operations = new Map();
|
|
110
|
+
function safe(r) {
|
|
111
|
+
const parent = join(stateDir, r.provider);
|
|
112
|
+
return resolve(r.folder) === r.folder && r.folder.startsWith(parent + '/') && /^[a-f0-9]+$/.test(r.folder.slice(parent.length + 1)) && directory(stateDir) && realpathSync(stateDir) === stateDir && directory(parent) && directory(r.folder);
|
|
113
|
+
}
|
|
114
|
+
function load() {
|
|
115
|
+
if (!directory(stateDir) || realpathSync(stateDir) !== stateDir)
|
|
116
|
+
throw new CliAccountError('bad-option');
|
|
117
|
+
const saved = json(file);
|
|
118
|
+
if (!record(saved) || !Array.isArray(saved.accounts))
|
|
119
|
+
return [];
|
|
120
|
+
const seen = new Set();
|
|
121
|
+
const rows = [];
|
|
122
|
+
for (const candidate of saved.accounts) {
|
|
123
|
+
if (!record(candidate) || !text(candidate.id) || !candidate.id || candidate.id.length > 128 || seen.has(candidate.id) || !providers.includes(candidate.provider) || !text(candidate.name) || candidate.name.length > 64 || !text(candidate.folder) || typeof candidate.found !== 'boolean')
|
|
124
|
+
continue;
|
|
125
|
+
const r = { id: candidate.id, provider: candidate.provider, name: candidate.name, folder: candidate.folder, found: candidate.found };
|
|
126
|
+
// Host-owned rows remain byte-compatible in the roster, without touching their folder.
|
|
127
|
+
if (!r.found && !r.id.startsWith('found-') && !safe(r))
|
|
128
|
+
continue;
|
|
129
|
+
seen.add(r.id);
|
|
130
|
+
rows.push(r);
|
|
131
|
+
}
|
|
132
|
+
return rows;
|
|
133
|
+
}
|
|
134
|
+
function atomic(path, value) {
|
|
135
|
+
if (!directory(stateDir) || realpathSync(stateDir) !== stateDir)
|
|
136
|
+
throw new CliAccountError('bad-option');
|
|
137
|
+
const tmp = `${path}.${randomUUID()}.tmp`;
|
|
138
|
+
try {
|
|
139
|
+
writeFileSync(tmp, JSON.stringify(value), { mode: 0o600, flag: 'wx' });
|
|
140
|
+
renameSync(tmp, path);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
throw new CliAccountError('prepare-failed');
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
rmSync(tmp, { force: true });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const save = (rows) => atomic(file, { version: 1, accounts: rows });
|
|
150
|
+
function row(id) {
|
|
151
|
+
const r = load().find((r) => r.id === id && !r.found && !r.id.startsWith('found-'));
|
|
152
|
+
if (!r)
|
|
153
|
+
throw new CliAccountError('unknown-account');
|
|
154
|
+
return r;
|
|
155
|
+
}
|
|
156
|
+
function binary(p) { const bin = bins[p]; if (!bin)
|
|
157
|
+
throw new CliAccountError('bad-option'); return bin; }
|
|
158
|
+
function launch(r) { return { set: { [folderVar(r.provider)]: r.folder }, unset: [...shed[r.provider]] }; }
|
|
159
|
+
function spawnEnv(r) {
|
|
160
|
+
const out = { ...env };
|
|
161
|
+
for (const key of shed[r.provider])
|
|
162
|
+
delete out[key];
|
|
163
|
+
delete out.CLAUDE_CONFIG_DIR;
|
|
164
|
+
delete out.CODEX_HOME;
|
|
165
|
+
return { ...out, ...launch(r).set };
|
|
166
|
+
}
|
|
167
|
+
function command(r) {
|
|
168
|
+
const argv = r.provider === 'claude' ? [binary(r.provider), 'auth', 'login', '--claudeai'] : [binary(r.provider), 'login', '--device-auth'];
|
|
169
|
+
const passed = spawnEnv(r);
|
|
170
|
+
const completion = complete(r);
|
|
171
|
+
const lock = join(r.folder, '.byokit-signin-lock');
|
|
172
|
+
const login = ['/usr/bin/env', '-i', ...Object.entries(passed).map(([k, v]) => `${k}=${v}`), ...argv].map(quote).join(' ');
|
|
173
|
+
// Cross-process guard: a native CLI owns any refresh transaction; the kit never copies or refreshes its grants.
|
|
174
|
+
const shell = `(umask 077; /bin/mkdir ${quote(lock)} || exit 1; trap ${quote(`/bin/rmdir ${quote(lock)}`)} EXIT; ${login} && (set -C; printf complete > ${quote(completion)}))`;
|
|
175
|
+
return { argv, env: passed, completion, shell };
|
|
176
|
+
}
|
|
177
|
+
function begin(r) {
|
|
178
|
+
const result = command(r);
|
|
179
|
+
// Refuse overlapping sign-in starts; reuse the pending command until completion or cancel.
|
|
180
|
+
if (!marker(pending(r)) || marker(complete(r))) {
|
|
181
|
+
rmSync(complete(r), { force: true });
|
|
182
|
+
if (!marker(pending(r)))
|
|
183
|
+
writeFileSync(pending(r), '', { mode: 0o600, flag: 'wx' });
|
|
184
|
+
}
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
function serial(id, action) {
|
|
188
|
+
const task = (operations.get(id) ?? Promise.resolve()).then(action, action);
|
|
189
|
+
operations.set(id, task);
|
|
190
|
+
return task.finally(() => { if (operations.get(id) === task)
|
|
191
|
+
operations.delete(id); });
|
|
192
|
+
}
|
|
193
|
+
async function readIdentity(r) {
|
|
194
|
+
if (r.provider === 'codex')
|
|
195
|
+
return identity({ provider: 'codex', bin: binary('codex'), home: r.folder, env: spawnEnv(r) });
|
|
196
|
+
return new Promise((accept) => {
|
|
197
|
+
const child = spawn(binary('claude'), ['auth', 'status'], { env: spawnEnv(r), stdio: ['ignore', 'pipe', 'ignore'] });
|
|
198
|
+
let stdout = '';
|
|
199
|
+
let bytes = 0;
|
|
200
|
+
let settled = false;
|
|
201
|
+
let escalation;
|
|
202
|
+
const finish = (answer) => {
|
|
203
|
+
if (settled)
|
|
204
|
+
return;
|
|
205
|
+
settled = true;
|
|
206
|
+
clearTimeout(timer);
|
|
207
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
208
|
+
child.kill('SIGTERM');
|
|
209
|
+
escalation = setTimeout(() => { if (child.exitCode === null && child.signalCode === null)
|
|
210
|
+
child.kill('SIGKILL'); }, 1000);
|
|
211
|
+
}
|
|
212
|
+
accept(answer);
|
|
213
|
+
};
|
|
214
|
+
const timer = setTimeout(() => finish({ signedIn: false }), 15_000);
|
|
215
|
+
child.once('error', () => finish({ signedIn: false }));
|
|
216
|
+
child.stdout.setEncoding('utf8');
|
|
217
|
+
child.stdout.on('data', (chunk) => {
|
|
218
|
+
if (settled)
|
|
219
|
+
return;
|
|
220
|
+
bytes += Buffer.byteLength(chunk);
|
|
221
|
+
if (bytes > 256 * 1024) {
|
|
222
|
+
finish({ signedIn: false });
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
stdout += chunk;
|
|
226
|
+
});
|
|
227
|
+
child.once('close', () => {
|
|
228
|
+
clearTimeout(escalation);
|
|
229
|
+
if (settled)
|
|
230
|
+
return;
|
|
231
|
+
if (bytes > 64 * 1024) {
|
|
232
|
+
finish({ signedIn: false });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
let raw;
|
|
236
|
+
try {
|
|
237
|
+
raw = JSON.parse(stdout);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
finish({ signedIn: false });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (!record(raw) || raw.loggedIn !== true) {
|
|
244
|
+
finish({ signedIn: false });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const email = typeof raw.email === 'string' && raw.email.length <= 320 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw.email) ? raw.email : undefined;
|
|
248
|
+
const plan = [raw.subscriptionType, raw.plan, raw.planName, raw.tier].find((v) => typeof v === 'string' && /^[a-zA-Z][a-zA-Z0-9 _+-]{0,63}$/.test(v));
|
|
249
|
+
finish({ signedIn: true, ...(email ? { email } : {}), ...(typeof plan === 'string' ? { plan } : {}) });
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
function suggestName(email, provider) {
|
|
254
|
+
const first = email?.split('@')[0]?.split(/[._-]+/).find(Boolean);
|
|
255
|
+
return first ? (first[0].toUpperCase() + first.slice(1).toLowerCase()).slice(0, 64) : provider === 'claude' ? 'Claude' : 'Codex';
|
|
256
|
+
}
|
|
257
|
+
async function status(id) {
|
|
258
|
+
return serial(id, async () => {
|
|
259
|
+
const r = row(id);
|
|
260
|
+
const signing = marker(pending(r)) && !marker(complete(r));
|
|
261
|
+
if (signing)
|
|
262
|
+
return { id: r.id, provider: r.provider, name: r.name.trim() || suggestName(undefined, r.provider), billing: 'subscription', state: 'signing' };
|
|
263
|
+
const info = await readIdentity(r);
|
|
264
|
+
if (marker(pending(r)))
|
|
265
|
+
rmSync(pending(r), { force: true });
|
|
266
|
+
// Pre-existing roster rows have no kit marker; their native status remains authoritative.
|
|
267
|
+
return { id: r.id, provider: r.provider, name: r.name.trim() || suggestName(info.email, r.provider), billing: 'subscription', state: info.signedIn ? 'ready' : 'signed_out', ...(info.email ? { email: info.email } : {}), ...(info.plan ? { plan: info.plan } : {}) };
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
async function remove(id) {
|
|
271
|
+
return serial(id, async () => { const r = row(id); rmSync(r.folder, { recursive: true, force: true }); save(load().filter((v) => v.id !== id)); created.delete(id); });
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
list: async () => Promise.all(load().filter((r) => !r.found && !r.id.startsWith('found-')).map((r) => status(r.id))),
|
|
275
|
+
async add(provider) {
|
|
276
|
+
if (!providers.includes(provider))
|
|
277
|
+
throw new CliAccountError('bad-option');
|
|
278
|
+
binary(provider);
|
|
279
|
+
const parent = join(stateDir, provider);
|
|
280
|
+
if (!directory(stateDir) || !directory(parent, true))
|
|
281
|
+
throw new CliAccountError('bad-option');
|
|
282
|
+
chmodSync(parent, 0o700);
|
|
283
|
+
const r = { id: `pa_${randomBytes(9).toString('hex')}`, provider, name: '', folder: join(parent, randomBytes(8).toString('hex')), found: false };
|
|
284
|
+
mkdirSync(r.folder, { mode: 0o700 });
|
|
285
|
+
try {
|
|
286
|
+
if (history[provider])
|
|
287
|
+
symlinkSync(history[provider], join(r.folder, provider === 'claude' ? 'projects' : 'sessions'), 'dir');
|
|
288
|
+
const signIn = begin(r);
|
|
289
|
+
await options.prepare?.(r.folder, provider);
|
|
290
|
+
if (!safe(r))
|
|
291
|
+
throw new CliAccountError('prepare-failed');
|
|
292
|
+
save([...load(), r]);
|
|
293
|
+
created.add(r.id);
|
|
294
|
+
return { account: { id: r.id, provider, name: suggestName(undefined, provider), billing: 'subscription', state: 'signing' }, signIn };
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
rmSync(r.folder, { recursive: true, force: true });
|
|
298
|
+
throw new CliAccountError('prepare-failed');
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
signInAgain: (id) => {
|
|
302
|
+
if (operations.has(id))
|
|
303
|
+
throw new CliAccountError('prepare-failed');
|
|
304
|
+
return begin(row(id));
|
|
305
|
+
},
|
|
306
|
+
status,
|
|
307
|
+
async cancel(id) {
|
|
308
|
+
if (created.has(id)) {
|
|
309
|
+
await remove(id);
|
|
310
|
+
return { removed: true };
|
|
311
|
+
}
|
|
312
|
+
return serial(id, async () => { const r = row(id); rmSync(pending(r), { force: true }); return { removed: false }; });
|
|
313
|
+
},
|
|
314
|
+
async rename(id, name) {
|
|
315
|
+
if (!text(name) || !name.trim() || name.trim().length > 64 || /[\x00-\x1f\x7f]/.test(name))
|
|
316
|
+
throw new CliAccountError('invalid-name');
|
|
317
|
+
await serial(id, async () => { row(id); const rows = load(); rows.find((r) => r.id === id).name = name.trim(); save(rows); });
|
|
318
|
+
return status(id);
|
|
319
|
+
},
|
|
320
|
+
remove,
|
|
321
|
+
launchEnv: (id) => launch(row(id)),
|
|
322
|
+
kinds: (provider) => providers.includes(provider) ? [provider] : [],
|
|
323
|
+
resumeArgs(kind, ref) {
|
|
324
|
+
if (!text(ref.value) || !ref.value || (kind !== 'claude' && kind !== 'codex') || (kind === 'claude' && ref.kind !== 'id') || (kind === 'codex' && ref.kind !== 'id'))
|
|
325
|
+
throw new CliAccountError('kind-mismatch');
|
|
326
|
+
return kind === 'claude' ? ['--resume', ref.value] : ['resume', ref.value];
|
|
327
|
+
},
|
|
328
|
+
usageSource(id) {
|
|
329
|
+
const r = row(id);
|
|
330
|
+
return r.provider === 'codex' ? { provider: 'codex', bin: binary('codex'), home: r.folder, env: spawnEnv(r) } : undefined;
|
|
331
|
+
},
|
|
332
|
+
termsAcknowledged: () => { if (!directory(stateDir))
|
|
333
|
+
throw new CliAccountError('bad-option'); const saved = json(join(stateDir, 'auto-terms-v1.json')); return record(saved) && saved.acknowledged === true; },
|
|
334
|
+
acknowledgeTerms: () => atomic(join(stateDir, 'auto-terms-v1.json'), { acknowledged: true }),
|
|
335
|
+
suggestName,
|
|
336
|
+
};
|
|
337
|
+
}
|
package/dist/portable.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
export { Accounts, planOf, portable, type AccountsOptions, type ClaudePlanAsk, type AnthropicAccountAsk, type AuthHost, type Loopback, type Member, type Platform, type SignIn, type Status } from './accounts.ts';
|
|
2
|
-
export { PROVIDERS, offered, provider, type Billing, type MultiAccountTerms, type Provider
|
|
2
|
+
export { PROVIDERS, offered, provider, type Billing, type MultiAccountTerms, type Provider } 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, classifyFailure, type Failure, type Kind } from './limits.ts';
|
|
5
|
-
export { IncompleteError, ResponseError, isFunctionCall, limitResponse, respond, sseReader, type Ask, type ResponseFunctionCall, type ResponseInputItem, type ResponseOutputItem, type ResponseOutputMessage, type ResponseReasoning, type ResponseResult, type ResponseStreamEvent, type ResponseText, type ResponseTextFormat, type ResponseTool, type ResponseToolChoice } from './responses.ts';
|
|
5
|
+
export { IncompleteError, ResponseError, isFunctionCall, limitResponse, respond, sseReader, type Ask, type ResponseFunctionCall, type ResponseInputItem, type ResponseOutputItem, type ResponseOutputMessage, type ResponseReasoning, type ResponseResult, type ResponseUsage, type ResponseStreamEvent, type ResponseText, type ResponseTextFormat, type ResponseTool, type ResponseToolChoice } from './responses.ts';
|
|
6
6
|
export { browserStore, keystoreStore, memoryStore, recordStore, secureStore, RefreshRequiredError, type RefreshStore, type SecureStoreLike } from './stores.ts';
|
|
7
7
|
export { WORDS, billingWords, callbackPage, clock, failure, say, signInError, type WordKey, type Why } from './words.ts';
|
|
8
|
-
export { chatgptPlan, UnsupportedAccountError, type ChatGPTPlanAccount, type ChatGPTPlanSession } from './chatgpt-plan.ts';
|
|
8
|
+
export { chatgptPlan, UnsupportedAccountError, type ChatGPTPlanAccount, type ChatGPTRespondAccount, type ChatGPTPlanSession } from './chatgpt-plan.ts';
|
|
9
9
|
export { anthropic, anthropicSseReader, AnthropicIncompleteError, type AnthropicAsk, type AnthropicCacheControl, type AnthropicContent, type AnthropicImage, type AnthropicMessage, type AnthropicOptions, type AnthropicRequest, type AnthropicResponse, type AnthropicResult, type AnthropicStreamEvent, type AnthropicText, type AnthropicThinking, type AnthropicTool, type AnthropicToolChoice, type AnthropicToolUse, type AnthropicUsage } from './anthropic.ts';
|
|
10
10
|
export { ClaudePlanExpiredError, ClaudePlanPlatformError, claudeAuthorization, claudeCode, type ClaudePlanOptions } from './claude-plan.ts';
|
|
11
11
|
export { chooseAccount, resolveSelection, roomOf, roomWords, type AccountLike, type AccountPick, type Considered, type Defaults, type PickWhy, type Room, type RoomSpan, type RunSelection, type SignInState } from './multi.ts';
|
package/dist/responses.d.ts
CHANGED
|
@@ -128,10 +128,17 @@ export type ResponseOutputItem = ResponseOutputMessage | ResponseFunctionCall |
|
|
|
128
128
|
};
|
|
129
129
|
/** Whether an output item is a function call, so an app can answer it with a `function_call_output` turn. */
|
|
130
130
|
export declare const isFunctionCall: (item: ResponseOutputItem) => item is ResponseFunctionCall;
|
|
131
|
-
/**
|
|
131
|
+
/** Provider-reported token counts, with native details retained. Missing usage is never guessed. */
|
|
132
|
+
export type ResponseUsage = {
|
|
133
|
+
input_tokens: number;
|
|
134
|
+
output_tokens: number;
|
|
135
|
+
[key: string]: unknown;
|
|
136
|
+
};
|
|
137
|
+
/** The model's answer: text, output items and token usage when the provider reports it. */
|
|
132
138
|
export type ResponseResult = {
|
|
133
139
|
text: string;
|
|
134
140
|
output: ResponseOutputItem[];
|
|
141
|
+
usage?: ResponseUsage;
|
|
135
142
|
};
|
|
136
143
|
/** What streams besides the words: each text piece, each tool call as it builds and lands, and each output item. */
|
|
137
144
|
export type ResponseStreamEvent = {
|
|
@@ -166,6 +173,8 @@ export declare function sseReader(onText?: (delta: string) => void, onEvent?: (e
|
|
|
166
173
|
result(): ResponseResult;
|
|
167
174
|
};
|
|
168
175
|
export type Ask = {
|
|
176
|
+
/** Return text, output items and reported usage even without tools. */
|
|
177
|
+
result?: boolean;
|
|
169
178
|
/** What the model is told to be. */
|
|
170
179
|
instructions: string;
|
|
171
180
|
/** The person's words, or the turns so far: messages (with `input_image` where the person attached one) and, after a
|
|
@@ -201,10 +210,15 @@ type Access = {
|
|
|
201
210
|
/** Ask ChatGPT with a signed-in token. `fetch`: pass one that streams (Expo's `expo/fetch`); any fetch works.
|
|
202
211
|
* Without `tools` the answer is the plain text, as before; with `tools` it is the text with every output item.
|
|
203
212
|
* Incomplete answers always throw IncompleteError and notify onEvent, including with tools. */
|
|
213
|
+
export declare function respond(o: Ask & Access & {
|
|
214
|
+
result: true;
|
|
215
|
+
}): Promise<ResponseResult>;
|
|
204
216
|
export declare function respond(o: Ask & Access & {
|
|
205
217
|
tools?: undefined;
|
|
218
|
+
result?: false;
|
|
206
219
|
}): Promise<string>;
|
|
207
220
|
export declare function respond(o: Ask & Access & {
|
|
208
221
|
tools: ResponseTool[];
|
|
209
222
|
}): Promise<ResponseResult>;
|
|
223
|
+
export declare function respond(o: Ask & Access): Promise<string | ResponseResult>;
|
|
210
224
|
export {};
|
package/dist/responses.js
CHANGED
|
@@ -58,6 +58,7 @@ export function sseReader(onText, onEvent) {
|
|
|
58
58
|
let buffer = '', text = '', completed;
|
|
59
59
|
let done = false, finished;
|
|
60
60
|
let incomplete;
|
|
61
|
+
let usage;
|
|
61
62
|
const output = [];
|
|
62
63
|
const emitted = new Set();
|
|
63
64
|
const calls = new Map();
|
|
@@ -115,6 +116,8 @@ export function sseReader(onText, onEvent) {
|
|
|
115
116
|
}
|
|
116
117
|
if (e.type === 'response.completed' || e.type === 'response.incomplete' || e.response?.status === 'incomplete') {
|
|
117
118
|
done = true;
|
|
119
|
+
if (isRecord(e.response?.usage))
|
|
120
|
+
usage = { ...e.response.usage };
|
|
118
121
|
if (Array.isArray(e.response?.output)) {
|
|
119
122
|
for (const [i, item] of e.response.output.entries())
|
|
120
123
|
land(item, i);
|
|
@@ -155,7 +158,7 @@ export function sseReader(onText, onEvent) {
|
|
|
155
158
|
throw new ResponseError('ChatGPT stopped before completing its answer.', 'network');
|
|
156
159
|
if (text === '' && whole !== '')
|
|
157
160
|
onText?.(whole);
|
|
158
|
-
finished = { text: whole, output };
|
|
161
|
+
finished = { text: whole, output, ...(usage && { usage }) };
|
|
159
162
|
}
|
|
160
163
|
if (incomplete !== undefined)
|
|
161
164
|
throw new IncompleteError(incomplete, finished);
|
|
@@ -214,5 +217,5 @@ export async function respond(o) {
|
|
|
214
217
|
else {
|
|
215
218
|
reader.push(await res.text()); // a fetch that can't stream: the whole answer at once
|
|
216
219
|
}
|
|
217
|
-
return o.tools ? reader.result() : reader.end();
|
|
220
|
+
return o.tools || o.result ? reader.result() : reader.end();
|
|
218
221
|
}
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -18,4 +18,4 @@ export declare function decoy(root: string): {
|
|
|
18
18
|
/** Marks left by the decoy's extension or CLIs: something ran code from someone else's setup. */
|
|
19
19
|
ran: () => string[];
|
|
20
20
|
};
|
|
21
|
-
export { mockJwt, mockOpenAI, type MockOpenAIOptions } from './mock-openai.ts';
|
|
21
|
+
export { mockJwt, mockOpenAI, type MockOpenAIAnswer, type MockOpenAIOptions } from './mock-openai.ts';
|
|
@@ -1,14 +1,23 @@
|
|
|
1
|
+
import type { ResponseUsage } from '../responses.ts';
|
|
2
|
+
/** First matching script wins. Strings match a substring; regexes and predicates inspect the joined input text.
|
|
3
|
+
* Unmatched prompts retain the echo/tool behavior. Counts are supplied explicitly, never estimated. */
|
|
4
|
+
export type MockOpenAIAnswer = {
|
|
5
|
+
match: string | RegExp | ((prompt: string) => boolean);
|
|
6
|
+
text: string;
|
|
7
|
+
usage?: ResponseUsage;
|
|
8
|
+
};
|
|
1
9
|
export type MockOpenAIOptions = {
|
|
2
10
|
port?: number;
|
|
3
11
|
host?: string;
|
|
4
12
|
plan?: string;
|
|
5
13
|
email?: string;
|
|
6
14
|
expiresIn?: number;
|
|
15
|
+
answers?: readonly MockOpenAIAnswer[];
|
|
7
16
|
log?: (line: string) => void;
|
|
8
17
|
};
|
|
9
18
|
/** An access token as OpenAI shapes it: the account, the plan and the email in its claims. */
|
|
10
19
|
export declare const mockJwt: (plan?: string, email?: string, n?: number) => string;
|
|
11
|
-
export declare function mockOpenAI({ port, host, plan, email, expiresIn, log }?: MockOpenAIOptions): Promise<{
|
|
20
|
+
export declare function mockOpenAI({ port, host, plan, email, expiresIn, answers, log }?: MockOpenAIOptions): Promise<{
|
|
12
21
|
base: string;
|
|
13
22
|
state: {
|
|
14
23
|
/** Refresh tokens OpenAI still honours; a refresh spends the old one (rotation), sign-out revokes one. */
|
|
@@ -17,6 +26,8 @@ export declare function mockOpenAI({ port, host, plan, email, expiresIn, log }?:
|
|
|
17
26
|
path: string;
|
|
18
27
|
body: string;
|
|
19
28
|
}[];
|
|
29
|
+
/** Scripts may be replaced between requests, without restarting the sign-in stand-in. */
|
|
30
|
+
answers: MockOpenAIAnswer[];
|
|
20
31
|
/** Refuse every refresh, as when the person signed out elsewhere. */
|
|
21
32
|
refuse: boolean;
|
|
22
33
|
/** Seconds each issued token lives. */
|
|
@@ -11,13 +11,15 @@ export const mockJwt = (plan = 'plus', email = 'sara@example.com', n = 0) => ['e
|
|
|
11
11
|
// As long as a real one, whose claims fill about 1.5 kB.
|
|
12
12
|
scp: ['openid', 'profile', 'email', 'offline_access'], pad: 'x'.repeat(1200),
|
|
13
13
|
})).toString('base64url'), 'sig'].join('.');
|
|
14
|
-
export async function mockOpenAI({ port = 0, host = '127.0.0.1', plan = 'plus', email = 'sara@example.com', expiresIn = 864_000, log } = {}) {
|
|
14
|
+
export async function mockOpenAI({ port = 0, host = '127.0.0.1', plan = 'plus', email = 'sara@example.com', expiresIn = 864_000, answers = [], log } = {}) {
|
|
15
15
|
const codes = new Map();
|
|
16
16
|
let issued = 0, asked = 0;
|
|
17
17
|
const state = {
|
|
18
18
|
/** Refresh tokens OpenAI still honours; a refresh spends the old one (rotation), sign-out revokes one. */
|
|
19
19
|
live: new Set(),
|
|
20
20
|
requests: [],
|
|
21
|
+
/** Scripts may be replaced between requests, without restarting the sign-in stand-in. */
|
|
22
|
+
answers: [...answers],
|
|
21
23
|
/** Refuse every refresh, as when the person signed out elsewhere. */
|
|
22
24
|
refuse: false,
|
|
23
25
|
/** Seconds each issued token lives. */
|
|
@@ -115,12 +117,20 @@ export async function mockOpenAI({ port = 0, host = '127.0.0.1', plan = 'plus',
|
|
|
115
117
|
said.push(part.text);
|
|
116
118
|
}
|
|
117
119
|
const words = said.join(' ');
|
|
120
|
+
let scripted;
|
|
121
|
+
try {
|
|
122
|
+
scripted = state.answers.find(({ match }) => typeof match === 'string' ? words.includes(match)
|
|
123
|
+
: typeof match === 'function' ? match(words) : new RegExp(match.source, match.flags).test(words));
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return send(500, { error: { message: 'The stand-in could not match this prompt.' } });
|
|
127
|
+
}
|
|
118
128
|
const schema = asked.text?.format?.type === 'json_schema';
|
|
119
|
-
const text = answered !== undefined ? `You did: ${answered}`
|
|
129
|
+
const text = scripted ? scripted.text : answered !== undefined ? `You did: ${answered}`
|
|
120
130
|
: schema ? JSON.stringify({ echo: words ? `You said: ${words}` : 'You said nothing' })
|
|
121
131
|
: `You said: ${words}`;
|
|
122
132
|
const called = Array.isArray(asked.tools) ? asked.tools.filter((t) => t?.type === 'function') : [];
|
|
123
|
-
if (called.length > 0 && answered === undefined) {
|
|
133
|
+
if (!scripted && called.length > 0 && answered === undefined) {
|
|
124
134
|
// A tool turn: the model calls the first function tool, streamed as argument deltas and one finished item,
|
|
125
135
|
// then the completion with the output list. The app answers with a `function_call_output` turn next.
|
|
126
136
|
const name = String(called[0].name ?? 'tool');
|
|
@@ -140,7 +150,9 @@ export async function mockOpenAI({ port = 0, host = '127.0.0.1', plan = 'plus',
|
|
|
140
150
|
res.write(`event: response.output_text.delta\ndata: ${JSON.stringify({ type: 'response.output_text.delta', delta })}\n\n`);
|
|
141
151
|
await new Promise((r) => setTimeout(r, 5));
|
|
142
152
|
}
|
|
143
|
-
return res.end(
|
|
153
|
+
return res.end(`event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: {
|
|
154
|
+
status: 'completed', ...(scripted?.usage && { usage: scripted.usage }),
|
|
155
|
+
} })}\n\ndata: [DONE]\n\n`);
|
|
144
156
|
}
|
|
145
157
|
case '/oauth/revoke':
|
|
146
158
|
state.live.delete(json().token);
|
package/dist/words.json
CHANGED
|
@@ -57,5 +57,10 @@
|
|
|
57
57
|
"pick.age": "Read {ago} ago",
|
|
58
58
|
"pick.ageUnknown": "Reading time unknown",
|
|
59
59
|
"ago.minutes": "{n} minutes",
|
|
60
|
-
"ago.hours": "{n} hours"
|
|
60
|
+
"ago.hours": "{n} hours",
|
|
61
|
+
"cli.unknownAccount": "Choose an account from the list.",
|
|
62
|
+
"cli.invalidName": "Give the account a name up to 64 characters.",
|
|
63
|
+
"cli.kindMismatch": "That agent cannot use this account.",
|
|
64
|
+
"cli.prepareFailed": "Could not prepare the account. Try again.",
|
|
65
|
+
"cli.badOption": "The app supplied invalid account options."
|
|
61
66
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@byokit/accounts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.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",
|
|
@@ -29,6 +29,10 @@
|
|
|
29
29
|
"types": "./dist/isolate.d.ts",
|
|
30
30
|
"default": "./dist/isolate.js"
|
|
31
31
|
},
|
|
32
|
+
"./cli": {
|
|
33
|
+
"types": "./dist/cli.d.ts",
|
|
34
|
+
"default": "./dist/cli.js"
|
|
35
|
+
},
|
|
32
36
|
"./testing": {
|
|
33
37
|
"types": "./dist/testing/index.d.ts",
|
|
34
38
|
"default": "./dist/testing/index.js"
|
|
@@ -48,6 +52,7 @@
|
|
|
48
52
|
"prepack": "tsc -b && node ../../scripts/fix-words-dts.cjs"
|
|
49
53
|
},
|
|
50
54
|
"dependencies": {
|
|
55
|
+
"@byokit/usage": "0.5.0",
|
|
51
56
|
"@earendil-works/pi-ai": "0.87.1"
|
|
52
57
|
},
|
|
53
58
|
"publishConfig": {
|