opencode-auth-load-balancer 0.4.1 → 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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # opencode-auth-load-balancer
2
2
 
3
- Load-balance [opencode](https://opencode.ai) across **multiple Claude (Anthropic) and Codex (OpenAI/ChatGPT) OAuth accounts** so you never have to stop and re-login when one account runs out of quota.
3
+ Load-balance [opencode](https://opencode.ai) across **multiple Claude (Anthropic) and Codex (OpenAI/ChatGPT) OAuth accounts and Kimi Code subscriptions** so you never have to stop and re-login when one account runs out of quota.
4
4
 
5
5
  Selection is **not** round-robin. It is weighted primarily by **weekly** usage, with a continuous "drain the soonest-resetting account first" rule, per-conversation **session affinity** (to preserve prompt caching), and a proactive switch **before** an account hits 100%.
6
6
 
@@ -8,7 +8,7 @@ Selection is **not** round-robin. It is weighted primarily by **weekly** usage,
8
8
 
9
9
  ## Features
10
10
 
11
- - **Account pool** — register many Claude / Codex OAuth accounts; the plugin manages and rotates them.
11
+ - **Account pool** — register many Claude / Codex / Kimi Code accounts (Kimi also by API key); the plugin manages and rotates them.
12
12
  - **Weekly-usage-weighted scheduling** — `urgency = weeklyRemaining / daysUntilWeeklyReset`. A sooner reset (e.g. 3 days) outranks a later one (7 days) at equal headroom; perishable quota is drained progressively, never crammed into the final hour.
13
13
  - **Automatic rotation** — on `429`/auth errors an account is cooled down and the next-best is tried; `retry-after` is honored.
14
14
  - **Model-tier fallback ladder (Fable, Opus, …)** — Claude Max accounts have *separate* weekly caps per premium model tier. When one is exhausted (a 429 whose `representative-claim` names a tier window, e.g. `seven_day_fable` / `seven_day_opus`), the balancer records a **per-tier** cooldown instead of cooling the whole account down (which used to cascade every account into a false "cooldown" and block *every* model on it). Requests for that tier then **steer to an account with tier headroom** — keeping the model you asked for — and only when the *whole pool* is tier-limited does the request descend **one rung down the fallback ladder**: the next model family in `fable → opus → sonnet → haiku` (order configurable via `OPENCODE_AUTH_LB_ANTHROPIC_FAMILY_ORDER`), picking the **highest-versioned model your provider config actually has** (a capped `claude-fable-5` prefers `claude-opus-4-9` over `claude-opus-4-8`). If that tier is capped too, it descends again (`fable → opus → sonnet`), each step toasted, preferably on the session's pinned account (keeping its prompt cache). Pin a fixed target or disable entirely via `OPENCODE_AUTH_LB_ANTHROPIC_OPUS_FALLBACK_MODEL`.
@@ -24,7 +24,7 @@ Selection is **not** round-robin. It is weighted primarily by **weekly** usage,
24
24
  ## Requirements
25
25
 
26
26
  - [Bun](https://bun.com) ≥ 1.3
27
- - opencode (TUI), with the Anthropic and/or OpenAI providers available
27
+ - opencode (TUI), with the Anthropic, OpenAI, and/or Kimi For Coding providers available
28
28
 
29
29
  ---
30
30
 
@@ -80,7 +80,7 @@ Run opencode's auth flow once **per account**:
80
80
 
81
81
  1. `opencode auth login`
82
82
  2. Choose **"Claude Pro/Max (add account to load balancer)"** (or **"ChatGPT/Codex …"**).
83
- 3. Open the URL, authorize, and paste the resulting code/URL back.
83
+ 3. Open the URL, authorize, and paste the result back. For Codex, the browser lands on a `localhost:1455` page that does not load — paste either its whole address-bar URL or just the `code` value from it; both work.
84
84
  4. Repeat for every account you want in the pool.
85
85
 
86
86
  Each login **appends** to the pool (it does not overwrite opencode's single auth slot). If you already logged in with the upstream `opencode-anthropic-auth` plugin, that credential is imported automatically on first run.
@@ -100,6 +100,19 @@ A third file, `auth-load-balancer-cc-version.json`, caches the discovered Claude
100
100
 
101
101
  > **OpenAI/Codex note:** the OpenAI path assumes opencode is configured to use the **Responses API** (the standard ChatGPT/Codex setup); `/responses` requests are routed to the Codex backend.
102
102
 
103
+ ### Kimi Code
104
+
105
+ A Kimi Code subscription joins the pool either through an **OAuth sign-in** (device code) or as a **static API key**. The plugin hooks both deployments in opencode's model catalog, each with its own pool: **Kimi For Coding (kimi.com)** (`kimi-code-plan-cn`, `api.kimi.com/coding/v1`, sign-in at `auth.kimi.com`) and **Kimi For Coding (kimi.ai)** (`kimi-code-plan-global`, `api.kimi.ai/coding/v1`, sign-in at `auth.kimi.ai`).
106
+
107
+ 1. `opencode auth login` → **Kimi For Coding (kimi.com)** (or **(kimi.ai)**), then either:
108
+ - **"Kimi Code (…) (add account to load balancer)"** — the sign-in: opencode shows a URL, you approve the sign-in there (the code is already in it), and the login completes on its own. Tokens refresh automatically.
109
+ - **"Kimi Code (…) API key (add account to load balancer)"** — create a key in the console the login links to ([kimi.com](https://www.kimi.com/code/console) / [kimi.ai](https://www.kimi.ai/code/console)) and paste it at the prompt; the CLI labels it "authorization code", but paste the key.
110
+ 2. Repeat for every subscription.
111
+
112
+ Every login is checked against the account behind it (`GET /me`), so one subscription stays one pool row: a second key or a sign-in of the same account replaces that row instead of double-counting its quota. A sign-in is stored in opencode as an `oauth` credential and a key as a plain `api` one; whichever opencode already stores for the provider is imported on first start. Usage (5h + weekly) comes from `/usages` alone — Kimi's inference responses carry no quota headers — polled at most every 5 minutes while requests flow. A revoked key (`401`) or refresh token (`invalid_grant`) switches the row to `re-login` instead of being retried, and the TUI's re-login uses the kind of login the row was added with.
113
+
114
+ The sign-in identifies itself to Kimi's OAuth host the way Kimi's own clients do, with `X-Msh-*` headers: platform `opencode_auth_load_balancer`, this machine's host name and OS, and a device id hashed from the host name and home directory.
115
+
103
116
  ---
104
117
 
105
118
  ## See which account is in use
@@ -270,7 +283,7 @@ opencode loads plugins once at startup, so the loop is:
270
283
 
271
284
  ```
272
285
  src/
273
- index.ts # plugin entry: 3 exports (Anthropic, OpenAI, Status-tool)
286
+ index.ts # plugin entry: 5 exports (Anthropic, OpenAI, Kimi Code ×2, Status-tool)
274
287
  fetch.ts # load-balanced fetch — the per-request choke point
275
288
  refresh.ts # singleflight OAuth refresh, invalid_grant handling
276
289
  accounts.ts # append / bootstrap accounts into the pool
@@ -288,6 +301,7 @@ src/
288
301
  providers/ # ProviderAdapter contract + headers
289
302
  anthropic/ # Claude OAuth + Claude Code request transforms + usage
290
303
  openai/ # ChatGPT/Codex OAuth + Responses transforms + usage
304
+ kimi/ # Kimi Code device-code OAuth + API-key login + /usages (kimi.com + kimi.ai)
291
305
  cli/status.ts # `bun run status`
292
306
  __tests__/ # all tests
293
307
  tui/
@@ -321,6 +335,7 @@ The account pool and durable pending references are separate JSON files (opencod
321
335
 
322
336
  - **Bottom status bar** ([`tui/auth-load-balancer-tui.ts`](tui/auth-load-balancer-tui.ts) + [`.view.tsx`](tui/auth-load-balancer-tui.view.tsx) + [`.logic.ts`](tui/auth-load-balancer-tui.logic.ts) + [`auth-load-balancer-scoring.ts`](tui/auth-load-balancer-scoring.ts)) is a SolidJS TUI artifact compiled by opencode (its JSX deps — `solid-js` + `@opentui/*` — are installed as devDependencies, so the `.tsx` view **is** typechecked via `tsconfig.tui.json` and linted, though not render-tested here since that needs a live opencode TUI; its scorer is a byte-identical copy of the unit-tested [`src/scheduler/score-core.ts`](src/scheduler/score-core.ts), enforced by a sync test). It is written against opencode `>=1.18.26` internals (the `app_bottom` slot + the `subagent-footer` usage computation, verified against source); confirm it renders in your opencode build. The toast/tool/CLI cover the account info regardless.
323
337
  - **OpenAI/Codex** assumes the Responses API; chat-completions → responses conversion is out of scope.
338
+ - **Kimi Code** sign-ins must be approved within 15 minutes, or the login fails and has to be started again. A key imported from opencode's store at startup is keyed by the key itself — the import makes no network call to learn its account — so log that subscription in again before adding it a second way, or it can count twice.
324
339
  - **Plugin reloads require restart**: opencode loads server plugins once at process startup. After installing a new build, restart the running opencode process once; durable turns are restored on that next start and are never sent while opencode is closed.
325
340
  - **Cross-process refresh**: per-process singleflight protects token rotation within one opencode instance. Running two opencode instances at once could still race the single-use refresh token.
326
341
  - **TUI pool writes**: the TUI sidebar's Rename / Delete actions write the pool file atomically (temp + rename) but WITHOUT the cross-process file lock the server uses around its own read-modify-write — so a server usage / cooldown / session / `tokenGen` update committed between the TUI's `readFileSync` and `renameSync` can be silently overwritten. Impact is bounded: the next request re-records usage from response headers, so the window is one cycle of staleness on the affected account; correctness recovers on its own.
@@ -1,11 +1,14 @@
1
1
  import { type PoolAccount, type TokenSet } from './types';
2
- /** The credential getter opencode passes to an auth loader. */
3
- export type OpencodeAuthGetter = () => Promise<{
2
+ /** A credential from opencode's auth store (`oauth` token pair or `api` key). */
3
+ interface OpencodeAuth {
4
4
  type: string;
5
5
  access?: string;
6
6
  refresh?: string;
7
7
  expires?: number;
8
- }>;
8
+ key?: string;
9
+ }
10
+ /** The credential getter opencode passes to an auth loader. */
11
+ export type OpencodeAuthGetter = () => Promise<OpencodeAuth>;
9
12
  /**
10
13
  * Append a freshly-authorized account to the pool, deduped by refresh token
11
14
  * or provider account id. Re-authorizing the same account refreshes its
@@ -13,8 +16,11 @@ export type OpencodeAuthGetter = () => Promise<{
13
16
  */
14
17
  export declare function addAccount(providerID: string, tokens: TokenSet, label?: string): Promise<PoolAccount>;
15
18
  /**
16
- * Seed the pool from opencode's existing single-slot OAuth credential (e.g. left by
17
- * the single-account anthropic-auth plugin) so the user keeps working without
18
- * re-login. No-op once the pool already has an account for this provider.
19
+ * Seed the pool from opencode's existing single-slot credential (e.g. an OAuth
20
+ * login left by the single-account anthropic-auth plugin, or a Kimi Code API
21
+ * key saved before this plugin was installed) so the user keeps working
22
+ * without re-login. No-op once the pool already has an account for this
23
+ * provider.
19
24
  */
20
- export declare function bootstrapFromOpencodeAuth(providerID: string, getAuth: OpencodeAuthGetter): Promise<void>;
25
+ export declare function bootstrapFromOpencodeAuth(providerID: string, getAuth: OpencodeAuthGetter, tokensFromApiKey?: (key: string) => TokenSet): Promise<void>;
26
+ export {};
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { Plugin } from '@opencode-ai/plugin';
2
2
  export declare const AnthropicLoadBalancerPlugin: Plugin;
3
3
  export declare const OpenAILoadBalancerPlugin: Plugin;
4
+ export declare const KimiCodeLoadBalancerPlugin: Plugin;
5
+ export declare const KimiCodeGlobalLoadBalancerPlugin: Plugin;
4
6
  /**
5
7
  * A standalone plugin that registers the `auth_lb_status` tool — an on-demand
6
8
  * dashboard (in-use account per provider, each account's weekly/5h usage,
package/dist/index.js CHANGED
@@ -12856,21 +12856,28 @@ async function addAccount(providerID, tokens, label) {
12856
12856
  return account;
12857
12857
  });
12858
12858
  }
12859
- async function bootstrapFromOpencodeAuth(providerID, getAuth) {
12859
+ function importableTokens(auth, tokensFromApiKey) {
12860
+ if (auth.type === "api")
12861
+ return tokensFromApiKey && auth.key ? tokensFromApiKey(auth.key) : null;
12862
+ if (auth.type !== "oauth" || !auth.access || !auth.refresh)
12863
+ return null;
12864
+ return {
12865
+ access: auth.access,
12866
+ refresh: auth.refresh,
12867
+ expires: auth.expires ?? 0
12868
+ };
12869
+ }
12870
+ async function bootstrapFromOpencodeAuth(providerID, getAuth, tokensFromApiKey) {
12860
12871
  const auth = await getAuth().catch(() => null);
12861
- if (!auth || auth.type !== "oauth" || !auth.access || !auth.refresh)
12872
+ const tokens = auth && importableTokens(auth, tokensFromApiKey);
12873
+ if (!tokens)
12862
12874
  return;
12863
- const { access, refresh, expires } = auth;
12864
12875
  if ((await readPool()).accounts.some((a) => a.providerID === providerID))
12865
12876
  return;
12866
12877
  await mutatePool((pool) => {
12867
12878
  if (pool.accounts.some((a) => a.providerID === providerID))
12868
12879
  return;
12869
- pool.accounts.push(makeAccount(providerID, `${providerID}-1`, {
12870
- access,
12871
- refresh,
12872
- expires: expires ?? 0
12873
- }));
12880
+ pool.accounts.push(makeAccount(providerID, `${providerID}-1`, tokens));
12874
12881
  });
12875
12882
  }
12876
12883
 
@@ -13616,6 +13623,14 @@ function parseCallbackInput(input, options = {}) {
13616
13623
  return { code, state };
13617
13624
  return null;
13618
13625
  }
13626
+ var BARE_CODE_RE = /^[\w.~-]+$/;
13627
+ function parseCodeInput(input) {
13628
+ const callback = parseCallbackInput(input);
13629
+ if (callback)
13630
+ return callback;
13631
+ const bare = input.trim();
13632
+ return BARE_CODE_RE.test(bare) ? { code: bare, state: null } : null;
13633
+ }
13619
13634
 
13620
13635
  // src/providers/pkce.ts
13621
13636
  function base64UrlEncode(bytes) {
@@ -14175,8 +14190,259 @@ var anthropicAdapter = {
14175
14190
  recoverClientVersion: recoverClaudeCodeVersion
14176
14191
  };
14177
14192
 
14193
+ // src/providers/kimi/constants.ts
14194
+ var KIMI_CODE = {
14195
+ id: "kimi-code-plan-cn",
14196
+ baseUrl: "https://api.kimi.com/coding/v1",
14197
+ oauthHost: "https://auth.kimi.com",
14198
+ consoleUrl: "https://www.kimi.com/code/console"
14199
+ };
14200
+ var KIMI_CODE_GLOBAL = {
14201
+ id: "kimi-code-plan-global",
14202
+ baseUrl: "https://api.kimi.ai/coding/v1",
14203
+ oauthHost: "https://auth.kimi.ai",
14204
+ consoleUrl: "https://www.kimi.ai/code/console"
14205
+ };
14206
+ var KIMI_OAUTH_CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
14207
+ var KIMI_DEVICE_PLATFORM = "opencode_auth_load_balancer";
14208
+ var KIMI_DEVICE_VERSION = "1";
14209
+ var DEVICE_LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
14210
+ var STATIC_KEY_EXPIRES = Number.MAX_SAFE_INTEGER;
14211
+
14212
+ // src/providers/kimi/key.ts
14213
+ import { createHash as createHash3 } from "node:crypto";
14214
+
14215
+ // src/providers/kimi/profile.ts
14216
+ async function fetchProfile(baseUrl, bearer) {
14217
+ const me = await fetchJson(`${baseUrl}/me`, { authorization: `Bearer ${bearer}`, accept: "application/json" }, USAGE_HTTP_TIMEOUT_MS);
14218
+ if (!isPlainObject2(me))
14219
+ return null;
14220
+ return typeof me.user_id === "string" && me.user_id !== "" ? { userId: me.user_id } : {};
14221
+ }
14222
+
14223
+ // src/providers/kimi/key.ts
14224
+ function tokensFromApiKey(key) {
14225
+ const fingerprint = createHash3("sha256").update(key).digest("hex");
14226
+ return {
14227
+ access: key,
14228
+ refresh: "",
14229
+ expires: STATIC_KEY_EXPIRES,
14230
+ accountId: `key:${fingerprint.slice(0, 16)}`
14231
+ };
14232
+ }
14233
+ async function exchange2(deployment, input) {
14234
+ const key = input.trim();
14235
+ if (!/^\S+$/.test(key))
14236
+ return null;
14237
+ const profile = await fetchProfile(deployment.baseUrl, key);
14238
+ if (!profile)
14239
+ return null;
14240
+ const tokens = tokensFromApiKey(key);
14241
+ return profile.userId ? { ...tokens, accountId: profile.userId } : tokens;
14242
+ }
14243
+
14244
+ // src/providers/kimi/oauth.ts
14245
+ import { createHash as createHash4 } from "node:crypto";
14246
+ import { arch, homedir as homedir2, hostname as hostname4, release, type } from "node:os";
14247
+ function ascii(value) {
14248
+ return value.replaceAll(/[^\x20-\x7E]/g, "").trim() || "unknown";
14249
+ }
14250
+ function deviceHeaders() {
14251
+ const os = type();
14252
+ const name = os === "Darwin" ? "macOS" : os === "Windows_NT" ? "Windows" : os;
14253
+ const id = createHash4("sha256").update(`${hostname4()}
14254
+ ${homedir2()}`);
14255
+ return {
14256
+ "X-Msh-Platform": KIMI_DEVICE_PLATFORM,
14257
+ "X-Msh-Version": KIMI_DEVICE_VERSION,
14258
+ "X-Msh-Device-Name": ascii(hostname4()),
14259
+ "X-Msh-Device-Model": ascii(`${name} ${release()} ${arch()}`),
14260
+ "X-Msh-Os-Version": ascii(release()),
14261
+ "X-Msh-Device-Id": id.digest("hex").slice(0, 32)
14262
+ };
14263
+ }
14264
+ function postForm(deployment, path, params) {
14265
+ return fetch(`${deployment.oauthHost}${path}`, {
14266
+ method: "POST",
14267
+ headers: {
14268
+ ...deviceHeaders(),
14269
+ "content-type": "application/x-www-form-urlencoded",
14270
+ accept: "application/json"
14271
+ },
14272
+ body: new URLSearchParams({ client_id: KIMI_OAUTH_CLIENT_ID, ...params }),
14273
+ signal: AbortSignal.timeout(OAUTH_HTTP_TIMEOUT_MS)
14274
+ });
14275
+ }
14276
+ function text(json, key) {
14277
+ const value = json[key];
14278
+ return typeof value === "string" && value !== "" ? value : null;
14279
+ }
14280
+ var REAL_CLOCK = { now: () => Date.now(), sleep };
14281
+ async function authorizeDevice(deployment, now) {
14282
+ const res = await postForm(deployment, "/api/oauth/device_authorization", {});
14283
+ const json = await res.json().catch(() => null);
14284
+ const body = isPlainObject2(json) ? json : {};
14285
+ const deviceCode = text(body, "device_code");
14286
+ const userCode = text(body, "user_code");
14287
+ const url = text(body, "verification_uri_complete");
14288
+ if (!res.ok || !deviceCode || !userCode || !url)
14289
+ throw new Error(`Kimi Code device authorization failed (HTTP ${res.status})`);
14290
+ const expiresInMs = Number(body.expires_in) * 1000;
14291
+ return {
14292
+ deviceCode,
14293
+ userCode,
14294
+ url,
14295
+ intervalMs: Math.max(Number(body.interval) || 5, 1) * 1000,
14296
+ deadline: now + (expiresInMs > 0 ? Math.min(expiresInMs, DEVICE_LOGIN_TIMEOUT_MS) : DEVICE_LOGIN_TIMEOUT_MS)
14297
+ };
14298
+ }
14299
+ async function pollDevice(deployment, device, clock) {
14300
+ let intervalMs = device.intervalMs;
14301
+ for (;; ) {
14302
+ const res = await postForm(deployment, "/api/oauth/token", {
14303
+ device_code: device.deviceCode,
14304
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code"
14305
+ });
14306
+ if (res.ok) {
14307
+ const json = await readTokenResponse(res);
14308
+ return json ? toTokenSet(json, "") : null;
14309
+ }
14310
+ const json = await res.json().catch(() => null);
14311
+ const error = isPlainObject2(json) ? json.error : undefined;
14312
+ if (error === "slow_down")
14313
+ intervalMs += 5000;
14314
+ else if (error !== "authorization_pending")
14315
+ return null;
14316
+ if (clock.now() + intervalMs >= device.deadline)
14317
+ return null;
14318
+ await clock.sleep(intervalMs);
14319
+ }
14320
+ }
14321
+ async function startDeviceLogin(deployment, clock = REAL_CLOCK) {
14322
+ const device = await authorizeDevice(deployment, clock.now());
14323
+ return {
14324
+ url: device.url,
14325
+ instructions: `Approve the sign-in in your browser (code ${device.userCode}).`,
14326
+ complete: async () => {
14327
+ const tokens = await pollDevice(deployment, device, clock);
14328
+ if (!tokens)
14329
+ return null;
14330
+ const profile = await fetchProfile(deployment.baseUrl, tokens.access);
14331
+ return profile?.userId ? { ...tokens, accountId: profile.userId } : tokens;
14332
+ }
14333
+ };
14334
+ }
14335
+ async function refreshOAuth(deployment, refreshToken) {
14336
+ if (!refreshToken)
14337
+ throw new Error("invalid_grant: a Kimi Code API key cannot be refreshed");
14338
+ const res = await postForm(deployment, "/api/oauth/token", {
14339
+ grant_type: "refresh_token",
14340
+ refresh_token: refreshToken
14341
+ });
14342
+ if (res.status === 403) {
14343
+ await res.body?.cancel().catch(ignore);
14344
+ throw new Error("invalid_grant: Kimi Code rejected the refresh token (HTTP 403)");
14345
+ }
14346
+ return toTokenSet(await readRefreshResponse(res), refreshToken);
14347
+ }
14348
+
14349
+ // src/providers/kimi/usage.ts
14350
+ var FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
14351
+ var UNIT_MS = new Map([
14352
+ ["TIME_UNIT_SECOND", 1000],
14353
+ ["TIME_UNIT_MINUTE", 60000],
14354
+ ["TIME_UNIT_HOUR", 3600000],
14355
+ ["TIME_UNIT_DAY", 86400000]
14356
+ ]);
14357
+ function toNumber(value) {
14358
+ const n = typeof value === "string" && value.trim() !== "" ? Number(value) : value;
14359
+ return typeof n === "number" && Number.isFinite(n) ? n : null;
14360
+ }
14361
+ function resetAt(value) {
14362
+ if (typeof value !== "string")
14363
+ return 0;
14364
+ const ms = Date.parse(value);
14365
+ return Number.isFinite(ms) && ms > 0 && !isImplausiblyFarFuture(ms) ? ms : 0;
14366
+ }
14367
+ function ratioWindow(raw) {
14368
+ if (!isPlainObject2(raw))
14369
+ return null;
14370
+ const ratio = toNumber(raw.used_ratio);
14371
+ if (ratio === null)
14372
+ return null;
14373
+ return { utilization: clamp01(ratio), resetAt: resetAt(raw.reset_time) };
14374
+ }
14375
+ function counterWindow(raw) {
14376
+ if (!isPlainObject2(raw))
14377
+ return null;
14378
+ const limit = toNumber(raw.limit);
14379
+ if (limit === null || limit <= 0)
14380
+ return null;
14381
+ const remaining = toNumber(raw.remaining);
14382
+ const used = toNumber(raw.used) ?? (remaining === null ? 0 : limit - remaining);
14383
+ return { utilization: clamp01(used / limit), resetAt: resetAt(raw.resetTime) };
14384
+ }
14385
+ function fiveHourCounters(limits) {
14386
+ if (!Array.isArray(limits))
14387
+ return null;
14388
+ for (const entry of limits) {
14389
+ if (!isPlainObject2(entry) || !isPlainObject2(entry.window))
14390
+ continue;
14391
+ const unitMs = UNIT_MS.get(String(entry.window.timeUnit));
14392
+ const duration = toNumber(entry.window.duration);
14393
+ if (unitMs !== undefined && duration !== null && duration * unitMs === FIVE_HOURS_MS)
14394
+ return counterWindow(entry.detail);
14395
+ }
14396
+ return null;
14397
+ }
14398
+ function parseUsages(json, now) {
14399
+ if (!isPlainObject2(json))
14400
+ return null;
14401
+ const ratios = isPlainObject2(json.usages) ? json.usages : {};
14402
+ const hourly = ratioWindow(ratios.limit_5h) ?? fiveHourCounters(json.limits);
14403
+ const weekly = ratioWindow(ratios.limit_7d) ?? counterWindow(json.usage);
14404
+ return hourly || weekly ? { hourly, weekly, capturedAt: now } : null;
14405
+ }
14406
+ function getUsages(baseUrl, key) {
14407
+ return fetchJson(`${baseUrl}/usages`, { authorization: `Bearer ${key}`, accept: "application/json" }, USAGE_HTTP_TIMEOUT_MS);
14408
+ }
14409
+ function usageFetcher(baseUrl) {
14410
+ return async (account, now) => parseUsages(await getUsages(baseUrl, account.access), now);
14411
+ }
14412
+
14413
+ // src/providers/kimi/adapter.ts
14414
+ function applyAuth(headers, account) {
14415
+ headers.delete("x-api-key");
14416
+ headers.set("authorization", `Bearer ${account.access}`);
14417
+ }
14418
+ function createKimiAdapter(deployment) {
14419
+ return {
14420
+ id: deployment.id,
14421
+ startDeviceLogin: () => startDeviceLogin(deployment),
14422
+ refresh: (refreshToken) => refreshOAuth(deployment, refreshToken),
14423
+ authorize: async () => ({
14424
+ url: deployment.consoleUrl,
14425
+ verifier: "",
14426
+ state: "",
14427
+ redirectUri: "",
14428
+ instructions: "Paste your Kimi Code API key here:"
14429
+ }),
14430
+ exchange: (input) => exchange2(deployment, input),
14431
+ tokensFromApiKey,
14432
+ applyAuth,
14433
+ transformUrl: (input) => input,
14434
+ transformBody: (body) => body,
14435
+ transformResponse: (response) => response,
14436
+ parseUsageHeaders: () => null,
14437
+ fetchUsage: usageFetcher(deployment.baseUrl),
14438
+ classifyError: classifyHttpStatus
14439
+ };
14440
+ }
14441
+ var kimiCodeAdapter = createKimiAdapter(KIMI_CODE);
14442
+ var kimiCodeGlobalAdapter = createKimiAdapter(KIMI_CODE_GLOBAL);
14443
+
14178
14444
  // src/providers/openai/constants.ts
14179
- import { arch, platform, release } from "node:os";
14445
+ import { arch as arch2, platform, release as release2 } from "node:os";
14180
14446
  var PROVIDER_ID2 = "openai";
14181
14447
  var CLIENT_ID2 = "app_EMoamEEZ73f0CkXaXp7hrann";
14182
14448
  var AUTHORIZE_URL2 = "https://auth.openai.com/oauth/authorize";
@@ -14188,7 +14454,7 @@ var USAGE_URL2 = "https://chatgpt.com/backend-api/wham/usage";
14188
14454
  var USAGE_USER_AGENT = "codex-cli";
14189
14455
  var ORIGINATOR = "opencode";
14190
14456
  var OPENAI_BETA = "responses=experimental";
14191
- var USER_AGENT = `opencode/0.2.0 (${platform()} ${release()}; ${arch()})`;
14457
+ var USER_AGENT = `opencode/0.2.0 (${platform()} ${release2()}; ${arch2()})`;
14192
14458
  var SESSION_ID = crypto.randomUUID();
14193
14459
 
14194
14460
  // src/providers/openai/jwt.ts
@@ -14270,11 +14536,11 @@ async function authorize2() {
14270
14536
  verifier: pkce.verifier
14271
14537
  };
14272
14538
  }
14273
- async function exchange2(input, verifier, redirectUri, expectedState) {
14274
- const callback = parseCallbackInput(input);
14539
+ async function exchange3(input, verifier, redirectUri, expectedState) {
14540
+ const callback = parseCodeInput(input);
14275
14541
  if (!callback)
14276
14542
  return null;
14277
- if (expectedState && callback.state !== expectedState)
14543
+ if (expectedState && callback.state !== null && callback.state !== expectedState)
14278
14544
  return null;
14279
14545
  const body = new URLSearchParams({
14280
14546
  grant_type: "authorization_code",
@@ -14299,7 +14565,7 @@ async function refresh2(refreshToken) {
14299
14565
  }
14300
14566
 
14301
14567
  // src/providers/openai/transform.ts
14302
- function applyAuth(headers, account) {
14568
+ function applyAuth2(headers, account) {
14303
14569
  headers.delete("x-api-key");
14304
14570
  headers.set("authorization", `Bearer ${account.access}`);
14305
14571
  const accountId = resolveAccountId(account);
@@ -14418,9 +14684,9 @@ async function fetchUsage2(account, now) {
14418
14684
  var openaiAdapter = {
14419
14685
  id: PROVIDER_ID2,
14420
14686
  authorize: authorize2,
14421
- exchange: exchange2,
14687
+ exchange: exchange3,
14422
14688
  refresh: refresh2,
14423
- applyAuth,
14689
+ applyAuth: applyAuth2,
14424
14690
  transformUrl: rewriteUrl2,
14425
14691
  transformBody: rewriteRequestBody2,
14426
14692
  transformResponse(response) {
@@ -14434,7 +14700,9 @@ var openaiAdapter = {
14434
14700
  // src/providers/registry.ts
14435
14701
  var ADAPTERS = [
14436
14702
  anthropicAdapter,
14437
- openaiAdapter
14703
+ openaiAdapter,
14704
+ kimiCodeAdapter,
14705
+ kimiCodeGlobalAdapter
14438
14706
  ];
14439
14707
  function adapterFor(adapters, providerID) {
14440
14708
  for (const adapter of adapters)
@@ -14559,7 +14827,7 @@ async function applyCooldown(accountId, fallbackMs, kind) {
14559
14827
  }
14560
14828
  }));
14561
14829
  }
14562
- async function recordRotation(adapter, accountId, res, fallbackMs, now, kind) {
14830
+ async function recordRotation(adapter, accountId, res, fallbackMs, now, kind, revokedKey) {
14563
14831
  const partial = adapter.parseUsageHeaders(res.headers);
14564
14832
  const until = cooldownUntilFrom(res, fallbackMs, now);
14565
14833
  await bestEffort("rotation", () => mutatePool((pool) => {
@@ -14572,6 +14840,8 @@ async function recordRotation(adapter, accountId, res, fallbackMs, now, kind) {
14572
14840
  account.cooldownUntil = until;
14573
14841
  account.cooldownKind = kind;
14574
14842
  }
14843
+ if (revokedKey !== undefined && account.access === revokedKey)
14844
+ account.disabledReason = `invalid API key: re-login required (${adapter.id}:${account.label})`;
14575
14845
  }));
14576
14846
  }
14577
14847
  async function recordModelCooldown(accountId, tier, until, partial, now) {
@@ -14782,7 +15052,7 @@ function createLoadBalancedFetch(adapter, hooks = {}, models = [], pending) {
14782
15052
  }
14783
15053
  }
14784
15054
  const ms = cls === "auth" ? AUTH_COOLDOWN_MS : ACCOUNT_COOLDOWN_MS;
14785
- await recordRotation(adapter, account.id, res, ms, Date.now(), cls === "auth" ? "auth" : "quota");
15055
+ await recordRotation(adapter, account.id, res, ms, Date.now(), cls === "auth" ? "auth" : "quota", res.status === 401 && adapter.tokensFromApiKey && !account.refresh ? account.access : undefined);
14786
15056
  if (cls === "account") {
14787
15057
  waitableCooldownIds ??= new Set;
14788
15058
  waitableCooldownIds.add(account.id);
@@ -14840,7 +15110,9 @@ function createLoadBalancedFetch(adapter, hooks = {}, models = [], pending) {
14840
15110
  // src/status.ts
14841
15111
  var PROVIDER_NAMES = {
14842
15112
  anthropic: "Claude",
14843
- openai: "Codex"
15113
+ openai: "Codex",
15114
+ "kimi-code-plan-cn": "Kimi",
15115
+ "kimi-code-plan-global": "Kimi (kimi.ai)"
14844
15116
  };
14845
15117
  function compareAscii(a, b) {
14846
15118
  return a < b ? -1 : a > b ? 1 : 0;
@@ -15109,7 +15381,7 @@ function acquirePendingLease(key, options = DEFAULT_PENDING_LEASE) {
15109
15381
  }
15110
15382
 
15111
15383
  // src/pending/store.ts
15112
- import { createHash as createHash3 } from "node:crypto";
15384
+ import { createHash as createHash5 } from "node:crypto";
15113
15385
  import { readFile as readFile4 } from "node:fs/promises";
15114
15386
  var PENDING_WRITE_LOCK = {
15115
15387
  staleMs: 30000,
@@ -15132,7 +15404,7 @@ class PendingReadError extends Error {
15132
15404
  }
15133
15405
  }
15134
15406
  function pendingKey(ref) {
15135
- return createHash3("sha256").update(JSON.stringify([
15407
+ return createHash5("sha256").update(JSON.stringify([
15136
15408
  ref.workspace,
15137
15409
  ref.providerID,
15138
15410
  ref.sessionID,
@@ -15582,7 +15854,9 @@ async function primeInUse(providerID, now) {
15582
15854
  // src/index.ts
15583
15855
  var PROVIDER_LABELS = {
15584
15856
  anthropic: "Claude Pro/Max",
15585
- openai: "ChatGPT/Codex"
15857
+ openai: "ChatGPT/Codex",
15858
+ "kimi-code-plan-cn": "Kimi Code (kimi.com)",
15859
+ "kimi-code-plan-global": "Kimi Code (kimi.ai)"
15586
15860
  };
15587
15861
  function zeroOutCost(provider) {
15588
15862
  for (const model of Object.values(provider.models)) {
@@ -15595,10 +15869,21 @@ function zeroOutCost(provider) {
15595
15869
  }
15596
15870
  function buildAuthHook(adapter, client, pending) {
15597
15871
  const label = PROVIDER_LABELS[adapter.id] ?? adapter.id;
15872
+ const startDeviceLogin = adapter.startDeviceLogin;
15873
+ const register = async (tokens) => {
15874
+ await addAccount(adapter.id, tokens);
15875
+ await refreshUsageInBackground(adapter, Date.now()).catch(ignore);
15876
+ };
15877
+ const oauthSuccess = (tokens) => ({
15878
+ type: "success",
15879
+ refresh: tokens.refresh,
15880
+ access: tokens.access,
15881
+ expires: tokens.expires
15882
+ });
15598
15883
  return {
15599
15884
  provider: adapter.id,
15600
15885
  async loader(getAuth, provider) {
15601
- await bootstrapFromOpencodeAuth(adapter.id, getAuth);
15886
+ await bootstrapFromOpencodeAuth(adapter.id, getAuth, adapter.tokensFromApiKey);
15602
15887
  zeroOutCost(provider);
15603
15888
  await adapter.prime?.(Date.now()).catch(ignore);
15604
15889
  refreshUsageInBackground(adapter, Date.now()).then(() => primeInUse(adapter.id, Date.now())).catch(ignore);
@@ -15615,27 +15900,44 @@ function buildAuthHook(adapter, client, pending) {
15615
15900
  };
15616
15901
  },
15617
15902
  methods: [
15903
+ ...startDeviceLogin ? [
15904
+ {
15905
+ label: `${label} (add account to load balancer)`,
15906
+ type: "oauth",
15907
+ authorize: async () => {
15908
+ const login = await startDeviceLogin();
15909
+ return {
15910
+ url: login.url,
15911
+ instructions: login.instructions,
15912
+ method: "auto",
15913
+ callback: async () => {
15914
+ const tokens = await login.complete();
15915
+ if (!tokens)
15916
+ return { type: "failed" };
15917
+ await register(tokens);
15918
+ return oauthSuccess(tokens);
15919
+ }
15920
+ };
15921
+ }
15922
+ }
15923
+ ] : [],
15618
15924
  {
15619
- label: `${label} (add account to load balancer)`,
15925
+ label: `${adapter.tokensFromApiKey ? `${label} API key` : label} (add account to load balancer)`,
15620
15926
  type: "oauth",
15621
15927
  authorize: async () => {
15622
15928
  const result = await adapter.authorize();
15623
15929
  return {
15624
15930
  url: result.url,
15625
- instructions: "Paste the authorization code here:",
15931
+ instructions: result.instructions ?? "Paste the authorization code here:",
15626
15932
  method: "code",
15627
15933
  callback: async (code) => {
15628
15934
  const tokens = await adapter.exchange(code, result.verifier, result.redirectUri, result.state);
15629
15935
  if (!tokens)
15630
15936
  return { type: "failed" };
15631
- await addAccount(adapter.id, tokens);
15632
- await refreshUsageInBackground(adapter, Date.now()).catch(ignore);
15633
- return {
15634
- type: "success",
15635
- refresh: tokens.refresh,
15636
- access: tokens.access,
15637
- expires: tokens.expires
15638
- };
15937
+ await register(tokens);
15938
+ if (adapter.tokensFromApiKey)
15939
+ return { type: "success", key: tokens.access };
15940
+ return oauthSuccess(tokens);
15639
15941
  }
15640
15942
  };
15641
15943
  }
@@ -15679,6 +15981,8 @@ function createProviderPlugin(adapter) {
15679
15981
  }
15680
15982
  var AnthropicLoadBalancerPlugin = createProviderPlugin(anthropicAdapter);
15681
15983
  var OpenAILoadBalancerPlugin = createProviderPlugin(openaiAdapter);
15984
+ var KimiCodeLoadBalancerPlugin = createProviderPlugin(kimiCodeAdapter);
15985
+ var KimiCodeGlobalLoadBalancerPlugin = createProviderPlugin(kimiCodeGlobalAdapter);
15682
15986
  var lbResult = (output) => ({ title: "Auth Load Balancer", output });
15683
15987
  var AuthLoadBalancerStatusPlugin = async (input) => ({
15684
15988
  tool: {
@@ -15753,5 +16057,7 @@ ${pending}` : status);
15753
16057
  export {
15754
16058
  AnthropicLoadBalancerPlugin,
15755
16059
  AuthLoadBalancerStatusPlugin,
16060
+ KimiCodeGlobalLoadBalancerPlugin,
16061
+ KimiCodeLoadBalancerPlugin,
15756
16062
  OpenAILoadBalancerPlugin
15757
16063
  };
@@ -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). */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-auth-load-balancer",
3
- "version": "0.4.1",
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