@byokit/accounts 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/NOTICE +30 -0
- package/README.md +125 -4
- package/dist/accounts.d.ts +37 -1
- package/dist/accounts.js +49 -5
- package/dist/anthropic.d.ts +236 -0
- package/dist/anthropic.js +268 -0
- package/dist/catalogue.d.ts +3 -0
- package/dist/catalogue.js +3 -4
- package/dist/catalogue.json +32 -0
- package/dist/claude-plan.d.ts +51 -0
- package/dist/claude-plan.js +167 -0
- package/dist/node-stores.d.ts +2 -0
- package/dist/node-stores.js +13 -1
- package/dist/portable.d.ts +4 -2
- package/dist/portable.js +3 -1
- package/dist/stores.d.ts +5 -0
- package/dist/stores.js +4 -0
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.12.0 (2026-09-30)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
- FIX: fileStore verifies optional sealing-adapter upgrades and atomically replaces authenticated ciphertext on read, allowing opt-in dual-wrap migration without losing the original store on interruption.
|
|
10
|
+
- Claude Pro/Max subscription PKCE sign-in, direct Messages and single-flight refresh with on-device credentials.
|
|
11
|
+
|
|
12
|
+
- Anthropic Messages with an app-passed API key (billed per use), explicit model and opt-in; typed native requests, streamed text/tools/thinking/message events, usage/raw results and the shared IncompleteError contract for max_tokens/refusal (including with tools) on every platform.
|
|
5
13
|
- Expose fresh subscription access to host-side capabilities using the app’s own sign-in.
|
|
6
14
|
|
|
7
15
|
## 0.11.0 (2026-09-30)
|
package/NOTICE
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
BYOKit accounts implements the Claude manual OAuth protocol independently, informed by:
|
|
2
|
+
|
|
3
|
+
Hermes Agent, Nous Research, copyright (c) 2025 Nous Research.
|
|
4
|
+
https://github.com/NousResearch/hermes-agent/tree/57a22675ef9f7761111feba9d24e5c366db3134b
|
|
5
|
+
agent/anthropic_credentials.py and agent/anthropic_adapter.py.
|
|
6
|
+
|
|
7
|
+
oh-my-pi, copyright (c) 2025 Mario Zechner; 2025-2026 Can Bölük; 2026 Stencil Labs.
|
|
8
|
+
https://github.com/can1357/oh-my-pi/tree/2b023d1b80133c523d66412602d99b5427408395
|
|
9
|
+
packages/catalog/src/compat/rules/auth/anthropic.kdl, packages/ai/src/registry/engine/refresh.ts.
|
|
10
|
+
|
|
11
|
+
These references are MIT licensed. No reference project's installed credential store or CLI is used.
|
|
12
|
+
The following MIT notice is retained for protocol adaptations, including constants and request construction:
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in
|
|
22
|
+
all copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
30
|
+
THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
</p>
|
|
9
9
|
|
|
10
10
|
<p align="center"><strong>Sign in with the AI plan you already pay for, inside your own app.</strong><br/>
|
|
11
|
-
ChatGPT on every platform; OpenRouter on computers when an app offers it (API billing, never by default); Grok and
|
|
12
|
-
GitHub Copilot hidden by default. Sign-ins go into your app's own store: on a computer (Node, Electron), in a browser
|
|
11
|
+
ChatGPT and Claude Pro/Max on every platform (Claude needs Web Crypto); OpenRouter on computers when an app offers it (API billing, never by default); Grok and
|
|
12
|
+
GitHub Copilot hidden by default. Anthropic uses an app-passed API key (billed per use), explicitly opted in. Sign-ins go into your app's own store: on a computer (Node, Electron), in a browser
|
|
13
13
|
(a PWA, Electron's renderer) and on a phone (React Native and Expo, iOS and Android). One import; your bundler picks
|
|
14
14
|
the platform's side (`package.json`'s `react-native` and `browser` conditions).</p>
|
|
15
15
|
|
|
@@ -135,6 +135,8 @@ and [`examples/pwa`](../../examples/pwa) (browser sign-in).
|
|
|
135
135
|
| | Computer (Node, Electron main) | Browser (PWA, Electron renderer) | Phone (React Native: iOS, Android) |
|
|
136
136
|
|---|---|---|---|
|
|
137
137
|
| ChatGPT (subscription) | Its own page, straight back to this computer (port 1455); a code when asked or stuck | Device code | Device code |
|
|
138
|
+
| Claude Pro/Max (subscription) | Provider page, paste its code back | Same PKCE flow; token endpoint CORS required | Same PKCE flow; app supplies Web Crypto |
|
|
139
|
+
| Anthropic (API key, billed per use) | App passes its own key, explicitly | Same fetch-only Messages provider | Same fetch-only Messages provider |
|
|
138
140
|
| OpenRouter (API billing) | Its own page, back to this computer (Pi's flow), when an app offers it (never by default) | Not yet | Not yet |
|
|
139
141
|
| Grok, Copilot (hidden) | Pi's flows | No | No |
|
|
140
142
|
| Where sign-ins are kept | `fileStore(path, safeStorage)`, sealing required | `browserStore(name)` (IndexedDB) | `secureStore(SecureStore, name)` (Keychain, Keystore) |
|
|
@@ -152,7 +154,7 @@ doesn't answer other web pages), so a PWA's model calls go through the app's own
|
|
|
152
154
|
platform are shown: OpenRouter is API-billed and never offered by default. An explicit list is not platform-filtered,
|
|
153
155
|
so choose from the table above. Show `billingWords(p)` next to every provider you list.
|
|
154
156
|
|
|
155
|
-
|
|
157
|
+
Anthropic Messages uses an app-passed API key (billed per use), with explicit opt-in; its authentication is separate from the Messages request.
|
|
156
158
|
|
|
157
159
|
```ts
|
|
158
160
|
import { Accounts, billingWords, offered } from '@byokit/accounts';
|
|
@@ -162,7 +164,7 @@ for (const p of offered(['chatgpt', 'openrouter'])) console.log(`${p.name}: ${bi
|
|
|
162
164
|
```
|
|
163
165
|
|
|
164
166
|
```text
|
|
165
|
-
[ 'chatgpt' ]
|
|
167
|
+
[ 'chatgpt', 'claude' ]
|
|
166
168
|
ChatGPT: Uses your ChatGPT plan.
|
|
167
169
|
OpenRouter: Charged per use to your OpenRouter account, not a plan.
|
|
168
170
|
```
|
|
@@ -349,6 +351,10 @@ and the Linux `basic_text` backend. Other adapters must protect their keys outsi
|
|
|
349
351
|
file and provide authenticated encryption. For a Node service, use a host-owned keystore through
|
|
350
352
|
`recordStore(load, save)`, or provide an equivalent sealing adapter; the kit never discovers a key
|
|
351
353
|
or invokes an OS keyring itself. Use `memoryStore()` for temporary sign-ins.
|
|
354
|
+
If the adapter exposes optional `upgrade(bytes)`, a read validates the decrypted record, verifies
|
|
355
|
+
that upgraded ciphertext decrypts to identical text and atomically replaces the original. This
|
|
356
|
+
supports opt-in dual-wrap migration through `@byokit/secrets`; a failed replacement leaves the
|
|
357
|
+
original envelope usable. Hold the host writer lock for read upgrades as well as ordinary writes.
|
|
352
358
|
|
|
353
359
|
Use an app-owned directory: the immediate folder must be a real 0700 directory and credential
|
|
354
360
|
files must be private regular files. Reuse one store instance for each path; a host lock is required
|
|
@@ -368,3 +374,118 @@ failures have no fallback rest. `classify` remains an alias. Pass a clock for de
|
|
|
368
374
|
classification; the default uses `Date.now()`. It stores and logs no error text.
|
|
369
375
|
|
|
370
376
|
For a capability running where the sign-in lives, `await accounts.access(member, signal)` returns fresh `{ access, accountId }` from the app’s own ChatGPT store. Keep these credentials in that process; proxy signaling or relay media for another device. This uses the same refresh and signed-out behavior as `respond`.
|
|
377
|
+
|
|
378
|
+
## Anthropic Messages: API key (billed per use)
|
|
379
|
+
|
|
380
|
+
This route is never a default or a subscription fallback. The app supplies the key and an explicit model.
|
|
381
|
+
The kit never reads keys from environment variables, files or another tool's sign-in.
|
|
382
|
+
|
|
383
|
+
```ts
|
|
384
|
+
import { anthropic, AnthropicIncompleteError } from '@byokit/accounts';
|
|
385
|
+
|
|
386
|
+
export async function askClaude(appKey: string, show: (text: string) => void) {
|
|
387
|
+
const claude = anthropic({ key: appKey });
|
|
388
|
+
try {
|
|
389
|
+
const answer = await claude.respond({
|
|
390
|
+
model: 'claude-opus-5-5', max_tokens: 1024,
|
|
391
|
+
system: 'Be brief.', messages: [{ role: 'user', content: 'Hello' }],
|
|
392
|
+
result: true, onText: (delta) => show(delta),
|
|
393
|
+
});
|
|
394
|
+
// answer.text, answer.output, answer.usage, answer.raw
|
|
395
|
+
} catch (error) {
|
|
396
|
+
if (error instanceof AnthropicIncompleteError) {
|
|
397
|
+
show(error.reason); // error.result carries partial text/output, usage and native raw response
|
|
398
|
+
} else throw error;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
Native `system`, `messages` (images, thinking, tool calls/results), `tools`, `tool_choice`, `thinking`,
|
|
404
|
+
`max_tokens`, `stop_sequences`, `metadata`, sampling and `output_config` pass through unchanged.
|
|
405
|
+
`onEvent` carries text deltas, tool JSON deltas, completed tools/content blocks, message events and an
|
|
406
|
+
incomplete event for `max_tokens` or `refusal`. A stream without `message_stop` fails. With `tools` or
|
|
407
|
+
`result: true`, the result carries metadata; without either, it returns text. Incomplete answers always
|
|
408
|
+
throw the shared `IncompleteError` contract (the `AnthropicIncompleteError` subtype retains typed native
|
|
409
|
+
metadata), including with tools or `result: true`. Call `isFunctionCall` on normalized `output` items; use native `raw.content` for the next Messages turn.
|
|
410
|
+
|
|
411
|
+
The catalogue's `label` is exactly `API key (billed per use)`; show it when presenting the explicit key route.
|
|
412
|
+
`billingWords` also supplies the existing plain sentence about per-use charges.
|
|
413
|
+
|
|
414
|
+
An `Accounts` instance can also use this route, with `offer: ['anthropic']` and
|
|
415
|
+
`accounts.respond(member, { provider: 'anthropic', key: appKey, model, max_tokens, messages, result: true })`.
|
|
416
|
+
The key is used for that request, never stored by the kit; `login` does not launch a plan flow for this route.
|
|
417
|
+
|
|
418
|
+
The existing `@byokit/decide` seam accepts it without a new dependency:
|
|
419
|
+
|
|
420
|
+
```ts
|
|
421
|
+
import { anthropic } from '@byokit/accounts';
|
|
422
|
+
import { answerer } from '@byokit/decide';
|
|
423
|
+
|
|
424
|
+
export function claudeBackend(appKey: string) {
|
|
425
|
+
const claude = anthropic({ key: appKey });
|
|
426
|
+
return answerer({ name: 'anthropic', leaves: true,
|
|
427
|
+
ask: (prompt, signal) => claude.respond({
|
|
428
|
+
model: 'claude-opus-5-5', max_tokens: 2048, signal,
|
|
429
|
+
messages: [{ role: 'user', content: prompt }],
|
|
430
|
+
}),
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
An incomplete answer throws through this text seam, so decide abstains rather than parsing a partial answer.
|
|
436
|
+
Browser apps should keep the API key in an app-owned server proxy (`base`); React Native can pass a streaming
|
|
437
|
+
fetch such as Expo's. `betas` explicitly opts into native beta headers.
|
|
438
|
+
|
|
439
|
+
## Claude Pro/Max subscription
|
|
440
|
+
|
|
441
|
+
Anthropic's [developer guidance](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use) prohibits third-party Claude.ai login without approval; BYOKit has no approval, and this route may stop working or lead to account restrictions (the separately billed API-key route is the documented alternative).
|
|
442
|
+
|
|
443
|
+
Claude is available by default. Open its page, then paste the returned `code#state` into the app:
|
|
444
|
+
|
|
445
|
+
```ts
|
|
446
|
+
import { Accounts, memoryStore } from '@byokit/accounts';
|
|
447
|
+
|
|
448
|
+
const member = 1;
|
|
449
|
+
const credentials = memoryStore(); // replace with device-owned persistent storage in your app
|
|
450
|
+
const accounts = new Accounts({ store: () => credentials });
|
|
451
|
+
const pastedCode = 'code#state'; // collect the actual returned value from the person
|
|
452
|
+
const pending = await accounts.login(member, 'claude');
|
|
453
|
+
// Open pending.url in the person's browser, then collect the code from that page.
|
|
454
|
+
accounts.paste(member, 'claude', pastedCode);
|
|
455
|
+
await accounts.finished(member, 'claude');
|
|
456
|
+
const text = await accounts.respond(member, {
|
|
457
|
+
provider: 'claude', model: 'claude-opus-5-5', max_tokens: 1024,
|
|
458
|
+
messages: [{ role: 'user', content: 'Hello' }],
|
|
459
|
+
});
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
The manual HTTPS callback works without a local listener or an installed CLI. It uses PKCE (secure random verifier,
|
|
463
|
+
SHA-256 challenge), independent state, strict `code#state` validation, and direct JSON exchange/refresh at
|
|
464
|
+
`platform.claude.com/v1/oauth/token`. Refresh is single-flight for one store, saves an attempt before sending and commits the rotated credentials before
|
|
465
|
+
returning access. An omitted replacement requires sign-in again. `ClaudePlanExpiredError` means sign in again, including after a refused or uncertain rotation or a
|
|
466
|
+
failed durable save; a persisted attempt prevents replay after restart. A custom store must provide the refresh transaction seam (use
|
|
467
|
+
`recordStore(load, save)`); a host lock is required for multiple processes. Local logout removes only this app's credentials; it does not promise server
|
|
468
|
+
revocation. A Messages authentication refusal requests re-authentication without replaying the request or switching
|
|
469
|
+
billing to an API key.
|
|
470
|
+
|
|
471
|
+
Tokens belong in one device-owned `CredentialStore` per member. `keystoreStore(hostKeystore, 'member.1')` adapts
|
|
472
|
+
`@byokit/secrets` without importing Node into the portable entry. Electron can pass safeStorage to `fileStore`;
|
|
473
|
+
its sealed file writes atomically with mode 0600; a sealing adapter is required. Use one process per file (or a host-supplied cross-process lock).
|
|
474
|
+
Phones use `secureStore` with device-only accessibility. PWA `browserStore` uses IndexedDB and Web Locks; page
|
|
475
|
+
scripts can read its credentials. Tokens are never collected by a BYOKit server or logged. The default is memory-only.
|
|
476
|
+
|
|
477
|
+
React Native hosts pass `claudePlan: { crypto: webCrypto }` when global Web Crypto is unavailable;
|
|
478
|
+
`ClaudePlanPlatformError` reports missing secure randomness/SHA-256. Browsers require the provider token endpoint's
|
|
479
|
+
CORS support; a network/CORS failure stays on-device and does not trigger a proxy. Browser User-Agent restrictions
|
|
480
|
+
can affect compatibility. Tests replay protocol-shaped captures offline; no live OAuth, CORS, or current inference
|
|
481
|
+
compatibility is claimed. This implementation supplies the manual-flow headers and Claude Code identity prelude,
|
|
482
|
+
without relying on Pi's provider or executing an installed Claude CLI.
|
|
483
|
+
|
|
484
|
+
Protocol references: [Hermes credentials at 57a22675, PKCE/exchange](https://github.com/NousResearch/hermes-agent/blob/57a22675ef9f7761111feba9d24e5c366db3134b/agent/anthropic_credentials.py#L747),
|
|
485
|
+
[Hermes headers/identity](https://github.com/NousResearch/hermes-agent/blob/57a22675ef9f7761111feba9d24e5c366db3134b/agent/anthropic_adapter.py#L219),
|
|
486
|
+
[oh-my-pi auth rule at 2b023d1b](https://github.com/can1357/oh-my-pi/blob/2b023d1b80133c523d66412602d99b5427408395/packages/catalog/src/compat/rules/auth/anthropic.kdl#L1),
|
|
487
|
+
and [oh-my-pi refresh](https://github.com/can1357/oh-my-pi/blob/2b023d1b80133c523d66412602d99b5427408395/packages/ai/src/registry/engine/refresh.ts#L62).
|
|
488
|
+
The common route follows Hermes's platform token host, three scopes and `axios/1.7.9` token User-Agent,
|
|
489
|
+
with inference `claude-code/2.1.74 (external, cli)`, `x-app: cli`, bearer authorization, Messages version
|
|
490
|
+
`2023-06-01` and betas `claude-code-20250219,oauth-2025-04-20`. The implementation is independent;
|
|
491
|
+
[NOTICE](NOTICE) records the MIT protocol references.
|
package/dist/accounts.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { CredentialStore, Models } from '@earendil-works/pi-ai';
|
|
2
|
+
import { type ClaudePlanOptions } from './claude-plan.ts';
|
|
3
|
+
import { type AnthropicAsk, type AnthropicResult, type AnthropicTool } from './anthropic.ts';
|
|
2
4
|
import { type Provider } from './catalogue.ts';
|
|
3
5
|
import { ResponseError, type Ask, type ResponseResult, type ResponseTool } from './responses.ts';
|
|
4
6
|
import { type EndingStore, type RefreshStore } from './stores.ts';
|
|
@@ -45,8 +47,15 @@ export type Platform = {
|
|
|
45
47
|
};
|
|
46
48
|
/** Phones and browsers: ChatGPT by device code, no listener. */
|
|
47
49
|
export declare const portable: Platform;
|
|
50
|
+
export type ClaudePlanAsk = AnthropicAsk & {
|
|
51
|
+
provider: 'claude';
|
|
52
|
+
};
|
|
53
|
+
export type AnthropicAccountAsk = AnthropicAsk & {
|
|
54
|
+
provider: 'anthropic';
|
|
55
|
+
key: string;
|
|
56
|
+
};
|
|
48
57
|
export type AccountsOptions<M extends Member = Member> = {
|
|
49
|
-
/** The accounts this app offers, in order. Default:
|
|
58
|
+
/** The accounts this app offers, in order. Default: supported subscription providers not hidden. */
|
|
50
59
|
offer?: readonly string[];
|
|
51
60
|
/** Each member's own store. Default: in memory. */
|
|
52
61
|
store?: (member: M) => CredentialStore;
|
|
@@ -64,6 +73,10 @@ export type AccountsOptions<M extends Member = Member> = {
|
|
|
64
73
|
authBase?: string;
|
|
65
74
|
/** Where ChatGPT answers `respond`, for a stand-in in tests and demos. */
|
|
66
75
|
apiBase?: string;
|
|
76
|
+
/** Anthropic Messages origin: an app-owned proxy or stand-in. */
|
|
77
|
+
anthropicBase?: string;
|
|
78
|
+
/** Claude PKCE transport and Web Crypto supplied by the app (React Native). */
|
|
79
|
+
claudePlan?: ClaudePlanOptions;
|
|
67
80
|
/** The fetch `respond` asks with: one that streams on a phone (Expo's `expo/fetch`). Default: the platform's. */
|
|
68
81
|
fetch?: typeof fetch;
|
|
69
82
|
/** The originator header `respond` sends. Default: 'byokit'. */
|
|
@@ -80,6 +93,7 @@ export declare class Accounts<R extends AuthHost = AuthHost, M extends Member =
|
|
|
80
93
|
private opts;
|
|
81
94
|
private runtimes;
|
|
82
95
|
private stores;
|
|
96
|
+
private baseStores;
|
|
83
97
|
private generations;
|
|
84
98
|
private signals;
|
|
85
99
|
private chains;
|
|
@@ -135,6 +149,28 @@ export declare class Accounts<R extends AuthHost = AuthHost, M extends Member =
|
|
|
135
149
|
* ResponseError with the words to show. Without `tools` the answer is the plain text, as before: pass `input` as
|
|
136
150
|
* words or as turns (messages with `input_image`, then the `function_call` with its `function_call_output`). With
|
|
137
151
|
* `tools` it is the text with every output item, and `onEvent` sees each tool call as it lands. */
|
|
152
|
+
respond(member: M, ask: ClaudePlanAsk & {
|
|
153
|
+
result: true;
|
|
154
|
+
}): Promise<AnthropicResult>;
|
|
155
|
+
respond(member: M, ask: ClaudePlanAsk & {
|
|
156
|
+
tools: AnthropicTool[];
|
|
157
|
+
}): Promise<AnthropicResult>;
|
|
158
|
+
respond(member: M, ask: ClaudePlanAsk & {
|
|
159
|
+
tools?: undefined;
|
|
160
|
+
result?: false;
|
|
161
|
+
}): Promise<string>;
|
|
162
|
+
respond(member: M, ask: ClaudePlanAsk): Promise<string | AnthropicResult>;
|
|
163
|
+
respond(member: M, ask: AnthropicAccountAsk & {
|
|
164
|
+
result: true;
|
|
165
|
+
}): Promise<AnthropicResult>;
|
|
166
|
+
respond(member: M, ask: AnthropicAccountAsk & {
|
|
167
|
+
tools: AnthropicTool[];
|
|
168
|
+
}): Promise<AnthropicResult>;
|
|
169
|
+
respond(member: M, ask: AnthropicAccountAsk & {
|
|
170
|
+
tools?: undefined;
|
|
171
|
+
result?: false;
|
|
172
|
+
}): Promise<string>;
|
|
173
|
+
respond(member: M, ask: AnthropicAccountAsk): Promise<string | AnthropicResult>;
|
|
138
174
|
respond(member: M, ask: Ask & {
|
|
139
175
|
tools?: undefined;
|
|
140
176
|
}): Promise<string>;
|
package/dist/accounts.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { CLAUDE_PLAN_ID, ClaudePlanExpiredError, claudePlanMessages, withClaudePlan } from "./claude-plan.js";
|
|
2
|
+
import { anthropic } from "./anthropic.js";
|
|
1
3
|
import { offered, provider } from "./catalogue.js";
|
|
2
4
|
import { claims, PORTABLE, portableEngine } from "./engine.js";
|
|
3
5
|
import { classify, REST_MS } from "./limits.js";
|
|
@@ -5,7 +7,7 @@ import { respond, ResponseError } from "./responses.js";
|
|
|
5
7
|
import { memoryStore, refreshCredential } from "./stores.js";
|
|
6
8
|
import { callbackPage, clock, failure, say, signInError } from "./words.js";
|
|
7
9
|
/** Phones and browsers: ChatGPT by device code, no listener. */
|
|
8
|
-
export const portable = { engine: (c, base) => portableEngine(c, { base }), signsIn: (pi) => PORTABLE.includes(pi) };
|
|
10
|
+
export const portable = { engine: (c, base) => portableEngine(c, { base }), signsIn: (pi) => pi === CLAUDE_PLAN_ID || PORTABLE.includes(pi) };
|
|
9
11
|
/** The ChatGPT plan behind a sign-in, from its own token: a work plan (Business, Enterprise, Edu) follows the employer's rules. */
|
|
10
12
|
export function planOf(access) {
|
|
11
13
|
let c = {};
|
|
@@ -30,6 +32,7 @@ export class Accounts {
|
|
|
30
32
|
opts;
|
|
31
33
|
runtimes = new Map();
|
|
32
34
|
stores = new Map();
|
|
35
|
+
baseStores = new Map();
|
|
33
36
|
generations = new Map();
|
|
34
37
|
signals = new WeakMap();
|
|
35
38
|
chains = new Map();
|
|
@@ -59,6 +62,7 @@ export class Accounts {
|
|
|
59
62
|
let s = this.stores.get(String(member));
|
|
60
63
|
if (!s) {
|
|
61
64
|
const base = (this.opts.store ?? memoryStore)(member);
|
|
65
|
+
this.baseStores.set(String(member), base);
|
|
62
66
|
let chain = Promise.resolve();
|
|
63
67
|
const serial = (fn) => { const result = chain.then(fn); chain = result.catch(() => { }); return result; };
|
|
64
68
|
s = {
|
|
@@ -172,7 +176,7 @@ export class Accounts {
|
|
|
172
176
|
}
|
|
173
177
|
engine(member, raw) {
|
|
174
178
|
const credentials = this.boundStore(member, raw);
|
|
175
|
-
return Promise.resolve(Object.assign(this.platform.engine(credentials, this.opts.authBase), {
|
|
179
|
+
return Promise.resolve(Object.assign(withClaudePlan(this.platform.engine(credentials, this.opts.authBase), credentials, this.baseStores.get(String(member)) ?? raw, { ...this.opts.claudePlan, fetch: this.opts.claudePlan?.fetch ?? this.opts.fetch }), {
|
|
176
180
|
credentialStore: credentials, readCredential: (id) => credentials.read(id),
|
|
177
181
|
}));
|
|
178
182
|
}
|
|
@@ -252,7 +256,45 @@ export class Accounts {
|
|
|
252
256
|
signal?.throwIfAborted();
|
|
253
257
|
return { access, accountId: String(c.accountId ?? '') };
|
|
254
258
|
}
|
|
255
|
-
async respond(member,
|
|
259
|
+
async respond(member, query) {
|
|
260
|
+
const ask = query;
|
|
261
|
+
if ('provider' in query && query.provider === 'claude') {
|
|
262
|
+
this.offer('claude');
|
|
263
|
+
const rt = await this.runtime(member);
|
|
264
|
+
const { provider: _provider, ...request } = query;
|
|
265
|
+
let access;
|
|
266
|
+
try {
|
|
267
|
+
access = (await rt.getAuth(CLAUDE_PLAN_ID))?.auth?.apiKey;
|
|
268
|
+
}
|
|
269
|
+
catch (e) {
|
|
270
|
+
if (e instanceof ClaudePlanExpiredError) {
|
|
271
|
+
this.forget(member, 'claude');
|
|
272
|
+
this.onExpired?.(member, 'claude');
|
|
273
|
+
}
|
|
274
|
+
throw e;
|
|
275
|
+
}
|
|
276
|
+
if (!access)
|
|
277
|
+
throw new ClaudePlanExpiredError();
|
|
278
|
+
try {
|
|
279
|
+
return await claudePlanMessages(access, { fetch: this.opts.fetch }).respond(request);
|
|
280
|
+
}
|
|
281
|
+
catch (e) {
|
|
282
|
+
if (e instanceof ResponseError && e.kind === 'signed_out') {
|
|
283
|
+
await this.logout(member, 'claude').catch(() => { });
|
|
284
|
+
this.forget(member, 'claude');
|
|
285
|
+
this.onExpired?.(member, 'claude');
|
|
286
|
+
throw new ClaudePlanExpiredError();
|
|
287
|
+
}
|
|
288
|
+
if (e instanceof ResponseError && e.kind)
|
|
289
|
+
await this.failed(member, 'claude', e);
|
|
290
|
+
throw e;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if ('provider' in query && query.provider === 'anthropic') {
|
|
294
|
+
this.offer('anthropic');
|
|
295
|
+
const { provider: _provider, key, ...request } = query;
|
|
296
|
+
return anthropic({ key, fetch: this.opts.fetch, base: this.opts.anthropicBase }).respond(request);
|
|
297
|
+
}
|
|
256
298
|
const key = 'chatgpt';
|
|
257
299
|
const p = this.offer(key);
|
|
258
300
|
const { access, accountId } = await this.access(member);
|
|
@@ -313,7 +355,9 @@ export class Accounts {
|
|
|
313
355
|
* stalls times out; nothing is kept unless the engine then sees a working sign-in; every failure ends in one plain
|
|
314
356
|
* sentence. Returns as soon as there is a page to open or a code to show (or it is over); the rest carries on by itself. */
|
|
315
357
|
async login(member, key, body = {}) {
|
|
316
|
-
this.offer(key);
|
|
358
|
+
const p = this.offer(key);
|
|
359
|
+
if (p.auth === 'api-key')
|
|
360
|
+
throw new Error('Use the app-provided API key (billed per use) to ask this provider.');
|
|
317
361
|
const id = `${member}:${key}`;
|
|
318
362
|
const pending = this.signingOut.get(id);
|
|
319
363
|
if (pending)
|
|
@@ -379,7 +423,7 @@ export class Accounts {
|
|
|
379
423
|
},
|
|
380
424
|
});
|
|
381
425
|
const timer = setTimeout(() => { flow.timedOut = true; flow.abort.abort(); }, this.opts.signInMs ?? 15 * 60_000);
|
|
382
|
-
const stuck = setTimeout(() => this.toCode(flow), this.opts.redirectMs ?? 3 * 60_000);
|
|
426
|
+
const stuck = p.key === 'claude' ? undefined : setTimeout(() => this.toCode(flow), this.opts.redirectMs ?? 3 * 60_000);
|
|
383
427
|
// Listen where the provider sends the browser back (the engine then finds the port taken and waits to be handed the address).
|
|
384
428
|
const port = p.callbackPort && (this.opts.callbackPort ?? p.callbackPort);
|
|
385
429
|
const catcher = port && body.via !== 'code' && this.platform.loopback ? await this.catchRedirect(this.platform.loopback, flow, p.name, port).catch(() => null) : undefined;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { IncompleteError, type ResponseResult } from './responses.ts';
|
|
2
|
+
export type AnthropicCacheControl = {
|
|
3
|
+
type: 'ephemeral';
|
|
4
|
+
ttl?: '5m' | '1h';
|
|
5
|
+
};
|
|
6
|
+
export type AnthropicText = {
|
|
7
|
+
type: 'text';
|
|
8
|
+
text: string;
|
|
9
|
+
cache_control?: AnthropicCacheControl;
|
|
10
|
+
citations?: unknown[];
|
|
11
|
+
};
|
|
12
|
+
export type AnthropicImage = {
|
|
13
|
+
type: 'image';
|
|
14
|
+
source: {
|
|
15
|
+
type: 'base64';
|
|
16
|
+
media_type: 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp';
|
|
17
|
+
data: string;
|
|
18
|
+
} | {
|
|
19
|
+
type: 'url';
|
|
20
|
+
url: string;
|
|
21
|
+
};
|
|
22
|
+
cache_control?: AnthropicCacheControl;
|
|
23
|
+
};
|
|
24
|
+
export type AnthropicToolUse = {
|
|
25
|
+
type: 'tool_use';
|
|
26
|
+
id: string;
|
|
27
|
+
name: string;
|
|
28
|
+
input: Record<string, unknown>;
|
|
29
|
+
};
|
|
30
|
+
export type AnthropicThinking = {
|
|
31
|
+
type: 'thinking';
|
|
32
|
+
thinking: string;
|
|
33
|
+
signature: string;
|
|
34
|
+
};
|
|
35
|
+
export type AnthropicContent = AnthropicText | AnthropicImage | AnthropicToolUse | AnthropicThinking | {
|
|
36
|
+
type: 'redacted_thinking';
|
|
37
|
+
data: string;
|
|
38
|
+
} | {
|
|
39
|
+
type: 'tool_result';
|
|
40
|
+
tool_use_id: string;
|
|
41
|
+
content?: string | AnthropicContent[];
|
|
42
|
+
is_error?: boolean;
|
|
43
|
+
cache_control?: AnthropicCacheControl;
|
|
44
|
+
} | {
|
|
45
|
+
type: string;
|
|
46
|
+
[key: string]: unknown;
|
|
47
|
+
};
|
|
48
|
+
export type AnthropicMessage = {
|
|
49
|
+
role: 'user' | 'assistant';
|
|
50
|
+
content: string | AnthropicContent[];
|
|
51
|
+
};
|
|
52
|
+
export type AnthropicTool = {
|
|
53
|
+
name: string;
|
|
54
|
+
description?: string;
|
|
55
|
+
input_schema: {
|
|
56
|
+
type: 'object';
|
|
57
|
+
properties?: Record<string, unknown>;
|
|
58
|
+
required?: string[];
|
|
59
|
+
[key: string]: unknown;
|
|
60
|
+
};
|
|
61
|
+
cache_control?: AnthropicCacheControl;
|
|
62
|
+
strict?: boolean;
|
|
63
|
+
defer_loading?: boolean;
|
|
64
|
+
input_examples?: Record<string, unknown>[];
|
|
65
|
+
allowed_callers?: string[];
|
|
66
|
+
[key: string]: unknown;
|
|
67
|
+
} | {
|
|
68
|
+
type: string;
|
|
69
|
+
name: string;
|
|
70
|
+
[key: string]: unknown;
|
|
71
|
+
};
|
|
72
|
+
export type AnthropicToolChoice = {
|
|
73
|
+
type: 'auto' | 'any' | 'none';
|
|
74
|
+
disable_parallel_tool_use?: boolean;
|
|
75
|
+
} | {
|
|
76
|
+
type: 'tool';
|
|
77
|
+
name: string;
|
|
78
|
+
disable_parallel_tool_use?: boolean;
|
|
79
|
+
};
|
|
80
|
+
export type AnthropicUsage = {
|
|
81
|
+
input_tokens: number;
|
|
82
|
+
output_tokens: number;
|
|
83
|
+
cache_creation_input_tokens?: number;
|
|
84
|
+
cache_read_input_tokens?: number;
|
|
85
|
+
[key: string]: unknown;
|
|
86
|
+
};
|
|
87
|
+
export type AnthropicResponse = {
|
|
88
|
+
id: string;
|
|
89
|
+
type: 'message';
|
|
90
|
+
role: 'assistant';
|
|
91
|
+
model: string;
|
|
92
|
+
content: AnthropicContent[];
|
|
93
|
+
stop_reason: string | null;
|
|
94
|
+
stop_sequence: string | null;
|
|
95
|
+
usage: AnthropicUsage;
|
|
96
|
+
[key: string]: unknown;
|
|
97
|
+
};
|
|
98
|
+
/** Native Messages options; no translation or silent selection of a billed provider/model. */
|
|
99
|
+
export type AnthropicRequest = {
|
|
100
|
+
model: string;
|
|
101
|
+
max_tokens: number;
|
|
102
|
+
messages: AnthropicMessage[];
|
|
103
|
+
system?: string | AnthropicText[];
|
|
104
|
+
tools?: AnthropicTool[];
|
|
105
|
+
tool_choice?: AnthropicToolChoice;
|
|
106
|
+
thinking?: {
|
|
107
|
+
type: 'enabled';
|
|
108
|
+
budget_tokens: number;
|
|
109
|
+
display?: 'summarized' | 'omitted' | 'updates';
|
|
110
|
+
} | {
|
|
111
|
+
type: 'disabled';
|
|
112
|
+
} | {
|
|
113
|
+
type: 'adaptive';
|
|
114
|
+
display?: 'summarized' | 'omitted' | 'updates';
|
|
115
|
+
};
|
|
116
|
+
stop_sequences?: string[];
|
|
117
|
+
metadata?: {
|
|
118
|
+
user_id?: string;
|
|
119
|
+
};
|
|
120
|
+
temperature?: number;
|
|
121
|
+
top_p?: number;
|
|
122
|
+
top_k?: number;
|
|
123
|
+
service_tier?: 'auto' | 'standard_only';
|
|
124
|
+
output_config?: {
|
|
125
|
+
effort?: 'low' | 'medium' | 'high' | 'max';
|
|
126
|
+
format?: {
|
|
127
|
+
type: 'json_schema';
|
|
128
|
+
schema: Record<string, unknown>;
|
|
129
|
+
};
|
|
130
|
+
[key: string]: unknown;
|
|
131
|
+
};
|
|
132
|
+
cache_control?: AnthropicCacheControl;
|
|
133
|
+
/** Additional native options are passed through unchanged. */
|
|
134
|
+
[key: string]: unknown;
|
|
135
|
+
};
|
|
136
|
+
export type AnthropicResult = ResponseResult & {
|
|
137
|
+
status: 'completed' | 'incomplete';
|
|
138
|
+
incompleteReason?: string;
|
|
139
|
+
usage: AnthropicUsage;
|
|
140
|
+
raw: AnthropicResponse;
|
|
141
|
+
};
|
|
142
|
+
/** The shared incomplete contract, with native usage/raw retained on the partial result. */
|
|
143
|
+
export declare class AnthropicIncompleteError extends IncompleteError {
|
|
144
|
+
result: AnthropicResult;
|
|
145
|
+
constructor(reason: string, result: AnthropicResult);
|
|
146
|
+
}
|
|
147
|
+
export type AnthropicStreamEvent = {
|
|
148
|
+
type: 'text_delta';
|
|
149
|
+
delta: string;
|
|
150
|
+
} | {
|
|
151
|
+
type: 'tool_use_delta';
|
|
152
|
+
index: number;
|
|
153
|
+
id: string;
|
|
154
|
+
name: string;
|
|
155
|
+
delta: string;
|
|
156
|
+
} | {
|
|
157
|
+
type: 'tool_use';
|
|
158
|
+
index: number;
|
|
159
|
+
tool: AnthropicToolUse;
|
|
160
|
+
} | {
|
|
161
|
+
type: 'content_block';
|
|
162
|
+
index: number;
|
|
163
|
+
block: AnthropicContent;
|
|
164
|
+
} | {
|
|
165
|
+
type: 'message_start';
|
|
166
|
+
message: AnthropicResponse;
|
|
167
|
+
} | {
|
|
168
|
+
type: 'message_delta';
|
|
169
|
+
delta: Partial<AnthropicResponse>;
|
|
170
|
+
usage?: Partial<AnthropicUsage>;
|
|
171
|
+
} | {
|
|
172
|
+
type: 'message_stop';
|
|
173
|
+
message: AnthropicResponse;
|
|
174
|
+
} | {
|
|
175
|
+
type: 'incomplete';
|
|
176
|
+
reason: string;
|
|
177
|
+
response: AnthropicResponse;
|
|
178
|
+
};
|
|
179
|
+
export type AnthropicAsk = AnthropicRequest & {
|
|
180
|
+
onText?: (delta: string) => void;
|
|
181
|
+
onEvent?: (event: AnthropicStreamEvent) => void;
|
|
182
|
+
signal?: AbortSignal;
|
|
183
|
+
/** Return metadata even without tools. */
|
|
184
|
+
result?: boolean;
|
|
185
|
+
};
|
|
186
|
+
export type AnthropicOptions = {
|
|
187
|
+
/** API key (billed per use), passed by the app. Never loaded from env/files. */
|
|
188
|
+
key: string;
|
|
189
|
+
fetch?: typeof fetch;
|
|
190
|
+
/** An app-owned proxy or a stand-in in tests. */
|
|
191
|
+
base?: string;
|
|
192
|
+
/** Native beta headers, only when the app explicitly requests them. */
|
|
193
|
+
betas?: readonly string[];
|
|
194
|
+
};
|
|
195
|
+
/** Recorded Messages SSE, split at arbitrary UTF-8/chunk boundaries. Completion requires message_stop. */
|
|
196
|
+
export declare function anthropicSseReader(onText?: (delta: string) => void, onEvent?: (event: AnthropicStreamEvent) => void): {
|
|
197
|
+
push(chunk: string): void;
|
|
198
|
+
result: () => AnthropicResult;
|
|
199
|
+
end(): string;
|
|
200
|
+
};
|
|
201
|
+
/** Explicit API-key backend. Use respond({ result: true }) for usage/raw. Incomplete answers always throw. */
|
|
202
|
+
export declare function anthropic(opts: AnthropicOptions): {
|
|
203
|
+
respond: {
|
|
204
|
+
(o: AnthropicAsk & {
|
|
205
|
+
result: true;
|
|
206
|
+
}): Promise<AnthropicResult>;
|
|
207
|
+
(o: AnthropicAsk & {
|
|
208
|
+
tools: AnthropicTool[];
|
|
209
|
+
}): Promise<AnthropicResult>;
|
|
210
|
+
(o: AnthropicAsk & {
|
|
211
|
+
tools?: undefined;
|
|
212
|
+
result?: false;
|
|
213
|
+
}): Promise<string>;
|
|
214
|
+
(o: AnthropicAsk): Promise<string | AnthropicResult>;
|
|
215
|
+
};
|
|
216
|
+
};
|
|
217
|
+
/** Internal auth seam for the API-key and subscription routes. */
|
|
218
|
+
export declare function anthropicMessages(opts: Pick<AnthropicOptions, 'fetch' | 'base' | 'betas'> & {
|
|
219
|
+
headers: Record<string, string>;
|
|
220
|
+
prepare?: (request: AnthropicRequest) => AnthropicRequest;
|
|
221
|
+
safeErrors?: boolean;
|
|
222
|
+
}): {
|
|
223
|
+
respond: {
|
|
224
|
+
(o: AnthropicAsk & {
|
|
225
|
+
result: true;
|
|
226
|
+
}): Promise<AnthropicResult>;
|
|
227
|
+
(o: AnthropicAsk & {
|
|
228
|
+
tools: AnthropicTool[];
|
|
229
|
+
}): Promise<AnthropicResult>;
|
|
230
|
+
(o: AnthropicAsk & {
|
|
231
|
+
tools?: undefined;
|
|
232
|
+
result?: false;
|
|
233
|
+
}): Promise<string>;
|
|
234
|
+
(o: AnthropicAsk): Promise<string | AnthropicResult>;
|
|
235
|
+
};
|
|
236
|
+
};
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// Messages over fetch alone. Authentication is explicit and separate from the request, so a future auth route can
|
|
2
|
+
// supply different headers without changing messages, tools, events or results. No environment or credential reads.
|
|
3
|
+
import { IncompleteError, ResponseError } from "./responses.js";
|
|
4
|
+
/** The shared incomplete contract, with native usage/raw retained on the partial result. */
|
|
5
|
+
export class AnthropicIncompleteError extends IncompleteError {
|
|
6
|
+
constructor(reason, result) {
|
|
7
|
+
super(reason, result);
|
|
8
|
+
this.message = 'Anthropic cut off its answer before it was complete.';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const record = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
12
|
+
const error = (status, value) => {
|
|
13
|
+
const e = record(value) && record(value.error) ? value.error : {};
|
|
14
|
+
const kind = status === 429 || e.type === 'rate_limit_error' ? 'rate_limit'
|
|
15
|
+
: status === 401 || status === 403 || e.type === 'authentication_error' ? 'signed_out'
|
|
16
|
+
: status >= 500 || e.type === 'overloaded_error' ? 'overloaded' : null;
|
|
17
|
+
return new ResponseError(typeof e.message === 'string' ? e.message : 'Anthropic could not answer. Try again.', kind);
|
|
18
|
+
};
|
|
19
|
+
/** Recorded Messages SSE, split at arbitrary UTF-8/chunk boundaries. Completion requires message_stop. */
|
|
20
|
+
export function anthropicSseReader(onText, onEvent) {
|
|
21
|
+
let buffer = '', message, stopped = false, finished;
|
|
22
|
+
const blocks = new Map();
|
|
23
|
+
const fail = () => new ResponseError('Anthropic stopped before completing its answer.', 'network');
|
|
24
|
+
const notifyText = (delta) => { if (delta) {
|
|
25
|
+
onText?.(delta);
|
|
26
|
+
onEvent?.({ type: 'text_delta', delta });
|
|
27
|
+
} };
|
|
28
|
+
const event = (block) => {
|
|
29
|
+
const data = block.split('\n').filter((l) => l.startsWith('data:')).map((l) => l.slice(5).replace(/^ /, '')).join('\n');
|
|
30
|
+
if (!data)
|
|
31
|
+
return;
|
|
32
|
+
let e;
|
|
33
|
+
try {
|
|
34
|
+
e = JSON.parse(data);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new ResponseError("Anthropic's answer could not be read.", null);
|
|
38
|
+
}
|
|
39
|
+
if (!record(e))
|
|
40
|
+
throw fail();
|
|
41
|
+
if (e.type === 'error')
|
|
42
|
+
throw error(0, e);
|
|
43
|
+
if (stopped)
|
|
44
|
+
return;
|
|
45
|
+
if (e.type === 'message_start') {
|
|
46
|
+
if (message || !record(e.message) || !Array.isArray(e.message.content) || !record(e.message.usage))
|
|
47
|
+
throw fail();
|
|
48
|
+
message = { ...e.message, content: [...e.message.content], usage: { ...e.message.usage } };
|
|
49
|
+
onEvent?.({ type: 'message_start', message: structuredCopy(message) });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (e.type === 'ping')
|
|
53
|
+
return;
|
|
54
|
+
if (e.type === 'content_block_start') {
|
|
55
|
+
if (!message || !Number.isSafeInteger(e.index) || e.index < 0 || blocks.has(e.index) || !record(e.content_block))
|
|
56
|
+
throw fail();
|
|
57
|
+
const b = { ...e.content_block };
|
|
58
|
+
blocks.set(e.index, { block: b, json: '', stopped: false, landed: false });
|
|
59
|
+
message.content[e.index] = b;
|
|
60
|
+
if (b.type === 'text' && typeof b.text === 'string')
|
|
61
|
+
notifyText(b.text);
|
|
62
|
+
}
|
|
63
|
+
else if (e.type === 'content_block_delta') {
|
|
64
|
+
const at = blocks.get(e.index);
|
|
65
|
+
if (!at || at.stopped || !record(e.delta))
|
|
66
|
+
throw fail();
|
|
67
|
+
const d = e.delta;
|
|
68
|
+
if (d.type === 'text_delta' && typeof d.text === 'string') {
|
|
69
|
+
if (at.block.type !== 'text')
|
|
70
|
+
throw fail();
|
|
71
|
+
at.block.text += d.text;
|
|
72
|
+
notifyText(d.text);
|
|
73
|
+
}
|
|
74
|
+
else if (d.type === 'input_json_delta' && typeof d.partial_json === 'string') {
|
|
75
|
+
if (at.block.type !== 'tool_use' && at.block.type !== 'server_tool_use')
|
|
76
|
+
throw fail();
|
|
77
|
+
at.json += d.partial_json;
|
|
78
|
+
onEvent?.({ type: 'tool_use_delta', index: e.index, id: at.block.id, name: at.block.name, delta: d.partial_json });
|
|
79
|
+
}
|
|
80
|
+
else if (d.type === 'thinking_delta' && typeof d.thinking === 'string')
|
|
81
|
+
at.block.thinking = (at.block.thinking ?? '') + d.thinking;
|
|
82
|
+
else if (d.type === 'signature_delta' && typeof d.signature === 'string')
|
|
83
|
+
at.block.signature = (at.block.signature ?? '') + d.signature;
|
|
84
|
+
else if (d.type === 'citations_delta' && record(d.citation))
|
|
85
|
+
at.block.citations = [...(at.block.citations ?? []), d.citation];
|
|
86
|
+
}
|
|
87
|
+
else if (e.type === 'content_block_stop') {
|
|
88
|
+
const at = blocks.get(e.index);
|
|
89
|
+
if (!at || at.stopped)
|
|
90
|
+
throw fail();
|
|
91
|
+
at.stopped = true;
|
|
92
|
+
// Emit valid blocks as they land. An interrupted JSON block must wait for the stop reason: max_tokens
|
|
93
|
+
// preserves that partial call without emitting an executable tool-use event.
|
|
94
|
+
if (at.json) {
|
|
95
|
+
try {
|
|
96
|
+
const input = JSON.parse(at.json);
|
|
97
|
+
if (record(input))
|
|
98
|
+
at.block.input = input;
|
|
99
|
+
else
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
onEvent?.({ type: 'content_block', index: e.index, block: structuredCopy(at.block) });
|
|
107
|
+
if (at.block.type === 'tool_use') {
|
|
108
|
+
if (typeof at.block.id !== 'string' || typeof at.block.name !== 'string' || !record(at.block.input))
|
|
109
|
+
throw fail();
|
|
110
|
+
onEvent?.({ type: 'tool_use', index: e.index, tool: structuredCopy(at.block) });
|
|
111
|
+
}
|
|
112
|
+
at.landed = true;
|
|
113
|
+
}
|
|
114
|
+
else if (e.type === 'message_delta') {
|
|
115
|
+
if (!message || !record(e.delta))
|
|
116
|
+
throw fail();
|
|
117
|
+
Object.assign(message, e.delta);
|
|
118
|
+
if (record(e.usage))
|
|
119
|
+
Object.assign(message.usage, e.usage); // cumulative, never summed
|
|
120
|
+
onEvent?.({ type: 'message_delta', delta: e.delta, ...(record(e.usage) ? { usage: e.usage } : {}) });
|
|
121
|
+
}
|
|
122
|
+
else if (e.type === 'message_stop') {
|
|
123
|
+
if (!message || !message.stop_reason || [...blocks.values()].some((b) => !b.stopped))
|
|
124
|
+
throw fail();
|
|
125
|
+
stopped = true;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const drain = (final) => {
|
|
129
|
+
let tail = '';
|
|
130
|
+
if (!final && buffer.endsWith('\r')) {
|
|
131
|
+
tail = '\r';
|
|
132
|
+
buffer = buffer.slice(0, -1);
|
|
133
|
+
}
|
|
134
|
+
const parts = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n\n');
|
|
135
|
+
buffer = (final ? '' : parts.pop()) + tail;
|
|
136
|
+
for (const part of parts)
|
|
137
|
+
event(part);
|
|
138
|
+
};
|
|
139
|
+
const result = () => {
|
|
140
|
+
if (finished) {
|
|
141
|
+
if (finished.status === 'incomplete')
|
|
142
|
+
throw new AnthropicIncompleteError(finished.incompleteReason, finished);
|
|
143
|
+
return finished;
|
|
144
|
+
}
|
|
145
|
+
drain(true);
|
|
146
|
+
if (!stopped || !message)
|
|
147
|
+
throw fail();
|
|
148
|
+
const incomplete = message.stop_reason === 'max_tokens' || message.stop_reason === 'refusal';
|
|
149
|
+
const output = [];
|
|
150
|
+
for (const [index, at] of [...blocks].sort(([a], [b]) => a - b)) {
|
|
151
|
+
if (at.json) {
|
|
152
|
+
try {
|
|
153
|
+
const input = JSON.parse(at.json);
|
|
154
|
+
if (!record(input))
|
|
155
|
+
throw new Error('not an object');
|
|
156
|
+
at.block.input = input;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
if (!incomplete)
|
|
160
|
+
throw new ResponseError("Anthropic's tool call could not be read.", null);
|
|
161
|
+
at.block.partial_json = at.json;
|
|
162
|
+
delete at.block.input;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const b = at.block;
|
|
166
|
+
if (!at.landed)
|
|
167
|
+
onEvent?.({ type: 'content_block', index, block: b });
|
|
168
|
+
if (b.type === 'tool_use' && record(b.input)) {
|
|
169
|
+
output.push({ type: 'function_call', call_id: b.id, name: b.name, arguments: JSON.stringify(b.input) });
|
|
170
|
+
if (!incomplete && !at.landed)
|
|
171
|
+
onEvent?.({ type: 'tool_use', index, tool: b });
|
|
172
|
+
}
|
|
173
|
+
else if (b.type === 'thinking' || b.type === 'redacted_thinking')
|
|
174
|
+
output.push({ ...b, type: 'reasoning' });
|
|
175
|
+
}
|
|
176
|
+
const text = message.content.filter((b) => b.type === 'text').map((b) => 'text' in b && typeof b.text === 'string' ? b.text : '').join('');
|
|
177
|
+
if (text)
|
|
178
|
+
output.unshift({ type: 'message', id: message.id, role: message.role, content: [{ type: 'output_text', text }] });
|
|
179
|
+
finished = { text, output, status: incomplete ? 'incomplete' : 'completed', ...(incomplete ? { incompleteReason: message.stop_reason } : {}), usage: message.usage, raw: message };
|
|
180
|
+
if (incomplete)
|
|
181
|
+
onEvent?.({ type: 'incomplete', reason: message.stop_reason, response: message });
|
|
182
|
+
onEvent?.({ type: 'message_stop', message });
|
|
183
|
+
if (incomplete)
|
|
184
|
+
throw new AnthropicIncompleteError(message.stop_reason, finished);
|
|
185
|
+
return finished;
|
|
186
|
+
};
|
|
187
|
+
return { push(chunk) { buffer += chunk; drain(false); }, result, end() { return result().text; } };
|
|
188
|
+
}
|
|
189
|
+
const structuredCopy = (value) => JSON.parse(JSON.stringify(value));
|
|
190
|
+
/** Explicit API-key backend. Use respond({ result: true }) for usage/raw. Incomplete answers always throw. */
|
|
191
|
+
export function anthropic(opts) {
|
|
192
|
+
if (!opts.key.trim())
|
|
193
|
+
throw new Error('Anthropic needs an API key (billed per use).');
|
|
194
|
+
return anthropicMessages({ ...opts, headers: { 'x-api-key': opts.key } });
|
|
195
|
+
}
|
|
196
|
+
/** Internal auth seam for the API-key and subscription routes. */
|
|
197
|
+
export function anthropicMessages(opts) {
|
|
198
|
+
async function respond(o) {
|
|
199
|
+
try {
|
|
200
|
+
return await run(o);
|
|
201
|
+
}
|
|
202
|
+
catch (e) {
|
|
203
|
+
if (!opts.safeErrors || e instanceof AnthropicIncompleteError)
|
|
204
|
+
throw e;
|
|
205
|
+
throw new ResponseError('Claude could not answer. Try again or sign in again.', e instanceof ResponseError ? e.kind : 'network');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async function run(o) {
|
|
209
|
+
const { onText, onEvent, signal, result, ...request } = o;
|
|
210
|
+
if (!request.model || !Number.isSafeInteger(request.max_tokens) || request.max_tokens <= 0)
|
|
211
|
+
throw new Error('Anthropic needs a model and a positive max_tokens.');
|
|
212
|
+
const res = await (opts.fetch ?? fetch)(`${(opts.base ?? 'https://api.anthropic.com').replace(/\/$/, '')}/v1/messages`, {
|
|
213
|
+
method: 'POST', signal,
|
|
214
|
+
headers: { 'content-type': 'application/json', accept: 'text/event-stream', 'anthropic-version': '2023-06-01', ...opts.headers,
|
|
215
|
+
...(opts.betas?.length ? { 'anthropic-beta': opts.betas.join(',') } : {}) },
|
|
216
|
+
body: JSON.stringify({ ...(opts.prepare ? opts.prepare(request) : request), stream: true }),
|
|
217
|
+
});
|
|
218
|
+
if (!res.ok) {
|
|
219
|
+
let body;
|
|
220
|
+
try {
|
|
221
|
+
body = await res.json();
|
|
222
|
+
}
|
|
223
|
+
catch { }
|
|
224
|
+
throw error(res.status, body);
|
|
225
|
+
}
|
|
226
|
+
const reader = anthropicSseReader(onText, onEvent);
|
|
227
|
+
const body = res.body;
|
|
228
|
+
if (res.headers?.get('content-type')?.includes('application/json')) {
|
|
229
|
+
let message;
|
|
230
|
+
try {
|
|
231
|
+
message = await res.json();
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
throw new ResponseError("Anthropic's answer could not be read.", null);
|
|
235
|
+
}
|
|
236
|
+
if (!record(message) || !Array.isArray(message.content))
|
|
237
|
+
throw new ResponseError("Anthropic's answer could not be read.", null);
|
|
238
|
+
const emit = (event) => reader.push(`data: ${JSON.stringify(event)}\n\n`);
|
|
239
|
+
emit({ type: 'message_start', message: { ...message, content: [] } });
|
|
240
|
+
for (const [index, block] of message.content.entries()) {
|
|
241
|
+
emit({ type: 'content_block_start', index, content_block: block });
|
|
242
|
+
emit({ type: 'content_block_stop', index });
|
|
243
|
+
}
|
|
244
|
+
emit({ type: 'message_delta', delta: { stop_reason: message.stop_reason, stop_sequence: message.stop_sequence }, usage: message.usage });
|
|
245
|
+
emit({ type: 'message_stop' });
|
|
246
|
+
}
|
|
247
|
+
else if (body?.getReader && typeof TextDecoder !== 'undefined') {
|
|
248
|
+
const r = body.getReader(), decoder = new TextDecoder();
|
|
249
|
+
try {
|
|
250
|
+
for (let c = await r.read(); !c.done; c = await r.read())
|
|
251
|
+
reader.push(decoder.decode(c.value, { stream: true }));
|
|
252
|
+
reader.push(decoder.decode());
|
|
253
|
+
}
|
|
254
|
+
catch (e) {
|
|
255
|
+
await r.cancel().catch(() => { });
|
|
256
|
+
throw e;
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
r.releaseLock();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
else
|
|
263
|
+
reader.push(await res.text());
|
|
264
|
+
const answer = reader.result();
|
|
265
|
+
return o.tools || result ? answer : answer.text;
|
|
266
|
+
}
|
|
267
|
+
return { respond };
|
|
268
|
+
}
|
package/dist/catalogue.d.ts
CHANGED
package/dist/catalogue.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// The AI accounts a person can bring, by the name they know, with each provider's billing and terms status as data
|
|
2
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.
|
|
4
|
-
//
|
|
5
|
-
// Kimi refuses anything but coding agents).
|
|
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.
|
|
6
5
|
import CATALOGUE from './catalogue.json' with { type: 'json' };
|
|
7
6
|
export const PROVIDERS = Object.fromEntries(Object.entries(CATALOGUE).map(([key, p]) => [key, { key, ...p }]));
|
|
8
7
|
export function provider(key) {
|
|
@@ -13,4 +12,4 @@ export function provider(key) {
|
|
|
13
12
|
}
|
|
14
13
|
/** What an app offers: the keys it names, in its order, or every subscription provider not hidden by default.
|
|
15
14
|
* 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');
|
|
15
|
+
export const offered = (keys) => keys ? keys.map(provider) : Object.values(PROVIDERS).filter((p) => !p.hidden && p.offer !== false && p.billing === 'subscription');
|
package/dist/catalogue.json
CHANGED
|
@@ -54,5 +54,37 @@
|
|
|
54
54
|
"hidden": true,
|
|
55
55
|
"why": "GitHub allows Copilot sign-in only in apps it has partnered with; this uses VS Code's client.",
|
|
56
56
|
"source": "https://github.blog/changelog/2026-01-16-github-copilot-now-supports-opencode"
|
|
57
|
+
},
|
|
58
|
+
"anthropic": {
|
|
59
|
+
"pi": "anthropic",
|
|
60
|
+
"name": "Claude",
|
|
61
|
+
"company": "Anthropic",
|
|
62
|
+
"models": {
|
|
63
|
+
"strong": "claude-opus-5-5"
|
|
64
|
+
},
|
|
65
|
+
"billing": "api",
|
|
66
|
+
"terms": "allowed",
|
|
67
|
+
"hidden": false,
|
|
68
|
+
"auth": "api-key",
|
|
69
|
+
"label": "API key (billed per use)",
|
|
70
|
+
"offer": false,
|
|
71
|
+
"why": "The app passes an Anthropic API key explicitly. API key (billed per use), never a default or subscription fallback.",
|
|
72
|
+
"source": "https://platform.claude.com/docs/en/api/overview"
|
|
73
|
+
},
|
|
74
|
+
"claude": {
|
|
75
|
+
"pi": "byokit-claude-plan",
|
|
76
|
+
"name": "Claude",
|
|
77
|
+
"label": "Claude Pro/Max",
|
|
78
|
+
"company": "Anthropic",
|
|
79
|
+
"models": {
|
|
80
|
+
"strong": "claude-opus-5-5"
|
|
81
|
+
},
|
|
82
|
+
"clientId": "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
|
83
|
+
"billing": "subscription",
|
|
84
|
+
"auth": "oauth",
|
|
85
|
+
"terms": "grey",
|
|
86
|
+
"hidden": false,
|
|
87
|
+
"why": "Anthropic prohibits third-party Claude.ai sign-in without approval; this route may stop working or lead to account restrictions.",
|
|
88
|
+
"source": "https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use"
|
|
57
89
|
}
|
|
58
90
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { CredentialStore } from '@earendil-works/pi-ai';
|
|
2
|
+
import type { AuthHost } from './accounts.ts';
|
|
3
|
+
export declare const CLAUDE_PLAN_ID = "byokit-claude-plan";
|
|
4
|
+
export declare const CLAUDE_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
5
|
+
/** A refused/uncertain rotation requires a new sign-in; its old refresh token must not be tried again. */
|
|
6
|
+
export declare class ClaudePlanExpiredError extends Error {
|
|
7
|
+
readonly code = "CLAUDE_PLAN_EXPIRED";
|
|
8
|
+
readonly status = 401;
|
|
9
|
+
constructor();
|
|
10
|
+
}
|
|
11
|
+
export declare class ClaudePlanPlatformError extends Error {
|
|
12
|
+
readonly code = "CLAUDE_PLAN_UNSUPPORTED_PLATFORM";
|
|
13
|
+
constructor();
|
|
14
|
+
}
|
|
15
|
+
export type ClaudePlanOptions = {
|
|
16
|
+
fetch?: typeof fetch;
|
|
17
|
+
/** Stand-ins for offline tests. Production uses the Hermes-reference endpoints directly. */
|
|
18
|
+
authorizeUrl?: string;
|
|
19
|
+
tokenUrl?: string;
|
|
20
|
+
/** On React Native, pass a Web Crypto implementation supplied by the app. */
|
|
21
|
+
crypto?: Pick<Crypto, 'getRandomValues' | 'subtle'>;
|
|
22
|
+
now?: () => number;
|
|
23
|
+
};
|
|
24
|
+
/** Pending state/verifier never enter persistent storage. */
|
|
25
|
+
export declare function claudeAuthorization(options?: ClaudePlanOptions): Promise<{
|
|
26
|
+
url: string;
|
|
27
|
+
verifier: string;
|
|
28
|
+
state: string;
|
|
29
|
+
redirect: string;
|
|
30
|
+
}>;
|
|
31
|
+
export declare function claudeCode(paste: string, state: string): string;
|
|
32
|
+
/** Adds only BYOKit's own Claude route; every other provider still belongs to the supplied engine. */
|
|
33
|
+
export declare function withClaudePlan(engine: AuthHost, credentials: CredentialStore, lockKey: CredentialStore, options?: ClaudePlanOptions): AuthHost;
|
|
34
|
+
/** Hermes's baseline native-client fingerprint. No installed CLI is consulted for its version. */
|
|
35
|
+
export declare function claudePlanMessages(access: string, options?: {
|
|
36
|
+
fetch?: typeof fetch;
|
|
37
|
+
}): {
|
|
38
|
+
respond: {
|
|
39
|
+
(o: import("./anthropic.ts").AnthropicAsk & {
|
|
40
|
+
result: true;
|
|
41
|
+
}): Promise<import("./anthropic.ts").AnthropicResult>;
|
|
42
|
+
(o: import("./anthropic.ts").AnthropicAsk & {
|
|
43
|
+
tools: import("./anthropic.ts").AnthropicTool[];
|
|
44
|
+
}): Promise<import("./anthropic.ts").AnthropicResult>;
|
|
45
|
+
(o: import("./anthropic.ts").AnthropicAsk & {
|
|
46
|
+
tools?: undefined;
|
|
47
|
+
result?: false;
|
|
48
|
+
}): Promise<string>;
|
|
49
|
+
(o: import("./anthropic.ts").AnthropicAsk): Promise<string | import("./anthropic.ts").AnthropicResult>;
|
|
50
|
+
};
|
|
51
|
+
};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { anthropicMessages } from "./anthropic.js";
|
|
2
|
+
import { needsReauth, refreshCredential } from "./stores.js";
|
|
3
|
+
export const CLAUDE_PLAN_ID = 'byokit-claude-plan';
|
|
4
|
+
export const CLAUDE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|
5
|
+
const REDIRECT = 'https://console.anthropic.com/oauth/code/callback';
|
|
6
|
+
const SCOPES = 'org:create_api_key user:profile user:inference';
|
|
7
|
+
/** A refused/uncertain rotation requires a new sign-in; its old refresh token must not be tried again. */
|
|
8
|
+
export class ClaudePlanExpiredError extends Error {
|
|
9
|
+
code = 'CLAUDE_PLAN_EXPIRED';
|
|
10
|
+
status = 401;
|
|
11
|
+
constructor() { super('Sign in with Claude again.'); this.name = 'ClaudePlanExpiredError'; }
|
|
12
|
+
}
|
|
13
|
+
export class ClaudePlanPlatformError extends Error {
|
|
14
|
+
code = 'CLAUDE_PLAN_UNSUPPORTED_PLATFORM';
|
|
15
|
+
constructor() { super('Claude sign-in needs secure random bytes and SHA-256 on this device.'); this.name = 'ClaudePlanPlatformError'; }
|
|
16
|
+
}
|
|
17
|
+
const base64url = (bytes) => btoa(Array.from(bytes, (b) => String.fromCharCode(b)).join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
18
|
+
/** Pending state/verifier never enter persistent storage. */
|
|
19
|
+
export async function claudeAuthorization(options = {}) {
|
|
20
|
+
const crypto = options.crypto ?? globalThis.crypto;
|
|
21
|
+
if (!crypto?.getRandomValues || !crypto.subtle?.digest)
|
|
22
|
+
throw new ClaudePlanPlatformError();
|
|
23
|
+
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
24
|
+
const state = base64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
25
|
+
const challenge = base64url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))));
|
|
26
|
+
const url = new URL(options.authorizeUrl ?? 'https://claude.ai/oauth/authorize');
|
|
27
|
+
url.search = new URLSearchParams({ code: 'true', response_type: 'code', client_id: CLAUDE_CLIENT_ID,
|
|
28
|
+
redirect_uri: REDIRECT, scope: SCOPES, state, code_challenge: challenge, code_challenge_method: 'S256' }).toString();
|
|
29
|
+
return { url: url.toString(), verifier, state, redirect: REDIRECT };
|
|
30
|
+
}
|
|
31
|
+
export function claudeCode(paste, state) {
|
|
32
|
+
let code, returned;
|
|
33
|
+
if (/^https?:\/\//.test(paste)) {
|
|
34
|
+
const url = new URL(paste);
|
|
35
|
+
code = url.searchParams.get('code');
|
|
36
|
+
returned = url.searchParams.get('state');
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
const parts = paste.trim().split('#');
|
|
40
|
+
code = parts.length === 2 ? parts[0] : null;
|
|
41
|
+
returned = parts.length === 2 ? parts[1] : null;
|
|
42
|
+
}
|
|
43
|
+
if (!code || !returned || returned !== state)
|
|
44
|
+
throw new Error('The Claude sign-in code does not match this sign-in. Try signing in again.');
|
|
45
|
+
return code;
|
|
46
|
+
}
|
|
47
|
+
const credential = (j, now, previous) => {
|
|
48
|
+
if (typeof j?.access_token !== 'string' || !j.access_token || typeof j.expires_in !== 'number'
|
|
49
|
+
|| !Number.isFinite(j.expires_in) || j.expires_in <= 0 || !Number.isFinite(now + j.expires_in * 1000)
|
|
50
|
+
|| (j.refresh_token !== undefined && (typeof j.refresh_token !== 'string' || !j.refresh_token)))
|
|
51
|
+
throw new Error('Claude could not complete the sign-in. Try signing in again.');
|
|
52
|
+
return { type: 'oauth', access: j.access_token, refresh: j.refresh_token ?? previous?.refresh ?? '', expires: now + j.expires_in * 1000 };
|
|
53
|
+
};
|
|
54
|
+
const states = new WeakMap();
|
|
55
|
+
/** Adds only BYOKit's own Claude route; every other provider still belongs to the supplied engine. */
|
|
56
|
+
export function withClaudePlan(engine, credentials, lockKey, options = {}) {
|
|
57
|
+
let shared = states.get(lockKey);
|
|
58
|
+
if (!shared)
|
|
59
|
+
states.set(lockKey, shared = { spent: new Set() });
|
|
60
|
+
const state = shared;
|
|
61
|
+
const now = options.now ?? Date.now;
|
|
62
|
+
const post = async (body, signal) => {
|
|
63
|
+
let res;
|
|
64
|
+
try {
|
|
65
|
+
res = await (options.fetch ?? fetch)(options.tokenUrl ?? 'https://platform.claude.com/v1/oauth/token', {
|
|
66
|
+
method: 'POST', signal: signal ?? AbortSignal.timeout(15_000),
|
|
67
|
+
headers: { 'content-type': 'application/json', 'user-agent': 'axios/1.7.9' }, body: JSON.stringify(body),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
throw new Error('Claude could not complete the sign-in on this connection.');
|
|
72
|
+
}
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
if ([400, 401, 403].includes(res.status))
|
|
75
|
+
throw new ClaudePlanExpiredError();
|
|
76
|
+
throw new Error('Claude could not complete the sign-in. Try again later.');
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
return await res.json();
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
throw new Error('Claude could not complete the sign-in. Try signing in again.');
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const login = async ({ signal, notify, prompt }) => {
|
|
86
|
+
const pending = await claudeAuthorization(options);
|
|
87
|
+
if (signal?.aborted)
|
|
88
|
+
throw new Error('Login cancelled');
|
|
89
|
+
// Register the paste receiver before publishing the URL, so an immediate paste cannot be lost.
|
|
90
|
+
const pasted = prompt({ type: 'manual_code', message: 'Paste the code from the Claude page.', signal });
|
|
91
|
+
notify({ type: 'auth_url', url: pending.url });
|
|
92
|
+
const code = claudeCode(await pasted, pending.state);
|
|
93
|
+
if (signal?.aborted)
|
|
94
|
+
throw new Error('Login cancelled');
|
|
95
|
+
const c = credential(await post({ grant_type: 'authorization_code', client_id: CLAUDE_CLIENT_ID,
|
|
96
|
+
code, state: pending.state, redirect_uri: pending.redirect, code_verifier: pending.verifier }, signal), now());
|
|
97
|
+
if (signal?.aborted)
|
|
98
|
+
throw new Error('Login cancelled');
|
|
99
|
+
try {
|
|
100
|
+
await credentials.modify(CLAUDE_PLAN_ID, async () => c, { signal });
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
throw new Error('Claude sign-in could not be saved on this device. Try signing in again.');
|
|
104
|
+
}
|
|
105
|
+
if (signal?.aborted) {
|
|
106
|
+
await credentials.delete(CLAUDE_PLAN_ID);
|
|
107
|
+
throw new Error('Login cancelled');
|
|
108
|
+
}
|
|
109
|
+
return c;
|
|
110
|
+
};
|
|
111
|
+
const access = async (minOAuthValidityMs = 0) => {
|
|
112
|
+
const min = Math.max(300_000, minOAuthValidityMs);
|
|
113
|
+
const due = (c) => now() + min >= c.expires;
|
|
114
|
+
const c = await credentials.read(CLAUDE_PLAN_ID);
|
|
115
|
+
if (c?.type !== 'oauth')
|
|
116
|
+
return undefined;
|
|
117
|
+
if (state.flight)
|
|
118
|
+
return state.flight;
|
|
119
|
+
if (needsReauth(c) || state.spent.has(c.refresh))
|
|
120
|
+
throw new ClaudePlanExpiredError();
|
|
121
|
+
if (!due(c))
|
|
122
|
+
return c;
|
|
123
|
+
const work = refreshCredential(credentials, CLAUDE_PLAN_ID, due, async (current) => {
|
|
124
|
+
if (!current.refresh || state.spent.has(current.refresh))
|
|
125
|
+
throw new ClaudePlanExpiredError();
|
|
126
|
+
state.spent.add(current.refresh);
|
|
127
|
+
return credential(await post({ grant_type: 'refresh_token', client_id: CLAUDE_CLIENT_ID, refresh_token: current.refresh }), now(), current);
|
|
128
|
+
}).catch(async () => {
|
|
129
|
+
await credentials.delete(CLAUDE_PLAN_ID).catch(() => { });
|
|
130
|
+
throw new ClaudePlanExpiredError();
|
|
131
|
+
});
|
|
132
|
+
state.flight = work;
|
|
133
|
+
try {
|
|
134
|
+
const next = await work;
|
|
135
|
+
if (next)
|
|
136
|
+
state.spent.delete(next.refresh);
|
|
137
|
+
return next;
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
if (state.flight === work)
|
|
141
|
+
state.flight = undefined;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
return Object.assign(engine, {
|
|
145
|
+
login: ((original) => (id, type, interaction) => id === CLAUDE_PLAN_ID ? login(interaction) : original.call(engine, id, type, interaction))(engine.login),
|
|
146
|
+
logout: ((original) => (id) => id === CLAUDE_PLAN_ID ? credentials.delete(id) : original.call(engine, id))(engine.logout),
|
|
147
|
+
checkAuth: ((original) => async (id) => id === CLAUDE_PLAN_ID
|
|
148
|
+
? await (async () => { const c = await credentials.read(id); return c?.type === 'oauth' && !needsReauth(c) ? { source: 'OAuth', type: 'oauth' } : undefined; })()
|
|
149
|
+
: original.call(engine, id))(engine.checkAuth),
|
|
150
|
+
getAuth: ((original) => async (id, overrides) => {
|
|
151
|
+
if (id !== CLAUDE_PLAN_ID)
|
|
152
|
+
return original.call(engine, id, overrides);
|
|
153
|
+
const c = await access(overrides?.minOAuthValidityMs);
|
|
154
|
+
return c ? { auth: { apiKey: c.access }, source: 'OAuth' } : undefined;
|
|
155
|
+
})(engine.getAuth),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
/** Hermes's baseline native-client fingerprint. No installed CLI is consulted for its version. */
|
|
159
|
+
export function claudePlanMessages(access, options = {}) {
|
|
160
|
+
return anthropicMessages({ ...options, headers: {
|
|
161
|
+
authorization: `Bearer ${access}`, 'anthropic-beta': 'claude-code-20250219,oauth-2025-04-20',
|
|
162
|
+
'user-agent': 'claude-code/2.1.74 (external, cli)', 'x-app': 'cli', 'anthropic-dangerous-direct-browser-access': 'true',
|
|
163
|
+
}, prepare: (request) => ({ ...request, system: [
|
|
164
|
+
{ type: 'text', text: "You are Claude Code, Anthropic's official CLI for Claude." },
|
|
165
|
+
...(typeof request.system === 'string' ? [{ type: 'text', text: request.system }] : request.system ?? []),
|
|
166
|
+
] }), safeErrors: true });
|
|
167
|
+
}
|
package/dist/node-stores.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { type EndingStore } from './stores.ts';
|
|
|
4
4
|
export type SafeStorageLike = {
|
|
5
5
|
encryptString(text: string): Uint8Array;
|
|
6
6
|
decryptString(data: Buffer): string;
|
|
7
|
+
/** Optional authenticated format upgrade; fileStore verifies and atomically replaces it. */
|
|
8
|
+
upgrade?(data: Buffer): Uint8Array | undefined;
|
|
7
9
|
isEncryptionAvailable?(): boolean;
|
|
8
10
|
getSelectedStorageBackend?(): string;
|
|
9
11
|
};
|
package/dist/node-stores.js
CHANGED
|
@@ -38,7 +38,16 @@ export function fileStore(path, safeStorage) {
|
|
|
38
38
|
const st = fstatSync(fd);
|
|
39
39
|
if (!st.isFile() || (st.mode & 0o077))
|
|
40
40
|
throw new Error('Credential storage requires a private regular file');
|
|
41
|
-
|
|
41
|
+
const bytes = readFileSync(fd);
|
|
42
|
+
const text = safeStorage.decryptString(bytes);
|
|
43
|
+
const data = JSON.parse(text);
|
|
44
|
+
const upgraded = safeStorage.upgrade?.(bytes);
|
|
45
|
+
if (upgraded) {
|
|
46
|
+
if (safeStorage.decryptString(Buffer.from(upgraded)) !== text)
|
|
47
|
+
throw new Error('Credential upgrade verification failed');
|
|
48
|
+
replace(upgraded);
|
|
49
|
+
}
|
|
50
|
+
return data;
|
|
42
51
|
}
|
|
43
52
|
finally {
|
|
44
53
|
closeSync(fd);
|
|
@@ -47,6 +56,9 @@ export function fileStore(path, safeStorage) {
|
|
|
47
56
|
const save = async (data) => {
|
|
48
57
|
ready();
|
|
49
58
|
const sealed = safeStorage.encryptString(JSON.stringify(data, null, 2));
|
|
59
|
+
replace(sealed);
|
|
60
|
+
};
|
|
61
|
+
const replace = (sealed) => {
|
|
50
62
|
mkdirSync(folder, { recursive: true, mode: 0o700 });
|
|
51
63
|
privateFolder();
|
|
52
64
|
const tmp = `${path}.${process.pid}.${randomBytes(12).toString('hex')}.tmp`;
|
package/dist/portable.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
export { Accounts, planOf, portable, type AccountsOptions, type AuthHost, type Loopback, type Member, type Platform, type SignIn, type Status } from './accounts.ts';
|
|
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
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, classifyFailure, type Failure, type Kind } from './limits.ts';
|
|
5
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';
|
|
6
|
-
export { browserStore, memoryStore, recordStore, secureStore, RefreshRequiredError, type RefreshStore, type SecureStoreLike } from './stores.ts';
|
|
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
8
|
export { chatgptPlan, UnsupportedAccountError, type ChatGPTPlanAccount, type ChatGPTPlanSession } from './chatgpt-plan.ts';
|
|
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
|
+
export { ClaudePlanExpiredError, ClaudePlanPlatformError, claudeAuthorization, claudeCode, type ClaudePlanOptions } from './claude-plan.ts';
|
package/dist/portable.js
CHANGED
|
@@ -5,6 +5,8 @@ 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, classifyFailure } from "./limits.js";
|
|
7
7
|
export { IncompleteError, ResponseError, isFunctionCall, limitResponse, respond, sseReader } from "./responses.js";
|
|
8
|
-
export { browserStore, memoryStore, recordStore, secureStore, RefreshRequiredError } from "./stores.js";
|
|
8
|
+
export { browserStore, keystoreStore, memoryStore, recordStore, secureStore, RefreshRequiredError } from "./stores.js";
|
|
9
9
|
export { WORDS, billingWords, callbackPage, clock, failure, say, signInError } from "./words.js";
|
|
10
10
|
export { chatgptPlan, UnsupportedAccountError } from "./chatgpt-plan.js";
|
|
11
|
+
export { anthropic, anthropicSseReader, AnthropicIncompleteError } from "./anthropic.js";
|
|
12
|
+
export { ClaudePlanExpiredError, ClaudePlanPlatformError, claudeAuthorization, claudeCode } from "./claude-plan.js";
|
package/dist/stores.d.ts
CHANGED
|
@@ -39,3 +39,8 @@ export declare function secureStore(secure: SecureStoreLike, name: string, optio
|
|
|
39
39
|
/** One person's sign-ins in the browser's IndexedDB (a PWA, or Electron's renderer), under `name`. A browser has no
|
|
40
40
|
* keychain: anything running on this page could read them, so keep the page free of scripts you don't control. */
|
|
41
41
|
export declare function browserStore(name: string, db?: string): EndingStore;
|
|
42
|
+
/** A device-owned @byokit/secrets backend, supplied by the host; no runtime Node import. One store per member/name. */
|
|
43
|
+
export declare function keystoreStore(keystore: {
|
|
44
|
+
get(name: string): Promise<string | null>;
|
|
45
|
+
set(name: string, secret: string): Promise<void>;
|
|
46
|
+
}, name: string): CredentialStore;
|
package/dist/stores.js
CHANGED
|
@@ -164,3 +164,7 @@ export function browserStore(name, db = 'byokit') {
|
|
|
164
164
|
end: (id, fn) => locked(() => store.end(id, fn)),
|
|
165
165
|
};
|
|
166
166
|
}
|
|
167
|
+
/** A device-owned @byokit/secrets backend, supplied by the host; no runtime Node import. One store per member/name. */
|
|
168
|
+
export function keystoreStore(keystore, name) {
|
|
169
|
+
return recordStore(async () => JSON.parse(await keystore.get(name) ?? '{}'), async (data) => keystore.set(name, JSON.stringify(data)));
|
|
170
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@byokit/accounts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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",
|
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
"files": [
|
|
42
42
|
"dist",
|
|
43
43
|
"CHANGELOG.md",
|
|
44
|
-
"SECURITY.md"
|
|
44
|
+
"SECURITY.md",
|
|
45
|
+
"NOTICE"
|
|
45
46
|
],
|
|
46
47
|
"scripts": {
|
|
47
48
|
"prepack": "tsc -b && node ../../scripts/fix-words-dts.cjs"
|