clawmoney 0.17.5 → 0.17.7
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/dist/commands/market-setup.d.ts +1 -0
- package/dist/commands/market-setup.js +209 -0
- package/dist/commands/relay-setup.js +24 -26
- package/dist/commands/relay.js +4 -0
- package/dist/index.js +13 -0
- package/dist/relay/provider.js +25 -0
- package/dist/relay/upstream/kimi-coding-api.d.ts +47 -0
- package/dist/relay/upstream/kimi-coding-api.js +395 -0
- package/dist/relay/upstream/passthrough-specs.js +38 -58
- package/package.json +1 -1
- package/scripts/probe-relay-call.mjs +17 -19
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function marketSetupCommand(): Promise<void>;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { intro, outro, select, text, confirm, spinner, isCancel, cancel, log, note, } from "@clack/prompts";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { apiPost } from "../utils/api.js";
|
|
4
|
+
import { loadConfig } from "../utils/config.js";
|
|
5
|
+
import { setupCommand } from "./setup.js";
|
|
6
|
+
const CATEGORIES = [
|
|
7
|
+
{ value: "generation/image", routing: "instant", timeoutS: 120, suggestedPrice: 0.02, priceRange: [0.01, 0.50] },
|
|
8
|
+
{ value: "generation/video", routing: "instant", timeoutS: 300, suggestedPrice: 0.10, priceRange: [0.05, 1.00] },
|
|
9
|
+
{ value: "generation/video_long", routing: "escrow", timeoutS: null, suggestedPrice: 5.00, priceRange: [1.00, 50.00] },
|
|
10
|
+
{ value: "generation/text", routing: "instant", timeoutS: 120, suggestedPrice: 0.01, priceRange: [0.005, 0.20] },
|
|
11
|
+
{ value: "generation/audio", routing: "instant", timeoutS: 180, suggestedPrice: 0.05, priceRange: [0.02, 0.50] },
|
|
12
|
+
{ value: "transformation/translate", routing: "instant", timeoutS: 60, suggestedPrice: 0.01, priceRange: [0.005, 0.10] },
|
|
13
|
+
{ value: "transformation/tts", routing: "instant", timeoutS: 120, suggestedPrice: 0.02, priceRange: [0.01, 0.20] },
|
|
14
|
+
{ value: "transformation/stt", routing: "instant", timeoutS: 120, suggestedPrice: 0.02, priceRange: [0.01, 0.20] },
|
|
15
|
+
{ value: "search/web", routing: "instant", timeoutS: 60, suggestedPrice: 0.01, priceRange: [0.005, 0.10] },
|
|
16
|
+
{ value: "analysis/data", routing: "instant", timeoutS: 180, suggestedPrice: 0.05, priceRange: [0.02, 0.50] },
|
|
17
|
+
{ value: "coding/generation", routing: "instant", timeoutS: 240, suggestedPrice: 0.05, priceRange: [0.02, 0.50] },
|
|
18
|
+
{ value: "coding/review", routing: "instant", timeoutS: 180, suggestedPrice: 0.05, priceRange: [0.02, 0.50] },
|
|
19
|
+
{ value: "other", routing: "auto", timeoutS: null, suggestedPrice: 0.02, priceRange: [0.01, 1.00] },
|
|
20
|
+
];
|
|
21
|
+
const PRICE_THRESHOLD_FOR_ESCROW = 1.0; // mirrors backend constant
|
|
22
|
+
function formatHint(row) {
|
|
23
|
+
if (row.routing === "escrow")
|
|
24
|
+
return "escrow · manual approve";
|
|
25
|
+
if (row.routing === "auto")
|
|
26
|
+
return "auto · by price";
|
|
27
|
+
return `instant · ${String(row.timeoutS).padStart(3, " ")}s timeout`;
|
|
28
|
+
}
|
|
29
|
+
// What skill_type will the backend resolve this to? Used for the review
|
|
30
|
+
// screen — the user gets to see and confirm the routing decision before
|
|
31
|
+
// commit. Keep this in sync with app/core/market_skill_routing.py.
|
|
32
|
+
function resolveSkillType(category, price) {
|
|
33
|
+
const row = CATEGORIES.find((c) => c.value === category);
|
|
34
|
+
if (!row)
|
|
35
|
+
return "instant";
|
|
36
|
+
if (row.routing === "instant")
|
|
37
|
+
return "instant";
|
|
38
|
+
if (row.routing === "escrow")
|
|
39
|
+
return "escrow";
|
|
40
|
+
return price > PRICE_THRESHOLD_FOR_ESCROW ? "escrow" : "instant";
|
|
41
|
+
}
|
|
42
|
+
function routingExplanation(skillType) {
|
|
43
|
+
if (skillType === "escrow") {
|
|
44
|
+
return [
|
|
45
|
+
"Callers fund the task up front, you accept, deliver,",
|
|
46
|
+
"and they approve to release funds. Good for tasks that",
|
|
47
|
+
"take minutes to hours.",
|
|
48
|
+
].join("\n");
|
|
49
|
+
}
|
|
50
|
+
return [
|
|
51
|
+
"Callers invoke with x402 payment, you respond via WebSocket,",
|
|
52
|
+
"they poll for the result. Good for tasks that finish in",
|
|
53
|
+
"seconds to a few minutes.",
|
|
54
|
+
].join("\n");
|
|
55
|
+
}
|
|
56
|
+
// ── Validators ──
|
|
57
|
+
// Skill names live in URLs (market/<agent_slug>/<skill_name>) and config
|
|
58
|
+
// files, so we keep them URL-safe and short. Same regex as backend slugs.
|
|
59
|
+
function validateSkillName(value) {
|
|
60
|
+
const v = value.trim();
|
|
61
|
+
if (!v)
|
|
62
|
+
return "Skill name is required";
|
|
63
|
+
if (v.length > 100)
|
|
64
|
+
return "Skill name must be 100 characters or fewer";
|
|
65
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(v)) {
|
|
66
|
+
return "Use lowercase letters, digits, and hyphens only (e.g. gen-image)";
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
function validateDescription(value) {
|
|
71
|
+
const v = value.trim();
|
|
72
|
+
if (!v)
|
|
73
|
+
return "Description is required";
|
|
74
|
+
if (v.length > 1000)
|
|
75
|
+
return "Description must be 1000 characters or fewer";
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
function validatePrice(value) {
|
|
79
|
+
const trimmed = value.trim();
|
|
80
|
+
if (!trimmed)
|
|
81
|
+
return "Price is required";
|
|
82
|
+
const n = Number(trimmed);
|
|
83
|
+
if (!Number.isFinite(n))
|
|
84
|
+
return "Price must be a number";
|
|
85
|
+
if (n < 0)
|
|
86
|
+
return "Price cannot be negative";
|
|
87
|
+
if (n > 10_000)
|
|
88
|
+
return "Price looks unreasonable (> $10,000)";
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
// ── Main wizard ──
|
|
92
|
+
export async function marketSetupCommand() {
|
|
93
|
+
// Step 0: ensure the agent is logged in. Mirrors relaySetupCommand's
|
|
94
|
+
// handoff to setupCommand so first-time users get a clean flow instead
|
|
95
|
+
// of "No config found" mid-wizard.
|
|
96
|
+
let existing = loadConfig();
|
|
97
|
+
if (!existing) {
|
|
98
|
+
await setupCommand();
|
|
99
|
+
existing = loadConfig();
|
|
100
|
+
if (!existing) {
|
|
101
|
+
console.log(chalk.red("\n Login did not complete. Run `clawmoney setup` manually, then re-run `clawmoney market setup`.\n"));
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
console.log("");
|
|
105
|
+
}
|
|
106
|
+
const config = existing;
|
|
107
|
+
intro(chalk.cyan(" ClawMoney Market Setup "));
|
|
108
|
+
log.message("Register a skill on the Market so other agents can call (and pay) you.");
|
|
109
|
+
// ── Step 1: category ──
|
|
110
|
+
const category = await select({
|
|
111
|
+
message: "Pick the skill category:",
|
|
112
|
+
options: CATEGORIES.map((row) => ({
|
|
113
|
+
value: row.value,
|
|
114
|
+
label: row.value,
|
|
115
|
+
hint: formatHint(row),
|
|
116
|
+
})),
|
|
117
|
+
initialValue: "generation/image",
|
|
118
|
+
});
|
|
119
|
+
if (isCancel(category)) {
|
|
120
|
+
cancel("Setup cancelled");
|
|
121
|
+
process.exit(0);
|
|
122
|
+
}
|
|
123
|
+
const categoryStr = category;
|
|
124
|
+
const categoryRow = CATEGORIES.find((c) => c.value === categoryStr);
|
|
125
|
+
// ── Step 2: skill name ──
|
|
126
|
+
const skillName = await text({
|
|
127
|
+
message: "Skill name (used in URLs, e.g. gen-image):",
|
|
128
|
+
placeholder: "gen-image",
|
|
129
|
+
validate: validateSkillName,
|
|
130
|
+
});
|
|
131
|
+
if (isCancel(skillName)) {
|
|
132
|
+
cancel("Setup cancelled");
|
|
133
|
+
process.exit(0);
|
|
134
|
+
}
|
|
135
|
+
const skillNameStr = skillName.trim();
|
|
136
|
+
// ── Step 3: description ──
|
|
137
|
+
const description = await text({
|
|
138
|
+
message: "One-line description (what does this skill do?):",
|
|
139
|
+
placeholder: "Generate a 1024x1024 image from a text prompt",
|
|
140
|
+
validate: validateDescription,
|
|
141
|
+
});
|
|
142
|
+
if (isCancel(description)) {
|
|
143
|
+
cancel("Setup cancelled");
|
|
144
|
+
process.exit(0);
|
|
145
|
+
}
|
|
146
|
+
const descriptionStr = description.trim();
|
|
147
|
+
// ── Step 4: price (suggested default per category) ──
|
|
148
|
+
const priceInput = await text({
|
|
149
|
+
message: `Price per call in USDC (suggested $${categoryRow.suggestedPrice.toFixed(2)}, range $${categoryRow.priceRange[0]}–$${categoryRow.priceRange[1]}):`,
|
|
150
|
+
placeholder: categoryRow.suggestedPrice.toFixed(2),
|
|
151
|
+
initialValue: categoryRow.suggestedPrice.toFixed(2),
|
|
152
|
+
validate: validatePrice,
|
|
153
|
+
});
|
|
154
|
+
if (isCancel(priceInput)) {
|
|
155
|
+
cancel("Setup cancelled");
|
|
156
|
+
process.exit(0);
|
|
157
|
+
}
|
|
158
|
+
const price = Number(priceInput.trim());
|
|
159
|
+
// ── Step 5: review (show the resolved skill_type so the user knows
|
|
160
|
+
// what they're agreeing to before commit) ──
|
|
161
|
+
const skillType = resolveSkillType(categoryStr, price);
|
|
162
|
+
const routingLabel = skillType === "escrow"
|
|
163
|
+
? "escrow (manual approve required)"
|
|
164
|
+
: "instant (poll for result)";
|
|
165
|
+
note([
|
|
166
|
+
`Name: ${chalk.cyan(skillNameStr)}`,
|
|
167
|
+
`Category: ${chalk.cyan(categoryStr)}`,
|
|
168
|
+
`Price: ${chalk.green(`$${price.toFixed(2)} USDC / call`)}`,
|
|
169
|
+
`Description: "${descriptionStr}"`,
|
|
170
|
+
"",
|
|
171
|
+
`Routing: ${chalk.bold(routingLabel)}`,
|
|
172
|
+
chalk.dim(routingExplanation(skillType)),
|
|
173
|
+
].join("\n"), "Review");
|
|
174
|
+
const proceed = await confirm({
|
|
175
|
+
message: "Confirm and register?",
|
|
176
|
+
initialValue: true,
|
|
177
|
+
});
|
|
178
|
+
if (isCancel(proceed) || !proceed) {
|
|
179
|
+
cancel("Setup cancelled — nothing was registered");
|
|
180
|
+
process.exit(0);
|
|
181
|
+
}
|
|
182
|
+
// ── Step 6: register ──
|
|
183
|
+
const submitSpin = spinner();
|
|
184
|
+
submitSpin.start("Registering skill...");
|
|
185
|
+
// Backend's AgentSkillCreate has extra='forbid', so we send ONLY the
|
|
186
|
+
// four allowed fields. skill_type is intentionally not sent — the server
|
|
187
|
+
// derives it from category and the routing rule we previewed above.
|
|
188
|
+
const resp = await apiPost("/api/v1/market/skills", {
|
|
189
|
+
skill_name: skillNameStr,
|
|
190
|
+
category: categoryStr,
|
|
191
|
+
description: descriptionStr,
|
|
192
|
+
price,
|
|
193
|
+
}, config.api_key);
|
|
194
|
+
if (!resp.ok) {
|
|
195
|
+
const raw = resp.data && typeof resp.data === "object" && "detail" in resp.data
|
|
196
|
+
? resp.data.detail
|
|
197
|
+
: resp.data;
|
|
198
|
+
const detail = typeof raw === "string" ? raw : JSON.stringify(raw);
|
|
199
|
+
submitSpin.stop(chalk.red(`Failed (${resp.status}): ${detail}`));
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
submitSpin.stop(chalk.green("Skill registered."));
|
|
203
|
+
outro([
|
|
204
|
+
chalk.green("Done."),
|
|
205
|
+
"",
|
|
206
|
+
chalk.dim(`Next: run ${chalk.cyan("clawmoney market start")} to accept incoming calls in the background.`),
|
|
207
|
+
chalk.dim(` See your skill listed: ${chalk.cyan("clawmoney market skills")}`),
|
|
208
|
+
].join("\n"));
|
|
209
|
+
}
|
|
@@ -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
|
|
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
|
-
|
|
85
|
-
|
|
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"
|
|
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
|
|
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
|
-
|
|
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");
|
package/dist/commands/relay.js
CHANGED
|
@@ -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");
|
package/dist/index.js
CHANGED
|
@@ -228,6 +228,19 @@ program
|
|
|
228
228
|
const market = program
|
|
229
229
|
.command('market')
|
|
230
230
|
.description('Agent Market: provide services, register skills');
|
|
231
|
+
market
|
|
232
|
+
.command('setup')
|
|
233
|
+
.description('Interactive: register a skill on the Market with a guided wizard (recommended for first-time setup)')
|
|
234
|
+
.action(async () => {
|
|
235
|
+
try {
|
|
236
|
+
const { marketSetupCommand } = await import('./commands/market-setup.js');
|
|
237
|
+
await marketSetupCommand();
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
console.error(err.message);
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
231
244
|
market
|
|
232
245
|
.command('start')
|
|
233
246
|
.description('Start Market Provider (background process)')
|
package/dist/relay/provider.js
CHANGED
|
@@ -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
|
-
// ──
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
//
|
|
61
|
-
//
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
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
|
@@ -212,14 +212,12 @@ console.log(`mock upstream at ${MOCK_URL}`);
|
|
|
212
212
|
console.log("");
|
|
213
213
|
|
|
214
214
|
try {
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
//
|
|
219
|
-
await probePassthrough("
|
|
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: "
|
|
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
|
-
|
|
237
|
-
{ model: "
|
|
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",
|
|
259
|
-
{ internal: "
|
|
260
|
-
{ internal: "
|
|
261
|
-
{ internal: "
|
|
262
|
-
{ internal: "
|
|
263
|
-
{ internal: "
|
|
264
|
-
{ internal: "
|
|
265
|
-
{ internal: "
|
|
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) {
|