opencode-auth-load-balancer 0.5.1 → 0.6.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/README.md +93 -3
- package/dist/accounts.d.ts +22 -2
- package/dist/index.js +1898 -234
- package/dist/login-health.d.ts +12 -0
- package/dist/notify.d.ts +6 -0
- package/dist/pairing.d.ts +22 -0
- package/dist/pool/paths.d.ts +8 -0
- package/dist/providers/anthropic/constants.d.ts +21 -0
- package/dist/providers/anthropic/oauth.d.ts +9 -0
- package/dist/providers/anthropic/setup-token.d.ts +14 -0
- package/dist/providers/anthropic/usage.d.ts +13 -0
- package/dist/providers/types.d.ts +27 -0
- package/dist/refresh.d.ts +12 -0
- package/dist/status.d.ts +6 -0
- package/dist/sync/apply.d.ts +66 -0
- package/dist/sync/crypto.d.ts +19 -0
- package/dist/sync/cycle.d.ts +19 -0
- package/dist/sync/engine.d.ts +44 -0
- package/dist/sync/errors.d.ts +9 -0
- package/dist/sync/gist.d.ts +33 -0
- package/dist/sync/github-auth.d.ts +15 -0
- package/dist/sync/intent.d.ts +29 -0
- package/dist/sync/loop.d.ts +40 -0
- package/dist/sync/merge.d.ts +49 -0
- package/dist/sync/origin.d.ts +2 -0
- package/dist/sync/own.d.ts +31 -0
- package/dist/sync/payload.d.ts +50 -0
- package/dist/sync/start.d.ts +5 -0
- package/dist/sync/state.d.ts +79 -0
- package/dist/sync/timing.d.ts +4 -0
- package/dist/token-mint.d.ts +25 -0
- package/dist/types.d.ts +75 -0
- package/dist/util.d.ts +7 -0
- package/package.json +1 -1
- package/tui/auth-load-balancer-tui.logic.ts +363 -7
- package/tui/auth-load-balancer-tui.view.tsx +299 -107
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export declare const PAYLOAD_VERSION = 2;
|
|
2
|
+
export declare const MAX_ENTRIES = 64;
|
|
3
|
+
export declare const MAX_LABEL = 80;
|
|
4
|
+
/** A machine's origin: 128 random bits as hex. Not a secret, and never derived from a host name. */
|
|
5
|
+
export declare const ORIGIN_RE: RegExp;
|
|
6
|
+
/** The providers a credential may sync for, and the shape its secret must have. */
|
|
7
|
+
/** A Map, not an object: a provider id from a gist must never resolve to an inherited member such as `constructor`. */
|
|
8
|
+
export declare const SECRET_SHAPES: ReadonlyMap<string, RegExp>;
|
|
9
|
+
/** One synced credential, identified by the listing machine and its row id there. */
|
|
10
|
+
export interface SyncEntry {
|
|
11
|
+
/** The machine that listed it. */
|
|
12
|
+
origin: string;
|
|
13
|
+
/** That machine's row id. */
|
|
14
|
+
id: string;
|
|
15
|
+
providerID: string;
|
|
16
|
+
label: string;
|
|
17
|
+
/** A Claude inference token, or a Kimi API key. */
|
|
18
|
+
secret: string;
|
|
19
|
+
/** epoch ms a minted Claude token lapses. */
|
|
20
|
+
expiresAt?: number;
|
|
21
|
+
}
|
|
22
|
+
/** The identity of an entry across machines. */
|
|
23
|
+
export declare function entryKey(entry: Pick<SyncEntry, 'origin' | 'id'>): string;
|
|
24
|
+
/** `text` with those characters turned into spaces, trimmed. */
|
|
25
|
+
export declare function printable(text: string): string;
|
|
26
|
+
export declare function parseEntry(raw: unknown): SyncEntry | null;
|
|
27
|
+
/** The entries in a stable order (by key), so equal lists produce equal payloads. */
|
|
28
|
+
export declare function sortEntries(entries: readonly SyncEntry[]): SyncEntry[];
|
|
29
|
+
/** A digest of the entries: when it changes, the gist is out of date. */
|
|
30
|
+
export declare function entriesDigest(entries: readonly SyncEntry[]): string;
|
|
31
|
+
/** The plaintext to encrypt. */
|
|
32
|
+
export declare function buildPayload(entries: readonly SyncEntry[], at: number): string;
|
|
33
|
+
/** A snapshot read back: when it was written, its valid entries, and every key it still lists. */
|
|
34
|
+
export interface ParsedPayload {
|
|
35
|
+
at: number;
|
|
36
|
+
entries: SyncEntry[];
|
|
37
|
+
/**
|
|
38
|
+
* Keys of every listed entry, valid or not. An entry that fails validation is
|
|
39
|
+
* not imported, but it is still listed: it must never read as "removed".
|
|
40
|
+
*/
|
|
41
|
+
listed: ReadonlySet<string>;
|
|
42
|
+
/** How many entries could not be read at all: a writer must not rewrite what it cannot parse. */
|
|
43
|
+
unreadable: number;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The snapshot in a decrypted payload. Unknown fields, unknown providers,
|
|
47
|
+
* malformed entries and duplicates are dropped; an unknown version, a
|
|
48
|
+
* malformed envelope, a missing timestamp, or too many entries is an error.
|
|
49
|
+
*/
|
|
50
|
+
export declare function parsePayload(text: string): ParsedPayload;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type SyncLoop } from './loop';
|
|
2
|
+
/** On unless `OPENCODE_AUTH_LB_SYNC` is `0` / `false` / `no` / `off`. */
|
|
3
|
+
export declare function syncEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
4
|
+
/** Start the gist-sync schedule for this process; null when sync is switched off. */
|
|
5
|
+
export declare function startSync(): SyncLoop | null;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** A pool row a synced entry landed on, and a digest of the secret it carried. */
|
|
2
|
+
export interface ImportedRef {
|
|
3
|
+
accountId: string;
|
|
4
|
+
fingerprint: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* An entry that was not imported because the account already has a credential:
|
|
8
|
+
* the digest of the entry's secret, and the row in the way with the digest of
|
|
9
|
+
* the static credential it held ('' when it holds none, such as an OAuth login).
|
|
10
|
+
* While that row still holds it, the entry is not probed again.
|
|
11
|
+
*/
|
|
12
|
+
export interface SkippedRef {
|
|
13
|
+
fingerprint: string;
|
|
14
|
+
blocker: string;
|
|
15
|
+
holds: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The latest outcome, for the TUI (its own file: there is nowhere else to say
|
|
19
|
+
* "no GitHub token" before any state exists). `reqAt` echoes the request it
|
|
20
|
+
* answers. Never holds the link or key.
|
|
21
|
+
*/
|
|
22
|
+
export interface SyncStatus {
|
|
23
|
+
at: number;
|
|
24
|
+
ok: boolean;
|
|
25
|
+
message: string;
|
|
26
|
+
reqAt?: number;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* What this machine can do besides downloading, as the last cycle found it:
|
|
30
|
+
* upload (`ok`), or not for lack of a GitHub login, because the GitHub account
|
|
31
|
+
* cannot update the gist, or because the gist holds entries this version
|
|
32
|
+
* cannot read and must not rewrite.
|
|
33
|
+
*/
|
|
34
|
+
export type SyncWrite = 'ok' | 'no-token' | 'denied' | 'unreadable';
|
|
35
|
+
/** Everything sync remembers. Holds the encryption key: the file is owner-only and not part of the pool. */
|
|
36
|
+
export interface SyncState {
|
|
37
|
+
v: 1;
|
|
38
|
+
gistId: string;
|
|
39
|
+
/** The link's key, base64url. */
|
|
40
|
+
key: string;
|
|
41
|
+
/** This machine created the gist, so it may show the link. */
|
|
42
|
+
creator: boolean;
|
|
43
|
+
/** The creator's GitHub login, for building the link. */
|
|
44
|
+
owner?: string;
|
|
45
|
+
write?: SyncWrite;
|
|
46
|
+
/** No upload attempt before this time: the back-off after the GitHub account was refused. */
|
|
47
|
+
writeCheckAt?: number;
|
|
48
|
+
etag?: string;
|
|
49
|
+
syncedAt?: number;
|
|
50
|
+
/** Digest of this machine's own entries as of the last cycle that brought the gist up to date. */
|
|
51
|
+
uploadedDigest?: string;
|
|
52
|
+
/** The t stamped on the last upload: the next one is strictly later, whatever the clock does. */
|
|
53
|
+
uploadedAt?: number;
|
|
54
|
+
/** The newest snapshot t applied: an older ciphertext replayed from the gist is refused. */
|
|
55
|
+
appliedAt?: number;
|
|
56
|
+
/** The next scheduled download (and upload check). */
|
|
57
|
+
pollAt?: number;
|
|
58
|
+
/** No background download or upload before this time: the retry delay and rate-limit back-off after a failure. */
|
|
59
|
+
retryAt?: number;
|
|
60
|
+
/** Entry key (origin/id) to the local row it was imported into. */
|
|
61
|
+
imported: Record<string, ImportedRef>;
|
|
62
|
+
/** Entry key to why it was not imported. */
|
|
63
|
+
skipped: Record<string, SkippedRef>;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* A key-indexed map. Keys come from another machine, so the record has no
|
|
67
|
+
* prototype: `__proto__`, `constructor` and `prototype` are then ordinary
|
|
68
|
+
* keys, and a lookup never finds an inherited member.
|
|
69
|
+
*/
|
|
70
|
+
export declare function newMap<T>(from?: Readonly<Record<string, T>>): Record<string, T>;
|
|
71
|
+
export declare function newRefs(from?: Readonly<Record<string, ImportedRef>>): Record<string, ImportedRef>;
|
|
72
|
+
export declare function readSyncState(): Promise<SyncState | null>;
|
|
73
|
+
/**
|
|
74
|
+
* Read-modify-write the state under a cross-process lock. `fn` returns the
|
|
75
|
+
* next state, or null to forget everything (the file is removed).
|
|
76
|
+
*/
|
|
77
|
+
export declare function updateSyncState(fn: (current: SyncState | null) => SyncState | null): Promise<SyncState | null>;
|
|
78
|
+
/** Publish the outcome for the TUI to show; best effort, since a lost line only loses a message. */
|
|
79
|
+
export declare function writeSyncStatus(status: SyncStatus): Promise<void>;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** A subscriber asks GitHub at most this often; the due time is persisted, so every window shares it. */
|
|
2
|
+
export declare const POLL_MS: number;
|
|
3
|
+
/** After a failed upload the publisher waits this long (or as long as GitHub asked) before the next try. */
|
|
4
|
+
export declare const PUBLISH_RETRY_MS: number;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ProviderAdapter } from './providers/types';
|
|
2
|
+
import type { PoolAccount, PoolFile } from './types';
|
|
3
|
+
/** On unless `OPENCODE_AUTH_LB_ANTHROPIC_AUTO_TOKEN` is `0` / `false` / `no` / `off`. */
|
|
4
|
+
export declare function autoTokenEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
5
|
+
/** A row whose OAuth login should mint it a token: it has none, or its minted one nears its end. */
|
|
6
|
+
export declare function tokenDue(account: PoolAccount, now: number): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Mint `accountId` a token under its refresh lock, since the grant spends the
|
|
9
|
+
* single-use refresh token: the row is re-read once the lock is held (another
|
|
10
|
+
* process may have minted already), and the result lands only while the row
|
|
11
|
+
* still holds the refresh token spent — whose rotated successor it keeps
|
|
12
|
+
* even when the token itself is not pooled. Resolves whether a token landed;
|
|
13
|
+
* rejects when the grant fails.
|
|
14
|
+
*/
|
|
15
|
+
export declare function mintToken(adapter: ProviderAdapter, accountId: string): Promise<boolean>;
|
|
16
|
+
/**
|
|
17
|
+
* Mint every due row of `adapter`'s provider a token, in the background of
|
|
18
|
+
* startup and the request path. A process asks for each row at most once
|
|
19
|
+
* per RETRY_MS, and a failed grant leaves the row as it was: the grant is a
|
|
20
|
+
* refresh like any other, so a dead login surfaces through the regular
|
|
21
|
+
* refresh instead.
|
|
22
|
+
*/
|
|
23
|
+
export declare function maintainTokens(adapter: ProviderAdapter, now: number, poolSnapshot?: PoolFile): Promise<void>;
|
|
24
|
+
/** Mint a just-landed OAuth login its token at once, so the login completes paired. */
|
|
25
|
+
export declare function mintAtLogin(adapter: ProviderAdapter, account: PoolAccount): Promise<boolean>;
|
package/dist/types.d.ts
CHANGED
|
@@ -33,7 +33,46 @@ export interface TokenSet {
|
|
|
33
33
|
expires: number;
|
|
34
34
|
/** Provider account id (e.g. chatgpt-account-id), when present. */
|
|
35
35
|
accountId?: string;
|
|
36
|
+
/** Organization the credential acts for (Anthropic), when known: see `PoolAccount.orgId`. */
|
|
37
|
+
orgId?: string;
|
|
38
|
+
/**
|
|
39
|
+
* A long-lived bearer that only serves inference (Claude's `claude
|
|
40
|
+
* setup-token`). It joins a row as its `inferenceToken` instead of
|
|
41
|
+
* replacing the row's OAuth login.
|
|
42
|
+
*/
|
|
43
|
+
inferenceOnly?: boolean;
|
|
44
|
+
/** epoch ms an `inferenceOnly` token lapses, when known: see `PoolAccount.inferenceExpires`. */
|
|
45
|
+
inferenceExpires?: number;
|
|
46
|
+
/** epoch ms the OAuth login itself expires: see `PoolAccount.refreshExpires`. */
|
|
47
|
+
refreshExpires?: number;
|
|
48
|
+
/**
|
|
49
|
+
* Usage measured while logging in (a setup-token's validation probe, an
|
|
50
|
+
* OAuth login's first poll). It seeds the row, and its weekly reset anchor
|
|
51
|
+
* tells pairing which row the login belongs to.
|
|
52
|
+
*/
|
|
53
|
+
usage?: UsageSnapshot;
|
|
54
|
+
}
|
|
55
|
+
/** When and why a login stopped working. */
|
|
56
|
+
export interface LostLogin {
|
|
57
|
+
at: number;
|
|
58
|
+
reason: string;
|
|
36
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* The logins of a row the provider refused for good: `oauth` when its
|
|
62
|
+
* refresh was refused (invalid_grant), `token` when its static credential (a
|
|
63
|
+
* setup-token or API key) answered 401.
|
|
64
|
+
*/
|
|
65
|
+
export interface LostLogins {
|
|
66
|
+
oauth?: LostLogin;
|
|
67
|
+
token?: LostLogin;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* `expires` stamped on a static credential (a Kimi API key, a Claude
|
|
71
|
+
* setup-token): it has no refresh token behind it, so `needsRefresh` must
|
|
72
|
+
* never fire — one that stops working answers 401 instead. Finite, so the
|
|
73
|
+
* pool store's `Number.isFinite(expires)` normalization keeps it.
|
|
74
|
+
*/
|
|
75
|
+
export declare const STATIC_CREDENTIAL_EXPIRES: number;
|
|
37
76
|
/** One pooled credential. */
|
|
38
77
|
export type CooldownKind = 'quota' | 'auth' | 'transient';
|
|
39
78
|
export interface PoolAccount {
|
|
@@ -56,6 +95,42 @@ export interface PoolAccount {
|
|
|
56
95
|
tokenGen?: number;
|
|
57
96
|
/** Provider account id (e.g. chatgpt-account-id), or null. */
|
|
58
97
|
accountId: string | null;
|
|
98
|
+
/**
|
|
99
|
+
* A long-lived inference-only bearer (Claude's `claude setup-token`). When
|
|
100
|
+
* set it serves every request, and the row's OAuth login (`access` +
|
|
101
|
+
* `refresh`), if any, only polls usage — so an OAuth logout no longer stops
|
|
102
|
+
* inference. On a row without an OAuth login, `access` mirrors it with
|
|
103
|
+
* `refresh: ''` and a never-due expiry, like any static credential.
|
|
104
|
+
*/
|
|
105
|
+
inferenceToken?: string;
|
|
106
|
+
/**
|
|
107
|
+
* epoch ms `inferenceToken` lapses, known only for a token the plugin
|
|
108
|
+
* minted from the row's OAuth login (`src/token-mint.ts`), which renews it
|
|
109
|
+
* ahead of time. A pasted token's lifetime is unknown, so it has none.
|
|
110
|
+
*/
|
|
111
|
+
inferenceExpires?: number;
|
|
112
|
+
/**
|
|
113
|
+
* Logins of this row the provider refused for good, each kept with when
|
|
114
|
+
* and why until that login is replaced. While the row's other login still
|
|
115
|
+
* works the row keeps serving on it, and the lost one shows as `oauth
|
|
116
|
+
* re-login` / `token re-login` — distinct from a whole-row `re-login`,
|
|
117
|
+
* which is what remains when nothing works.
|
|
118
|
+
*/
|
|
119
|
+
lostLogins?: LostLogins;
|
|
120
|
+
/**
|
|
121
|
+
* epoch ms the row's OAuth login itself expires — its refresh token, from
|
|
122
|
+
* `refresh_token_expires_in` or else Claude Code's own 30-day assumption at
|
|
123
|
+
* login. Refreshes do not extend it, so the dashboards warn in its last
|
|
124
|
+
* days, as Claude Code does.
|
|
125
|
+
*/
|
|
126
|
+
refreshExpires?: number;
|
|
127
|
+
/**
|
|
128
|
+
* Organization the row's credentials act for (Anthropic), when known. A
|
|
129
|
+
* setup-token carries no account identity — only the organization its
|
|
130
|
+
* responses report — so this is the key that pairs it with its account's
|
|
131
|
+
* OAuth login on one row.
|
|
132
|
+
*/
|
|
133
|
+
orgId?: string;
|
|
59
134
|
usage: UsageSnapshot;
|
|
60
135
|
/** epoch ms; the account is skipped until this time. 0 = no cooldown. */
|
|
61
136
|
cooldownUntil: number;
|
package/dist/util.d.ts
CHANGED
|
@@ -40,6 +40,13 @@ export declare function isFiniteNumber(v: unknown): v is number;
|
|
|
40
40
|
* copy by design — the TUI runtime cannot import `src/`.
|
|
41
41
|
*/
|
|
42
42
|
export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
|
|
43
|
+
/**
|
|
44
|
+
* A one-line summary of a rejected HTTP response for the dashboards: the
|
|
45
|
+
* status plus what the common JSON error envelopes name — RFC 6749's
|
|
46
|
+
* `{error, error_description}`, Anthropic's and OpenAI's `{error: {type,
|
|
47
|
+
* message}}` — or else the start of the raw body.
|
|
48
|
+
*/
|
|
49
|
+
export declare function errorSummary(status: number, body: string): string;
|
|
43
50
|
/**
|
|
44
51
|
* Collect the `text` of every content block into one string. A message's
|
|
45
52
|
* `content` arrives as either a plain string (passed through) or an array of
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-auth-load-balancer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Load-balance opencode requests across multiple Anthropic (Claude) and OpenAI (Codex) OAuth accounts and Kimi Code subscriptions, weighted primarily by weekly usage with drain-before-reset scheduling.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|