pi-provider-kimi-code 0.3.1 → 0.4.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 +149 -62
- package/index.ts +325 -879
- package/package.json +10 -6
- package/src/config.ts +151 -0
- package/src/constants.ts +48 -0
- package/src/device.ts +164 -0
- package/src/models.ts +129 -0
- package/src/oauth.ts +397 -0
- package/src/payload.ts +446 -0
- package/src/stream.ts +299 -0
- package/src/tools/moonshot.ts +316 -0
package/index.ts
CHANGED
|
@@ -7,952 +7,377 @@
|
|
|
7
7
|
* Usage:
|
|
8
8
|
* pi -e ~/workshop/pi-provider-kimi-code
|
|
9
9
|
* # Then /login kimi-coding, or set KIMI_API_KEY=...
|
|
10
|
+
*
|
|
11
|
+
* Source layout:
|
|
12
|
+
* src/constants.ts — module-level consts + env-driven configuration
|
|
13
|
+
* src/device.ts — device id + kimi-cli-compatible request headers
|
|
14
|
+
* src/oauth.ts — device flow, token refresh, kimi-cli reuse,
|
|
15
|
+
* login/refresh handlers, stream-level auth refresh
|
|
16
|
+
* src/models.ts — /v1/models discovery + extras-merging helpers
|
|
17
|
+
* src/payload.ts — payload pipeline + file upload + transforms
|
|
18
|
+
* src/stream.ts — empty-response filter + streamSimpleKimi orchestrator
|
|
10
19
|
*/
|
|
11
20
|
|
|
12
|
-
import {
|
|
13
|
-
import { randomBytes } from "node:crypto";
|
|
14
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
-
import os from "node:os";
|
|
16
|
-
import { dirname, join } from "node:path";
|
|
17
|
-
import type {
|
|
18
|
-
OAuthCredentials,
|
|
19
|
-
OAuthLoginCallbacks,
|
|
20
|
-
AssistantMessageEvent,
|
|
21
|
-
CacheRetention,
|
|
22
|
-
Context,
|
|
23
|
-
Model,
|
|
24
|
-
SimpleStreamOptions,
|
|
25
|
-
ThinkingLevel,
|
|
26
|
-
} from "@mariozechner/pi-ai";
|
|
21
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
27
22
|
import {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
} from "@
|
|
32
|
-
import
|
|
33
|
-
import {
|
|
34
|
-
|
|
35
|
-
// =============================================================================
|
|
36
|
-
// Constants
|
|
37
|
-
// =============================================================================
|
|
38
|
-
|
|
39
|
-
const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
|
|
40
|
-
const DEFAULT_OAUTH_HOST = "https://auth.kimi.com";
|
|
41
|
-
const PROTOCOL =
|
|
42
|
-
process.env.KIMI_CODE_PROTOCOL === "openai" ? "openai-completions" : "anthropic-messages";
|
|
43
|
-
// Use a custom api identifier so this provider never conflicts with the
|
|
44
|
-
// built-in "anthropic-messages" or "openai-completions" stream handlers.
|
|
45
|
-
const KIMI_API_TYPE = PROTOCOL === "openai-completions" ? "kimi-openai-completions" : "kimi-anthropic-messages";
|
|
46
|
-
const DEFAULT_BASE_URL =
|
|
47
|
-
PROTOCOL === "openai-completions"
|
|
48
|
-
? "https://api.kimi.com/coding/v1"
|
|
49
|
-
: "https://api.kimi.com/coding";
|
|
50
|
-
const KIMI_CLI_VERSION = "1.30.0";
|
|
51
|
-
const KIMI_CLI_USER_AGENT = `KimiCLI/${KIMI_CLI_VERSION}`;
|
|
52
|
-
const KIMI_PLATFORM = "kimi_cli";
|
|
53
|
-
const DEVICE_ID_PATH = join(os.homedir(), ".pi", "providers", "kimi-coding", "device_id");
|
|
54
|
-
|
|
55
|
-
// =============================================================================
|
|
56
|
-
// Device identification
|
|
57
|
-
// =============================================================================
|
|
58
|
-
|
|
59
|
-
function getOAuthHost(): string {
|
|
60
|
-
const value =
|
|
61
|
-
process.env.KIMI_CODE_OAUTH_HOST || process.env.KIMI_OAUTH_HOST || DEFAULT_OAUTH_HOST;
|
|
62
|
-
return value.trim() || DEFAULT_OAUTH_HOST;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function getBaseUrl(): string {
|
|
66
|
-
const value = process.env.KIMI_CODE_BASE_URL || DEFAULT_BASE_URL;
|
|
67
|
-
return value.trim() || DEFAULT_BASE_URL;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function createDeviceId(): string {
|
|
71
|
-
return randomBytes(16).toString("hex");
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function ensurePrivateFile(path: string): void {
|
|
75
|
-
try {
|
|
76
|
-
chmodSync(path, 0o600);
|
|
77
|
-
} catch {
|
|
78
|
-
// Ignore chmod failures on platforms/filesystems that do not support it.
|
|
79
|
-
}
|
|
80
|
-
}
|
|
23
|
+
AuthStorage,
|
|
24
|
+
type ExtensionAPI,
|
|
25
|
+
type ExtensionCommandContext,
|
|
26
|
+
} from "@earendil-works/pi-coding-agent";
|
|
27
|
+
import os from "node:os";
|
|
28
|
+
import { relative } from "node:path";
|
|
81
29
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
30
|
+
import {
|
|
31
|
+
type KimiCodeConfig,
|
|
32
|
+
getProjectKimiCodeConfigPath,
|
|
33
|
+
getGlobalKimiCodeConfigPath,
|
|
34
|
+
loadHomeKimiCodeConfig,
|
|
35
|
+
loadKimiCodeConfig,
|
|
36
|
+
loadProjectKimiCodeConfig,
|
|
37
|
+
saveHomeKimiCodeConfig,
|
|
38
|
+
saveProjectKimiCodeConfig,
|
|
39
|
+
} from "./src/config.ts";
|
|
40
|
+
import {
|
|
41
|
+
DEFAULT_KIMI_MODEL_INPUT,
|
|
42
|
+
KIMI_API_TYPE,
|
|
43
|
+
PROVIDER_ID,
|
|
44
|
+
getBaseUrl,
|
|
45
|
+
} from "./src/constants.ts";
|
|
46
|
+
import { getCommonHeaders } from "./src/device.ts";
|
|
47
|
+
import {
|
|
48
|
+
type KimiOAuthCredentials,
|
|
49
|
+
applyKimiEnvOverridesToModel,
|
|
50
|
+
applyKimiOAuthExtrasToModel,
|
|
51
|
+
} from "./src/models.ts";
|
|
52
|
+
import { loginKimiCode, refreshKimiCodeToken } from "./src/oauth.ts";
|
|
53
|
+
import { streamSimpleKimi } from "./src/stream.ts";
|
|
54
|
+
import { buildMoonshotFetchTool, buildMoonshotSearchTool } from "./src/tools/moonshot.ts";
|
|
55
|
+
|
|
56
|
+
const MOONSHOT_TOOL_NAMES = ["moonshot_search", "moonshot_fetch"] as const;
|
|
57
|
+
type MoonshotToolName = (typeof MOONSHOT_TOOL_NAMES)[number];
|
|
58
|
+
type KimiConfigScope = "project" | "home";
|
|
59
|
+
const MEMBERSHIP_LEVEL_NAMES: Record<string, string> = {
|
|
60
|
+
LEVEL_FREE: "Free",
|
|
61
|
+
LEVEL_BASIC: "Adagio",
|
|
62
|
+
LEVEL_STANDARD: "Moderato",
|
|
63
|
+
LEVEL_INTERMEDIATE: "Allegretto",
|
|
64
|
+
LEVEL_ADVANCED: "Allegro",
|
|
65
|
+
LEVEL_PREMIUM: "Vivace",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
interface UsageRow {
|
|
69
|
+
label: string;
|
|
70
|
+
used: number;
|
|
71
|
+
limit: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function registerConfiguredMoonshotTools(
|
|
75
|
+
pi: ExtensionAPI,
|
|
76
|
+
config: KimiCodeConfig,
|
|
77
|
+
options: { updateActiveTools: boolean },
|
|
78
|
+
): void {
|
|
79
|
+
if (config.tools.moonshot_search.enabled) {
|
|
80
|
+
pi.registerTool(
|
|
81
|
+
buildMoonshotSearchTool({
|
|
82
|
+
defaultCollapsed: config.tools.moonshot_search.default_collapsed,
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
89
85
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
ensurePrivateFile(DEVICE_ID_PATH);
|
|
97
|
-
} catch {
|
|
98
|
-
// Ignore persistence failures and fall back to the in-memory device id.
|
|
86
|
+
if (config.tools.moonshot_fetch.enabled) {
|
|
87
|
+
pi.registerTool(
|
|
88
|
+
buildMoonshotFetchTool({
|
|
89
|
+
defaultCollapsed: config.tools.moonshot_fetch.default_collapsed,
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
99
92
|
}
|
|
100
|
-
}
|
|
101
93
|
|
|
102
|
-
|
|
103
|
-
try {
|
|
104
|
-
return execSync("sw_vers -productVersion", { encoding: "utf8" }).trim();
|
|
105
|
-
} catch {
|
|
106
|
-
return os.release();
|
|
107
|
-
}
|
|
108
|
-
}
|
|
94
|
+
if (!options.updateActiveTools) return;
|
|
109
95
|
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
if (platform === "win32") {
|
|
118
|
-
// Only show the major release (e.g. "Windows 10", "Windows 11") to match
|
|
119
|
-
// the upstream kimi-cli behavior in `def _device_model() -> str:`.
|
|
120
|
-
// See: https://github.com/MoonshotAI/kimi-cli/blob/main/src/kimi_cli/auth/oauth.py
|
|
121
|
-
const parts = os.release().split(".");
|
|
122
|
-
let release = parts[0];
|
|
123
|
-
if (release === "10" && parts.length >= 3) {
|
|
124
|
-
const build = parseInt(parts[2], 10);
|
|
125
|
-
if (!isNaN(build) && build >= 22000) {
|
|
126
|
-
release = "11";
|
|
127
|
-
}
|
|
96
|
+
const activeTools = new Set(pi.getActiveTools());
|
|
97
|
+
for (const toolName of MOONSHOT_TOOL_NAMES) {
|
|
98
|
+
if (config.tools[toolName].enabled) {
|
|
99
|
+
activeTools.add(toolName);
|
|
100
|
+
} else {
|
|
101
|
+
activeTools.delete(toolName);
|
|
128
102
|
}
|
|
129
|
-
return release && arch ? `Windows ${release} ${arch}` : `Windows ${arch}`;
|
|
130
103
|
}
|
|
131
|
-
|
|
132
|
-
// upstream kimi-cli (e.g. "Linux", "FreeBSD", "SunOS"), since Node's
|
|
133
|
-
// `process.platform` is always lower-case.
|
|
134
|
-
const SYSTEM_NAME: Record<string, string> = {
|
|
135
|
-
aix: "AIX",
|
|
136
|
-
freebsd: "FreeBSD",
|
|
137
|
-
linux: "Linux",
|
|
138
|
-
openbsd: "OpenBSD",
|
|
139
|
-
sunos: "SunOS",
|
|
140
|
-
};
|
|
141
|
-
const system = SYSTEM_NAME[platform] ?? platform;
|
|
142
|
-
const release = os.release();
|
|
143
|
-
return release && arch ? `${system} ${release} ${arch}` : `${system} ${arch}`;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function asciiHeaderValue(value: string, fallback = "unknown"): string {
|
|
147
|
-
const trimmed = value.trim();
|
|
148
|
-
/* oxlint-disable-next-line no-control-regex */
|
|
149
|
-
if (/^[\x00-\x7F]*$/.test(trimmed)) {
|
|
150
|
-
return trimmed;
|
|
151
|
-
}
|
|
152
|
-
/* oxlint-disable-next-line no-control-regex */
|
|
153
|
-
const sanitized = trimmed.replace(/[^\x00-\x7F]/g, "").trim();
|
|
154
|
-
return sanitized || fallback;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const DEVICE_MODEL = getDeviceModel();
|
|
158
|
-
let DEVICE_ID: string | null = null;
|
|
159
|
-
|
|
160
|
-
function getStableDeviceId(): string {
|
|
161
|
-
if (DEVICE_ID) {
|
|
162
|
-
return DEVICE_ID;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const persisted = readPersistedDeviceId();
|
|
166
|
-
if (persisted) {
|
|
167
|
-
DEVICE_ID = persisted;
|
|
168
|
-
return DEVICE_ID;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
DEVICE_ID = createDeviceId();
|
|
172
|
-
persistDeviceId(DEVICE_ID);
|
|
173
|
-
return DEVICE_ID;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function getCommonHeaders(): Record<string, string> {
|
|
177
|
-
const headers = {
|
|
178
|
-
"User-Agent": KIMI_CLI_USER_AGENT,
|
|
179
|
-
"X-Msh-Platform": KIMI_PLATFORM,
|
|
180
|
-
"X-Msh-Version": KIMI_CLI_VERSION,
|
|
181
|
-
"X-Msh-Device-Name": os.hostname(),
|
|
182
|
-
"X-Msh-Device-Model": DEVICE_MODEL,
|
|
183
|
-
"X-Msh-Os-Version": os.release(),
|
|
184
|
-
"X-Msh-Device-Id": getStableDeviceId(),
|
|
185
|
-
};
|
|
186
|
-
return Object.fromEntries(
|
|
187
|
-
Object.entries(headers).map(([key, value]) => [key, asciiHeaderValue(value)]),
|
|
188
|
-
) as Record<string, string>;
|
|
104
|
+
pi.setActiveTools([...activeTools]);
|
|
189
105
|
}
|
|
190
106
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
user_code: string;
|
|
197
|
-
device_code: string;
|
|
198
|
-
verification_uri: string;
|
|
199
|
-
verification_uri_complete: string;
|
|
200
|
-
expires_in: number;
|
|
201
|
-
interval: number;
|
|
107
|
+
function getKimiUsageToken(): string | null {
|
|
108
|
+
const credential = AuthStorage.create().get(PROVIDER_ID);
|
|
109
|
+
if (credential?.type === "oauth" && credential.access) return credential.access;
|
|
110
|
+
const apiKey = process.env.KIMI_API_KEY?.trim();
|
|
111
|
+
return apiKey || null;
|
|
202
112
|
}
|
|
203
113
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
expires_in: number;
|
|
208
|
-
scope: string;
|
|
209
|
-
token_type: string;
|
|
114
|
+
function toNumber(value: unknown): number | null {
|
|
115
|
+
const number = Number(value);
|
|
116
|
+
return Number.isFinite(number) ? number : null;
|
|
210
117
|
}
|
|
211
118
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
client_id: CLIENT_ID,
|
|
221
|
-
}),
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
if (!response.ok) {
|
|
225
|
-
const text = await response.text().catch(() => "");
|
|
226
|
-
throw new Error(`Device authorization failed: ${response.status} ${text}`);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
const data = (await response.json()) as {
|
|
230
|
-
user_code?: string;
|
|
231
|
-
device_code?: string;
|
|
232
|
-
verification_uri?: string;
|
|
233
|
-
verification_uri_complete?: string;
|
|
234
|
-
expires_in?: number;
|
|
235
|
-
interval?: number;
|
|
236
|
-
};
|
|
237
|
-
|
|
238
|
-
if (!data.user_code || !data.device_code || !data.verification_uri_complete) {
|
|
239
|
-
throw new Error("Invalid device authorization response");
|
|
240
|
-
}
|
|
241
|
-
|
|
119
|
+
function parseUsageRow(value: unknown, fallbackLabel: string): UsageRow | null {
|
|
120
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
121
|
+
const record = value as Record<string, unknown>;
|
|
122
|
+
const limit = toNumber(record.limit);
|
|
123
|
+
const usedValue = toNumber(record.used);
|
|
124
|
+
const remaining = toNumber(record.remaining);
|
|
125
|
+
const used = usedValue ?? (limit !== null && remaining !== null ? limit - remaining : null);
|
|
126
|
+
if (limit === null && used === null) return null;
|
|
242
127
|
return {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
verification_uri_complete: data.verification_uri_complete,
|
|
247
|
-
expires_in: data.expires_in || 1800,
|
|
248
|
-
interval: data.interval || 5,
|
|
128
|
+
label: String(record.name || record.title || fallbackLabel),
|
|
129
|
+
used: used ?? 0,
|
|
130
|
+
limit: limit ?? 0,
|
|
249
131
|
};
|
|
250
132
|
}
|
|
251
133
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
headers: {
|
|
256
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
257
|
-
...getCommonHeaders(),
|
|
258
|
-
},
|
|
259
|
-
body: new URLSearchParams({
|
|
260
|
-
client_id: CLIENT_ID,
|
|
261
|
-
device_code: auth.device_code,
|
|
262
|
-
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
263
|
-
}),
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
if (response.status === 200) {
|
|
267
|
-
const data = (await response.json()) as TokenResponse;
|
|
268
|
-
if (data.access_token && data.refresh_token) {
|
|
269
|
-
return data;
|
|
270
|
-
}
|
|
271
|
-
throw new Error("Token response missing required fields");
|
|
134
|
+
function parseUsageSummary(payload: unknown): string {
|
|
135
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
136
|
+
return "Usage: unavailable";
|
|
272
137
|
}
|
|
273
138
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
139
|
+
const record = payload as Record<string, unknown>;
|
|
140
|
+
const lines: string[] = [];
|
|
141
|
+
const membership = parseMembership(record);
|
|
142
|
+
if (membership) lines.push(membership);
|
|
143
|
+
|
|
144
|
+
const rows: UsageRow[] = [];
|
|
145
|
+
const summary = parseUsageRow(record.usage, "Weekly limit");
|
|
146
|
+
if (summary) rows.push(summary);
|
|
147
|
+
|
|
148
|
+
if (Array.isArray(record.limits)) {
|
|
149
|
+
for (const [index, item] of record.limits.entries()) {
|
|
150
|
+
const detail =
|
|
151
|
+
typeof item === "object" && item !== null && !Array.isArray(item)
|
|
152
|
+
? ((item as Record<string, unknown>).detail ?? item)
|
|
153
|
+
: item;
|
|
154
|
+
const row = parseUsageRow(detail, index === 0 ? "5h rate limit" : `Limit #${index + 1}`);
|
|
155
|
+
if (row) rows.push(row);
|
|
278
156
|
}
|
|
279
|
-
if (data.error === "expired_token") {
|
|
280
|
-
throw new Error("expired_token");
|
|
281
|
-
}
|
|
282
|
-
throw new Error(`Token request failed: ${data.error_description || data.error || "unknown"}`);
|
|
283
157
|
}
|
|
284
158
|
|
|
285
|
-
|
|
286
|
-
|
|
159
|
+
lines.push(...rows.map(formatUsageRow));
|
|
160
|
+
return lines.length === 0 ? "Usage: no usage data" : lines.join("\n");
|
|
287
161
|
}
|
|
288
162
|
|
|
289
|
-
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
},
|
|
296
|
-
body: new URLSearchParams({
|
|
297
|
-
client_id: CLIENT_ID,
|
|
298
|
-
grant_type: "refresh_token",
|
|
299
|
-
refresh_token: refreshToken,
|
|
300
|
-
}),
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
if (!response.ok) {
|
|
304
|
-
const text = await response.text().catch(() => "");
|
|
305
|
-
if (response.status === 401 || response.status === 403) {
|
|
306
|
-
throw new Error(`Token refresh unauthorized: ${text}`);
|
|
307
|
-
}
|
|
308
|
-
throw new Error(`Token refresh failed: ${response.status} ${text}`);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
const data = (await response.json()) as TokenResponse;
|
|
312
|
-
if (!data.access_token || !data.refresh_token) {
|
|
313
|
-
throw new Error("Token refresh response missing required fields");
|
|
163
|
+
function parseMembership(record: Record<string, unknown>): string | null {
|
|
164
|
+
const user = record.user;
|
|
165
|
+
if (typeof user !== "object" || user === null || Array.isArray(user)) return null;
|
|
166
|
+
const membership = (user as Record<string, unknown>).membership;
|
|
167
|
+
if (typeof membership !== "object" || membership === null || Array.isArray(membership)) {
|
|
168
|
+
return null;
|
|
314
169
|
}
|
|
315
|
-
|
|
316
|
-
return
|
|
170
|
+
const level = (membership as Record<string, unknown>).level;
|
|
171
|
+
if (typeof level !== "string" || !level) return null;
|
|
172
|
+
const name = MEMBERSHIP_LEVEL_NAMES[level];
|
|
173
|
+
return name ? `Membership: ${name} (${level})` : `Membership: ${level}`;
|
|
317
174
|
}
|
|
318
175
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
// Keep trying until we get a token (handles expired device codes)
|
|
325
|
-
while (true) {
|
|
326
|
-
const auth = await requestDeviceAuthorization();
|
|
327
|
-
|
|
328
|
-
callbacks.onAuth({
|
|
329
|
-
url: auth.verification_uri_complete,
|
|
330
|
-
instructions: `Please visit the URL to authorize. Your code: ${auth.user_code}`,
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
const interval = Math.max(auth.interval, 1) * 1000;
|
|
334
|
-
const expiresAt = Date.now() + auth.expires_in * 1000;
|
|
335
|
-
|
|
336
|
-
let token: TokenResponse | null = null;
|
|
337
|
-
let printedWaiting = false;
|
|
338
|
-
|
|
339
|
-
while (Date.now() < expiresAt) {
|
|
340
|
-
try {
|
|
341
|
-
token = await requestDeviceToken(auth);
|
|
342
|
-
if (token) break;
|
|
343
|
-
} catch (error) {
|
|
344
|
-
if (error instanceof Error && error.message === "expired_token") {
|
|
345
|
-
// Device code expired, restart the flow
|
|
346
|
-
if (callbacks.onProgress) {
|
|
347
|
-
callbacks.onProgress("Device code expired, restarting...");
|
|
348
|
-
}
|
|
349
|
-
break;
|
|
350
|
-
}
|
|
351
|
-
throw error;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
if (!printedWaiting) {
|
|
355
|
-
if (callbacks.onProgress) {
|
|
356
|
-
callbacks.onProgress("Waiting for authorization...");
|
|
357
|
-
}
|
|
358
|
-
printedWaiting = true;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// Check for abort
|
|
362
|
-
if (callbacks.signal?.aborted) {
|
|
363
|
-
throw new Error("Authorization aborted");
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
if (token) {
|
|
370
|
-
return {
|
|
371
|
-
access: token.access_token,
|
|
372
|
-
refresh: token.refresh_token,
|
|
373
|
-
expires: Date.now() + token.expires_in * 1000,
|
|
374
|
-
};
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// If we get here without a token, the device code expired - loop will retry
|
|
378
|
-
}
|
|
176
|
+
function formatUsageRow(row: UsageRow): string {
|
|
177
|
+
if (row.limit <= 0) return `${row.label}: ${row.used} used`;
|
|
178
|
+
const remaining = Math.max(0, Math.min(row.limit - row.used, row.limit));
|
|
179
|
+
const percent = Math.round((remaining / row.limit) * 100);
|
|
180
|
+
return `${row.label}: ${quotaBar(remaining, row.limit)} ${percent}% left (${remaining}/${row.limit})`;
|
|
379
181
|
}
|
|
380
182
|
|
|
381
|
-
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
refresh: token.refresh_token,
|
|
386
|
-
expires: Date.now() + token.expires_in * 1000,
|
|
387
|
-
};
|
|
183
|
+
function quotaBar(remaining: number, limit: number): string {
|
|
184
|
+
const width = 20;
|
|
185
|
+
const filled = Math.max(0, Math.min(width, Math.round((remaining / limit) * width)));
|
|
186
|
+
return `[${"#".repeat(filled)}${"-".repeat(width - filled)}]`;
|
|
388
187
|
}
|
|
389
188
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
const EMPTY_RESPONSE_PREFIX = "(Empty response:";
|
|
395
|
-
const DEFAULT_KIMI_INLINE_UPLOAD_THRESHOLD_BYTES = 1 * 1024 * 1024;
|
|
189
|
+
async function fetchKimiUsageSummary(): Promise<string> {
|
|
190
|
+
const token = getKimiUsageToken();
|
|
191
|
+
if (!token) return "Usage: missing credentials. Run /login kimi-coding.";
|
|
396
192
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
cacheRetention: CacheRetention;
|
|
417
|
-
reasoning?: ThinkingLevel;
|
|
418
|
-
envOverrides: KimiEnvOverrides;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
function isRecord(value: unknown): value is JsonRecord {
|
|
422
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
function mapThinkingLevel(level?: string): { effort: string | null; enabled: boolean } | undefined {
|
|
426
|
-
if (!level) return undefined;
|
|
427
|
-
if (level === "none" || level === "off") return { effort: null, enabled: false };
|
|
428
|
-
if (level === "minimal" || level === "low") return { effort: "low", enabled: true };
|
|
429
|
-
if (level === "medium") return { effort: "medium", enabled: true };
|
|
430
|
-
if (level === "high" || level === "xhigh") return { effort: "high", enabled: true };
|
|
431
|
-
return undefined;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
function parseInlineUploadThreshold(raw: string | undefined): number {
|
|
435
|
-
const parsed = Number.parseInt(raw ?? "", 10);
|
|
436
|
-
return Number.isFinite(parsed) && parsed >= 0
|
|
437
|
-
? parsed
|
|
438
|
-
: DEFAULT_KIMI_INLINE_UPLOAD_THRESHOLD_BYTES;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
function deriveFilesBaseUrl(baseUrl: string): string {
|
|
442
|
-
const trimmed = baseUrl.replace(/\/$/, "");
|
|
443
|
-
return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;
|
|
193
|
+
const controller = new AbortController();
|
|
194
|
+
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
195
|
+
try {
|
|
196
|
+
const response = await fetch("https://api.kimi.com/coding/v1/usages", {
|
|
197
|
+
method: "GET",
|
|
198
|
+
headers: {
|
|
199
|
+
...getCommonHeaders(),
|
|
200
|
+
Authorization: `Bearer ${token}`,
|
|
201
|
+
},
|
|
202
|
+
signal: controller.signal,
|
|
203
|
+
});
|
|
204
|
+
if (!response.ok) return `Usage: fetch failed (${response.status})`;
|
|
205
|
+
return parseUsageSummary(await response.json());
|
|
206
|
+
} catch (error) {
|
|
207
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
208
|
+
return `Usage: fetch failed (${message})`;
|
|
209
|
+
} finally {
|
|
210
|
+
clearTimeout(timeout);
|
|
211
|
+
}
|
|
444
212
|
}
|
|
445
213
|
|
|
446
|
-
function
|
|
447
|
-
|
|
448
|
-
|
|
214
|
+
function moonshotStatus(config: KimiCodeConfig): string {
|
|
215
|
+
return MOONSHOT_TOOL_NAMES.map((toolName) => {
|
|
216
|
+
const tool = config.tools[toolName];
|
|
217
|
+
const enabled = tool.enabled ? "enabled" : "disabled";
|
|
218
|
+
const collapsed = tool.default_collapsed ? "collapsed" : "expanded";
|
|
219
|
+
return `${toolName}: ${enabled}, default ${collapsed}`;
|
|
220
|
+
}).join("\n");
|
|
449
221
|
}
|
|
450
222
|
|
|
451
|
-
function
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
223
|
+
function toggleEnabled(config: KimiCodeConfig, toolName: MoonshotToolName): KimiCodeConfig {
|
|
224
|
+
return {
|
|
225
|
+
tools: {
|
|
226
|
+
...config.tools,
|
|
227
|
+
[toolName]: {
|
|
228
|
+
...config.tools[toolName],
|
|
229
|
+
enabled: !config.tools[toolName].enabled,
|
|
230
|
+
},
|
|
231
|
+
},
|
|
459
232
|
};
|
|
460
|
-
return map[mimeType] ?? (mimeType.startsWith("video/") ? "upload.mp4" : "upload.bin");
|
|
461
233
|
}
|
|
462
234
|
|
|
463
|
-
function
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
235
|
+
function toggleCollapsed(config: KimiCodeConfig, toolName: MoonshotToolName): KimiCodeConfig {
|
|
236
|
+
return {
|
|
237
|
+
tools: {
|
|
238
|
+
...config.tools,
|
|
239
|
+
[toolName]: {
|
|
240
|
+
...config.tools[toolName],
|
|
241
|
+
default_collapsed: !config.tools[toolName].default_collapsed,
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
};
|
|
472
245
|
}
|
|
473
246
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
async function uploadKimiFile(
|
|
479
|
-
apiKey: string,
|
|
480
|
-
mimeType: string,
|
|
481
|
-
data: string,
|
|
482
|
-
): Promise<string | null> {
|
|
483
|
-
const buffer = Buffer.from(data, "base64");
|
|
484
|
-
const isVideo = mimeType.startsWith("video/");
|
|
485
|
-
const threshold = parseInlineUploadThreshold(process.env.KIMI_CODE_UPLOAD_THRESHOLD_BYTES);
|
|
486
|
-
if (!isVideo && buffer.length <= threshold) return null;
|
|
487
|
-
|
|
488
|
-
const filename = getUploadFilename(mimeType);
|
|
489
|
-
const formData = new FormData();
|
|
490
|
-
formData.append("file", new Blob([buffer], { type: mimeType }), filename);
|
|
491
|
-
formData.append("purpose", isVideo ? "video" : "image");
|
|
492
|
-
|
|
493
|
-
const baseUrl = process.env.KIMI_CODE_BASE_URL || DEFAULT_BASE_URL;
|
|
494
|
-
const uploadUrl = `${deriveFilesBaseUrl(baseUrl)}/files`;
|
|
495
|
-
const debug = process.env.KIMI_CODE_DEBUG === "1";
|
|
496
|
-
if (debug) {
|
|
497
|
-
console.log(
|
|
498
|
-
`\n[kimi-coding] Uploading ${filename} to ${uploadUrl} (${(buffer.length / 1024 / 1024).toFixed(2)} MB)`,
|
|
499
|
-
);
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
try {
|
|
503
|
-
const response = await fetch(uploadUrl, {
|
|
504
|
-
method: "POST",
|
|
505
|
-
headers: { Authorization: `Bearer ${apiKey}`, ...getCommonHeaders() },
|
|
506
|
-
body: formData,
|
|
507
|
-
});
|
|
508
|
-
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
|
|
509
|
-
const fileObj = (await response.json()) as { id?: string };
|
|
510
|
-
if (!fileObj.id) throw new Error("missing file id");
|
|
511
|
-
const fileUrl = `ms://${fileObj.id}`;
|
|
512
|
-
if (debug) console.log(`[kimi-coding] Upload success: ${fileUrl}`);
|
|
513
|
-
return fileUrl;
|
|
514
|
-
} catch (err) {
|
|
515
|
-
console.error("[kimi-coding] Upload failed:", err);
|
|
516
|
-
return null;
|
|
517
|
-
}
|
|
518
|
-
}
|
|
247
|
+
async function runKimiCommand(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
|
|
248
|
+
let config = loadKimiCodeConfig({ cwd: ctx.cwd, home: os.homedir() });
|
|
249
|
+
let usage = await fetchKimiUsageSummary();
|
|
250
|
+
ctx.ui.notify(usage);
|
|
519
251
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
block.type === "image_url" ? "image_url" : block.type === "video_url" ? "video_url" : null;
|
|
539
|
-
if (!key) continue;
|
|
540
|
-
|
|
541
|
-
const field = block[key];
|
|
542
|
-
const urlValue =
|
|
543
|
-
typeof field === "string"
|
|
544
|
-
? field
|
|
545
|
-
: isRecord(field) && typeof field.url === "string"
|
|
546
|
-
? field.url
|
|
547
|
-
: null;
|
|
548
|
-
if (!urlValue || urlValue.startsWith("ms://")) continue;
|
|
549
|
-
|
|
550
|
-
const parsed = parseDataUrl(urlValue);
|
|
551
|
-
if (!parsed) continue;
|
|
552
|
-
|
|
553
|
-
const uploaded = cache.get(urlValue) ?? (await upload(parsed.mimeType, parsed.data));
|
|
554
|
-
if (!uploaded) continue;
|
|
555
|
-
cache.set(urlValue, uploaded);
|
|
556
|
-
|
|
557
|
-
block[key] =
|
|
558
|
-
typeof field === "string" ? uploaded : { ...(field as JsonRecord), url: uploaded };
|
|
252
|
+
while (true) {
|
|
253
|
+
const choice = await ctx.ui.select(buildKimiMainTitle(config, ctx.cwd), [
|
|
254
|
+
`Edit project config (${relative(ctx.cwd, getProjectKimiCodeConfigPath(ctx.cwd))})`,
|
|
255
|
+
`Edit home config (${homeRelative(getGlobalKimiCodeConfigPath(os.homedir()))})`,
|
|
256
|
+
"Refresh usage",
|
|
257
|
+
"Done",
|
|
258
|
+
]);
|
|
259
|
+
|
|
260
|
+
if (!choice || choice === "Done") return;
|
|
261
|
+
if (choice === "Refresh usage") {
|
|
262
|
+
usage = await fetchKimiUsageSummary();
|
|
263
|
+
ctx.ui.notify(usage);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (choice.startsWith("Edit project config")) {
|
|
267
|
+
config = await editConfigScope(pi, ctx, "project");
|
|
268
|
+
} else if (choice.startsWith("Edit home config")) {
|
|
269
|
+
config = await editConfigScope(pi, ctx, "home");
|
|
559
270
|
}
|
|
560
271
|
}
|
|
561
272
|
}
|
|
562
273
|
|
|
563
|
-
async function
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
if (
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
const next: JsonRecord = { type: "image", source: { type: "url", url: uploaded } };
|
|
584
|
-
if (block.cache_control !== undefined) next.cache_control = block.cache_control;
|
|
585
|
-
return next;
|
|
586
|
-
};
|
|
587
|
-
|
|
588
|
-
for (const message of payload.messages) {
|
|
589
|
-
if (!isRecord(message) || !Array.isArray(message.content)) continue;
|
|
590
|
-
|
|
591
|
-
for (let i = 0; i < message.content.length; i++) {
|
|
592
|
-
const block = message.content[i];
|
|
593
|
-
if (isRecord(block) && block.type === "tool_result" && Array.isArray(block.content)) {
|
|
594
|
-
for (let j = 0; j < block.content.length; j++) {
|
|
595
|
-
block.content[j] = await transformImageBlock(block.content[j]);
|
|
596
|
-
}
|
|
597
|
-
continue;
|
|
598
|
-
}
|
|
599
|
-
message.content[i] = await transformImageBlock(block);
|
|
274
|
+
async function editConfigScope(
|
|
275
|
+
pi: ExtensionAPI,
|
|
276
|
+
ctx: ExtensionCommandContext,
|
|
277
|
+
scope: KimiConfigScope,
|
|
278
|
+
): Promise<KimiCodeConfig> {
|
|
279
|
+
let current = loadScopeKimiCodeConfig(scope, ctx.cwd);
|
|
280
|
+
while (true) {
|
|
281
|
+
const choice = await ctx.ui.select(buildConfigScopeTitle(scope, current, ctx.cwd), [
|
|
282
|
+
toolMenuItem(current, "moonshot_search"),
|
|
283
|
+
toolMenuItem(current, "moonshot_fetch"),
|
|
284
|
+
"Back",
|
|
285
|
+
]);
|
|
286
|
+
if (!choice || choice === "Back") {
|
|
287
|
+
return loadKimiCodeConfig({ cwd: ctx.cwd, home: os.homedir() });
|
|
288
|
+
}
|
|
289
|
+
if (choice.startsWith("moonshot_search")) {
|
|
290
|
+
current = await editMoonshotTool(pi, ctx, scope, current, "moonshot_search");
|
|
291
|
+
} else if (choice.startsWith("moonshot_fetch")) {
|
|
292
|
+
current = await editMoonshotTool(pi, ctx, scope, current, "moonshot_fetch");
|
|
600
293
|
}
|
|
601
294
|
}
|
|
602
295
|
}
|
|
603
296
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
297
|
+
async function editMoonshotTool(
|
|
298
|
+
pi: ExtensionAPI,
|
|
299
|
+
ctx: ExtensionCommandContext,
|
|
300
|
+
scope: KimiConfigScope,
|
|
301
|
+
config: KimiCodeConfig,
|
|
302
|
+
toolName: MoonshotToolName,
|
|
303
|
+
): Promise<KimiCodeConfig> {
|
|
304
|
+
let current = config;
|
|
305
|
+
while (true) {
|
|
306
|
+
const tool = current.tools[toolName];
|
|
307
|
+
const choice = await ctx.ui.select(
|
|
308
|
+
`Edit ${toolName}\n\n${formatToolStatus(current, toolName)}`,
|
|
309
|
+
[
|
|
310
|
+
tool.enabled ? `Disable ${toolName}` : `Enable ${toolName}`,
|
|
311
|
+
tool.default_collapsed ? "Expand previews by default" : "Collapse previews by default",
|
|
312
|
+
"Back",
|
|
313
|
+
],
|
|
620
314
|
);
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
await transformOpenAIPayloadFiles(payload, ctx.upload);
|
|
627
|
-
} else if (ctx.api === "anthropic-messages") {
|
|
628
|
-
await transformAnthropicPayloadFiles(payload, ctx.upload);
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
// 3. prompt_cache_key injection. Respect any key already on the payload,
|
|
633
|
-
// otherwise fall back to the caller-provided cacheKey (sessionId or
|
|
634
|
-
// explicit options.prompt_cache_key override). Skipped entirely when
|
|
635
|
-
// cacheRetention is "none" (via options.cacheRetention or
|
|
636
|
-
// PI_CACHE_RETENTION) so callers can truly disable caching — otherwise
|
|
637
|
-
// Kimi's native session cache would still fire even if pi-ai's
|
|
638
|
-
// Anthropic-style cache_control markers are omitted.
|
|
639
|
-
if (ctx.cacheRetention !== "none") {
|
|
640
|
-
const existing = payload.prompt_cache_key;
|
|
641
|
-
const resolved = (typeof existing === "string" && existing) || ctx.cacheKey;
|
|
642
|
-
if (resolved) payload.prompt_cache_key = resolved;
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
// 4. Env-level hyperparameter overrides (pre-parsed into numbers by caller).
|
|
646
|
-
const { temperature, topP, maxTokens } = ctx.envOverrides;
|
|
647
|
-
if (temperature !== undefined) payload.temperature = temperature;
|
|
648
|
-
if (topP !== undefined) payload.top_p = topP;
|
|
649
|
-
if (maxTokens !== undefined) payload.max_tokens = maxTokens;
|
|
650
|
-
|
|
651
|
-
// 5. Reasoning effort mapping.
|
|
652
|
-
if (ctx.reasoning) {
|
|
653
|
-
const mapped = mapThinkingLevel(ctx.reasoning);
|
|
654
|
-
if (mapped) {
|
|
655
|
-
payload.reasoning_effort = mapped.effort;
|
|
656
|
-
const extraBody = isRecord(payload.extra_body) ? payload.extra_body : {};
|
|
657
|
-
extraBody.thinking = { type: mapped.enabled ? "enabled" : "disabled" };
|
|
658
|
-
payload.extra_body = extraBody;
|
|
315
|
+
if (!choice || choice === "Back") return current;
|
|
316
|
+
if (choice.startsWith("Enable") || choice.startsWith("Disable")) {
|
|
317
|
+
current = toggleEnabled(current, toolName);
|
|
318
|
+
} else if (choice.endsWith("previews by default")) {
|
|
319
|
+
current = toggleCollapsed(current, toolName);
|
|
659
320
|
}
|
|
321
|
+
saveScopeKimiCodeConfig(scope, ctx.cwd, current);
|
|
322
|
+
const effective = loadKimiCodeConfig({ cwd: ctx.cwd, home: os.homedir() });
|
|
323
|
+
registerConfiguredMoonshotTools(pi, effective, { updateActiveTools: true });
|
|
324
|
+
ctx.ui.notify(`Saved ${toolName} config`, "info");
|
|
660
325
|
}
|
|
661
326
|
}
|
|
662
327
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
// block like: (Empty response: {'content': [{'type': 'thinking', ...}]}).
|
|
668
|
-
// This leaks internal state to the user. We buffer text_start/text_delta
|
|
669
|
-
// events and drop the whole block if text_end starts with the marker.
|
|
670
|
-
// Pure async generator — no closure dependencies, testable with synthetic
|
|
671
|
-
// event arrays.
|
|
672
|
-
|
|
673
|
-
async function* filterEmptyResponseStream(
|
|
674
|
-
upstream: AsyncIterable<AssistantMessageEvent>,
|
|
675
|
-
): AsyncIterable<AssistantMessageEvent> {
|
|
676
|
-
const suppressedIndices = new Set<number>();
|
|
677
|
-
let textBuffer: AssistantMessageEvent[] = [];
|
|
678
|
-
let bufferingIndex: number | null = null;
|
|
679
|
-
|
|
680
|
-
for await (const event of upstream) {
|
|
681
|
-
// Start buffering when a new text block begins.
|
|
682
|
-
if (event.type === "text_start") {
|
|
683
|
-
bufferingIndex = event.contentIndex;
|
|
684
|
-
textBuffer = [event];
|
|
685
|
-
continue;
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
// Accumulate text deltas + detect the empty-response marker on text_end.
|
|
689
|
-
if (
|
|
690
|
-
bufferingIndex !== null &&
|
|
691
|
-
"contentIndex" in event &&
|
|
692
|
-
event.contentIndex === bufferingIndex
|
|
693
|
-
) {
|
|
694
|
-
if (event.type === "text_delta") {
|
|
695
|
-
textBuffer.push(event);
|
|
696
|
-
continue;
|
|
697
|
-
}
|
|
698
|
-
if (event.type === "text_end") {
|
|
699
|
-
if (event.content.startsWith(EMPTY_RESPONSE_PREFIX)) {
|
|
700
|
-
// Suppress entire text block. Do NOT splice the message content
|
|
701
|
-
// array: it is a shared reference into session state, and mutating
|
|
702
|
-
// it would shift subsequent contentIndex values, corrupting the
|
|
703
|
-
// stream.
|
|
704
|
-
suppressedIndices.add(bufferingIndex);
|
|
705
|
-
} else {
|
|
706
|
-
// Legitimate text block — flush buffered events + end event.
|
|
707
|
-
for (const buffered of textBuffer) yield buffered;
|
|
708
|
-
yield event;
|
|
709
|
-
}
|
|
710
|
-
textBuffer = [];
|
|
711
|
-
bufferingIndex = null;
|
|
712
|
-
continue;
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
// Skip any event targeting an already-suppressed content index.
|
|
717
|
-
if ("contentIndex" in event && suppressedIndices.has(event.contentIndex)) {
|
|
718
|
-
continue;
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
// Clean suppressed blocks out of the final message.
|
|
722
|
-
if (event.type === "done" && suppressedIndices.size > 0) {
|
|
723
|
-
event.message.content = event.message.content.filter(
|
|
724
|
-
(block) =>
|
|
725
|
-
!(
|
|
726
|
-
block.type === "text" &&
|
|
727
|
-
typeof block.text === "string" &&
|
|
728
|
-
block.text.startsWith(EMPTY_RESPONSE_PREFIX)
|
|
729
|
-
),
|
|
730
|
-
);
|
|
731
|
-
}
|
|
328
|
+
function loadScopeKimiCodeConfig(scope: KimiConfigScope, cwd: string): KimiCodeConfig {
|
|
329
|
+
if (scope === "project") return loadProjectKimiCodeConfig(cwd);
|
|
330
|
+
return loadHomeKimiCodeConfig(os.homedir());
|
|
331
|
+
}
|
|
732
332
|
|
|
733
|
-
|
|
333
|
+
function saveScopeKimiCodeConfig(
|
|
334
|
+
scope: KimiConfigScope,
|
|
335
|
+
cwd: string,
|
|
336
|
+
config: KimiCodeConfig,
|
|
337
|
+
): void {
|
|
338
|
+
if (scope === "project") {
|
|
339
|
+
saveProjectKimiCodeConfig(cwd, config);
|
|
340
|
+
} else {
|
|
341
|
+
saveHomeKimiCodeConfig(os.homedir(), config);
|
|
734
342
|
}
|
|
735
343
|
}
|
|
736
344
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
// =============================================================================
|
|
740
|
-
// pi-coding-agent only refreshes an OAuth token when the locally cached
|
|
741
|
-
// `expires` is in the past. If the server rotates/revokes the access token
|
|
742
|
-
// before that (common with short-lived session tokens), every request keeps
|
|
743
|
-
// returning 401. We detect that situation by inspecting the first event of
|
|
744
|
-
// the upstream stream, force a refresh through AuthStorage (which persists
|
|
745
|
-
// the new credentials under a file lock), and retry once.
|
|
746
|
-
|
|
747
|
-
const PROVIDER_ID = "kimi-coding";
|
|
748
|
-
|
|
749
|
-
async function refreshKimiAuthToken(currentKey: string): Promise<string | null> {
|
|
750
|
-
try {
|
|
751
|
-
const storage = AuthStorage.create();
|
|
752
|
-
const cred = storage.get(PROVIDER_ID);
|
|
753
|
-
if (!cred || cred.type !== "oauth") {
|
|
754
|
-
console.error(
|
|
755
|
-
`[kimi-coding] auth refresh skipped: no OAuth credentials for ${PROVIDER_ID} on disk`,
|
|
756
|
-
);
|
|
757
|
-
return null;
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
// If disk already has a different valid token (e.g., another process or a
|
|
761
|
-
// previous retry refreshed it while the caller's in-memory cache went
|
|
762
|
-
// stale), reuse it without hitting the OAuth endpoint.
|
|
763
|
-
if (cred.access !== currentKey && Date.now() < cred.expires) {
|
|
764
|
-
console.error("[kimi-coding] auth refresh: reusing newer on-disk token");
|
|
765
|
-
return cred.access;
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
console.error("[kimi-coding] auth refresh: requesting new access token");
|
|
769
|
-
const refreshed = await refreshAccessToken(cred.refresh);
|
|
770
|
-
const newCred: OAuthCredential = {
|
|
771
|
-
type: "oauth",
|
|
772
|
-
access: refreshed.access_token,
|
|
773
|
-
refresh: refreshed.refresh_token,
|
|
774
|
-
expires: Date.now() + refreshed.expires_in * 1000,
|
|
775
|
-
};
|
|
776
|
-
storage.set(PROVIDER_ID, newCred);
|
|
777
|
-
console.error("[kimi-coding] auth refresh: new token persisted");
|
|
778
|
-
return newCred.access;
|
|
779
|
-
} catch (err) {
|
|
780
|
-
console.error("[kimi-coding] auth refresh failed:", err);
|
|
781
|
-
return null;
|
|
782
|
-
}
|
|
345
|
+
function buildKimiMainTitle(_config: KimiCodeConfig, _cwd: string): string {
|
|
346
|
+
return "Kimi settings";
|
|
783
347
|
}
|
|
784
348
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
options?: SimpleStreamOptions,
|
|
797
|
-
): AssistantMessageEventStream {
|
|
798
|
-
const filtered = new AssistantMessageEventStream();
|
|
799
|
-
const initialKey = options?.apiKey || process.env.KIMI_API_KEY || "";
|
|
800
|
-
|
|
801
|
-
const cacheKeyOverride = (
|
|
802
|
-
options as (SimpleStreamOptions & { prompt_cache_key?: unknown }) | undefined
|
|
803
|
-
)?.prompt_cache_key;
|
|
804
|
-
const cacheKey = (typeof cacheKeyOverride === "string" && cacheKeyOverride) || options?.sessionId;
|
|
805
|
-
const cacheRetention = resolveCacheRetention(options?.cacheRetention);
|
|
806
|
-
const envOverrides = readEnvOverrides();
|
|
807
|
-
const originalOnPayload = options?.onPayload;
|
|
808
|
-
|
|
809
|
-
const buildPatchedOptions = (apiKey: string): SimpleStreamOptions => {
|
|
810
|
-
const upload: Uploader | undefined = apiKey
|
|
811
|
-
? (mimeType, data) => uploadKimiFile(apiKey, mimeType, data)
|
|
812
|
-
: undefined;
|
|
813
|
-
// Only forward apiKey if we actually have one — never override the
|
|
814
|
-
// caller's credential (e.g. Claude Code OAuth token) with an empty string.
|
|
815
|
-
const apiKeyOverride = apiKey ? { apiKey } : {};
|
|
816
|
-
return {
|
|
817
|
-
...options,
|
|
818
|
-
...apiKeyOverride,
|
|
819
|
-
onPayload: async (payload, modelData) => {
|
|
820
|
-
let nextPayload: unknown = payload;
|
|
821
|
-
|
|
822
|
-
if (isRecord(nextPayload)) {
|
|
823
|
-
await applyKimiPayloadMutations(nextPayload, {
|
|
824
|
-
api: model.api,
|
|
825
|
-
upload,
|
|
826
|
-
cacheKey,
|
|
827
|
-
cacheRetention,
|
|
828
|
-
reasoning: options?.reasoning,
|
|
829
|
-
envOverrides,
|
|
830
|
-
});
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
if (originalOnPayload) {
|
|
834
|
-
const res = await originalOnPayload(nextPayload, modelData);
|
|
835
|
-
if (res !== undefined) nextPayload = res;
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
return nextPayload;
|
|
839
|
-
},
|
|
840
|
-
};
|
|
841
|
-
};
|
|
349
|
+
function buildConfigScopeTitle(
|
|
350
|
+
scope: KimiConfigScope,
|
|
351
|
+
config: KimiCodeConfig,
|
|
352
|
+
cwd: string,
|
|
353
|
+
): string {
|
|
354
|
+
const path =
|
|
355
|
+
scope === "project"
|
|
356
|
+
? relative(cwd, getProjectKimiCodeConfigPath(cwd))
|
|
357
|
+
: homeRelative(getGlobalKimiCodeConfigPath(os.homedir()));
|
|
358
|
+
return [`Edit ${scope} config`, `File: ${path}`, "", moonshotStatus(config)].join("\n");
|
|
359
|
+
}
|
|
842
360
|
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
while (true) {
|
|
848
|
-
const patchedOptions = buildPatchedOptions(currentKey);
|
|
849
|
-
// Route by the module-level PROTOCOL, not model.api, since we register
|
|
850
|
-
// with a custom api type (kimi-openai-completions / kimi-anthropic-messages)
|
|
851
|
-
// to avoid overriding the built-in Anthropic/OpenAI stream handlers.
|
|
852
|
-
const upstream =
|
|
853
|
-
PROTOCOL === "openai-completions"
|
|
854
|
-
? streamSimpleOpenAICompletions(
|
|
855
|
-
model as Model<"openai-completions">,
|
|
856
|
-
context,
|
|
857
|
-
patchedOptions,
|
|
858
|
-
)
|
|
859
|
-
: streamSimpleAnthropic(model as Model<"anthropic-messages">, context, patchedOptions);
|
|
860
|
-
|
|
861
|
-
let shouldRetry = false;
|
|
862
|
-
let prefixBuffer: AssistantMessageEvent[] = [];
|
|
863
|
-
|
|
864
|
-
try {
|
|
865
|
-
for await (const event of filterEmptyResponseStream(upstream)) {
|
|
866
|
-
// streamAnthropic emits a synthetic "start" event synchronously,
|
|
867
|
-
// before the for-await loop begins iterating and therefore before
|
|
868
|
-
// the HTTP request is actually made. If the request 401s, the loop
|
|
869
|
-
// throws and the catch block emits "error". Without buffering, the
|
|
870
|
-
// "start" event (which carries an empty AssistantMessage) leaks into
|
|
871
|
-
// the session history and the TUI, leaving a phantom empty assistant
|
|
872
|
-
// bubble. We buffer "start" events and only flush them once we see
|
|
873
|
-
// a non-error event that proves the stream is alive.
|
|
874
|
-
if (event.type === "start") {
|
|
875
|
-
prefixBuffer.push(event);
|
|
876
|
-
continue;
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
// Speculative OAuth refresh on the first error. We retry once so
|
|
880
|
-
// that short-lived Kimi tokens invalidated by the server before the
|
|
881
|
-
// local expires timestamp lapses don't surface as raw 401s to the
|
|
882
|
-
// user. Non-auth errors still trigger one wasted refresh, but the
|
|
883
|
-
// retried request fails the same way and we forward it.
|
|
884
|
-
if (attempt === 0 && event.type === "error") {
|
|
885
|
-
console.error(
|
|
886
|
-
`[kimi-coding] upstream error on first event, attempting refresh: ${event.error?.errorMessage?.slice(0, 200)}`,
|
|
887
|
-
);
|
|
888
|
-
const refreshed = await refreshKimiAuthToken(currentKey);
|
|
889
|
-
if (refreshed && refreshed !== currentKey) {
|
|
890
|
-
console.error("[kimi-coding] retrying stream with refreshed token");
|
|
891
|
-
currentKey = refreshed;
|
|
892
|
-
shouldRetry = true;
|
|
893
|
-
break; // discard prefixBuffer — don't leak the stale start
|
|
894
|
-
}
|
|
895
|
-
console.error(
|
|
896
|
-
"[kimi-coding] refresh did not yield a new token, forwarding original error",
|
|
897
|
-
);
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
// First non-start, non-retry event: flush buffered prefix, then
|
|
901
|
-
// stream normally.
|
|
902
|
-
for (const e of prefixBuffer) filtered.push(e);
|
|
903
|
-
prefixBuffer = [];
|
|
904
|
-
filtered.push(event);
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
// Stream ended normally: flush any remaining buffered starts.
|
|
908
|
-
for (const e of prefixBuffer) filtered.push(e);
|
|
909
|
-
} catch (err) {
|
|
910
|
-
// Upstream threw rather than emitting a stream `error` event. This can
|
|
911
|
-
// be the same stale-token 401 surfaced as an exception (depending on
|
|
912
|
-
// the SDK path / network layer), so mirror the in-stream refresh
|
|
913
|
-
// branch: on attempt 0, try one OAuth refresh + retry. Either way,
|
|
914
|
-
// discard `prefixBuffer` — we never confirmed the stream actually
|
|
915
|
-
// started, and flushing the buffered `start` would resurrect the
|
|
916
|
-
// phantom empty assistant message this PR set out to fix.
|
|
917
|
-
console.error("[kimi-coding] stream error:", err);
|
|
918
|
-
if (attempt === 0) {
|
|
919
|
-
const refreshed = await refreshKimiAuthToken(currentKey);
|
|
920
|
-
if (refreshed && refreshed !== currentKey) {
|
|
921
|
-
console.error("[kimi-coding] retrying stream after thrown error with refreshed token");
|
|
922
|
-
currentKey = refreshed;
|
|
923
|
-
shouldRetry = true;
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
if (!shouldRetry) {
|
|
927
|
-
filtered.push({
|
|
928
|
-
type: "error",
|
|
929
|
-
reason: "error",
|
|
930
|
-
error: {
|
|
931
|
-
content: [],
|
|
932
|
-
stopReason: "error",
|
|
933
|
-
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 },
|
|
934
|
-
},
|
|
935
|
-
} as AssistantMessageEvent & { type: "error" });
|
|
936
|
-
}
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
if (shouldRetry) {
|
|
940
|
-
attempt++;
|
|
941
|
-
continue;
|
|
942
|
-
}
|
|
943
|
-
break;
|
|
944
|
-
}
|
|
945
|
-
})();
|
|
361
|
+
function homeRelative(filePath: string): string {
|
|
362
|
+
const home = os.homedir();
|
|
363
|
+
return filePath.startsWith(`${home}/`) ? `~/${filePath.slice(home.length + 1)}` : filePath;
|
|
364
|
+
}
|
|
946
365
|
|
|
947
|
-
|
|
366
|
+
function toolMenuItem(config: KimiCodeConfig, toolName: MoonshotToolName): string {
|
|
367
|
+
return `${toolName} -> ${formatToolStatus(config, toolName)}`;
|
|
948
368
|
}
|
|
949
369
|
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
370
|
+
function formatToolStatus(config: KimiCodeConfig, toolName: MoonshotToolName): string {
|
|
371
|
+
const tool = config.tools[toolName];
|
|
372
|
+
const enabled = tool.enabled ? "enabled" : "disabled";
|
|
373
|
+
const collapsed = tool.default_collapsed ? "default collapsed" : "default expanded";
|
|
374
|
+
return `${enabled}, ${collapsed}`;
|
|
375
|
+
}
|
|
953
376
|
|
|
954
377
|
export default function (pi: ExtensionAPI) {
|
|
955
|
-
|
|
378
|
+
const config = loadKimiCodeConfig({ cwd: process.cwd(), home: os.homedir() });
|
|
379
|
+
|
|
380
|
+
pi.registerProvider(PROVIDER_ID, {
|
|
956
381
|
baseUrl: getBaseUrl(),
|
|
957
382
|
apiKey: "KIMI_API_KEY",
|
|
958
383
|
api: KIMI_API_TYPE,
|
|
@@ -961,15 +386,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
961
386
|
headers: getCommonHeaders(),
|
|
962
387
|
|
|
963
388
|
models: [
|
|
964
|
-
{
|
|
389
|
+
applyKimiEnvOverridesToModel({
|
|
965
390
|
id: "kimi-for-coding",
|
|
966
391
|
name: "Kimi for Coding",
|
|
967
392
|
reasoning: true,
|
|
968
|
-
input: ["text"
|
|
393
|
+
input: [...DEFAULT_KIMI_MODEL_INPUT] as unknown as ("text" | "image")[],
|
|
969
394
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
970
395
|
contextWindow: 262144,
|
|
971
396
|
maxTokens: 32000,
|
|
972
|
-
},
|
|
397
|
+
} as Model<Api>),
|
|
973
398
|
],
|
|
974
399
|
|
|
975
400
|
oauth: {
|
|
@@ -977,6 +402,27 @@ export default function (pi: ExtensionAPI) {
|
|
|
977
402
|
login: loginKimiCode,
|
|
978
403
|
refreshToken: refreshKimiCodeToken,
|
|
979
404
|
getApiKey: (cred) => cred.access,
|
|
405
|
+
// Reflect server-side model identity on the registered model after login
|
|
406
|
+
// / refresh. We never rewrite the model id (pi-side `/model` selections
|
|
407
|
+
// and persisted sessions reference it); only the human-facing name, the
|
|
408
|
+
// context window, and an out-of-band `wireModelId` carried into the
|
|
409
|
+
// request payload by streamSimpleKimi.
|
|
410
|
+
modifyModels: (models, cred) => {
|
|
411
|
+
const extras = cred as KimiOAuthCredentials;
|
|
412
|
+
return models.map((model) => {
|
|
413
|
+
if (model.id !== "kimi-for-coding") return model;
|
|
414
|
+
return applyKimiEnvOverridesToModel(applyKimiOAuthExtrasToModel(model, extras));
|
|
415
|
+
});
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
registerConfiguredMoonshotTools(pi, config, { updateActiveTools: false });
|
|
421
|
+
|
|
422
|
+
pi.registerCommand("kimi-settings", {
|
|
423
|
+
description: "Show Kimi usage and configure optional Kimi tools",
|
|
424
|
+
handler: async (_args, ctx) => {
|
|
425
|
+
await runKimiCommand(pi, ctx);
|
|
980
426
|
},
|
|
981
427
|
});
|
|
982
428
|
}
|