clawmoney 0.17.5 → 0.17.6

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.
@@ -76,23 +76,14 @@ const RECOMMENDED_MODELS = {
76
76
  "antigravity-gemini-3-flash",
77
77
  "antigravity-gemini-2.5-pro",
78
78
  ],
79
- // ── Z.AI / GLM ──
80
- // One cli_type per openclaw onboarding choice. Coding-plan variants share
81
- // the same recommended catalog — the cli_type distinguishes the upstream
82
- // baseUrl at call time, not the model id.
79
+ // ── Z.AI GLM Coding Plan ──
83
80
  "zai-coding": ["glm-5", "glm-4.7", "glm-4.7-flash", "glm-4.5-air"],
84
- zai: ["glm-5", "glm-4.7", "glm-4.7-flash", "glm-4.5-air"],
85
- // ── Moonshot / Kimi ──
86
- moonshot: ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo"],
87
- "kimi-coding": ["kimi-code"],
81
+ // ── Kimi Coding Plan ──
82
+ "kimi-coding": ["kimi-k2.5", "kimi-k2-thinking", "kimi-code"],
88
83
  // ── Qwen Coding Plan ──
89
84
  "qwen-coding": ["qwen3.6-plus", "qwen-coder-plus", "qwen3-coder"],
90
- // ── MiniMax ──
85
+ // ── MiniMax Coding Plan ──
91
86
  minimax: ["MiniMax-M2.7", "MiniMax-M2.7-highspeed"],
92
- // ── OpenAI API-key (distinct from "codex" subscription adapter) ──
93
- // Uses the buyer's own API key; same model catalog as codex Coding CLI
94
- // plus the o-series reasoning models that codex can't serve.
95
- openai: ["gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex", "o4-mini"],
96
87
  };
