opencode-auth-load-balancer 0.4.0 → 0.5.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.
@@ -83,6 +83,33 @@ export declare const FALLBACK_CLAUDE_CODE_VERSION = "2.1.258";
83
83
  * `cch` fingerprint algorithm and the newest version starts 400ing).
84
84
  */
85
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;
86
113
  /**
87
114
  * npm dist-tags for the Claude Code CLI — `{"latest":"2.1.258",…}`, ~56 bytes.
88
115
  * Deliberately NOT `/@anthropic-ai/claude-code/latest`, which returns the whole
@@ -20,6 +20,27 @@ export declare function usageUserAgent(): string;
20
20
  * usable answer, so a failure does NOT refresh the TTL).
21
21
  */
22
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>;
23
44
  /**
24
45
  * Startup hook. Awaits only the LOCAL cache read — cheap, and enough to have
25
46
  * the right version in hand before the loader returns the fetch — then leaves
@@ -0,0 +1,13 @@
1
+ import { type ProviderAdapter } from '../types';
2
+ import { type KimiDeployment } from './constants';
3
+ /**
4
+ * Kimi Code adapter for one deployment. A subscription joins the pool either
5
+ * through a device-code OAuth login (`startDeviceLogin`, refreshed by
6
+ * `refresh`) or as a pasted API key (`authorize`/`exchange` +
7
+ * `tokensFromApiKey`). opencode already shapes the requests as
8
+ * OpenAI-compatible chat completions, so they pass through untouched, and
9
+ * inference responses carry no quota headers: usage comes from `/usages`.
10
+ */
11
+ export declare function createKimiAdapter(deployment: KimiDeployment): ProviderAdapter;
12
+ export declare const kimiCodeAdapter: ProviderAdapter;
13
+ export declare const kimiCodeGlobalAdapter: ProviderAdapter;
@@ -0,0 +1,36 @@
1
+ export { OAUTH_HTTP_TIMEOUT_MS, USAGE_HTTP_TIMEOUT_MS } from '../http-timeouts';
2
+ /**
3
+ * One Kimi Code deployment. kimi.com and kimi.ai are separate hosts with
4
+ * separate accounts, and opencode's models.dev catalog lists each as its own
5
+ * provider — so each gets its own adapter, and its own pool.
6
+ */
7
+ export interface KimiDeployment {
8
+ /** opencode provider id (models.dev). */
9
+ readonly id: string;
10
+ /** OpenAI-compatible API base (the catalog's `api`); `/usages` and `/me` live here too. */
11
+ readonly baseUrl: string;
12
+ /** OAuth host for the device-code login and token refresh. */
13
+ readonly oauthHost: string;
14
+ /** Console page where a subscriber creates the API key the key login asks for. */
15
+ readonly consoleUrl: string;
16
+ }
17
+ export declare const KIMI_CODE: KimiDeployment;
18
+ export declare const KIMI_CODE_GLOBAL: KimiDeployment;
19
+ /** Kimi Code's public OAuth client, shared by both deployments (no secret). */
20
+ export declare const KIMI_OAUTH_CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
21
+ /**
22
+ * `X-Msh-Platform` / `X-Msh-Version` sent to the OAuth host. Kimi's SDK asks
23
+ * every host to name itself rather than borrow its CLI's `kimi_code_cli`, so
24
+ * this names the plugin; the version is this integration's, bumped when its
25
+ * handling of the login protocol changes.
26
+ */
27
+ export declare const KIMI_DEVICE_PLATFORM = "opencode_auth_load_balancer";
28
+ export declare const KIMI_DEVICE_VERSION = "1";
29
+ /** Local wall-clock budget for one device-code login (Kimi's own clients use 15 min). */
30
+ export declare const DEVICE_LOGIN_TIMEOUT_MS: number;
31
+ /**
32
+ * `expires` stamped on a static-key row: a key does not expire client-side,
33
+ * so `needsRefresh` must never fire. Finite, so the pool store's
34
+ * `Number.isFinite(expires)` normalization keeps it.
35
+ */
36
+ export declare const STATIC_KEY_EXPIRES: number;
@@ -0,0 +1,18 @@
1
+ import type { TokenSet } from '../../types';
2
+ import { type KimiDeployment } from './constants';
3
+ /**
4
+ * Map a Kimi Code API key onto the pool's token shape: the key is the bearer,
5
+ * with no refresh token and an expiry that never comes due. `accountId` is a
6
+ * fingerprint of the key — `addAccount` dedups on it — which a login upgrades
7
+ * to the Kimi account id; this bare form serves the startup import, where no
8
+ * network call can run.
9
+ */
10
+ export declare function tokensFromApiKey(key: string): TokenSet;
11
+ /**
12
+ * The pasted key, checked against `GET /me` before it can join the pool: a
13
+ * typo, a Moonshot open-platform key, or a stray URL fails the login instead
14
+ * of adding a row that only ever 401s. The row is keyed by the Kimi account
15
+ * behind the key, so a second key — or an OAuth login — for the same
16
+ * subscription replaces its row instead of double-counting one quota.
17
+ */
18
+ export declare function exchange(deployment: KimiDeployment, input: string): Promise<TokenSet | null>;
@@ -0,0 +1,22 @@
1
+ import type { TokenSet } from '../../types';
2
+ import type { DeviceLogin } from '../types';
3
+ import { type KimiDeployment } from './constants';
4
+ /** The poll loop's clock — a seam so tests need not wait out real intervals. */
5
+ export interface PollClock {
6
+ readonly now: () => number;
7
+ readonly sleep: (ms: number) => Promise<void>;
8
+ }
9
+ /**
10
+ * Start a device-code login: the URL (user code embedded) to approve in a
11
+ * browser, and `complete` — the wait for that approval. The approved login is
12
+ * keyed by the Kimi account behind it (`GET /me`), like a key login, so one
13
+ * subscription stays one pool row however it signs in.
14
+ */
15
+ export declare function startDeviceLogin(deployment: KimiDeployment, clock?: PollClock): Promise<DeviceLogin>;
16
+ /**
17
+ * Refresh a Kimi Code OAuth row. Kimi answers a revoked grant with 400
18
+ * `invalid_grant`, 401 or 403, and each means re-login: 400/401 already carry
19
+ * the status refresh.ts parks rows on, so only 403 is spelled out as
20
+ * `invalid_grant`. A key row has no refresh token to spend at all.
21
+ */
22
+ export declare function refreshOAuth(deployment: KimiDeployment, refreshToken: string): Promise<TokenSet>;
@@ -0,0 +1,10 @@
1
+ /** What `GET /me` says about the account behind a bearer. */
2
+ export interface KimiProfile {
3
+ /** Kimi's account id: one per subscription, whichever key or login reaches it. */
4
+ readonly userId?: string;
5
+ }
6
+ /**
7
+ * `GET {baseUrl}/me` with an API key or an OAuth access token. Null when the
8
+ * bearer is refused or the call fails, so it doubles as the key check.
9
+ */
10
+ export declare function fetchProfile(baseUrl: string, bearer: string): Promise<KimiProfile | null>;
@@ -0,0 +1,12 @@
1
+ import type { PoolAccount, UsageSnapshot } from '../../types';
2
+ /**
3
+ * Parse `GET /usages` into the pool's snapshot, one window at a time: the
4
+ * ratio shape (`usages.limit_5h` / `limit_7d`) first, the counter shape as
5
+ * that window's fallback. Null when neither window parses, so a foreign or
6
+ * broken body keeps the last-known snapshot instead of reading as 0% used.
7
+ */
8
+ export declare function parseUsages(json: unknown, now: number): UsageSnapshot | null;
9
+ /** `GET {baseUrl}/usages` — free, spends no quota. Null on any failure. */
10
+ export declare function getUsages(baseUrl: string, key: string): Promise<unknown>;
11
+ /** The adapter's `fetchUsage` for one deployment. */
12
+ export declare function usageFetcher(baseUrl: string): (account: PoolAccount, now: number) => Promise<UsageSnapshot | null>;
@@ -98,3 +98,17 @@ export declare function parseCallbackInput(input: string, options?: {
98
98
  code: string;
99
99
  state: string;
100
100
  } | null;
101
+ /**
102
+ * `parseCallbackInput`, plus the authorization code on its own — what a user
103
+ * naturally copies out of the callback's `?code=…` when the login prompt asks
104
+ * for "the authorization code". A bare code carries no `state`, so it comes
105
+ * back as `state: null` and the caller skips its CSRF comparison for it. PKCE
106
+ * keeps that safe: a code only redeems together with the `code_verifier`
107
+ * generated for THIS login attempt, so a code minted for any other attempt is
108
+ * useless here. A pasted callback still returns its `state` for the caller to
109
+ * enforce.
110
+ */
111
+ export declare function parseCodeInput(input: string): {
112
+ code: string;
113
+ state: string | null;
114
+ } | null;
@@ -3,9 +3,12 @@ import type { AuthorizeRequest } from '../types';
3
3
  /** Begin the ChatGPT PKCE authorization flow. */
4
4
  export declare function authorize(): Promise<AuthorizeRequest>;
5
5
  /**
6
- * Exchange a pasted callback URL (or `code#state`) for tokens. Returns null on
7
- * failure. The redirect lands on http://localhost:1455/auth/callback — the user
8
- * copies that URL from the browser address bar and pastes it here.
6
+ * Exchange the pasted login result for tokens. Returns null on failure. The
7
+ * redirect lands on http://localhost:1455/auth/callback, a page that never
8
+ * loads — so the user pastes either that whole URL from the address bar (or
9
+ * its `code=…&state=…` query) or just the authorization code itself, which is
10
+ * what the login prompt asks for. A pasted `state` must match this attempt's;
11
+ * a bare code has none to compare (see `parseCodeInput`).
9
12
  */
10
13
  export declare function exchange(input: string, verifier: string, redirectUri: string, expectedState?: string): Promise<TokenSet | null>;
11
14
  /** Refresh an access token. Throws on failure; message includes the HTTP status. */
@@ -28,6 +28,17 @@ export interface AuthorizeRequest {
28
28
  verifier: string;
29
29
  state: string;
30
30
  redirectUri: string;
31
+ /** Login prompt text; absent = the OAuth default ("Paste the authorization code here:"). */
32
+ instructions?: string;
33
+ }
34
+ /** A device-code login (RFC 8628) in flight: where to approve it, and the wait for approval. */
35
+ export interface DeviceLogin {
36
+ /** Verification URL to open (the user code rides in it). */
37
+ url: string;
38
+ /** Prompt shown with the URL, e.g. the user code to confirm. */
39
+ instructions: string;
40
+ /** Resolve with the approved tokens, or null when denied, expired, or failed. */
41
+ complete(): Promise<TokenSet | null>;
31
42
  }
32
43
  /**
33
44
  * A request-body rewrite that downgrades the requested model to a cheaper
@@ -79,6 +90,24 @@ export interface ProviderAdapter {
79
90
  exchange(input: string, verifier: string, redirectUri: string, state: string): Promise<TokenSet | null>;
80
91
  /** Refresh an access token. Throws on failure (callers handle invalid_grant). */
81
92
  refresh(refreshToken: string): Promise<TokenSet>;
93
+ /**
94
+ * Start a device-code login, for providers whose OAuth has no redirect to
95
+ * paste back (Kimi Code). Registered as an `auto` login method ahead of the
96
+ * paste flow; absent on Claude/Codex.
97
+ */
98
+ startDeviceLogin?(): Promise<DeviceLogin>;
99
+ /**
100
+ * Present on providers that also take a static API key (Kimi Code): maps a
101
+ * key onto the pool's TokenSet. A key has no refresh token behind it, so
102
+ * its presence switches three credential paths:
103
+ * - the paste login (`authorize`/`exchange`) collects a key, and its
104
+ * callback hands opencode `{ key }`, stored as a plain `api` credential;
105
+ * - the auth loader also imports opencode's `api` credential, which
106
+ * OAuth-only providers ignore;
107
+ * - a 401 parks a key row (one without a refresh token) for a re-login
108
+ * instead of cooling it down — no refresh can repair a revoked key.
109
+ */
110
+ tokensFromApiKey?(key: string): TokenSet;
82
111
  /** Set auth + provider headers on an outgoing request for the chosen account. */
83
112
  applyAuth(headers: Headers, account: PoolAccount): void;
84
113
  /** Optionally rewrite the request URL (e.g. add ?beta=true, swap base URL). */
@@ -136,4 +165,24 @@ export interface ProviderAdapter {
136
165
  * downgraded body. null = treat the 429 as a normal account rotation.
137
166
  */
138
167
  planReactiveFallback?(res: Response, body: string, now: number, models: readonly string[]): ReactiveModelFallback | null;
168
+ /**
169
+ * REACTIVE recovery from a CLIENT-VERSION gate: a provider may reject an
170
+ * otherwise-valid request until the plugin claims to be a newer client
171
+ * (Anthropic answers a too-new model with 400 `claude_code_version_too_old`,
172
+ * naming the minimum it wants). Such a status classifies as `ok` — the fetch
173
+ * loop is one statement away from handing it to the caller — so this is the
174
+ * last point at which it can become a recoverable event instead of a failed
175
+ * turn.
176
+ *
177
+ * Return true ONLY when the version the adapter will now report actually
178
+ * changed: the fetch loop reads that as "a retry can succeed" and restarts
179
+ * the request once. Returning true without a change would buy a second,
180
+ * byte-identical round-trip and the same rejection.
181
+ *
182
+ * `res` is a CLONE the implementation may consume freely; the original is
183
+ * still returned to the caller when this resolves false. Absent on providers
184
+ * with no such gate (OpenAI) — the fetch loop treats that as "nothing to
185
+ * recover".
186
+ */
187
+ recoverClientVersion?(res: Response, now: number): Promise<boolean>;
139
188
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-auth-load-balancer",
3
- "version": "0.4.0",
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.",
3
+ "version": "0.5.0",
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",
7
7
  "author": "owjs3901",
@@ -62,6 +62,8 @@
62
62
  "claude",
63
63
  "openai",
64
64
  "codex",
65
+ "kimi",
66
+ "kimi-code",
65
67
  "oauth",
66
68
  "load-balancer"
67
69
  ],
@@ -62,6 +62,8 @@ export interface PoolAccount {
62
62
  /** LEGACY pre-tier-map field (folded into `modelCooldownsUntil.opus` for display until the server migrates the file). */
63
63
  opusCooldownUntil?: number
64
64
  disabledReason?: string | null
65
+ /** Empty on a static API-key row (Kimi Code), which re-logs in with a new key. */
66
+ refresh?: string
65
67
  }
66
68
  /** Loosely-typed view of the on-disk pool JSON (one shape for reads AND read-modify-writes). */
67
69
  export interface PoolShape {
@@ -276,16 +278,24 @@ export function clearReloginTargetInPool(path: string = POOL_FILE): void {
276
278
  * opencode aggregates auth methods from every plugin registered for a provider,
277
279
  * so this plugin's position is not stable. Prefer its pooled-login label, then
278
280
  * fall back to the provider's first OAuth method when no such label is present.
281
+ * A provider can offer both an account login and an API-key login (Kimi
282
+ * Code), so `apiKey` picks the kind that matches how the row was added.
279
283
  */
280
284
  export function pickAuthMethodIndex(
281
285
  methods: readonly { type?: string; label?: string }[] | undefined,
286
+ apiKey = false,
282
287
  ): number | null {
283
- const pooled = methods?.findIndex(
288
+ const pooled = (method: { type?: string; label?: string }): boolean =>
289
+ method.type === 'oauth' &&
290
+ (method.label?.toLowerCase().includes('load balancer') ?? false)
291
+ const sameKind = methods?.findIndex(
284
292
  (method) =>
285
- method.type === 'oauth' &&
286
- method.label?.toLowerCase().includes('load balancer'),
293
+ pooled(method) &&
294
+ (method.label?.toLowerCase().includes('api key') ?? false) === apiKey,
287
295
  )
288
- if (pooled !== undefined && pooled >= 0) return pooled
296
+ if (sameKind !== undefined && sameKind >= 0) return sameKind
297
+ const anyPooled = methods?.findIndex(pooled)
298
+ if (anyPooled !== undefined && anyPooled >= 0) return anyPooled
289
299
  const oauth = methods?.findIndex((method) => method.type === 'oauth')
290
300
  return oauth !== undefined && oauth >= 0 ? oauth : null
291
301
  }
@@ -57,6 +57,8 @@ import {
57
57
  const PROVIDER_NAMES: Record<string, string> = {
58
58
  anthropic: 'Claude',
59
59
  openai: 'Codex',
60
+ 'kimi-code-plan-cn': 'Kimi',
61
+ 'kimi-code-plan-global': 'Kimi (kimi.ai)',
60
62
  }
61
63
  /** Shared fallback lookup used by both the bottom bar and sidebar group labels. */
62
64
  function providerLabel(id: string): string {
@@ -200,6 +202,8 @@ interface Row extends WindowDisplay {
200
202
  rank: number | null
201
203
  state: string
202
204
  manuallyDisabled: boolean
205
+ /** A static API-key row: re-login asks for a new key, not an OAuth sign-in. */
206
+ apiKey: boolean
203
207
  }
204
208
  interface Group {
205
209
  provider: string
@@ -264,6 +268,7 @@ function SidebarPanel(props: {
264
268
  // tier annotation forever until the server heals the file.
265
269
  state: stateOf(sa, tierResets(a, now), now),
266
270
  manuallyDisabled: a.disabledReason === MANUAL_DISABLED_REASON,
271
+ apiKey: a.refresh === '',
267
272
  }
268
273
  })
269
274
  return { provider: providerLabel(providerID), rows }
@@ -306,12 +311,13 @@ function SidebarPanel(props: {
306
311
  id: string,
307
312
  label: string,
308
313
  providerID: string,
314
+ apiKey: boolean,
309
315
  ): Promise<void> {
310
316
  try {
311
317
  const methods = (await props.api.client.provider.auth()).data?.[
312
318
  providerID
313
319
  ]
314
- const method = pickAuthMethodIndex(methods)
320
+ const method = pickAuthMethodIndex(methods, apiKey)
315
321
  if (method === null) {
316
322
  props.api.ui.toast({
317
323
  variant: 'error',
@@ -372,6 +378,15 @@ function SidebarPanel(props: {
372
378
  }
373
379
 
374
380
  if (auth.method === 'auto') {
381
+ // A device-code login (Kimi Code) completes in the browser: show where
382
+ // to approve it for as long as the callback waits.
383
+ dialog().replace(() =>
384
+ props.api.ui.DialogAlert({
385
+ title: `Re-login "${label}"`,
386
+ message: `${auth.instructions}\n\n${auth.url}`,
387
+ onConfirm: () => dialog().clear(),
388
+ }),
389
+ )
375
390
  await completeRelogin()
376
391
  return
377
392
  }
@@ -423,6 +438,7 @@ function SidebarPanel(props: {
423
438
  label: string,
424
439
  providerID: string,
425
440
  manuallyDisabled: boolean,
441
+ apiKey: boolean,
426
442
  ): void {
427
443
  const items: { title: string; run: () => void }[] = [
428
444
  { title: 'Rename', run: () => openRename(id, label) },
@@ -440,7 +456,7 @@ function SidebarPanel(props: {
440
456
  {
441
457
  title: 'Re-login — re-authorize with the provider',
442
458
  run: () => {
443
- void openRelogin(id, label, providerID)
459
+ void openRelogin(id, label, providerID, apiKey)
444
460
  },
445
461
  },
446
462
  { title: 'Delete — remove from pool', run: () => openDelete(id, label) },
@@ -503,7 +519,13 @@ function SidebarPanel(props: {
503
519
  {(r) => (
504
520
  <box
505
521
  onMouseUp={() =>
506
- openMenu(r.id, r.label, r.providerID, r.manuallyDisabled)
522
+ openMenu(
523
+ r.id,
524
+ r.label,
525
+ r.providerID,
526
+ r.manuallyDisabled,
527
+ r.apiKey,
528
+ )
507
529
  }
508
530
  >
509
531
  <text