@byokit/accounts 0.12.0 → 0.13.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 +7 -0
- package/README.md +62 -0
- package/dist/catalogue.d.ts +7 -0
- package/dist/catalogue.json +36 -6
- package/dist/multi.d.ts +77 -0
- package/dist/multi.js +134 -0
- package/dist/portable.d.ts +2 -1
- package/dist/portable.js +1 -0
- package/dist/words.json +35 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.13.0 (2026-10-01)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
- Add portable, choose-once multi-account selection with most-room ordering, unknown room above exhausted, and secret-free candidate explanations. Auto and default fallback use subscription accounts only; an API key (billed per use) must be explicitly selected.
|
|
10
|
+
- Export roomOf and roomWords, shared Auto conformance fixtures, and descriptive multi-account terms data.
|
|
11
|
+
|
|
5
12
|
## 0.12.0 (2026-09-30)
|
|
6
13
|
|
|
7
14
|
|
package/README.md
CHANGED
|
@@ -489,3 +489,65 @@ The common route follows Hermes's platform token host, three scopes and `axios/1
|
|
|
489
489
|
with inference `claude-code/2.1.74 (external, cli)`, `x-app: cli`, bearer authorization, Messages version
|
|
490
490
|
`2023-06-01` and betas `claude-code-20250219,oauth-2025-04-20`. The implementation is independent;
|
|
491
491
|
[NOTICE](NOTICE) records the MIT protocol references.
|
|
492
|
+
|
|
493
|
+
## Choosing between accounts
|
|
494
|
+
|
|
495
|
+
The portable entry exports `chooseAccount`, `resolveSelection`, `roomOf`, `roomWords` and the structural
|
|
496
|
+
`AccountLike`, `AccountPick`, `Room`, `Considered`, `RunSelection` and `Defaults` types. The host supplies its own
|
|
497
|
+
account records and readings; these functions never read credentials, sign in, refresh, start a run or switch a
|
|
498
|
+
running conversation. Resolve once before starting and keep the selected account for the whole run.
|
|
499
|
+
|
|
500
|
+
```ts
|
|
501
|
+
import { resolveSelection, roomOf, type AccountLike, type Defaults } from '@byokit/accounts';
|
|
502
|
+
|
|
503
|
+
// Host-provided account, model and measurement accessors:
|
|
504
|
+
declare const accounts: readonly AccountLike[];
|
|
505
|
+
declare const defaults: Defaults;
|
|
506
|
+
declare function windowsFor(account: AccountLike, demand: readonly string[]):
|
|
507
|
+
{ usedPercent: number; kind: string; resetsAt?: number }[];
|
|
508
|
+
declare function measuredAt(account: AccountLike): number | undefined;
|
|
509
|
+
declare function modelsFor(account: AccountLike): { id: string; available: boolean }[];
|
|
510
|
+
declare function startRunWith(account: AccountLike, model: string): void;
|
|
511
|
+
|
|
512
|
+
const pick = resolveSelection(accounts, defaults, { account: 'auto', needs: ['provider/model'] },
|
|
513
|
+
(account, demand) => roomOf(windowsFor(account, demand), measuredAt(account), 'milliseconds'), Date.now(),
|
|
514
|
+
(account) => modelsFor(account));
|
|
515
|
+
if (pick.ok) startRunWith(pick.account, pick.model);
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
Auto uses ready subscription accounts (including a rest whose deadline has elapsed). It ranks usable readings
|
|
519
|
+
by most room, earlier refill, then list order; unknown readings follow; exhausted accounts refill first. A reading
|
|
520
|
+
older than 24 hours counts as unknown. A numeric reading without a measurement time retains its room tier,
|
|
521
|
+
with unknown age/confidence. Explicit account ids bypass state and billing filtering: the host must verify the
|
|
522
|
+
selected account is ready before starting, and a failed explicit selection never falls back to another account.
|
|
523
|
+
API key accounts (billed per use) are used only when explicitly selected by id or a ready default.
|
|
524
|
+
|
|
525
|
+
`sel.model` and deduplicated `sel.needs` form the demand passed to the room reader. Supply `models` to verify
|
|
526
|
+
every demanded model is available. Without it, the host owns model eligibility; `model` is the explicit or default
|
|
527
|
+
model, or an empty string when neither is provided. With a model list, no available model returns `not_included`.
|
|
528
|
+
Without a demand, selection stays within the default account's provider, or the first provider in list order.
|
|
529
|
+
|
|
530
|
+
Each pick includes every account's `considered` row in list order. It holds only ids, exclusion/ranking codes,
|
|
531
|
+
room figures, measurement age and confidence (`known`, `stale`, `unknown`); no names, emails, credential fields
|
|
532
|
+
or engine messages are copied. The winning row's `reason` is the pick's `why`; other candidate reasons describe
|
|
533
|
+
the deterministic comparison to the Auto winner. Excluded rows carry their first exclusion code. Account ids
|
|
534
|
+
and model ids supplied by the host must themselves be secret-free. The generic returned `account` is the original
|
|
535
|
+
host record: keep credentials outside that record before exposing the whole pick to UI or logs.
|
|
536
|
+
|
|
537
|
+
`AccountLike.until`, `nowMs`, `Room.at`, `Room.resetsAt` and `Considered.age` use **milliseconds**.
|
|
538
|
+
`roomOf(windows, at, resetUnit?)` accepts structural windows. Legacy reset **epoch seconds** are the default,
|
|
539
|
+
converted by 1000 exactly once. For normalized `@byokit/usage` 0.2.0+ windows, pass `'milliseconds'` as the
|
|
540
|
+
third argument; reset times then stay unchanged. A normalized usage `Room` also passes directly to the chooser.
|
|
541
|
+
The optional `at` is always the original measurement time in epoch milliseconds.
|
|
542
|
+
The current structural input contains `usedPercent`, `kind`, and optional `resetsAt`; hard-limit/model-scope and
|
|
543
|
+
poll-health ingestion is a follow-up to the pending usage extension. Hosts must supply demand-filtered windows
|
|
544
|
+
and authoritative eligibility rather than interpreting an unavailable quota reading as a fresh successful read.
|
|
545
|
+
|
|
546
|
+
`Provider.multiAccount` gives a terms assessment, reason and source for multiple accounts of that service.
|
|
547
|
+
A grey assessment records missing explicit documentation; it does not gate selection. `auto.terms`, `auto.*`,
|
|
548
|
+
`room.*` and `pick.*` words are exported in `WORDS`.
|
|
549
|
+
|
|
550
|
+
Identity and re-authentication stay with the host's canonical device store or engine. A provider account id
|
|
551
|
+
scoped by member/provider proves identity; names and emails do not. The TypeScript identity fixture records
|
|
552
|
+
wrong-account, duplicate identity, changed-email, absent-identity, removal/refresh and extension-field boundaries
|
|
553
|
+
for runtime integration; the chooser consumes host-validated state and never adopts credentials itself.
|
package/dist/catalogue.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
export type Terms = 'allowed' | 'grey' | 'partner' | 'forbidden';
|
|
2
2
|
/** How the person pays: their plan (`subscription`), or per-use charges to their account (`api`, never offered by default). */
|
|
3
3
|
export type Billing = 'subscription' | 'api';
|
|
4
|
+
/** Terms assessment for choosing between several accounts; descriptive, never an eligibility gate. */
|
|
5
|
+
export type MultiAccountTerms = {
|
|
6
|
+
terms: Terms;
|
|
7
|
+
why: string;
|
|
8
|
+
source: string;
|
|
9
|
+
};
|
|
4
10
|
/** `callbackPort`: where the provider sends the browser back after its own sign-in page, fixed for the client Pi signs in as.
|
|
5
11
|
* `revoke`: where signing out ends the sign-in on the provider's side too, for the client `clientId`. */
|
|
6
12
|
export type Provider = {
|
|
@@ -23,6 +29,7 @@ export type Provider = {
|
|
|
23
29
|
hidden: boolean;
|
|
24
30
|
why: string;
|
|
25
31
|
source: string;
|
|
32
|
+
multiAccount: MultiAccountTerms;
|
|
26
33
|
};
|
|
27
34
|
export declare const PROVIDERS: Record<string, Provider>;
|
|
28
35
|
export declare function provider(key: string): Provider;
|
package/dist/catalogue.json
CHANGED
|
@@ -14,7 +14,12 @@
|
|
|
14
14
|
"terms": "grey",
|
|
15
15
|
"hidden": false,
|
|
16
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
|
-
"source": "https://developers.openai.com/codex/auth"
|
|
17
|
+
"source": "https://developers.openai.com/codex/auth",
|
|
18
|
+
"multiAccount": {
|
|
19
|
+
"terms": "grey",
|
|
20
|
+
"why": "This source does not explicitly document choosing between several accounts of this service in another app.",
|
|
21
|
+
"source": "https://developers.openai.com/codex/auth"
|
|
22
|
+
}
|
|
18
23
|
},
|
|
19
24
|
"openrouter": {
|
|
20
25
|
"pi": "openrouter",
|
|
@@ -27,7 +32,12 @@
|
|
|
27
32
|
"terms": "allowed",
|
|
28
33
|
"hidden": false,
|
|
29
34
|
"why": "Documented sign-in for any app, no registration. Pay-as-you-go credits, not a subscription.",
|
|
30
|
-
"source": "https://openrouter.ai/docs/guides/overview/auth/oauth"
|
|
35
|
+
"source": "https://openrouter.ai/docs/guides/overview/auth/oauth",
|
|
36
|
+
"multiAccount": {
|
|
37
|
+
"terms": "grey",
|
|
38
|
+
"why": "This source does not explicitly document choosing between several accounts of this service in another app.",
|
|
39
|
+
"source": "https://openrouter.ai/docs/guides/overview/auth/oauth"
|
|
40
|
+
}
|
|
31
41
|
},
|
|
32
42
|
"grok": {
|
|
33
43
|
"pi": "xai",
|
|
@@ -40,7 +50,12 @@
|
|
|
40
50
|
"terms": "partner",
|
|
41
51
|
"hidden": true,
|
|
42
52
|
"why": "xAI allows plan sign-in only in apps it has partnered with.",
|
|
43
|
-
"source": "https://x.ai/news/grok-opencode"
|
|
53
|
+
"source": "https://x.ai/news/grok-opencode",
|
|
54
|
+
"multiAccount": {
|
|
55
|
+
"terms": "grey",
|
|
56
|
+
"why": "This source does not explicitly document choosing between several accounts of this service in another app.",
|
|
57
|
+
"source": "https://x.ai/news/grok-opencode"
|
|
58
|
+
}
|
|
44
59
|
},
|
|
45
60
|
"copilot": {
|
|
46
61
|
"pi": "github-copilot",
|
|
@@ -53,7 +68,12 @@
|
|
|
53
68
|
"terms": "partner",
|
|
54
69
|
"hidden": true,
|
|
55
70
|
"why": "GitHub allows Copilot sign-in only in apps it has partnered with; this uses VS Code's client.",
|
|
56
|
-
"source": "https://github.blog/changelog/2026-01-16-github-copilot-now-supports-opencode"
|
|
71
|
+
"source": "https://github.blog/changelog/2026-01-16-github-copilot-now-supports-opencode",
|
|
72
|
+
"multiAccount": {
|
|
73
|
+
"terms": "grey",
|
|
74
|
+
"why": "This source does not explicitly document choosing between several accounts of this service in another app.",
|
|
75
|
+
"source": "https://github.blog/changelog/2026-01-16-github-copilot-now-supports-opencode"
|
|
76
|
+
}
|
|
57
77
|
},
|
|
58
78
|
"anthropic": {
|
|
59
79
|
"pi": "anthropic",
|
|
@@ -69,7 +89,12 @@
|
|
|
69
89
|
"label": "API key (billed per use)",
|
|
70
90
|
"offer": false,
|
|
71
91
|
"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"
|
|
92
|
+
"source": "https://platform.claude.com/docs/en/api/overview",
|
|
93
|
+
"multiAccount": {
|
|
94
|
+
"terms": "grey",
|
|
95
|
+
"why": "This source does not explicitly document choosing between several accounts of this service in another app.",
|
|
96
|
+
"source": "https://platform.claude.com/docs/en/api/overview"
|
|
97
|
+
}
|
|
73
98
|
},
|
|
74
99
|
"claude": {
|
|
75
100
|
"pi": "byokit-claude-plan",
|
|
@@ -85,6 +110,11 @@
|
|
|
85
110
|
"terms": "grey",
|
|
86
111
|
"hidden": false,
|
|
87
112
|
"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"
|
|
113
|
+
"source": "https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use",
|
|
114
|
+
"multiAccount": {
|
|
115
|
+
"terms": "grey",
|
|
116
|
+
"why": "This source does not explicitly document choosing between several accounts of this service in another app.",
|
|
117
|
+
"source": "https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use"
|
|
118
|
+
}
|
|
89
119
|
}
|
|
90
120
|
}
|
package/dist/multi.d.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export type SignInState = 'ready' | 'signing' | 'resting' | 'signed_out' | 'needs_again' | 'not_included';
|
|
2
|
+
export type AccountLike = {
|
|
3
|
+
id: string;
|
|
4
|
+
provider: string;
|
|
5
|
+
name: string;
|
|
6
|
+
state: SignInState;
|
|
7
|
+
billing: 'subscription' | 'api';
|
|
8
|
+
until?: number;
|
|
9
|
+
};
|
|
10
|
+
export type RoomSpan = 'session' | 'week' | 'month' | 'tightest';
|
|
11
|
+
/** Every time here is epoch milliseconds; `at` is the source measurement time, never its receipt time. */
|
|
12
|
+
export type Room = {
|
|
13
|
+
left: number;
|
|
14
|
+
span: RoomSpan;
|
|
15
|
+
resetsAt?: number;
|
|
16
|
+
at?: number;
|
|
17
|
+
} | {
|
|
18
|
+
left: 'unknown';
|
|
19
|
+
at?: number;
|
|
20
|
+
};
|
|
21
|
+
export type RunSelection = {
|
|
22
|
+
account: string | 'default' | 'auto';
|
|
23
|
+
model?: string;
|
|
24
|
+
needs?: string[];
|
|
25
|
+
};
|
|
26
|
+
export type Defaults = {
|
|
27
|
+
account?: string;
|
|
28
|
+
model?: string;
|
|
29
|
+
auto?: boolean;
|
|
30
|
+
};
|
|
31
|
+
export type PickWhy = 'chosen' | 'default' | 'first_ready' | 'only' | 'most_room' | 'earlier_reset' | 'list_order' | 'no_reading' | 'refills_first';
|
|
32
|
+
export type Considered = {
|
|
33
|
+
id: string;
|
|
34
|
+
out?: 'state' | 'resting' | 'billing' | 'model' | 'provider';
|
|
35
|
+
until?: number;
|
|
36
|
+
missing?: string;
|
|
37
|
+
tier?: 'room' | 'unknown' | 'exhausted';
|
|
38
|
+
left: number | 'unknown';
|
|
39
|
+
span?: RoomSpan;
|
|
40
|
+
resetsAt?: number;
|
|
41
|
+
age: number | 'unknown';
|
|
42
|
+
confidence: 'known' | 'stale' | 'unknown';
|
|
43
|
+
reason: PickWhy | 'state' | 'resting' | 'billing' | 'model' | 'provider';
|
|
44
|
+
};
|
|
45
|
+
export type AccountPick<A extends AccountLike = AccountLike> = {
|
|
46
|
+
ok: true;
|
|
47
|
+
account: A;
|
|
48
|
+
model: string;
|
|
49
|
+
how: 'chosen' | 'default' | 'auto';
|
|
50
|
+
why: PickWhy;
|
|
51
|
+
reason: string;
|
|
52
|
+
considered: Considered[];
|
|
53
|
+
} | {
|
|
54
|
+
ok: false;
|
|
55
|
+
code: 'none' | 'not_included' | 'unknown_account';
|
|
56
|
+
reason: string;
|
|
57
|
+
considered: Considered[];
|
|
58
|
+
};
|
|
59
|
+
type Models<A> = (a: A) => readonly {
|
|
60
|
+
id: string;
|
|
61
|
+
available: boolean;
|
|
62
|
+
}[];
|
|
63
|
+
/** Structural windows: legacy reset seconds by default; usage 0.2.0+ must pass milliseconds explicitly. */
|
|
64
|
+
export declare function roomOf(windows: readonly {
|
|
65
|
+
usedPercent: number;
|
|
66
|
+
kind: string;
|
|
67
|
+
resetsAt?: number;
|
|
68
|
+
}[], at?: number, resetUnit?: 'seconds' | 'milliseconds'): Room;
|
|
69
|
+
export declare function roomWords(room: Room): string;
|
|
70
|
+
/** Auto's winner only. Pure and portable; no timers, reads, refreshes or mid-run switching. */
|
|
71
|
+
export declare function chooseAccount<A extends AccountLike>(accounts: readonly A[], room: (a: A) => Room, nowMs: number): {
|
|
72
|
+
account: A;
|
|
73
|
+
why: PickWhy;
|
|
74
|
+
} | undefined;
|
|
75
|
+
/** Resolve once before starting. Without `models`, membership is host-validated and a missing model is ''. */
|
|
76
|
+
export declare function resolveSelection<A extends AccountLike>(accounts: readonly A[], defaults: Defaults, sel: RunSelection, room: (a: A, demand: readonly string[]) => Room, nowMs: number, models?: Models<A>): AccountPick<A>;
|
|
77
|
+
export {};
|
package/dist/multi.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Pure start-of-run selection. Callers own identities and credentials and keep the returned account for the run.
|
|
2
|
+
import { clock, say } from "./words.js";
|
|
3
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
4
|
+
const finite = (n) => typeof n === 'number' && Number.isFinite(n);
|
|
5
|
+
const reset = (r) => r.resetsAt ?? Infinity;
|
|
6
|
+
/** Structural windows: legacy reset seconds by default; usage 0.2.0+ must pass milliseconds explicitly. */
|
|
7
|
+
export function roomOf(windows, at, resetUnit = 'seconds') {
|
|
8
|
+
const known = windows.filter((w) => finite(w.usedPercent));
|
|
9
|
+
if (!known.length)
|
|
10
|
+
return { left: 'unknown', ...(finite(at) ? { at } : {}) };
|
|
11
|
+
const w = known.reduce((a, b) => b.usedPercent > a.usedPercent ? b : a);
|
|
12
|
+
const span = w.kind === 'session' ? 'session' : w.kind === 'weekly' ? 'week' : w.kind === 'monthly' ? 'month' : 'tightest';
|
|
13
|
+
return { left: Math.max(0, Math.min(100, 100 - w.usedPercent)), span,
|
|
14
|
+
...(finite(w.resetsAt) ? { resetsAt: w.resetsAt * (resetUnit === 'seconds' ? 1000 : 1) } : {}), ...(finite(at) ? { at } : {}) };
|
|
15
|
+
}
|
|
16
|
+
export function roomWords(room) {
|
|
17
|
+
return room.left === 'unknown' ? say('room.unknown') : say(`room.${room.span}`, { left: `${Math.round(room.left)}%` });
|
|
18
|
+
}
|
|
19
|
+
// One shared eligibility predicate for direct picks, Auto and all explanation rows. Room is read once per row.
|
|
20
|
+
function consider(accounts, room, nowMs, demand = [], models, provider, named) {
|
|
21
|
+
return accounts.map((account) => {
|
|
22
|
+
const r = room(account);
|
|
23
|
+
const age = finite(r.at) ? Math.max(0, nowMs - r.at) : 'unknown';
|
|
24
|
+
const stale = typeof age === 'number' && age > DAY_MS;
|
|
25
|
+
const left = r.left === 'unknown' || !finite(r.left) || stale ? 'unknown' : Math.max(0, Math.min(100, r.left));
|
|
26
|
+
const row = { id: account.id, left, age,
|
|
27
|
+
confidence: stale ? 'stale' : left === 'unknown' || age === 'unknown' ? 'unknown' : 'known', reason: 'no_reading' };
|
|
28
|
+
if (r.left !== 'unknown') {
|
|
29
|
+
row.span = r.span;
|
|
30
|
+
if (finite(r.resetsAt))
|
|
31
|
+
row.resetsAt = r.resetsAt;
|
|
32
|
+
}
|
|
33
|
+
// Explicit ids bypass state and billing: the host verifies readiness before starting, never falls back.
|
|
34
|
+
if (account.id !== named) {
|
|
35
|
+
if (account.state === 'resting' && finite(account.until) && account.until > nowMs) {
|
|
36
|
+
row.out = 'resting';
|
|
37
|
+
row.until = account.until;
|
|
38
|
+
}
|
|
39
|
+
else if (account.state !== 'ready' && !(account.state === 'resting' && finite(account.until) && account.until <= nowMs))
|
|
40
|
+
row.out = 'state';
|
|
41
|
+
else if (account.billing !== 'subscription')
|
|
42
|
+
row.out = 'billing';
|
|
43
|
+
}
|
|
44
|
+
if (!row.out && demand.length && models) {
|
|
45
|
+
const available = models(account);
|
|
46
|
+
const missing = demand.find((id) => !available.some((m) => m.id === id && m.available));
|
|
47
|
+
if (missing !== undefined) {
|
|
48
|
+
row.out = 'model';
|
|
49
|
+
row.missing = missing;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (!row.out && !demand.length && provider !== undefined && account.provider !== provider && account.id !== named)
|
|
53
|
+
row.out = 'provider';
|
|
54
|
+
if (row.out)
|
|
55
|
+
row.reason = row.out;
|
|
56
|
+
else
|
|
57
|
+
row.tier = left === 'unknown' ? 'unknown' : left > 0 || reset(row) <= nowMs ? 'room' : 'exhausted';
|
|
58
|
+
return { account, row };
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
const tierOrder = { room: 0, unknown: 1, exhausted: 2 };
|
|
62
|
+
function compare(a, b) {
|
|
63
|
+
const tier = tierOrder[a.row.tier] - tierOrder[b.row.tier];
|
|
64
|
+
if (tier)
|
|
65
|
+
return tier;
|
|
66
|
+
if (a.row.tier === 'unknown')
|
|
67
|
+
return 0;
|
|
68
|
+
if (a.row.tier === 'room' && a.row.left !== b.row.left)
|
|
69
|
+
return Number(b.row.left) - Number(a.row.left);
|
|
70
|
+
// Avoid Infinity - Infinity (NaN); stable sort preserves list order for ties.
|
|
71
|
+
return reset(a.row) === reset(b.row) ? 0 : reset(a.row) < reset(b.row) ? -1 : 1;
|
|
72
|
+
}
|
|
73
|
+
function rankingReason(winner, next) {
|
|
74
|
+
if (winner.row.tier === 'unknown')
|
|
75
|
+
return 'no_reading';
|
|
76
|
+
if (winner.row.tier === 'exhausted')
|
|
77
|
+
return 'refills_first';
|
|
78
|
+
if (!next || next.row.tier !== 'room' || winner.row.left !== next.row.left)
|
|
79
|
+
return 'most_room';
|
|
80
|
+
return reset(winner.row) < reset(next.row) ? 'earlier_reset' : 'list_order';
|
|
81
|
+
}
|
|
82
|
+
function rank(rows) {
|
|
83
|
+
const ranked = rows.filter((c) => !c.row.out).sort(compare);
|
|
84
|
+
const winner = ranked[0];
|
|
85
|
+
if (!winner)
|
|
86
|
+
return undefined;
|
|
87
|
+
const why = ranked.length === 1 ? 'only' : rankingReason(winner, ranked[1]);
|
|
88
|
+
for (const c of ranked)
|
|
89
|
+
c.row.reason = c === winner ? why : rankingReason(winner, c);
|
|
90
|
+
return { account: winner.account, why, row: winner.row };
|
|
91
|
+
}
|
|
92
|
+
/** Auto's winner only. Pure and portable; no timers, reads, refreshes or mid-run switching. */
|
|
93
|
+
export function chooseAccount(accounts, room, nowMs) {
|
|
94
|
+
const pick = rank(consider(accounts, room, nowMs));
|
|
95
|
+
return pick && { account: pick.account, why: pick.why };
|
|
96
|
+
}
|
|
97
|
+
/** Resolve once before starting. Without `models`, membership is host-validated and a missing model is ''. */
|
|
98
|
+
export function resolveSelection(accounts, defaults, sel, room, nowMs, models) {
|
|
99
|
+
const demand = [...new Set([...(sel.model ? [sel.model] : []), ...(sel.needs ?? [])])];
|
|
100
|
+
const defaultAccount = accounts.find((a) => a.id === defaults.account);
|
|
101
|
+
const named = sel.account === 'default' ? defaultAccount?.state === 'ready' ? defaultAccount.id : undefined : sel.account === 'auto' ? undefined : sel.account;
|
|
102
|
+
const provider = defaultAccount?.provider ?? accounts[0]?.provider;
|
|
103
|
+
const rows = consider(accounts, (a) => room(a, demand), nowMs, demand, models, provider, named);
|
|
104
|
+
const considered = rows.map((c) => c.row);
|
|
105
|
+
const auto = rank(rows); // also fills explanations for every eligible row, even on explicit selection
|
|
106
|
+
if (named !== undefined) {
|
|
107
|
+
const chosen = rows.find((c) => c.account.id === named);
|
|
108
|
+
if (!chosen)
|
|
109
|
+
return { ok: false, code: 'unknown_account', reason: say('pick.unknownAccount'), considered };
|
|
110
|
+
if (chosen.row.out)
|
|
111
|
+
return { ok: false, code: 'not_included', reason: say('pick.out.model', { model: chosen.row.missing ?? '' }), considered };
|
|
112
|
+
return finish(chosen.account, chosen.row, sel.account === 'default' ? 'default' : 'chosen', sel.account === 'default' ? 'default' : 'chosen');
|
|
113
|
+
}
|
|
114
|
+
if (sel.account === 'default' && defaults.auto === false) {
|
|
115
|
+
const first = rows.find((c) => !c.row.out);
|
|
116
|
+
if (first)
|
|
117
|
+
return finish(first.account, first.row, 'default', 'first_ready');
|
|
118
|
+
}
|
|
119
|
+
else if (auto)
|
|
120
|
+
return finish(auto.account, auto.row, 'auto', auto.why);
|
|
121
|
+
return { ok: false, code: 'none', reason: say('auto.none', { provider: provider ?? '' }), considered };
|
|
122
|
+
function finish(account, row, how, why) {
|
|
123
|
+
const available = models?.(account).filter((m) => m.available);
|
|
124
|
+
const model = sel.model ?? (defaults.model && (!available || available.some((m) => m.id === defaults.model)) ? defaults.model : available?.[0]?.id) ?? '';
|
|
125
|
+
if (models && !model)
|
|
126
|
+
return { ok: false, code: 'not_included', reason: say('pick.noModel'), considered };
|
|
127
|
+
row.reason = why;
|
|
128
|
+
const slots = { name: account.name, provider: account.provider };
|
|
129
|
+
const reason = how !== 'auto' ? say(`pick.why.${why}`, slots) : row.tier === 'unknown' ? say('auto.unknown', slots) : row.tier === 'exhausted' ?
|
|
130
|
+
say(finite(row.resetsAt) ? 'auto.refills' : 'auto.refillsNoTime', { ...slots, time: finite(row.resetsAt) ? clock(row.resetsAt) : '' }) :
|
|
131
|
+
say('auto.room', { ...slots, room: roomWords({ left: Number(row.left), span: row.span ?? 'tightest' }) });
|
|
132
|
+
return { ok: true, account, model, how, why, reason, considered };
|
|
133
|
+
}
|
|
134
|
+
}
|
package/dist/portable.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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 Provider, type Terms } from './catalogue.ts';
|
|
2
|
+
export { PROVIDERS, offered, provider, type Billing, type MultiAccountTerms, 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';
|
|
@@ -8,3 +8,4 @@ export { WORDS, billingWords, callbackPage, clock, failure, say, signInError, ty
|
|
|
8
8
|
export { chatgptPlan, UnsupportedAccountError, type ChatGPTPlanAccount, 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
|
+
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/portable.js
CHANGED
|
@@ -10,3 +10,4 @@ export { WORDS, billingWords, callbackPage, clock, failure, say, signInError } f
|
|
|
10
10
|
export { chatgptPlan, UnsupportedAccountError } from "./chatgpt-plan.js";
|
|
11
11
|
export { anthropic, anthropicSseReader, AnthropicIncompleteError } from "./anthropic.js";
|
|
12
12
|
export { ClaudePlanExpiredError, ClaudePlanPlatformError, claudeAuthorization, claudeCode } from "./claude-plan.js";
|
|
13
|
+
export { chooseAccount, resolveSelection, roomOf, roomWords } from "./multi.js";
|
package/dist/words.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"signIn.opening": "Opening {name}
|
|
2
|
+
"signIn.opening": "Opening {name}\u2026",
|
|
3
3
|
"signIn.waitingUrl": "Sign in on the {name} page that just opened.",
|
|
4
4
|
"signIn.waitingCode": "On the {name} page, type this code: {code}",
|
|
5
5
|
"signIn.pasteHint": "Having trouble? Copy the address from the browser and paste it here.",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"signIn.busy": "Something else on this computer is signing in to {name}. Try again in a minute.",
|
|
13
13
|
"signIn.failed": "{name} didn't finish the sign-in. Tap Sign in with {name} to try again.",
|
|
14
14
|
"status.ready": "{name} is connected.",
|
|
15
|
-
"status.signing": "Signing in to {name}
|
|
15
|
+
"status.signing": "Signing in to {name}\u2026",
|
|
16
16
|
"status.resting": "{name} is resting until {until}.",
|
|
17
17
|
"status.busy": "{name} is busy right now.",
|
|
18
18
|
"status.signedOut": "{name} isn't signed in yet.",
|
|
@@ -25,5 +25,37 @@
|
|
|
25
25
|
"callback.declined": "No problem. Nothing was changed. You can go back to {app}.",
|
|
26
26
|
"callback.failed": "{error} Go back to {app}.",
|
|
27
27
|
"callback.nearly": "Nearly there. Go back to {app} to finish.",
|
|
28
|
-
"callback.outOfDate": "This sign-in page is out of date. Go back to {app} and tap Sign in with {name} again."
|
|
28
|
+
"callback.outOfDate": "This sign-in page is out of date. Go back to {app} and tap Sign in with {name} again.",
|
|
29
|
+
"auto.terms": "Auto may use either of a provider's accounts.",
|
|
30
|
+
"auto.room": "Right now that's {name}: {room}",
|
|
31
|
+
"auto.unknown": "Right now that's {name} (no recent reading)",
|
|
32
|
+
"auto.refills": "All {provider} accounts are out of room until {time}. {name} refills first.",
|
|
33
|
+
"auto.refillsNoTime": "All {provider} accounts are out of room. {name} refills first.",
|
|
34
|
+
"auto.none": "No signed-in {provider} account.",
|
|
35
|
+
"room.unknown": "Room left unknown",
|
|
36
|
+
"room.session": "{left} left this session",
|
|
37
|
+
"room.week": "{left} left this week",
|
|
38
|
+
"room.month": "{left} left this month",
|
|
39
|
+
"room.tightest": "{left} left for now",
|
|
40
|
+
"pick.out.state": "Not signed in right now",
|
|
41
|
+
"pick.out.resting": "Taking a break until {time}",
|
|
42
|
+
"pick.out.billing": "Billed per use, so used only when you choose it",
|
|
43
|
+
"pick.out.model": "Doesn't include {model}",
|
|
44
|
+
"pick.out.provider": "A different service",
|
|
45
|
+
"pick.out.bound": "This conversation uses another account",
|
|
46
|
+
"pick.unknownAccount": "That account is no longer in your list.",
|
|
47
|
+
"pick.noModel": "That account doesn't include a model for this run.",
|
|
48
|
+
"pick.why.chosen": "You chose {name}.",
|
|
49
|
+
"pick.why.default": "{name} is your default.",
|
|
50
|
+
"pick.why.first_ready": "Your default isn't ready, so {name}, the first ready account.",
|
|
51
|
+
"pick.why.only": "{name} is the only account that can take this.",
|
|
52
|
+
"pick.why.most_room": "{name} has the most room left.",
|
|
53
|
+
"pick.why.earlier_reset": "{name} has as much room left and refills sooner.",
|
|
54
|
+
"pick.why.list_order": "{name} is tied for room and comes first in your list.",
|
|
55
|
+
"pick.why.no_reading": "No account has a recent reading, so {name}, first in your list.",
|
|
56
|
+
"pick.why.refills_first": "All accounts are out of room; {name} refills first.",
|
|
57
|
+
"pick.age": "Read {ago} ago",
|
|
58
|
+
"pick.ageUnknown": "Reading time unknown",
|
|
59
|
+
"ago.minutes": "{n} minutes",
|
|
60
|
+
"ago.hours": "{n} hours"
|
|
29
61
|
}
|