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 +20 -5
- package/dist/accounts.d.ts +13 -7
- package/dist/index.d.ts +2 -0
- package/dist/index.js +340 -34
- package/dist/providers/kimi/adapter.d.ts +13 -0
- package/dist/providers/kimi/constants.d.ts +36 -0
- package/dist/providers/kimi/key.d.ts +18 -0
- package/dist/providers/kimi/oauth.d.ts +22 -0
- package/dist/providers/kimi/profile.d.ts +10 -0
- package/dist/providers/kimi/usage.d.ts +12 -0
- package/dist/providers/oauth-callback.d.ts +14 -0
- package/dist/providers/openai/oauth.d.ts +6 -3
- package/dist/providers/types.d.ts +29 -0
- package/package.json +4 -2
- package/tui/auth-load-balancer-tui.logic.ts +14 -4
- package/tui/auth-load-balancer-tui.view.tsx +25 -3
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
|
|
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
|
|
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
|
|
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:
|
|
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.
|
package/dist/accounts.d.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { type PoolAccount, type TokenSet } from './types';
|
|
2
|
-
/**
|
|
3
|
-
|
|
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
|
|
17
|
-
* the single-account anthropic-auth plugin
|
|
18
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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()} ${
|
|
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
|
|
14274
|
-
const callback =
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
15632
|
-
|
|
15633
|
-
|
|
15634
|
-
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
"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 =
|
|
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
|
|
286
|
-
method.label?.toLowerCase().includes('
|
|
293
|
+
pooled(method) &&
|
|
294
|
+
(method.label?.toLowerCase().includes('api key') ?? false) === apiKey,
|
|
287
295
|
)
|
|
288
|
-
if (
|
|
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(
|
|
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
|