opencode-auth-load-balancer 0.3.0 → 0.4.1
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 +51 -7
- package/dist/fetch.d.ts +12 -1
- package/dist/index.js +3114 -2286
- package/dist/notify.d.ts +8 -0
- package/dist/pending/coordinator.d.ts +135 -0
- package/dist/pending/lease.d.ts +3 -0
- package/dist/pending/recovery.d.ts +16 -0
- package/dist/pending/store.d.ts +12 -0
- package/dist/pending/types.d.ts +23 -0
- package/dist/pool/lock.d.ts +1 -2
- package/dist/pool/paths.d.ts +11 -0
- package/dist/providers/anthropic/constants.d.ts +73 -6
- package/dist/providers/anthropic/version.d.ts +50 -0
- package/dist/providers/types.d.ts +33 -0
- package/dist/providers/usage-http.d.ts +7 -6
- package/dist/session.d.ts +2 -0
- package/dist/status.d.ts +3 -0
- package/dist/types.d.ts +3 -0
- package/dist/usage-refresh.d.ts +1 -0
- package/package.json +10 -10
- package/tui/auth-load-balancer-tui.logic.ts +9 -1
- package/tui/auth-load-balancer-tui.view.tsx +31 -10
package/dist/notify.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ProviderRecovery } from './pending/recovery';
|
|
1
2
|
import type { PoolAccount } from './types';
|
|
2
3
|
/** The slice of the opencode SDK client we need to show a toast. */
|
|
3
4
|
export interface ToastClient {
|
|
@@ -45,3 +46,10 @@ export declare function notifyOnSwitch(client: ToastClient, providerID: string,
|
|
|
45
46
|
* toast never affects the request.
|
|
46
47
|
*/
|
|
47
48
|
export declare function notifyModelFallback(client: ToastClient, providerID: string, account: PoolAccount, fromModel: string, toModel: string, fromTier?: string): Promise<void>;
|
|
49
|
+
/** Announce a durable provider wait. Coordinator-level signatures dedupe it. */
|
|
50
|
+
export declare function notifyPending(client: ToastClient, providerID: string, recovery: Extract<ProviderRecovery, {
|
|
51
|
+
state: 'quota-blocked';
|
|
52
|
+
}>, now?: number): Promise<void>;
|
|
53
|
+
export declare function notifyPendingResumed(client: ToastClient, providerID: string): Promise<void>;
|
|
54
|
+
export declare function notifyPendingRestored(client: ToastClient, providerID: string, count: number): Promise<void>;
|
|
55
|
+
export declare function notifyPendingRestoreError(client: ToastClient, providerID: string, messageID: string): Promise<void>;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { LockHandle } from '../pool/lock';
|
|
2
|
+
import type { ProviderAdapter } from '../providers/types';
|
|
3
|
+
import type { SchedulerConfig } from '../scheduler/config';
|
|
4
|
+
import type { PoolFile } from '../types';
|
|
5
|
+
import { type ProviderRecovery } from './recovery';
|
|
6
|
+
import type { PendingRef, PendingSchedule, PendingTurn } from './types';
|
|
7
|
+
export type CapacityWaitResult = 'available' | 'unusable';
|
|
8
|
+
interface RestoreUserMessage {
|
|
9
|
+
id: string;
|
|
10
|
+
sessionID: string;
|
|
11
|
+
role: 'user';
|
|
12
|
+
agent: string;
|
|
13
|
+
model: {
|
|
14
|
+
providerID: string;
|
|
15
|
+
modelID: string;
|
|
16
|
+
};
|
|
17
|
+
system?: string;
|
|
18
|
+
tools?: Record<string, boolean>;
|
|
19
|
+
}
|
|
20
|
+
interface RestoreAssistantMessage {
|
|
21
|
+
id: string;
|
|
22
|
+
sessionID: string;
|
|
23
|
+
role: 'assistant';
|
|
24
|
+
parentID: string;
|
|
25
|
+
finish?: string;
|
|
26
|
+
error?: unknown;
|
|
27
|
+
}
|
|
28
|
+
type RestoreMessage = RestoreUserMessage | RestoreAssistantMessage;
|
|
29
|
+
interface RestoreResult<T> {
|
|
30
|
+
data?: T;
|
|
31
|
+
error?: unknown;
|
|
32
|
+
response?: {
|
|
33
|
+
status: number;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
interface RestoreReadOptions {
|
|
37
|
+
path: {
|
|
38
|
+
id: string;
|
|
39
|
+
messageID: string;
|
|
40
|
+
};
|
|
41
|
+
query: {
|
|
42
|
+
directory: string;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
interface RestoreSessionOptions {
|
|
46
|
+
path: {
|
|
47
|
+
id: string;
|
|
48
|
+
};
|
|
49
|
+
query: {
|
|
50
|
+
directory: string;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
interface RestorePromptOptions extends RestoreSessionOptions {
|
|
54
|
+
body: {
|
|
55
|
+
messageID: string;
|
|
56
|
+
model: {
|
|
57
|
+
providerID: string;
|
|
58
|
+
modelID: string;
|
|
59
|
+
};
|
|
60
|
+
agent: string;
|
|
61
|
+
system?: string;
|
|
62
|
+
tools?: Record<string, boolean>;
|
|
63
|
+
parts: [];
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export interface PendingRestoreClient {
|
|
67
|
+
session: {
|
|
68
|
+
message(options: RestoreReadOptions): Promise<RestoreResult<{
|
|
69
|
+
info: RestoreMessage;
|
|
70
|
+
parts: unknown[];
|
|
71
|
+
}>>;
|
|
72
|
+
messages(options: RestoreSessionOptions): Promise<RestoreResult<{
|
|
73
|
+
info: RestoreMessage;
|
|
74
|
+
parts: unknown[];
|
|
75
|
+
}[]>>;
|
|
76
|
+
prompt(options: RestorePromptOptions): Promise<RestoreResult<unknown>>;
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
export interface PendingCoordinatorDependencies {
|
|
80
|
+
now(): number;
|
|
81
|
+
sleep(ms: number, signal?: AbortSignal): Promise<void>;
|
|
82
|
+
readPool(): Promise<PoolFile>;
|
|
83
|
+
refreshUsage(now: number): Promise<void>;
|
|
84
|
+
upsert(ref: PendingRef, schedule: PendingSchedule): Promise<PendingTurn>;
|
|
85
|
+
remove(ref: PendingRef | string): Promise<boolean>;
|
|
86
|
+
acquireLease(key: string): Promise<LockHandle>;
|
|
87
|
+
list(workspace: string, providerID?: string): Promise<PendingTurn[]>;
|
|
88
|
+
removeSession(workspace: string, sessionID: string): Promise<number>;
|
|
89
|
+
onPending(ref: PendingRef, recovery: Extract<ProviderRecovery, {
|
|
90
|
+
state: 'quota-blocked';
|
|
91
|
+
}>): Promise<void> | void;
|
|
92
|
+
onResumed(ref: PendingRef): Promise<void> | void;
|
|
93
|
+
onRestored(count: number): Promise<void> | void;
|
|
94
|
+
onRestoreError(ref: PendingRef, error: unknown): Promise<void> | void;
|
|
95
|
+
}
|
|
96
|
+
export interface PendingCoordinatorOptions {
|
|
97
|
+
workspace: string;
|
|
98
|
+
adapter: ProviderAdapter;
|
|
99
|
+
config: SchedulerConfig;
|
|
100
|
+
dependencies?: Partial<PendingCoordinatorDependencies>;
|
|
101
|
+
}
|
|
102
|
+
export declare class PendingCoordinator {
|
|
103
|
+
readonly workspace: string;
|
|
104
|
+
readonly providerID: string;
|
|
105
|
+
private readonly adapter;
|
|
106
|
+
private readonly config;
|
|
107
|
+
private readonly dependencies;
|
|
108
|
+
private readonly leases;
|
|
109
|
+
private readonly acquiring;
|
|
110
|
+
private readonly active;
|
|
111
|
+
private readonly restoring;
|
|
112
|
+
private readonly pendingToasts;
|
|
113
|
+
private disposing;
|
|
114
|
+
constructor(options: PendingCoordinatorOptions);
|
|
115
|
+
private ensureLease;
|
|
116
|
+
private releaseLease;
|
|
117
|
+
private notifyPending;
|
|
118
|
+
private clear;
|
|
119
|
+
waitForCapacity(ref: PendingRef, initial: Extract<ProviderRecovery, {
|
|
120
|
+
state: 'quota-blocked';
|
|
121
|
+
}>, outerSignal?: AbortSignal): Promise<CapacityWaitResult>;
|
|
122
|
+
/** Remove a completed/terminal turn and release any execution lease it owns. */
|
|
123
|
+
complete(ref: PendingRef): Promise<void>;
|
|
124
|
+
/** Explicit user cancellation removes durable state; shutdown preserves it. */
|
|
125
|
+
abort(ref: PendingRef): Promise<void>;
|
|
126
|
+
private restoreOnce;
|
|
127
|
+
private restoreWithRetry;
|
|
128
|
+
/** Restore this workspace/provider's durable references independently. */
|
|
129
|
+
restore(client: PendingRestoreClient): Promise<void>;
|
|
130
|
+
/** Remove all pending providers for a deleted session in this workspace. */
|
|
131
|
+
removeSession(sessionID: string): Promise<number>;
|
|
132
|
+
/** Mark shutdown before aborting sleepers so their abort path preserves rows. */
|
|
133
|
+
dispose(): Promise<void>;
|
|
134
|
+
}
|
|
135
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ScoreConfig } from '../scheduler/score-core';
|
|
2
|
+
import type { PoolAccount } from '../types';
|
|
3
|
+
export type ProviderRecovery = {
|
|
4
|
+
state: 'available';
|
|
5
|
+
} | {
|
|
6
|
+
state: 'quota-blocked';
|
|
7
|
+
nextCheckAt: number;
|
|
8
|
+
resumeAt: number | null;
|
|
9
|
+
} | {
|
|
10
|
+
state: 'unusable';
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Determine whether a provider can serve now, is provably blocked only by
|
|
14
|
+
* account-wide quota, or has no account that waiting for quota can repair.
|
|
15
|
+
*/
|
|
16
|
+
export declare function classifyProviderRecovery(accounts: readonly PoolAccount[], providerID: string, cfg: Pick<ScoreConfig, 'exhaustedAt'>, now: number, pollMs: number): ProviderRecovery;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PendingFile, PendingRef, PendingSchedule, PendingTurn } from './types';
|
|
2
|
+
export declare class PendingReadError extends Error {
|
|
3
|
+
readonly path: string;
|
|
4
|
+
readonly reason: unknown;
|
|
5
|
+
constructor(path: string, reason: unknown);
|
|
6
|
+
}
|
|
7
|
+
export declare function pendingKey(ref: PendingRef): string;
|
|
8
|
+
export declare function readPending(): Promise<PendingFile>;
|
|
9
|
+
export declare function upsertPending(ref: PendingRef, schedule: PendingSchedule): Promise<PendingTurn>;
|
|
10
|
+
export declare function removePending(refOrKey: PendingRef | string): Promise<boolean>;
|
|
11
|
+
export declare function removePendingSession(workspace: string, sessionID: string): Promise<number>;
|
|
12
|
+
export declare function listPendingForWorkspace(workspace: string, providerID?: string): Promise<PendingTurn[]>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface PendingRef {
|
|
2
|
+
workspace: string;
|
|
3
|
+
providerID: string;
|
|
4
|
+
sessionID: string;
|
|
5
|
+
messageID: string;
|
|
6
|
+
}
|
|
7
|
+
/** Durable scheduling metadata. Prompt content remains owned by OpenCode. */
|
|
8
|
+
export interface PendingTurn extends PendingRef {
|
|
9
|
+
key: string;
|
|
10
|
+
createdAt: number;
|
|
11
|
+
updatedAt: number;
|
|
12
|
+
nextCheckAt: number;
|
|
13
|
+
resumeAt: number | null;
|
|
14
|
+
}
|
|
15
|
+
export interface PendingFile {
|
|
16
|
+
version: 1;
|
|
17
|
+
turns: PendingTurn[];
|
|
18
|
+
}
|
|
19
|
+
export interface PendingSchedule {
|
|
20
|
+
now: number;
|
|
21
|
+
nextCheckAt: number;
|
|
22
|
+
resumeAt: number | null;
|
|
23
|
+
}
|
package/dist/pool/lock.d.ts
CHANGED
|
@@ -25,7 +25,7 @@ export interface LockOptions {
|
|
|
25
25
|
/** Re-touch the held lock this often so a live holder never looks stale. */
|
|
26
26
|
readonly heartbeatMs: number;
|
|
27
27
|
}
|
|
28
|
-
interface LockHandle {
|
|
28
|
+
export interface LockHandle {
|
|
29
29
|
release(): Promise<void>;
|
|
30
30
|
}
|
|
31
31
|
export declare class LockTimeoutError extends Error {
|
|
@@ -36,4 +36,3 @@ export declare class LockTimeoutError extends Error {
|
|
|
36
36
|
export declare function acquireLock(lockDir: string, opts: LockOptions): Promise<LockHandle>;
|
|
37
37
|
/** Run `fn` while holding `lockDir`, releasing it even if `fn` throws. */
|
|
38
38
|
export declare function withLock<T>(lockDir: string, opts: LockOptions, fn: () => Promise<T>): Promise<T>;
|
|
39
|
-
export {};
|
package/dist/pool/paths.d.ts
CHANGED
|
@@ -13,4 +13,15 @@ interface DataDirEnv {
|
|
|
13
13
|
export declare function resolveDataDir(env: DataDirEnv, home: string): string;
|
|
14
14
|
/** Path to the load-balancer's credential pool file. */
|
|
15
15
|
export declare function poolFilePath(): string;
|
|
16
|
+
/** Path to the reference-only durable pending-turn file. */
|
|
17
|
+
export declare function pendingFilePath(): string;
|
|
18
|
+
/**
|
|
19
|
+
* Path to the cached Claude Code version discovered from the npm registry
|
|
20
|
+
* (see `providers/anthropic/version.ts`). A THIRD file rather than a field in
|
|
21
|
+
* the pool: the pool's every read-modify-write takes a cross-process lock, and
|
|
22
|
+
* this cache is a best-effort, provider-owned scratch value that must never
|
|
23
|
+
* contend with — let alone fail — a credential/usage write. Same data dir and
|
|
24
|
+
* same env-keyed memo as the two files above.
|
|
25
|
+
*/
|
|
26
|
+
export declare function versionCacheFilePath(): string;
|
|
16
27
|
export {};
|
|
@@ -53,15 +53,82 @@ export declare const OPENCODE_IDENTITY_PREFIX = "You are OpenCode";
|
|
|
53
53
|
export declare const CLAUDE_CODE_IDENTITY = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
|
|
54
54
|
export declare const CCH_SALT = "59cf53e54c78";
|
|
55
55
|
export declare const CCH_POSITIONS: number[];
|
|
56
|
-
export declare const CLAUDE_CODE_VERSION = "2.1.87";
|
|
57
56
|
export declare const CLAUDE_CODE_ENTRYPOINT = "sdk-cli";
|
|
58
|
-
/** User-Agent for /v1/messages (matches the reference plugin's validated value). */
|
|
59
|
-
export declare const USER_AGENT = "claude-cli/2.1.87 (external, cli)";
|
|
60
57
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
58
|
+
* Version used when nothing better is known — the floor the resolver in
|
|
59
|
+
* `version.ts` never goes below.
|
|
60
|
+
*
|
|
61
|
+
* Anthropic GATES NEW MODELS ON THE CLIENT VERSION we report (in the
|
|
62
|
+
* `claude-cli/<version>` UA and the `cc_version=` billing header): requesting a
|
|
63
|
+
* too-new model with a too-old version is rejected outright with
|
|
64
|
+
* `{"error_code":"claude_code_version_too_old"}` — e.g. `claude-fable-5-1`
|
|
65
|
+
* requires ≥ 2.1.251. A hard-pinned constant therefore BREAKS on every future
|
|
66
|
+
* model launch, which is why the live value is resolved from the npm registry
|
|
67
|
+
* (`version.ts`) and this is only the offline floor.
|
|
68
|
+
*
|
|
69
|
+
* Verified against the real API: `claude-fable-5-1` returns 400
|
|
70
|
+
* (`claude_code_version_too_old`) at `2.1.87` and 200 at this value, using the
|
|
71
|
+
* SAME `CCH_SALT`/`CCH_POSITIONS` above — the fingerprint algorithm did not
|
|
72
|
+
* change across that range, so bumping the version string alone is sufficient.
|
|
73
|
+
* Keep this at a version confirmed to work; the resolver only ever moves UP
|
|
74
|
+
* from here.
|
|
63
75
|
*/
|
|
64
|
-
export declare const
|
|
76
|
+
export declare const FALLBACK_CLAUDE_CODE_VERSION = "2.1.258";
|
|
77
|
+
/**
|
|
78
|
+
* Pin the reported Claude Code version explicitly, bypassing BOTH the npm
|
|
79
|
+
* lookup and the `FALLBACK_CLAUDE_CODE_VERSION` floor — the escape hatch for
|
|
80
|
+
* the two directions the resolver cannot guess: pinning FORWARD to a version
|
|
81
|
+
* npm has not tagged `latest` yet, or pinning BACK (e.g. to reproduce this
|
|
82
|
+
* `claude_code_version_too_old` failure, or if a future release changes the
|
|
83
|
+
* `cch` fingerprint algorithm and the newest version starts 400ing).
|
|
84
|
+
*/
|
|
85
|
+
export declare const CLAUDE_CODE_VERSION_ENV = "OPENCODE_AUTH_LB_ANTHROPIC_CLAUDE_CODE_VERSION";
|
|
86
|
+
/**
|
|
87
|
+
* The structured error code Anthropic answers a version-gated model with:
|
|
88
|
+
*
|
|
89
|
+
* ```json
|
|
90
|
+
* {"type":"error","error":{"type":"invalid_request_error",
|
|
91
|
+
* "message":"Claude Code 2.1.278 does not support this model; version 2.1.280 or newer is required.",
|
|
92
|
+
* "details":{"error_code":"claude_code_version_too_old"}}}
|
|
93
|
+
* ```
|
|
94
|
+
*
|
|
95
|
+
* Matched as a SUBSTRING of the raw body rather than through a parsed path
|
|
96
|
+
* (`error.details.error_code`): the envelope's shape is Anthropic's to change,
|
|
97
|
+
* and this string appearing anywhere in a rejection is both sufficient — no
|
|
98
|
+
* other error carries it — and immune to a reshuffle that would silently break
|
|
99
|
+
* a path lookup. See `recoverClaudeCodeVersion` in version.ts.
|
|
100
|
+
*/
|
|
101
|
+
export declare const CLAUDE_CODE_VERSION_GATE_CODE = "claude_code_version_too_old";
|
|
102
|
+
/**
|
|
103
|
+
* The MINIMUM version that rejection's human-readable message demands.
|
|
104
|
+
*
|
|
105
|
+
* Anchored on the `or newer is required` tail rather than the first version in
|
|
106
|
+
* the string, because the message names TWO: the one we just sent ("Claude
|
|
107
|
+
* Code 2.1.278 does not support this model") and the one being asked for
|
|
108
|
+
* ("version 2.1.280 or newer is required"). Capturing the leading one would
|
|
109
|
+
* adopt the version that just failed — a no-op the recovery would correctly
|
|
110
|
+
* refuse to retry, i.e. a silent regression to the old stuck behavior.
|
|
111
|
+
*/
|
|
112
|
+
export declare const CLAUDE_CODE_REQUIRED_VERSION_RE: RegExp;
|
|
113
|
+
/**
|
|
114
|
+
* npm dist-tags for the Claude Code CLI — `{"latest":"2.1.258",…}`, ~56 bytes.
|
|
115
|
+
* Deliberately NOT `/@anthropic-ai/claude-code/latest`, which returns the whole
|
|
116
|
+
* 3.3 KB version packument for the one field we read.
|
|
117
|
+
*/
|
|
118
|
+
export declare const CLAUDE_CODE_REGISTRY_URL = "https://registry.npmjs.org/-/package/@anthropic-ai%2Fclaude-code/dist-tags";
|
|
119
|
+
/**
|
|
120
|
+
* How long a registry answer stays authoritative on disk. Claude Code ships
|
|
121
|
+
* roughly daily, and a stale-by-a-day version only matters on the day a new
|
|
122
|
+
* model launches — so a day-long TTL keeps startup network-free almost always
|
|
123
|
+
* while still converging well inside the window that matters.
|
|
124
|
+
*/
|
|
125
|
+
export declare const CLAUDE_CODE_VERSION_TTL_MS: number;
|
|
126
|
+
/**
|
|
127
|
+
* Bound the registry lookup. Shorter than the 30 s OAuth/usage budgets because
|
|
128
|
+
* nothing waits on this: it runs fire-and-forget at startup and a miss simply
|
|
129
|
+
* leaves the previous (cached or fallback) version in place.
|
|
130
|
+
*/
|
|
131
|
+
export declare const REGISTRY_HTTP_TIMEOUT_MS = 10000;
|
|
65
132
|
export declare const PARAGRAPH_REMOVAL_ANCHORS: string[];
|
|
66
133
|
export declare const TEXT_REPLACEMENTS: {
|
|
67
134
|
match: string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The version to report right now. Reads the env override on every call (like
|
|
3
|
+
* `resolveBaseUrl`/`poolFilePath` do for theirs) so a pin takes effect without
|
|
4
|
+
* a restart.
|
|
5
|
+
*/
|
|
6
|
+
export declare function claudeCodeVersion(): string;
|
|
7
|
+
/** User-Agent for /v1/messages. */
|
|
8
|
+
export declare function userAgent(): string;
|
|
9
|
+
/**
|
|
10
|
+
* User-Agent for the /api/oauth/usage endpoint. That endpoint hard-rejects
|
|
11
|
+
* (429) requests whose UA is not `claude-code/<version>` — note the different
|
|
12
|
+
* product token and the absence of the ` (external, cli)` suffix.
|
|
13
|
+
*/
|
|
14
|
+
export declare function usageUserAgent(): string;
|
|
15
|
+
/**
|
|
16
|
+
* Ask npm for the newest Claude Code, adopt it if newer, and persist the
|
|
17
|
+
* result. Best-effort throughout: a registry outage, a shape change, or an
|
|
18
|
+
* unwritable data dir all leave the in-memory version untouched and simply
|
|
19
|
+
* defer the next attempt to the next startup (the cache is written only on a
|
|
20
|
+
* usable answer, so a failure does NOT refresh the TTL).
|
|
21
|
+
*/
|
|
22
|
+
export declare function refreshClaudeCodeVersion(now: number): Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* REACTIVE recovery, driven by the gate's own rejection.
|
|
25
|
+
*
|
|
26
|
+
* `primeClaudeCodeVersion` resolves once at startup and then trusts the cache
|
|
27
|
+
* for `CLAUDE_CODE_VERSION_TTL_MS`. A model that launches INSIDE that window is
|
|
28
|
+
* therefore rejected for as long as the TTL has left to run — up to a full day
|
|
29
|
+
* of hard failures — even when npm already tags the version Anthropic is asking
|
|
30
|
+
* for, and even though the rejection itself NAMES that version. No polling
|
|
31
|
+
* cadence fixes this in general either: Anthropic can gate a model before npm
|
|
32
|
+
* tags the release, which is why the env pin exists as a manual escape hatch.
|
|
33
|
+
*
|
|
34
|
+
* So learn from the rejection instead. Adopt the minimum it demands, then ask
|
|
35
|
+
* npm for the real `latest` in the same breath — the minimum only clears the
|
|
36
|
+
* model that just failed, whereas `latest` is what the NEXT launch will want.
|
|
37
|
+
*
|
|
38
|
+
* Returns whether the reported version actually MOVED, which is precisely the
|
|
39
|
+
* condition under which a retry can succeed; the caller spends a second
|
|
40
|
+
* round-trip on true and passes the rejection through untouched on false.
|
|
41
|
+
* `res` is consumed, so callers that still need the body must pass a clone.
|
|
42
|
+
*/
|
|
43
|
+
export declare function recoverClaudeCodeVersion(res: Response, now: number): Promise<boolean>;
|
|
44
|
+
/**
|
|
45
|
+
* Startup hook. Awaits only the LOCAL cache read — cheap, and enough to have
|
|
46
|
+
* the right version in hand before the loader returns the fetch — then leaves
|
|
47
|
+
* any registry round-trip running in the background so neither opencode's
|
|
48
|
+
* startup nor the first request ever blocks on npm.
|
|
49
|
+
*/
|
|
50
|
+
export declare function primeClaudeCodeVersion(now: number): Promise<void>;
|
|
@@ -97,6 +97,19 @@ export interface ProviderAdapter {
|
|
|
97
97
|
fetchUsage(account: PoolAccount, now: number): Promise<UsageSnapshot | null>;
|
|
98
98
|
/** Classify an HTTP status for rotation decisions. */
|
|
99
99
|
classifyError(status: number): ErrorClass;
|
|
100
|
+
/**
|
|
101
|
+
* One-time startup warm-up, awaited by the auth loader BEFORE it returns the
|
|
102
|
+
* load-balanced fetch — so anything a request's shaping depends on is in
|
|
103
|
+
* place before the first request is served. Anthropic uses it to resolve the
|
|
104
|
+
* Claude Code version it must claim (Anthropic gates new models on it; see
|
|
105
|
+
* `providers/anthropic/version.ts`); OpenAI leaves it unset, and the loader
|
|
106
|
+
* treats an absent hook as "nothing to warm up".
|
|
107
|
+
*
|
|
108
|
+
* MUST stay cheap and MUST NOT throw — it sits on opencode's startup path.
|
|
109
|
+
* Implementations do local I/O only and push anything network-bound into the
|
|
110
|
+
* background themselves.
|
|
111
|
+
*/
|
|
112
|
+
prime?(now: number): Promise<void>;
|
|
100
113
|
/**
|
|
101
114
|
* The model TIER `body` asks for (the `modelCooldownsUntil` key the fetch
|
|
102
115
|
* loop consults BEFORE sending, to steer the request onto an account with
|
|
@@ -123,4 +136,24 @@ export interface ProviderAdapter {
|
|
|
123
136
|
* downgraded body. null = treat the 429 as a normal account rotation.
|
|
124
137
|
*/
|
|
125
138
|
planReactiveFallback?(res: Response, body: string, now: number, models: readonly string[]): ReactiveModelFallback | null;
|
|
139
|
+
/**
|
|
140
|
+
* REACTIVE recovery from a CLIENT-VERSION gate: a provider may reject an
|
|
141
|
+
* otherwise-valid request until the plugin claims to be a newer client
|
|
142
|
+
* (Anthropic answers a too-new model with 400 `claude_code_version_too_old`,
|
|
143
|
+
* naming the minimum it wants). Such a status classifies as `ok` — the fetch
|
|
144
|
+
* loop is one statement away from handing it to the caller — so this is the
|
|
145
|
+
* last point at which it can become a recoverable event instead of a failed
|
|
146
|
+
* turn.
|
|
147
|
+
*
|
|
148
|
+
* Return true ONLY when the version the adapter will now report actually
|
|
149
|
+
* changed: the fetch loop reads that as "a retry can succeed" and restarts
|
|
150
|
+
* the request once. Returning true without a change would buy a second,
|
|
151
|
+
* byte-identical round-trip and the same rejection.
|
|
152
|
+
*
|
|
153
|
+
* `res` is a CLONE the implementation may consume freely; the original is
|
|
154
|
+
* still returned to the caller when this resolves false. Absent on providers
|
|
155
|
+
* with no such gate (OpenAI) — the fetch loop treats that as "nothing to
|
|
156
|
+
* recover".
|
|
157
|
+
*/
|
|
158
|
+
recoverClientVersion?(res: Response, now: number): Promise<boolean>;
|
|
126
159
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared HTTP shell for the
|
|
3
|
-
* OpenAI `/wham/usage`)
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Shared HTTP shell for the plugin's read-only JSON GETs — the provider usage
|
|
3
|
+
* endpoints (Anthropic `/usage`, OpenAI `/wham/usage`) and the npm dist-tags
|
|
4
|
+
* lookup behind `providers/anthropic/version.ts`. GET with a timeout, null on
|
|
5
|
+
* ANY failure — network throw/timeout, non-ok status (body cancelled so the
|
|
6
|
+
* connection is released), or a 200 whose body is not JSON. Callers keep only
|
|
7
|
+
* their own headers and shape guards; the transport contract lives here ONCE.
|
|
7
8
|
*/
|
|
8
|
-
export declare function
|
|
9
|
+
export declare function fetchJson<T>(url: string, headers: Record<string, string>, timeoutMs: number): Promise<T | null>;
|
package/dist/session.d.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* fetch. Stripped before the request leaves for the provider.
|
|
5
5
|
*/
|
|
6
6
|
export declare const SESSION_HEADER = "x-allb-session";
|
|
7
|
+
/** OpenCode user-message id paired with SESSION_HEADER for durable turn identity. */
|
|
8
|
+
export declare const MESSAGE_HEADER = "x-allb-message";
|
|
7
9
|
/**
|
|
8
10
|
* Derive a stable key identifying the conversation a request belongs to.
|
|
9
11
|
*
|
package/dist/status.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PendingTurn } from './pending/types';
|
|
1
2
|
import { type SchedulerConfig } from './scheduler/config';
|
|
2
3
|
import { type PoolFile } from './types';
|
|
3
4
|
interface AccountStatus {
|
|
@@ -50,6 +51,8 @@ export declare function pct(u: number | null | undefined): string;
|
|
|
50
51
|
*/
|
|
51
52
|
export declare function displayWidth(s: string): number;
|
|
52
53
|
export declare function providerName(providerID: string): string;
|
|
54
|
+
/** Compact workspace-scoped pending summary appended by `auth_lb_status`. */
|
|
55
|
+
export declare function renderPendingStatus(turns: readonly PendingTurn[], now?: number): string;
|
|
53
56
|
/** Render the status model as a compact text dashboard (for tools/commands/CLI). */
|
|
54
57
|
export declare function renderStatus(providers: ProviderStatus[], now?: number): string;
|
|
55
58
|
export {};
|
package/dist/types.d.ts
CHANGED
|
@@ -35,6 +35,7 @@ export interface TokenSet {
|
|
|
35
35
|
accountId?: string;
|
|
36
36
|
}
|
|
37
37
|
/** One pooled credential. */
|
|
38
|
+
export type CooldownKind = 'quota' | 'auth' | 'transient';
|
|
38
39
|
export interface PoolAccount {
|
|
39
40
|
/** Stable internal id (uuid). */
|
|
40
41
|
id: string;
|
|
@@ -58,6 +59,8 @@ export interface PoolAccount {
|
|
|
58
59
|
usage: UsageSnapshot;
|
|
59
60
|
/** epoch ms; the account is skipped until this time. 0 = no cooldown. */
|
|
60
61
|
cooldownUntil: number;
|
|
62
|
+
/** Why the active account-wide cooldown was written. Absent on legacy rows. */
|
|
63
|
+
cooldownKind?: CooldownKind;
|
|
61
64
|
/**
|
|
62
65
|
* Per MODEL-TIER cooldowns: tier name (e.g. "opus", "fable") → epoch ms until
|
|
63
66
|
* that tier's separate weekly cap resets. While an entry is `> now`, requests
|
package/dist/usage-refresh.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ProviderAdapter } from './providers/types';
|
|
2
2
|
import type { PoolFile } from './types';
|
|
3
|
+
export declare const USAGE_REFRESH_TTL_MS: number;
|
|
3
4
|
/**
|
|
4
5
|
* Max lifetime of a TRANSIENT (non-quota) cooldown. fetch.ts cools a thrown
|
|
5
6
|
* network error or a header-less 429/402 for at most `ACCOUNT_COOLDOWN_MS`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-auth-load-balancer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Load-balance opencode requests across multiple Anthropic (Claude) and OpenAI (Codex) OAuth accounts, weighted primarily by weekly usage with drain-before-reset scheduling.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,18 +41,18 @@
|
|
|
41
41
|
"prepare": "husky"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"@opencode-ai/plugin": ">=1.
|
|
44
|
+
"@opencode-ai/plugin": ">=1.18.26"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
|
-
"@opencode-ai/plugin": "^1.
|
|
48
|
-
"@opentui/core": "0.
|
|
49
|
-
"@opentui/solid": "0.
|
|
50
|
-
"@types/bun": "^1.
|
|
51
|
-
"@types/node": "^26.
|
|
52
|
-
"eslint-plugin-devup": "^2.0
|
|
47
|
+
"@opencode-ai/plugin": "^1.18.26",
|
|
48
|
+
"@opentui/core": "0.5.10",
|
|
49
|
+
"@opentui/solid": "0.5.10",
|
|
50
|
+
"@types/bun": "^1.4.0",
|
|
51
|
+
"@types/node": "^26.4.1",
|
|
52
|
+
"eslint-plugin-devup": "^2.1.0",
|
|
53
53
|
"husky": "^9.1",
|
|
54
|
-
"oxlint": "1.
|
|
55
|
-
"solid-js": "1.9.
|
|
54
|
+
"oxlint": "1.81",
|
|
55
|
+
"solid-js": "1.9.15",
|
|
56
56
|
"typescript": "^7.0.2"
|
|
57
57
|
},
|
|
58
58
|
"keywords": [
|
|
@@ -242,10 +242,18 @@ export const MANUAL_DISABLED_REASON = 'manually disabled'
|
|
|
242
242
|
*/
|
|
243
243
|
export const RELOGIN_TTL_MS = 600_000
|
|
244
244
|
|
|
245
|
+
/**
|
|
246
|
+
* `now` DEFAULTS to the wall clock so the view can fire this without reading
|
|
247
|
+
* the clock itself: the call sits in an async re-login handler, and a bare
|
|
248
|
+
* `Date.now()` written inside a component body is flagged by `react(purity)`
|
|
249
|
+
* even on a non-render path the rule cannot distinguish. Tests still pass an
|
|
250
|
+
* explicit `now` — stamping the TTL from an injectable instant is what keeps
|
|
251
|
+
* the expiry assertion deterministic.
|
|
252
|
+
*/
|
|
245
253
|
export function setReloginTargetInPool(
|
|
246
254
|
accountId: string,
|
|
247
255
|
providerID: string,
|
|
248
|
-
now: number,
|
|
256
|
+
now: number = Date.now(),
|
|
249
257
|
path: string = POOL_FILE,
|
|
250
258
|
): void {
|
|
251
259
|
mutatePoolFile((pool) => {
|
|
@@ -82,11 +82,33 @@ function routeSessionId(api: TuiPluginApi): string | undefined {
|
|
|
82
82
|
// shared with auth-load-balancer-tui.logic.ts (imported above as `cfg`) so it is
|
|
83
83
|
// computed exactly once.
|
|
84
84
|
|
|
85
|
+
/** One poll sample: the pool, plus the instant it was read. */
|
|
86
|
+
interface PoolSample {
|
|
87
|
+
pool: PoolShape
|
|
88
|
+
now: number
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Capture the pool AND the clock together, beside each other.
|
|
93
|
+
*
|
|
94
|
+
* The timestamp deliberately lives HERE rather than in each consuming memo.
|
|
95
|
+
* A memo that calls `Date.now()` itself is impure — it depends on something
|
|
96
|
+
* Solid cannot track, so its countdowns refresh only by the coincidence that
|
|
97
|
+
* the poll signal happens to change on the same tick — and it measures every
|
|
98
|
+
* `until()` against an instant slightly LATER than the snapshot it is
|
|
99
|
+
* describing. Sampling both at once makes the memos pure functions of one
|
|
100
|
+
* reactive input and keeps each countdown consistent with the pool state it
|
|
101
|
+
* is rendered from.
|
|
102
|
+
*/
|
|
103
|
+
function pollSample(): PoolSample {
|
|
104
|
+
return { pool: readPool(), now: Date.now() }
|
|
105
|
+
}
|
|
106
|
+
|
|
85
107
|
function usePool() {
|
|
86
|
-
const [
|
|
87
|
-
const timer = setInterval(() =>
|
|
108
|
+
const [sample, setSample] = createSignal<PoolSample>(pollSample())
|
|
109
|
+
const timer = setInterval(() => setSample(pollSample()), POLL_MS)
|
|
88
110
|
onCleanup(() => clearInterval(timer))
|
|
89
|
-
return
|
|
111
|
+
return sample
|
|
90
112
|
}
|
|
91
113
|
|
|
92
114
|
/** Shared theme-color accessor used identically by `BottomBar` and `SidebarPanel`. */
|
|
@@ -111,12 +133,11 @@ interface Chip extends WindowDisplay {
|
|
|
111
133
|
|
|
112
134
|
/** Always-visible bottom bar (app_bottom): the in-use account per provider. */
|
|
113
135
|
function BottomBar(props: { api: TuiPluginApi }) {
|
|
114
|
-
const
|
|
136
|
+
const sample = usePool()
|
|
115
137
|
const color = themeColor(props.api)
|
|
116
138
|
const chips = createMemo<Chip[]>(() => {
|
|
117
|
-
const p =
|
|
139
|
+
const { pool: p, now } = sample()
|
|
118
140
|
const accounts = p.accounts ?? []
|
|
119
|
-
const now = Date.now()
|
|
120
141
|
const out: Chip[] = []
|
|
121
142
|
const sid = routeSessionId(props.api)
|
|
122
143
|
// Show the account THIS session is using per provider — NOT the global
|
|
@@ -190,12 +211,11 @@ function SidebarPanel(props: {
|
|
|
190
211
|
api: TuiPluginApi
|
|
191
212
|
sessionId: string | undefined
|
|
192
213
|
}) {
|
|
193
|
-
const
|
|
214
|
+
const sample = usePool()
|
|
194
215
|
const color = themeColor(props.api)
|
|
195
216
|
const groups = createMemo<Group[]>(() => {
|
|
196
|
-
const p =
|
|
217
|
+
const { pool: p, now } = sample()
|
|
197
218
|
const accounts = p.accounts ?? []
|
|
198
|
-
const now = Date.now()
|
|
199
219
|
const providerIds = [...new Set(accounts.map((a) => a.providerID))].sort(
|
|
200
220
|
compareAscii,
|
|
201
221
|
)
|
|
@@ -314,7 +334,8 @@ function SidebarPanel(props: {
|
|
|
314
334
|
return
|
|
315
335
|
}
|
|
316
336
|
|
|
317
|
-
|
|
337
|
+
// Timestamp intentionally left to the callee's default — see its doc.
|
|
338
|
+
setReloginTargetInPool(id, providerID)
|
|
318
339
|
|
|
319
340
|
const completeRelogin = async (code?: string): Promise<void> => {
|
|
320
341
|
try {
|