97
88
  function modelsForCli(cli) {
98
89
  const all = Object.keys(API_PRICES);
@@ -114,14 +105,11 @@ function modelsForCli(cli) {
114
105
  // the antigravity cli_type, not the standalone gemini cli_type.
115
106
  return all.filter((m) => m.startsWith("gemini-") && !m.startsWith("antigravity-"));
116
107
  }
117
- if (cli === "zai-coding" || cli === "zai") {
108
+ if (cli === "zai-coding") {
118
109
  return all.filter((m) => m.startsWith("glm-"));
119
110
  }
120
- if (cli === "moonshot") {
121
- return all.filter((m) => m.startsWith("kimi-k2"));
122
- }
123
111
  if (cli === "kimi-coding") {
124
- return ["kimi-code"].filter((m) => m in API_PRICES);
112
+ return all.filter((m) => m.startsWith("kimi-"));
125
113
  }
126
114
  if (cli === "qwen-coding") {
127
115
  return all.filter((m) => m.startsWith("qwen"));
@@ -129,10 +117,6 @@ function modelsForCli(cli) {
129
117
  if (cli === "minimax") {
130
118
  return all.filter((m) => m.startsWith("MiniMax-"));
131
119
  }
132
- if (cli === "openai") {
133
- // OpenAI API-key passthrough — gpt-5.x + o-series reasoning models.
134
- return all.filter((m) => m.startsWith("gpt-") || m === "o3" || m === "o4-mini");
135
- }
136
120
  return [];
137
121
  }
138
122
  function detectInstalledClis() {
@@ -185,11 +169,10 @@ function detectInstalledClis() {
185
169
  // env var. Pair of (provider-id-in-openclaw, env-var-name, cli_type).
186
170
  const passthroughDetection = [
187
171
  { cli: "zai-coding", openclawProvider: "zai", env: "ZAI_API_KEY" },
188
- { cli: "zai", openclawProvider: "zai", env: "ZAI_API_KEY" },
189
- { cli: "moonshot", openclawProvider: "moonshot", env: "MOONSHOT_API_KEY" },
190
- { cli: "kimi-coding", openclawProvider: "kimi", env: "KIMI_API_KEY" },
191
172
  { cli: "qwen-coding", openclawProvider: "qwen", env: "BAILIAN_CODING_PLAN_API_KEY" },
192
- { cli: "openai", openclawProvider: "openai", env: "OPENAI_API_KEY" },
173
+ // NOTE: kimi-coding + minimax are intentionally absent — they have their
174
+ // own OAuth-aware detection blocks below. Pay-per-token cli_types
175
+ // (moonshot, zai, openai) were removed as provider-hostile.
193
176
  ];
194
177
  const openclawApiKeyProviders = new Set(listOpenclawApiKeyProviders());
195
178
  for (const { cli, openclawProvider, env } of passthroughDetection) {
@@ -205,6 +188,21 @@ function detectInstalledClis() {
205
188
  hint = `no key found (openclaw ${openclawProvider} profile or ${env})`;
206
189
  results.push({ cli, available, hint });
207
190
  }
191
+ // Kimi Coding: OAuth via kimi-cli (~/.kimi/credentials/kimi-code.json),
192
+ // or api_key fallback from openclaw / env. Listed separately so the hint
193
+ // can explain which path will actually be used at runtime.
194
+ const kimiOAuthPath = join(homedir(), ".kimi", "credentials", "kimi-code.json");
195
+ const hasKimiOAuth = existsSync(kimiOAuthPath);
196
+ const hasKimiKey = openclawApiKeyProviders.has("kimi") || !!process.env.KIMI_API_KEY;
197
+ results.push({
198
+ cli: "kimi-coding",
199
+ available: hasKimiOAuth || hasKimiKey,
200
+ hint: hasKimiOAuth
201
+ ? "kimi-cli OAuth token (~/.kimi/credentials/kimi-code.json)"
202
+ : hasKimiKey
203
+ ? "Kimi api_key (openclaw or KIMI_API_KEY env)"
204
+ : "no Kimi credential (run `kimi login` via kimi-cli, export KIMI_API_KEY, or `openclaw onboard --auth-choice kimi-code-api-key`)",
205
+ });
208
206
  // MiniMax: OAuth Coding Plan OR api_key fallback. List separately so the
209
207
  // hint can explain which path was detected.
210
208
  const hasMinimaxOauth = openclawProviders.has("minimax-portal");
@@ -455,6 +455,10 @@ async function resolvePreflightFn(cli) {
455
455
  const { preflightMinimaxApi } = await import("../relay/upstream/minimax-api.js");
456
456
  return () => preflightMinimaxApi();
457
457
  }
458
+ case "kimi-coding": {
459
+ const { preflightKimiCodingApi } = await import("../relay/upstream/kimi-coding-api.js");
460
+ return () => preflightKimiCodingApi();
461
+ }
458
462
  default: {
459
463
  // Passthrough cli_type (zai / moonshot / kimi-coding / qwen-coding / openai).
460
464
  const { preflightPassthroughApi, getPassthroughSpec } = await import("../relay/upstream/passthrough-api.js");
@@ -8,6 +8,7 @@ import { callCodexApi, callCodexApiPassthrough, preflightCodexApi, getRateGuardS
8
8
  import { callGeminiApi, preflightGeminiApi, getGeminiRateGuardSnapshot, } from "./upstream/gemini-api.js";
9
9
  import { callAntigravityApi, preflightAntigravityApi, getAntigravityRateGuardSnapshot, } from "./upstream/antigravity-api.js";
10
10
  import { callMinimaxApi, preflightMinimaxApi, getMinimaxRateGuardSnapshot, } from "./upstream/minimax-api.js";
11
+ import { callKimiCodingApi, preflightKimiCodingApi, getKimiCodingRateGuardSnapshot, } from "./upstream/kimi-coding-api.js";
11
12
  import { callPassthroughApi, preflightPassthroughApi, getPassthroughRateGuardSnapshot, } from "./upstream/passthrough-api.js";
12
13
  // Side-effect import: registers all static-key passthrough specs at module
13
14
  // load time (zai, zai-coding, moonshot, kimi-coding, qwen-coding, openai).
@@ -29,6 +30,8 @@ function getRateGuardSnapshotForCli(cli) {
29
30
  return getAntigravityRateGuardSnapshot();
30
31
  case "minimax":
31
32
  return getMinimaxRateGuardSnapshot();
33
+ case "kimi-coding":
34
+ return getKimiCodingRateGuardSnapshot();
32
35
  case "api-key":
33
36
  // api-key multiplexes multiple internal specs; without model context
34
37
  // we can't pick one snapshot. Hub treats null as "no signal", which
@@ -363,6 +366,16 @@ async function executeRelayRequest(request, config, sendChunk) {
363
366
  onRawEvent: sendChunk,
364
367
  });
365
368
  }
369
+ else if (internalSpec === "kimi-coding") {
370
+ // OAuth-aware Kimi adapter — reads kimi-cli's local token store.
371
+ parsed = await callKimiCodingApi({
372
+ prompt,
373
+ passthroughBody: request.passthrough_body,
374
+ model,
375
+ maxTokens: max_budget_usd ? undefined : 8192,
376
+ onRawEvent: sendChunk,
377
+ });
378
+ }
366
379
  else {
367
380
  parsed = await callPassthroughApi({
368
381
  cliType: internalSpec,
@@ -385,6 +398,16 @@ async function executeRelayRequest(request, config, sendChunk) {
385
398
  onRawEvent: sendChunk,
386
399
  });
387
400
  }
401
+ else if (cliType === "kimi-coding") {
402
+ // Ditto — kept for direct probes. Production traffic arrives as "api-key".
403
+ parsed = await callKimiCodingApi({
404
+ prompt,
405
+ passthroughBody: request.passthrough_body,
406
+ model,
407
+ maxTokens: max_budget_usd ? undefined : 8192,
408
+ onRawEvent: sendChunk,
409
+ });
410
+ }
388
411
  else if (PASSTHROUGH_CLI_TYPES.has(cliType)) {
389
412
  // Same story — fine-grained cli_type path retained so local probe
390
413
  // scripts can target a specific spec without faking the Hub side.
@@ -515,6 +538,8 @@ function getPreflightFn(cliType) {
515
538
  return preflightAntigravityApi;
516
539
  case "minimax":
517
540
  return preflightMinimaxApi;
541
+ case "kimi-coding":
542
+ return preflightKimiCodingApi;
518
543
  case "api-key":
519
544
  // Credential validation for api-key happens lazily on first request —
520
545
  // we can't know which internal specs to preflight without the list of
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Kimi Code (Moonshot Kimi Coding Plan) adapter.
3
+ *
4
+ * Supports three credential sources, in order of preference:
5
+ *
6
+ * 1. kimi-cli's native OAuth store at ~/.kimi/credentials/kimi-code.json
7
+ * (populated by `kimi login`; refreshed against auth.kimi.com).
8
+ * 2. An OpenClaw api_key profile (provider="kimi") — static Bearer from
9
+ * `openclaw onboard --auth-choice kimi-code-api-key`.
10
+ * 3. `KIMI_API_KEY` env var — static Bearer for providers who want to
11
+ * ship their own key without involving kimi-cli or openclaw.
12
+ *
13
+ * Wire is OpenAI-compatible (/chat/completions + SSE), just like the
14
+ * moonshot / openai / zai passthrough specs. The wrinkles on top of
15
+ * vanilla passthrough are OAuth-specific:
16
+ *
17
+ * - Token auto-refresh against https://auth.kimi.com/api/oauth/token
18
+ * (standard OAuth2 refresh_token grant, client_id
19
+ * 17e5f671-d194-4dfb-9706-5516cb48c098 — same value the kimi-cli
20
+ * public binary ships with).
21
+ * - Refreshed tokens written back to the same file kimi-cli reads, so
22
+ * our relay daemon and a concurrent `kimi` TUI on the same machine
23
+ * stay in sync instead of fighting over token state.
24
+ * - Moonshot-flavored fingerprint headers (X-Msh-Platform, -Version,
25
+ * -Device-Id, etc.) — matches what a real kimi-cli sends so upstream
26
+ * fraud detection doesn't flag relay traffic as unknown-client.
27
+ * Device id is read from ~/.kimi/device_id; if the operator hasn't
28
+ * run kimi-cli locally we synthesize one and persist it (same thing
29
+ * kimi-cli does on first launch).
30
+ *
31
+ * Source of truth for all the above is
32
+ * https://github.com/MoonshotAI/kimi-cli/blob/main/src/kimi_cli/auth/oauth.py.
33
+ */
34
+ import type { ParsedOutput, RelayRateGuardConfig } from "../types.js";
35
+ import { RateGuard, RateGuardBudgetExceededError, RateGuardCooldownError } from "./rate-guard.js";
36
+ export { RateGuardBudgetExceededError, RateGuardCooldownError };
37
+ export declare function configureKimiCodingRateGuard(config?: RelayRateGuardConfig): void;
38
+ export declare function getKimiCodingRateGuardSnapshot(): ReturnType<RateGuard["currentLoad"]> | null;
39
+ export declare function preflightKimiCodingApi(config?: RelayRateGuardConfig): Promise<void>;
40
+ export interface CallKimiCodingApiOptions {
41
+ prompt?: string;
42
+ passthroughBody?: Record<string, unknown>;
43
+ model: string;
44
+ maxTokens?: number;
45
+ onRawEvent?: (rawFrame: string) => void;
46
+ }
47
+ export declare function callKimiCodingApi(opts: CallKimiCodingApiOptions): Promise<ParsedOutput>;
@@ -0,0 +1,395 @@
1
+ /**
2
+ * Kimi Code (Moonshot Kimi Coding Plan) adapter.
3
+ *
4
+ * Supports three credential sources, in order of preference:
5
+ *
6
+ * 1. kimi-cli's native OAuth store at ~/.kimi/credentials/kimi-code.json
7
+ * (populated by `kimi login`; refreshed against auth.kimi.com).
8
+ * 2. An OpenClaw api_key profile (provider="kimi") — static Bearer from
9
+ * `openclaw onboard --auth-choice kimi-code-api-key`.
10
+ * 3. `KIMI_API_KEY` env var — static Bearer for providers who want to
11
+ * ship their own key without involving kimi-cli or openclaw.
12
+ *
13
+ * Wire is OpenAI-compatible (/chat/completions + SSE), just like the
14
+ * moonshot / openai / zai passthrough specs. The wrinkles on top of
15
+ * vanilla passthrough are OAuth-specific:
16
+ *
17
+ * - Token auto-refresh against https://auth.kimi.com/api/oauth/token
18
+ * (standard OAuth2 refresh_token grant, client_id
19
+ * 17e5f671-d194-4dfb-9706-5516cb48c098 — same value the kimi-cli
20
+ * public binary ships with).
21
+ * - Refreshed tokens written back to the same file kimi-cli reads, so
22
+ * our relay daemon and a concurrent `kimi` TUI on the same machine
23
+ * stay in sync instead of fighting over token state.
24
+ * - Moonshot-flavored fingerprint headers (X-Msh-Platform, -Version,
25
+ * -Device-Id, etc.) — matches what a real kimi-cli sends so upstream
26
+ * fraud detection doesn't flag relay traffic as unknown-client.
27
+ * Device id is read from ~/.kimi/device_id; if the operator hasn't
28
+ * run kimi-cli locally we synthesize one and persist it (same thing
29
+ * kimi-cli does on first launch).
30
+ *
31
+ * Source of truth for all the above is
32
+ * https://github.com/MoonshotAI/kimi-cli/blob/main/src/kimi_cli/auth/oauth.py.
33
+ */
34
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, } from "node:fs";
35
+ import { join } from "node:path";
36
+ import { homedir, hostname, platform as osPlatform, release as osRelease, arch as osArch, type as osType } from "node:os";
37
+ import { randomUUID } from "node:crypto";
38
+ import { fetch, ProxyAgent, setGlobalDispatcher } from "undici";
39
+ import { relayLogger as logger } from "../logger.js";
40
+ import { RateGuard, RateGuardBudgetExceededError, RateGuardCooldownError, } from "./rate-guard.js";
41
+ import { calculateCost } from "../pricing.js";
42
+ import { readOpenclawApiKeyProfile } from "./openclaw-creds.js";
43
+ export { RateGuardBudgetExceededError, RateGuardCooldownError };
44
+ // ── Constants sourced from kimi-cli's auth/oauth.py ──────────────────────
45
+ const KIMI_CODE_CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
46
+ const KIMI_OAUTH_HOST = "https://auth.kimi.com";
47
+ const KIMI_COD_BASE_URL = "https://api.kimi.com/coding/v1";
48
+ const KIMI_SHARE_DIR = join(homedir(), ".kimi");
49
+ const KIMI_CREDENTIALS_FILE = join(KIMI_SHARE_DIR, "credentials", "kimi-code.json");
50
+ const KIMI_DEVICE_ID_FILE = join(KIMI_SHARE_DIR, "device_id");
51
+ // Refresh proactively when within 5 minutes of expiry, matching kimi-cli's
52
+ // MIN_REFRESH_THRESHOLD_SECONDS = 300.
53
+ const REFRESH_SKEW_MS = 5 * 60 * 1000;
54
+ // ── Dispatcher (HTTPS_PROXY support, same pattern as other adapters) ────
55
+ let dispatcherConfigured = false;
56
+ function configureDispatcher() {
57
+ if (dispatcherConfigured)
58
+ return;
59
+ const proxyUrl = process.env.HTTPS_PROXY ??
60
+ process.env.https_proxy ??
61
+ process.env.HTTP_PROXY ??
62
+ process.env.http_proxy;
63
+ if (proxyUrl) {
64
+ setGlobalDispatcher(new ProxyAgent(proxyUrl));
65
+ logger.info(`[kimi-coding] upstream proxy ${proxyUrl}`);
66
+ }
67
+ dispatcherConfigured = true;
68
+ }
69
+ // ── Device id (~/.kimi/device_id) ────────────────────────────────────────
70
+ let cachedDeviceId = null;
71
+ function getDeviceId() {
72
+ if (cachedDeviceId)
73
+ return cachedDeviceId;
74
+ try {
75
+ if (existsSync(KIMI_DEVICE_ID_FILE)) {
76
+ const raw = readFileSync(KIMI_DEVICE_ID_FILE, "utf-8").trim();
77
+ if (raw) {
78
+ cachedDeviceId = raw;
79
+ return raw;
80
+ }
81
+ }
82
+ }
83
+ catch (err) {
84
+ logger.warn(`[kimi-coding] failed to read device_id: ${err.message}`);
85
+ }
86
+ // First launch on this host — synthesize and persist the same way kimi-cli does.
87
+ const fresh = randomUUID().replace(/-/g, "");
88
+ try {
89
+ mkdirSync(KIMI_SHARE_DIR, { recursive: true });
90
+ writeFileSync(KIMI_DEVICE_ID_FILE, fresh, { encoding: "utf-8", mode: 0o600 });
91
+ }
92
+ catch (err) {
93
+ logger.warn(`[kimi-coding] failed to persist device_id: ${err.message}`);
94
+ }
95
+ cachedDeviceId = fresh;
96
+ return fresh;
97
+ }
98
+ // ── X-Msh-* fingerprint headers ──────────────────────────────────────────
99
+ function asciiHeaderValue(value) {
100
+ // Node's undici rejects non-ASCII header values; kimi-cli falls back to a
101
+ // filtered substring too (see _ascii_header_value in oauth.py).
102
+ const ascii = value.replace(/[^\x20-\x7e]/g, "").trim();
103
+ return ascii || "unknown";
104
+ }
105
+ function commonMshHeaders() {
106
+ let deviceModel = osType();
107
+ if (osPlatform() === "darwin") {
108
+ deviceModel = `macOS ${osRelease()} ${osArch()}`;
109
+ }
110
+ else if (osPlatform() === "win32") {
111
+ deviceModel = `Windows ${osRelease()} ${osArch()}`;
112
+ }
113
+ else {
114
+ deviceModel = `${osType()} ${osRelease()} ${osArch()}`;
115
+ }
116
+ return {
117
+ "X-Msh-Platform": "kimi_cli",
118
+ "X-Msh-Version": asciiHeaderValue(process.env.KIMI_CLI_VERSION ?? "0.1.0"),
119
+ "X-Msh-Device-Name": asciiHeaderValue(hostname()),
120
+ "X-Msh-Device-Model": asciiHeaderValue(deviceModel),
121
+ "X-Msh-Os-Version": asciiHeaderValue(osRelease()),
122
+ "X-Msh-Device-Id": getDeviceId(),
123
+ };
124
+ }
125
+ // ── Credential I/O ───────────────────────────────────────────────────────
126
+ function readCredentialsFile() {
127
+ if (!existsSync(KIMI_CREDENTIALS_FILE))
128
+ return null;
129
+ try {
130
+ const parsed = JSON.parse(readFileSync(KIMI_CREDENTIALS_FILE, "utf-8"));
131
+ if (!parsed.access_token || !parsed.refresh_token)
132
+ return null;
133
+ return parsed;
134
+ }
135
+ catch (err) {
136
+ logger.warn(`[kimi-coding] failed to parse ${KIMI_CREDENTIALS_FILE}: ${err.message}`);
137
+ return null;
138
+ }
139
+ }
140
+ function writeCredentialsFile(file) {
141
+ mkdirSync(join(KIMI_SHARE_DIR, "credentials"), { recursive: true });
142
+ const tmp = `${KIMI_CREDENTIALS_FILE}.tmp`;
143
+ writeFileSync(tmp, JSON.stringify(file, null, 2), { encoding: "utf-8", mode: 0o600 });
144
+ renameSync(tmp, KIMI_CREDENTIALS_FILE);
145
+ }
146
+ function loadCreds() {
147
+ // Preferred: ~/.kimi/credentials/kimi-code.json (OAuth).
148
+ const file = readCredentialsFile();
149
+ if (file) {
150
+ return {
151
+ source: "kimi-cli-file",
152
+ accessToken: file.access_token,
153
+ refreshToken: file.refresh_token,
154
+ expiresAt: file.expires_at * 1000, // s → ms
155
+ _rawFile: file,
156
+ };
157
+ }
158
+ // Fall back: OpenClaw api_key profile.
159
+ const apiKeyProfile = readOpenclawApiKeyProfile("kimi");
160
+ if (apiKeyProfile) {
161
+ logger.info(`[kimi-coding] using OpenClaw api_key fallback (profile=${apiKeyProfile.profileKey})`);
162
+ return {
163
+ source: "openclaw-apikey",
164
+ accessToken: apiKeyProfile.key,
165
+ expiresAt: Infinity,
166
+ };
167
+ }
168
+ // Last resort: env var.
169
+ const envKey = process.env.KIMI_API_KEY;
170
+ if (envKey && envKey.length > 0) {
171
+ return {
172
+ source: "env",
173
+ accessToken: envKey,
174
+ expiresAt: Infinity,
175
+ };
176
+ }
177
+ throw new Error(`Kimi Coding credentials not found (checked ${KIMI_CREDENTIALS_FILE}, ` +
178
+ `openclaw kimi api_key profile, and env KIMI_API_KEY). ` +
179
+ `Run \`kimi login\` (installs kimi-cli from pypi), \`openclaw onboard --auth-choice kimi-code-api-key\`, ` +
180
+ `or \`export KIMI_API_KEY=sk-...\`.`);
181
+ }
182
+ async function refreshUpstreamToken(refreshToken) {
183
+ const url = `${KIMI_OAUTH_HOST}/api/oauth/token`;
184
+ const body = new URLSearchParams({
185
+ grant_type: "refresh_token",
186
+ client_id: KIMI_CODE_CLIENT_ID,
187
+ refresh_token: refreshToken,
188
+ });
189
+ const resp = await fetch(url, {
190
+ method: "POST",
191
+ headers: {
192
+ accept: "application/json",
193
+ "content-type": "application/x-www-form-urlencoded",
194
+ ...commonMshHeaders(),
195
+ },
196
+ body: body.toString(),
197
+ });
198
+ if (!resp.ok) {
199
+ const text = await resp.text();
200
+ throw new Error(`Kimi token refresh failed: ${resp.status} ${text.slice(0, 300)}`);
201
+ }
202
+ const data = (await resp.json());
203
+ if (!data.access_token || !data.refresh_token) {
204
+ throw new Error("Kimi refresh response missing access_token / refresh_token");
205
+ }
206
+ const expiresIn = data.expires_in ?? 3600;
207
+ return {
208
+ accessToken: data.access_token,
209
+ refreshToken: data.refresh_token,
210
+ expiresAt: Date.now() + expiresIn * 1000,
211
+ scope: data.scope,
212
+ tokenType: data.token_type,
213
+ expiresIn,
214
+ };
215
+ }
216
+ let cachedCreds = null;
217
+ let refreshInflight = null;
218
+ async function doRefreshAndPersist(current) {
219
+ if (current.source !== "kimi-cli-file" || !current.refreshToken || !current._rawFile) {
220
+ // Static-key sources don't refresh.
221
+ return current;
222
+ }
223
+ logger.info("[kimi-coding] refreshing OAuth token...");
224
+ const fresh = await refreshUpstreamToken(current.refreshToken);
225
+ // Persist first; see claude-api / codex-api rationale for
226
+ // "write-before-advance" to avoid two-tokens-in-flight hijack signal.
227
+ const updatedFile = {
228
+ access_token: fresh.accessToken,
229
+ refresh_token: fresh.refreshToken,
230
+ expires_at: Math.floor(fresh.expiresAt / 1000), // ms → s to match kimi-cli
231
+ scope: fresh.scope ?? current._rawFile.scope,
232
+ token_type: fresh.tokenType ?? current._rawFile.token_type ?? "Bearer",
233
+ expires_in: fresh.expiresIn ?? current._rawFile.expires_in,
234
+ };
235
+ try {
236
+ writeCredentialsFile(updatedFile);
237
+ logger.info(`[kimi-coding] ${KIMI_CREDENTIALS_FILE} updated`);
238
+ }
239
+ catch (err) {
240
+ logger.error(`[kimi-coding] CRITICAL: persist failed — keeping old token: ${err.message}`);
241
+ return current;
242
+ }
243
+ return {
244
+ source: "kimi-cli-file",
245
+ accessToken: fresh.accessToken,
246
+ refreshToken: fresh.refreshToken,
247
+ expiresAt: fresh.expiresAt,
248
+ _rawFile: updatedFile,
249
+ };
250
+ }
251
+ async function getFreshCreds() {
252
+ if (!cachedCreds) {
253
+ cachedCreds = loadCreds();
254
+ }
255
+ if (cachedCreds.source !== "kimi-cli-file") {
256
+ return cachedCreds;
257
+ }
258
+ if (Date.now() < cachedCreds.expiresAt - REFRESH_SKEW_MS) {
259
+ return cachedCreds;
260
+ }
261
+ if (!refreshInflight) {
262
+ const prior = cachedCreds;
263
+ refreshInflight = doRefreshAndPersist(prior).finally(() => {
264
+ refreshInflight = null;
265
+ });
266
+ }
267
+ cachedCreds = await refreshInflight;
268
+ return cachedCreds;
269
+ }
270
+ // ── Rate guard ───────────────────────────────────────────────────────────
271
+ let rateGuard = null;
272
+ export function configureKimiCodingRateGuard(config) {
273
+ rateGuard = new RateGuard(config
274
+ ? {
275
+ maxConcurrency: config.max_concurrency,
276
+ quietHoursMaxConcurrency: config.quiet_hours_max_concurrency,
277
+ quietHours: config.quiet_hours,
278
+ minRequestGapMs: config.min_request_gap_ms,
279
+ jitterMs: config.jitter_ms,
280
+ dailyBudgetUsd: config.daily_budget_usd,
281
+ maxRelayUtilization: config.max_relay_utilization,
282
+ }
283
+ : {});
284
+ }
285
+ export function getKimiCodingRateGuardSnapshot() {
286
+ return rateGuard ? rateGuard.currentLoad() : null;
287
+ }
288
+ // ── Preflight ────────────────────────────────────────────────────────────
289
+ export async function preflightKimiCodingApi(config) {
290
+ configureDispatcher();
291
+ if (!rateGuard)
292
+ configureKimiCodingRateGuard(config);
293
+ const creds = await getFreshCreds();
294
+ const expLabel = creds.expiresAt === Infinity
295
+ ? "never"
296
+ : `${Math.floor((creds.expiresAt - Date.now()) / 1000)}s`;
297
+ logger.info(`[kimi-coding] preflight OK (source=${creds.source}, expires_in=${expLabel})`);
298
+ }
299
+ export async function callKimiCodingApi(opts) {
300
+ configureDispatcher();
301
+ if (!rateGuard)
302
+ configureKimiCodingRateGuard();
303
+ return rateGuard.run(() => doCall(opts));
304
+ }
305
+ async function doCall(opts) {
306
+ const creds = await getFreshCreds();
307
+ const baseUrl = (process.env.KIMI_CODE_BASE_URL ?? KIMI_COD_BASE_URL).replace(/\/+$/, "");
308
+ const body = opts.passthroughBody
309
+ ? { ...opts.passthroughBody, model: opts.model, stream: true }
310
+ : {
311
+ model: opts.model,
312
+ stream: true,
313
+ messages: [{ role: "user", content: opts.prompt ?? "" }],
314
+ ...(opts.maxTokens ? { max_tokens: opts.maxTokens } : {}),
315
+ };
316
+ const url = `${baseUrl}/chat/completions`;
317
+ const resp = await fetch(url, {
318
+ method: "POST",
319
+ headers: {
320
+ "content-type": "application/json",
321
+ accept: "text/event-stream",
322
+ authorization: `Bearer ${creds.accessToken}`,
323
+ ...commonMshHeaders(),
324
+ },
325
+ body: JSON.stringify(body),
326
+ });
327
+ if (!resp.ok) {
328
+ const text = await resp.text();
329
+ throw new Error(`kimi-coding upstream ${resp.status}: ${text.slice(0, 500)}`);
330
+ }
331
+ const reader = resp.body?.getReader();
332
+ if (!reader)
333
+ throw new Error("kimi-coding upstream returned empty body");
334
+ const decoder = new TextDecoder();
335
+ let buffered = "";
336
+ let text = "";
337
+ let usage;
338
+ let modelUsed = opts.model;
339
+ let sessionId = "";
340
+ for (;;) {
341
+ const { done, value } = await reader.read();
342
+ if (done)
343
+ break;
344
+ buffered += decoder.decode(value, { stream: true });
345
+ let sepIdx;
346
+ while ((sepIdx = buffered.indexOf("\n\n")) !== -1) {
347
+ const frame = buffered.slice(0, sepIdx);
348
+ buffered = buffered.slice(sepIdx + 2);
349
+ if (!frame.trim())
350
+ continue;
351
+ if (opts.onRawEvent)
352
+ opts.onRawEvent(`${frame}\n\n`);
353
+ for (const line of frame.split("\n")) {
354
+ if (!line.startsWith("data:"))
355
+ continue;
356
+ const payload = line.slice(5).trim();
357
+ if (!payload || payload === "[DONE]")
358
+ continue;
359
+ try {
360
+ const parsed = JSON.parse(payload);
361
+ if (parsed.model && !modelUsed)
362
+ modelUsed = parsed.model;
363
+ if (parsed.id && !sessionId)
364
+ sessionId = parsed.id;
365
+ for (const ch of parsed.choices ?? []) {
366
+ const delta = ch.delta?.content ?? ch.message?.content;
367
+ if (typeof delta === "string")
368
+ text += delta;
369
+ }
370
+ if (parsed.usage)
371
+ usage = parsed.usage;
372
+ }
373
+ catch {
374
+ // ignore non-JSON / heartbeat frames
375
+ }
376
+ }
377
+ }
378
+ }
379
+ const inputTokens = usage?.prompt_tokens ?? 0;
380
+ const cacheReadTokens = usage?.prompt_tokens_details?.cached_tokens ?? 0;
381
+ const outputTokens = usage?.completion_tokens ?? 0;
382
+ const breakdown = calculateCost(modelUsed || opts.model, Math.max(0, inputTokens - cacheReadTokens), outputTokens, 0, cacheReadTokens);
383
+ return {
384
+ text,
385
+ sessionId,
386
+ usage: {
387
+ input_tokens: Math.max(0, inputTokens - cacheReadTokens),
388
+ output_tokens: outputTokens,
389
+ cache_creation_tokens: 0,
390
+ cache_read_tokens: cacheReadTokens,
391
+ },
392
+ model: modelUsed || opts.model,
393
+ costUsd: breakdown.apiCost,
394
+ };
395
+ }
@@ -16,10 +16,23 @@ function envOr(name, fallback) {
16
16
  const v = process.env[name];
17
17
  return v && v.length > 0 ? v : fallback;
18
18
  }
19
- // ── Z.AI / GLM ────────────────────────────────────────────────────────────
20
- // Two coding-plan variants (global + cn) and two general-API variants,
21
- // all sharing the `zai` openclaw provider id and the `ZAI_API_KEY` env var.
22
- // cli_type is the only field distinguishing them on the relay side.
19
+ // ── Design note: subscription-only catalog ───────────────────────────────
20
+ //
21
+ // clawmoney relay only supports upstreams where the provider is selling
22
+ // *idle capacity from a fixed monthly subscription*. Pay-per-token API
23
+ // keys (Moonshot Open Platform, generic Z.AI API, openai.com API, raw
24
+ // DashScope) are deliberately NOT registered here: a provider would spend
25
+ // real money per request while the buyer only pays 20% of the API price
26
+ // (RELAY_DISCOUNT) — a guaranteed loss on every call. Keeping only
27
+ // subscription-backed cli_types means every entry is actually usable.
28
+ //
29
+ // Anthropic follows the same rule: no "anthropic" api-key spec, only the
30
+ // `claude` OAuth subscription path + `antigravity` (Google Ultra quota
31
+ // that also serves Claude models).
32
+ // ── Z.AI GLM Coding Plan ──────────────────────────────────────────────────
33
+ // Z.AI sells a monthly Coding Plan subscription separately from their
34
+ // token-priced general API. Only the subscription endpoint is routable
35
+ // from clawmoney.
23
36
  registerPassthroughSpec({
24
37
  cliType: "zai-coding",
25
38
  openclawProvider: "zai",
@@ -28,37 +41,14 @@ registerPassthroughSpec({
28
41
  api: "openai-completions",
29
42
  label: "Z.AI Coding Plan",
30
43
  });
31
- registerPassthroughSpec({
32
- cliType: "zai",
33
- openclawProvider: "zai",
34
- envVarName: "ZAI_API_KEY",
35
- baseUrl: envOr("ZAI_BASE_URL", "https://api.z.ai/api/paas/v4"),
36
- api: "openai-completions",
37
- label: "Z.AI General",
38
- });
39
- // ── Moonshot / Kimi K2 ────────────────────────────────────────────────────
40
- registerPassthroughSpec({
41
- cliType: "moonshot",
42
- openclawProvider: "moonshot",
43
- envVarName: "MOONSHOT_API_KEY",
44
- baseUrl: envOr("MOONSHOT_BASE_URL", "https://api.moonshot.ai/v1"),
45
- api: "openai-completions",
46
- label: "Moonshot (Kimi K2)",
47
- });
48
- // Kimi Coding is a separate product from Moonshot's public API: different
49
- // key, different endpoint, different catalog. Per openclaw docs the keys
50
- // are not interchangeable.
51
- registerPassthroughSpec({
52
- cliType: "kimi-coding",
53
- openclawProvider: "kimi",
54
- envVarName: "KIMI_API_KEY",
55
- baseUrl: envOr("KIMI_CODING_BASE_URL", "https://api.moonshot.ai/v1"),
56
- api: "openai-completions",
57
- label: "Kimi Coding",
58
- });
44
+ // kimi-coding + minimax are subscription-based too but have OAuth flows
45
+ // that need refresh handling, so they ship as dedicated adapters
46
+ // (kimi-coding-api.ts, minimax-api.ts) and are dispatched directly from
47
+ // provider.ts rather than through this passthrough engine.
59
48
  // ── Qwen / Alibaba ModelStudio Coding Plan ────────────────────────────────
60
- // Qwen's OAuth free tier was killed 2026-04-15; paid usage goes through
61
- // the ModelStudio Coding Plan (BAILIAN_CODING_PLAN_API_KEY, OpenAI-compat).
49
+ // Paid subscription (the OAuth free tier was killed 2026-04-15). Uses a
50
+ // static BAILIAN_CODING_PLAN_API_KEY against an OpenAI-compat endpoint,
51
+ // so it fits the passthrough engine cleanly.
62
52
  registerPassthroughSpec({
63
53
  cliType: "qwen-coding",
64
54
  openclawProvider: "qwen",
@@ -67,26 +57,17 @@ registerPassthroughSpec({
67
57
  api: "openai-completions",
68
58
  label: "Qwen Coding Plan",
69
59
  });
70
- // ── OpenAI API key (distinct from cli_type "codex" which uses subscription OAuth) ──
71
- registerPassthroughSpec({
72
- cliType: "openai",
73
- openclawProvider: "openai",
74
- envVarName: "OPENAI_API_KEY",
75
- baseUrl: envOr("OPENAI_BASE_URL", "https://api.openai.com/v1"),
76
- api: "openai-completions",
77
- label: "OpenAI API",
78
- });
79
60
  // Catalog of every cli_type served by the passthrough engine. Exported so
80
61
  // provider.ts can switch on membership in one line instead of per-cli-type
81
62
  // cases. These are INTERNAL cli_type names — the Hub sees all of them
82
63
  // under the single "api-key" cli_type (see `ApiKeyInternalRoute` below).
83
64
  export const PASSTHROUGH_CLI_TYPES = new Set([
84
65
  "zai-coding",
85
- "zai",
86
- "moonshot",
87
- "kimi-coding",
88
66
  "qwen-coding",
89
- "openai",
67
+ // Note: "kimi-coding" and "minimax" are NOT here — they have dedicated
68
+ // OAuth-aware adapters in kimi-coding-api.ts and minimax-api.ts.
69
+ // Pay-per-token cli_types (moonshot, zai, openai) were removed because
70
+ // they guarantee a loss to the provider under the flat RELAY_DISCOUNT.
90
71
  ]);
91
72
  // ── Hub-side cli_type mapping ─────────────────────────────────────────────
92
73
  //
@@ -112,8 +93,13 @@ export const HUB_CLI_TYPE_FOR_PASSTHROUGH = "api-key";
112
93
  export function hubCliTypeFor(internalCli) {
113
94
  if (PASSTHROUGH_CLI_TYPES.has(internalCli))
114
95
  return HUB_CLI_TYPE_FOR_PASSTHROUGH;
115
- if (internalCli === "minimax")
96
+ // minimax + kimi-coding have dedicated adapters but still register as
97
+ // Hub-canonical "api-key" — to the Hub they're just Bearer-auth
98
+ // OpenAI-compat providers, the OAuth + refresh lives entirely in the
99
+ // daemon.
100
+ if (internalCli === "minimax" || internalCli === "kimi-coding") {
116
101
  return HUB_CLI_TYPE_FOR_PASSTHROUGH;
102
+ }
117
103
  // claude / codex / gemini / antigravity pass through unchanged.
118
104
  return internalCli;
119
105
  }
@@ -134,18 +120,12 @@ export function resolveSpecByModel(model) {
134
120
  return "minimax";
135
121
  if (model.startsWith("glm-") || model.startsWith("zai-"))
136
122
  return "zai-coding";
137
- if (model.startsWith("kimi-k2"))
138
- return "moonshot";
139
- if (model === "kimi-code")
123
+ if (model.startsWith("kimi-k2") || model === "kimi-code")
140
124
  return "kimi-coding";
141
125
  if (model.startsWith("qwen"))
142
126
  return "qwen-coding";
143
- // OpenAI API-key path serves the same gpt-* / o-series catalog the
144
- // codex OAuth path does, but dispatch comes in under cli_type="api-key"
145
- // so there's no ambiguity at this point codex traffic never reaches
146
- // the resolver.
147
- if (model.startsWith("gpt-") || model === "o3" || model === "o4-mini") {
148
- return "openai";
149
- }
127
+ // Intentionally nothing for gpt-*/o3/o4-mini codex OAuth subscription
128
+ // is the only sanctioned path; raw openai.com API-key passthrough was
129
+ // removed because the provider would lose money on every buyer request.
150
130
  return null;
151
131
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmoney",
3
- "version": "0.17.5",
3
+ "version": "0.17.6",
4
4
  "description": "ClawMoney CLI -- Earn rewards with your AI agent",
5
5
  "type": "module",
6
6
  "bin": {
@@ -212,14 +212,12 @@ console.log(`mock upstream at ${MOCK_URL}`);
212
212
  console.log("");
213
213
 
214
214
  try {
215
- // openclaw fixture supplies zai's key via api_key profile
216
- await probePassthrough("zai-coding", "glm-5", "sk-zai-openclaw");
217
- await probePassthrough("zai", "glm-4.7", "sk-zai-openclaw");
218
- // others fall back to env
219
- await probePassthrough("moonshot", "kimi-k2.5", "sk-moonshot-env");
220
- await probePassthrough("kimi-coding", "kimi-code", "sk-kimi-env");
215
+ // Current subscription-only passthrough catalog:
216
+ // zai-coding openclaw api_key
217
+ // qwen-coding ← env var fallback
218
+ // (moonshot / zai general / openai were removed as pay-per-token.)
219
+ await probePassthrough("zai-coding", "glm-5", "sk-zai-openclaw");
221
220
  await probePassthrough("qwen-coding", "qwen3.6-plus", "sk-qwen-env");
222
- await probePassthrough("openai", "gpt-5.4", "sk-openai-env");
223
221
 
224
222
  // minimax: fresh vs expired
225
223
  await probeMinimaxFresh();
@@ -229,12 +227,13 @@ try {
229
227
  // via model prefix. Covers each family the resolver handles.
230
228
  const dispatchCases = [
231
229
  { model: "glm-5", expected: "zai-coding" },
232
- { model: "kimi-k2.5", expected: "moonshot" },
230
+ { model: "kimi-k2.5", expected: "kimi-coding" },
233
231
  { model: "kimi-code", expected: "kimi-coding" },
234
232
  { model: "qwen3.6-plus", expected: "qwen-coding" },
235
233
  { model: "MiniMax-M2.7", expected: "minimax" },
236
- { model: "gpt-5.4", expected: "openai" },
237
- { model: "o4-mini", expected: "openai" },
234
+ // gpt-* no longer mapped — the openai passthrough was removed.
235
+ { model: "gpt-5.4", expected: null },
236
+ { model: "o4-mini", expected: null },
238
237
  { model: "unknown-model", expected: null },
239
238
  ];
240
239
  let dispatchFails = 0;
@@ -255,15 +254,14 @@ try {
255
254
  // hubCliTypeFor collapses fine-grained → "api-key" and leaves legacy OAuth
256
255
  // cli_types untouched.
257
256
  const collapseCases = [
258
- { internal: "zai-coding", hub: "api-key" },
259
- { internal: "moonshot", hub: "api-key" },
260
- { internal: "qwen-coding", hub: "api-key" },
261
- { internal: "openai", hub: "api-key" },
262
- { internal: "minimax", hub: "api-key" },
263
- { internal: "claude", hub: "claude" },
264
- { internal: "codex", hub: "codex" },
265
- { internal: "gemini", hub: "gemini" },
266
- { internal: "antigravity", hub: "antigravity" },
257
+ { internal: "zai-coding", hub: "api-key" },
258
+ { internal: "qwen-coding", hub: "api-key" },
259
+ { internal: "kimi-coding", hub: "api-key" },
260
+ { internal: "minimax", hub: "api-key" },
261
+ { internal: "claude", hub: "claude" },
262
+ { internal: "codex", hub: "codex" },
263
+ { internal: "gemini", hub: "gemini" },
264
+ { internal: "antigravity", hub: "antigravity" },
267
265
  ];
268
266
  let collapseFails = 0;
269
267
  for (const { internal, hub } of collapseCases) {