pi-provider-kimi-code 0.1.8 → 0.2.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.
Files changed (3) hide show
  1. package/README.md +107 -29
  2. package/index.ts +737 -356
  3. package/package.json +45 -12
package/index.ts CHANGED
@@ -9,12 +9,26 @@
9
9
  * # Then /login kimi-coding, or set KIMI_API_KEY=...
10
10
  */
11
11
 
12
+ import { execSync } from "node:child_process";
12
13
  import { randomBytes } from "node:crypto";
13
14
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
15
  import os from "node:os";
15
16
  import { dirname, join } from "node:path";
16
- import type { OAuthCredentials, OAuthLoginCallbacks, AssistantMessageEvent, Context, Model, SimpleStreamOptions } from "@mariozechner/pi-ai";
17
- import { streamSimpleAnthropic, AssistantMessageEventStream } from "@mariozechner/pi-ai";
17
+ import type {
18
+ OAuthCredentials,
19
+ OAuthLoginCallbacks,
20
+ AssistantMessageEvent,
21
+ CacheRetention,
22
+ Context,
23
+ Model,
24
+ SimpleStreamOptions,
25
+ ThinkingLevel,
26
+ } from "@mariozechner/pi-ai";
27
+ import {
28
+ streamSimpleAnthropic,
29
+ streamSimpleOpenAICompletions,
30
+ AssistantMessageEventStream,
31
+ } from "@mariozechner/pi-ai";
18
32
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
19
33
 
20
34
  // =============================================================================
@@ -23,8 +37,13 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
23
37
 
24
38
  const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
25
39
  const DEFAULT_OAUTH_HOST = "https://auth.kimi.com";
26
- const DEFAULT_BASE_URL = "https://api.kimi.com/coding";
27
- const KIMI_CLI_VERSION = "1.28.0";
40
+ const PROTOCOL =
41
+ process.env.KIMI_CODE_PROTOCOL === "openai" ? "openai-completions" : "anthropic-messages";
42
+ const DEFAULT_BASE_URL =
43
+ PROTOCOL === "openai-completions"
44
+ ? "https://api.kimi.com/coding/v1"
45
+ : "https://api.kimi.com/coding";
46
+ const KIMI_CLI_VERSION = "1.30.0";
28
47
  const KIMI_CLI_USER_AGENT = `KimiCLI/${KIMI_CLI_VERSION}`;
29
48
  const KIMI_PLATFORM = "kimi_cli";
30
49
  const DEVICE_ID_PATH = join(os.homedir(), ".pi", "providers", "kimi-coding", "device_id");
@@ -34,99 +53,114 @@ const DEVICE_ID_PATH = join(os.homedir(), ".pi", "providers", "kimi-coding", "de
34
53
  // =============================================================================
35
54
 
36
55
  function getOAuthHost(): string {
37
- const value = process.env.KIMI_CODE_OAUTH_HOST || process.env.KIMI_OAUTH_HOST || DEFAULT_OAUTH_HOST;
38
- return value.trim() || DEFAULT_OAUTH_HOST;
56
+ const value =
57
+ process.env.KIMI_CODE_OAUTH_HOST || process.env.KIMI_OAUTH_HOST || DEFAULT_OAUTH_HOST;
58
+ return value.trim() || DEFAULT_OAUTH_HOST;
39
59
  }
40
60
 
41
61
  function getBaseUrl(): string {
42
- const value = process.env.KIMI_CODE_BASE_URL || DEFAULT_BASE_URL;
43
- return value.trim() || DEFAULT_BASE_URL;
62
+ const value = process.env.KIMI_CODE_BASE_URL || DEFAULT_BASE_URL;
63
+ return value.trim() || DEFAULT_BASE_URL;
44
64
  }
45
65
 
46
66
  function createDeviceId(): string {
47
- return randomBytes(16).toString("hex");
67
+ return randomBytes(16).toString("hex");
48
68
  }
49
69
 
50
70
  function ensurePrivateFile(path: string): void {
51
- try {
52
- chmodSync(path, 0o600);
53
- } catch {
54
- // Ignore chmod failures on platforms/filesystems that do not support it.
55
- }
71
+ try {
72
+ chmodSync(path, 0o600);
73
+ } catch {
74
+ // Ignore chmod failures on platforms/filesystems that do not support it.
75
+ }
56
76
  }
57
77
 
58
78
  function readPersistedDeviceId(): string | null {
59
- try {
60
- if (!existsSync(DEVICE_ID_PATH)) return null;
61
- const deviceId = readFileSync(DEVICE_ID_PATH, "utf8").trim();
62
- return deviceId || null;
63
- } catch {
64
- return null;
65
- }
79
+ try {
80
+ if (!existsSync(DEVICE_ID_PATH)) return null;
81
+ const deviceId = readFileSync(DEVICE_ID_PATH, "utf8").trim();
82
+ return deviceId || null;
83
+ } catch {
84
+ return null;
85
+ }
66
86
  }
67
87
 
68
88
  function persistDeviceId(deviceId: string): void {
69
- try {
70
- mkdirSync(dirname(DEVICE_ID_PATH), { recursive: true });
71
- writeFileSync(DEVICE_ID_PATH, deviceId, "utf8");
72
- ensurePrivateFile(DEVICE_ID_PATH);
73
- } catch {
74
- // Ignore persistence failures and fall back to the in-memory device id.
75
- }
89
+ try {
90
+ mkdirSync(dirname(DEVICE_ID_PATH), { recursive: true });
91
+ writeFileSync(DEVICE_ID_PATH, deviceId, "utf8");
92
+ ensurePrivateFile(DEVICE_ID_PATH);
93
+ } catch {
94
+ // Ignore persistence failures and fall back to the in-memory device id.
95
+ }
96
+ }
97
+
98
+ function getMacOSVersion(): string {
99
+ try {
100
+ return execSync("sw_vers -productVersion", { encoding: "utf8" }).trim();
101
+ } catch {
102
+ return os.release();
103
+ }
76
104
  }
77
105
 
78
106
  function getDeviceModel(): string {
79
- const platform = process.platform;
80
- const arch = os.machine() || process.arch;
81
- const release = os.release();
82
- if (platform === "darwin") {
83
- return release && arch ? `macOS ${release} ${arch}` : `macOS ${arch}`;
84
- }
85
- if (platform === "win32") {
86
- return release && arch ? `Windows ${release} ${arch}` : `Windows ${arch}`;
87
- }
88
- return release && arch ? `${platform} ${release} ${arch}` : `${platform} ${arch}`;
107
+ const platform = process.platform;
108
+ const arch = os.machine() || process.arch;
109
+ if (platform === "darwin") {
110
+ const version = getMacOSVersion();
111
+ return version && arch ? `macOS ${version} ${arch}` : `macOS ${arch}`;
112
+ }
113
+ if (platform === "win32") {
114
+ const release = os.release();
115
+ return release && arch ? `Windows ${release} ${arch}` : `Windows ${arch}`;
116
+ }
117
+ const release = os.release();
118
+ return release && arch ? `${platform} ${release} ${arch}` : `${platform} ${arch}`;
89
119
  }
90
120
 
91
121
  function asciiHeaderValue(value: string, fallback = "unknown"): string {
92
- const trimmed = value.trim();
93
- if (/^[\x00-\x7F]*$/.test(trimmed)) {
94
- return trimmed;
95
- }
96
- const sanitized = trimmed.replace(/[^\x00-\x7F]/g, "").trim();
97
- return sanitized || fallback;
122
+ const trimmed = value.trim();
123
+ /* oxlint-disable-next-line no-control-regex */
124
+ if (/^[\x00-\x7F]*$/.test(trimmed)) {
125
+ return trimmed;
126
+ }
127
+ /* oxlint-disable-next-line no-control-regex */
128
+ const sanitized = trimmed.replace(/[^\x00-\x7F]/g, "").trim();
129
+ return sanitized || fallback;
98
130
  }
99
131
 
100
132
  const DEVICE_MODEL = getDeviceModel();
101
133
  let DEVICE_ID: string | null = null;
102
134
 
103
135
  function getStableDeviceId(): string {
104
- if (DEVICE_ID) {
105
- return DEVICE_ID;
106
- }
107
-
108
- const persisted = readPersistedDeviceId();
109
- if (persisted) {
110
- DEVICE_ID = persisted;
111
- return DEVICE_ID;
112
- }
113
-
114
- DEVICE_ID = createDeviceId();
115
- persistDeviceId(DEVICE_ID);
116
- return DEVICE_ID;
136
+ if (DEVICE_ID) {
137
+ return DEVICE_ID;
138
+ }
139
+
140
+ const persisted = readPersistedDeviceId();
141
+ if (persisted) {
142
+ DEVICE_ID = persisted;
143
+ return DEVICE_ID;
144
+ }
145
+
146
+ DEVICE_ID = createDeviceId();
147
+ persistDeviceId(DEVICE_ID);
148
+ return DEVICE_ID;
117
149
  }
118
150
 
119
151
  function getCommonHeaders(): Record<string, string> {
120
- const headers = {
121
- "User-Agent": KIMI_CLI_USER_AGENT,
122
- "X-Msh-Platform": KIMI_PLATFORM,
123
- "X-Msh-Version": KIMI_CLI_VERSION,
124
- "X-Msh-Device-Name": os.hostname(),
125
- "X-Msh-Device-Model": DEVICE_MODEL,
126
- "X-Msh-Os-Version": os.release(),
127
- "X-Msh-Device-Id": getStableDeviceId(),
128
- };
129
- return Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, asciiHeaderValue(value)])) as Record<string, string>;
152
+ const headers = {
153
+ "User-Agent": KIMI_CLI_USER_AGENT,
154
+ "X-Msh-Platform": KIMI_PLATFORM,
155
+ "X-Msh-Version": KIMI_CLI_VERSION,
156
+ "X-Msh-Device-Name": os.hostname(),
157
+ "X-Msh-Device-Model": DEVICE_MODEL,
158
+ "X-Msh-Os-Version": os.release(),
159
+ "X-Msh-Device-Id": getStableDeviceId(),
160
+ };
161
+ return Object.fromEntries(
162
+ Object.entries(headers).map(([key, value]) => [key, asciiHeaderValue(value)]),
163
+ ) as Record<string, string>;
130
164
  }
131
165
 
132
166
  // =============================================================================
@@ -134,127 +168,127 @@ function getCommonHeaders(): Record<string, string> {
134
168
  // =============================================================================
135
169
 
136
170
  interface DeviceAuthorization {
137
- user_code: string;
138
- device_code: string;
139
- verification_uri: string;
140
- verification_uri_complete: string;
141
- expires_in: number;
142
- interval: number;
171
+ user_code: string;
172
+ device_code: string;
173
+ verification_uri: string;
174
+ verification_uri_complete: string;
175
+ expires_in: number;
176
+ interval: number;
143
177
  }
144
178
 
145
179
  interface TokenResponse {
146
- access_token: string;
147
- refresh_token: string;
148
- expires_in: number;
149
- scope: string;
150
- token_type: string;
180
+ access_token: string;
181
+ refresh_token: string;
182
+ expires_in: number;
183
+ scope: string;
184
+ token_type: string;
151
185
  }
152
186
 
153
187
  async function requestDeviceAuthorization(): Promise<DeviceAuthorization> {
154
- const response = await fetch(`${getOAuthHost()}/api/oauth/device_authorization`, {
155
- method: "POST",
156
- headers: {
157
- "Content-Type": "application/x-www-form-urlencoded",
158
- ...getCommonHeaders(),
159
- },
160
- body: new URLSearchParams({
161
- client_id: CLIENT_ID,
162
- }),
163
- });
164
-
165
- if (!response.ok) {
166
- const text = await response.text().catch(() => "");
167
- throw new Error(`Device authorization failed: ${response.status} ${text}`);
168
- }
169
-
170
- const data = (await response.json()) as {
171
- user_code?: string;
172
- device_code?: string;
173
- verification_uri?: string;
174
- verification_uri_complete?: string;
175
- expires_in?: number;
176
- interval?: number;
177
- };
178
-
179
- if (!data.user_code || !data.device_code || !data.verification_uri_complete) {
180
- throw new Error("Invalid device authorization response");
181
- }
182
-
183
- return {
184
- user_code: data.user_code,
185
- device_code: data.device_code,
186
- verification_uri: data.verification_uri || "",
187
- verification_uri_complete: data.verification_uri_complete,
188
- expires_in: data.expires_in || 1800,
189
- interval: data.interval || 5,
190
- };
188
+ const response = await fetch(`${getOAuthHost()}/api/oauth/device_authorization`, {
189
+ method: "POST",
190
+ headers: {
191
+ "Content-Type": "application/x-www-form-urlencoded",
192
+ ...getCommonHeaders(),
193
+ },
194
+ body: new URLSearchParams({
195
+ client_id: CLIENT_ID,
196
+ }),
197
+ });
198
+
199
+ if (!response.ok) {
200
+ const text = await response.text().catch(() => "");
201
+ throw new Error(`Device authorization failed: ${response.status} ${text}`);
202
+ }
203
+
204
+ const data = (await response.json()) as {
205
+ user_code?: string;
206
+ device_code?: string;
207
+ verification_uri?: string;
208
+ verification_uri_complete?: string;
209
+ expires_in?: number;
210
+ interval?: number;
211
+ };
212
+
213
+ if (!data.user_code || !data.device_code || !data.verification_uri_complete) {
214
+ throw new Error("Invalid device authorization response");
215
+ }
216
+
217
+ return {
218
+ user_code: data.user_code,
219
+ device_code: data.device_code,
220
+ verification_uri: data.verification_uri || "",
221
+ verification_uri_complete: data.verification_uri_complete,
222
+ expires_in: data.expires_in || 1800,
223
+ interval: data.interval || 5,
224
+ };
191
225
  }
192
226
 
193
227
  async function requestDeviceToken(auth: DeviceAuthorization): Promise<TokenResponse | null> {
194
- const response = await fetch(`${getOAuthHost()}/api/oauth/token`, {
195
- method: "POST",
196
- headers: {
197
- "Content-Type": "application/x-www-form-urlencoded",
198
- ...getCommonHeaders(),
199
- },
200
- body: new URLSearchParams({
201
- client_id: CLIENT_ID,
202
- device_code: auth.device_code,
203
- grant_type: "urn:ietf:params:oauth:grant-type:device_code",
204
- }),
205
- });
206
-
207
- if (response.status === 200) {
208
- const data = (await response.json()) as TokenResponse;
209
- if (data.access_token && data.refresh_token) {
210
- return data;
211
- }
212
- throw new Error("Token response missing required fields");
213
- }
214
-
215
- if (response.status === 400) {
216
- const data = (await response.json()) as { error?: string; error_description?: string };
217
- if (data.error === "authorization_pending") {
218
- return null;
219
- }
220
- if (data.error === "expired_token") {
221
- throw new Error("expired_token");
222
- }
223
- throw new Error(`Token request failed: ${data.error_description || data.error || "unknown"}`);
224
- }
225
-
226
- const text = await response.text().catch(() => "");
227
- throw new Error(`Token request failed: ${response.status} ${text}`);
228
+ const response = await fetch(`${getOAuthHost()}/api/oauth/token`, {
229
+ method: "POST",
230
+ headers: {
231
+ "Content-Type": "application/x-www-form-urlencoded",
232
+ ...getCommonHeaders(),
233
+ },
234
+ body: new URLSearchParams({
235
+ client_id: CLIENT_ID,
236
+ device_code: auth.device_code,
237
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
238
+ }),
239
+ });
240
+
241
+ if (response.status === 200) {
242
+ const data = (await response.json()) as TokenResponse;
243
+ if (data.access_token && data.refresh_token) {
244
+ return data;
245
+ }
246
+ throw new Error("Token response missing required fields");
247
+ }
248
+
249
+ if (response.status === 400) {
250
+ const data = (await response.json()) as { error?: string; error_description?: string };
251
+ if (data.error === "authorization_pending") {
252
+ return null;
253
+ }
254
+ if (data.error === "expired_token") {
255
+ throw new Error("expired_token");
256
+ }
257
+ throw new Error(`Token request failed: ${data.error_description || data.error || "unknown"}`);
258
+ }
259
+
260
+ const text = await response.text().catch(() => "");
261
+ throw new Error(`Token request failed: ${response.status} ${text}`);
228
262
  }
229
263
 
230
264
  async function refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
231
- const response = await fetch(`${getOAuthHost()}/api/oauth/token`, {
232
- method: "POST",
233
- headers: {
234
- "Content-Type": "application/x-www-form-urlencoded",
235
- ...getCommonHeaders(),
236
- },
237
- body: new URLSearchParams({
238
- client_id: CLIENT_ID,
239
- grant_type: "refresh_token",
240
- refresh_token: refreshToken,
241
- }),
242
- });
243
-
244
- if (!response.ok) {
245
- const text = await response.text().catch(() => "");
246
- if (response.status === 401 || response.status === 403) {
247
- throw new Error(`Token refresh unauthorized: ${text}`);
248
- }
249
- throw new Error(`Token refresh failed: ${response.status} ${text}`);
250
- }
251
-
252
- const data = (await response.json()) as TokenResponse;
253
- if (!data.access_token || !data.refresh_token) {
254
- throw new Error("Token refresh response missing required fields");
255
- }
256
-
257
- return data;
265
+ const response = await fetch(`${getOAuthHost()}/api/oauth/token`, {
266
+ method: "POST",
267
+ headers: {
268
+ "Content-Type": "application/x-www-form-urlencoded",
269
+ ...getCommonHeaders(),
270
+ },
271
+ body: new URLSearchParams({
272
+ client_id: CLIENT_ID,
273
+ grant_type: "refresh_token",
274
+ refresh_token: refreshToken,
275
+ }),
276
+ });
277
+
278
+ if (!response.ok) {
279
+ const text = await response.text().catch(() => "");
280
+ if (response.status === 401 || response.status === 403) {
281
+ throw new Error(`Token refresh unauthorized: ${text}`);
282
+ }
283
+ throw new Error(`Token refresh failed: ${response.status} ${text}`);
284
+ }
285
+
286
+ const data = (await response.json()) as TokenResponse;
287
+ if (!data.access_token || !data.refresh_token) {
288
+ throw new Error("Token refresh response missing required fields");
289
+ }
290
+
291
+ return data;
258
292
  }
259
293
 
260
294
  // =============================================================================
@@ -262,153 +296,500 @@ async function refreshAccessToken(refreshToken: string): Promise<TokenResponse>
262
296
  // =============================================================================
263
297
 
264
298
  async function loginKimiCode(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
265
- // Keep trying until we get a token (handles expired device codes)
266
- while (true) {
267
- const auth = await requestDeviceAuthorization();
268
-
269
- callbacks.onAuth({
270
- url: auth.verification_uri_complete,
271
- instructions: `Please visit the URL to authorize. Your code: ${auth.user_code}`,
272
- });
273
-
274
- const interval = Math.max(auth.interval, 1) * 1000;
275
- const expiresAt = Date.now() + auth.expires_in * 1000;
276
-
277
- let token: TokenResponse | null = null;
278
- let printedWaiting = false;
279
-
280
- while (Date.now() < expiresAt) {
281
- try {
282
- token = await requestDeviceToken(auth);
283
- if (token) break;
284
- } catch (error) {
285
- if (error instanceof Error && error.message === "expired_token") {
286
- // Device code expired, restart the flow
287
- if (callbacks.onProgress) {
288
- callbacks.onProgress("Device code expired, restarting...");
289
- }
290
- break;
291
- }
292
- throw error;
293
- }
294
-
295
- if (!printedWaiting) {
296
- if (callbacks.onProgress) {
297
- callbacks.onProgress("Waiting for authorization...");
298
- }
299
- printedWaiting = true;
300
- }
301
-
302
- // Check for abort
303
- if (callbacks.signal?.aborted) {
304
- throw new Error("Authorization aborted");
305
- }
306
-
307
- await new Promise((resolve) => setTimeout(resolve, interval));
308
- }
309
-
310
- if (token) {
311
- return {
312
- access: token.access_token,
313
- refresh: token.refresh_token,
314
- expires: Date.now() + token.expires_in * 1000,
315
- };
316
- }
317
-
318
- // If we get here without a token, the device code expired - loop will retry
319
- }
299
+ // Keep trying until we get a token (handles expired device codes)
300
+ while (true) {
301
+ const auth = await requestDeviceAuthorization();
302
+
303
+ callbacks.onAuth({
304
+ url: auth.verification_uri_complete,
305
+ instructions: `Please visit the URL to authorize. Your code: ${auth.user_code}`,
306
+ });
307
+
308
+ const interval = Math.max(auth.interval, 1) * 1000;
309
+ const expiresAt = Date.now() + auth.expires_in * 1000;
310
+
311
+ let token: TokenResponse | null = null;
312
+ let printedWaiting = false;
313
+
314
+ while (Date.now() < expiresAt) {
315
+ try {
316
+ token = await requestDeviceToken(auth);
317
+ if (token) break;
318
+ } catch (error) {
319
+ if (error instanceof Error && error.message === "expired_token") {
320
+ // Device code expired, restart the flow
321
+ if (callbacks.onProgress) {
322
+ callbacks.onProgress("Device code expired, restarting...");
323
+ }
324
+ break;
325
+ }
326
+ throw error;
327
+ }
328
+
329
+ if (!printedWaiting) {
330
+ if (callbacks.onProgress) {
331
+ callbacks.onProgress("Waiting for authorization...");
332
+ }
333
+ printedWaiting = true;
334
+ }
335
+
336
+ // Check for abort
337
+ if (callbacks.signal?.aborted) {
338
+ throw new Error("Authorization aborted");
339
+ }
340
+
341
+ await new Promise((resolve) => setTimeout(resolve, interval));
342
+ }
343
+
344
+ if (token) {
345
+ return {
346
+ access: token.access_token,
347
+ refresh: token.refresh_token,
348
+ expires: Date.now() + token.expires_in * 1000,
349
+ };
350
+ }
351
+
352
+ // If we get here without a token, the device code expired - loop will retry
353
+ }
320
354
  }
321
355
 
322
356
  async function refreshKimiCodeToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
323
- const token = await refreshAccessToken(credentials.refresh);
324
- return {
325
- access: token.access_token,
326
- refresh: token.refresh_token,
327
- expires: Date.now() + token.expires_in * 1000,
328
- };
357
+ const token = await refreshAccessToken(credentials.refresh);
358
+ return {
359
+ access: token.access_token,
360
+ refresh: token.refresh_token,
361
+ expires: Date.now() + token.expires_in * 1000,
362
+ };
329
363
  }
330
364
 
331
365
  // =============================================================================
332
- // Stream wrapper: strip "(Empty response: ...)" text blocks from Kimi API
366
+ // Payload / stream helpers: types + pure utilities
333
367
  // =============================================================================
334
- // The Kimi API wraps thinking-only responses (no text content) into a text
335
- // block like: (Empty response: {'content': [{'type': 'thinking', ...}]})
336
- // This leaks internal state to the user. We detect and suppress such blocks.
337
368
 
338
369
  const EMPTY_RESPONSE_PREFIX = "(Empty response:";
370
+ const DEFAULT_KIMI_INLINE_UPLOAD_THRESHOLD_BYTES = 1 * 1024 * 1024;
371
+
372
+ type JsonRecord = Record<string, unknown>;
373
+ type Uploader = (mimeType: string, data: string) => Promise<string | null>;
374
+
375
+ interface KimiEnvOverrides {
376
+ temperature?: number;
377
+ topP?: number;
378
+ maxTokens?: number;
379
+ }
380
+
381
+ function resolveCacheRetention(value?: CacheRetention): CacheRetention {
382
+ if (value === "none" || value === "short" || value === "long") return value;
383
+ if (process.env.PI_CACHE_RETENTION === "long") return "long";
384
+ return "short";
385
+ }
386
+
387
+ interface KimiPayloadContext {
388
+ api: "anthropic-messages" | "openai-completions";
389
+ upload?: Uploader;
390
+ cacheKey?: string;
391
+ cacheRetention: CacheRetention;
392
+ reasoning?: ThinkingLevel;
393
+ envOverrides: KimiEnvOverrides;
394
+ }
395
+
396
+ function isRecord(value: unknown): value is JsonRecord {
397
+ return typeof value === "object" && value !== null && !Array.isArray(value);
398
+ }
399
+
400
+ function mapThinkingLevel(level?: string): { effort: string | null; enabled: boolean } | undefined {
401
+ if (!level) return undefined;
402
+ if (level === "none" || level === "off") return { effort: null, enabled: false };
403
+ if (level === "minimal" || level === "low") return { effort: "low", enabled: true };
404
+ if (level === "medium") return { effort: "medium", enabled: true };
405
+ if (level === "high" || level === "xhigh") return { effort: "high", enabled: true };
406
+ return undefined;
407
+ }
408
+
409
+ function parseInlineUploadThreshold(raw: string | undefined): number {
410
+ const parsed = Number.parseInt(raw ?? "", 10);
411
+ return Number.isFinite(parsed) && parsed >= 0
412
+ ? parsed
413
+ : DEFAULT_KIMI_INLINE_UPLOAD_THRESHOLD_BYTES;
414
+ }
415
+
416
+ function deriveFilesBaseUrl(baseUrl: string): string {
417
+ const trimmed = baseUrl.replace(/\/$/, "");
418
+ return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;
419
+ }
420
+
421
+ function parseDataUrl(url: string): { mimeType: string; data: string } | null {
422
+ const match = url.match(/^data:([^;,]+);base64,([A-Za-z0-9+/=]+)$/);
423
+ return match ? { mimeType: match[1], data: match[2] } : null;
424
+ }
425
+
426
+ function getUploadFilename(mimeType: string): string {
427
+ const map: Record<string, string> = {
428
+ "image/jpeg": "upload.jpg",
429
+ "image/png": "upload.png",
430
+ "image/gif": "upload.gif",
431
+ "image/webp": "upload.webp",
432
+ "video/mp4": "upload.mp4",
433
+ "video/quicktime": "upload.mov",
434
+ };
435
+ return map[mimeType] ?? (mimeType.startsWith("video/") ? "upload.mp4" : "upload.bin");
436
+ }
437
+
438
+ function readEnvOverrides(): KimiEnvOverrides {
439
+ const out: KimiEnvOverrides = {};
440
+ const temp = process.env.KIMI_MODEL_TEMPERATURE;
441
+ if (temp) out.temperature = parseFloat(temp);
442
+ const topP = process.env.KIMI_MODEL_TOP_P;
443
+ if (topP) out.topP = parseFloat(topP);
444
+ const maxTokens = process.env.KIMI_MODEL_MAX_TOKENS;
445
+ if (maxTokens) out.maxTokens = parseInt(maxTokens, 10);
446
+ return out;
447
+ }
448
+
449
+ // =============================================================================
450
+ // File upload (I/O edge)
451
+ // =============================================================================
452
+
453
+ async function uploadKimiFile(
454
+ apiKey: string,
455
+ mimeType: string,
456
+ data: string,
457
+ ): Promise<string | null> {
458
+ const buffer = Buffer.from(data, "base64");
459
+ const isVideo = mimeType.startsWith("video/");
460
+ const threshold = parseInlineUploadThreshold(process.env.KIMI_CODE_UPLOAD_THRESHOLD_BYTES);
461
+ if (!isVideo && buffer.length <= threshold) return null;
462
+
463
+ const filename = getUploadFilename(mimeType);
464
+ const formData = new FormData();
465
+ formData.append("file", new Blob([buffer], { type: mimeType }), filename);
466
+ formData.append("purpose", isVideo ? "video" : "image");
467
+
468
+ const baseUrl = process.env.KIMI_CODE_BASE_URL || DEFAULT_BASE_URL;
469
+ const uploadUrl = `${deriveFilesBaseUrl(baseUrl)}/files`;
470
+ const debug = process.env.KIMI_CODE_DEBUG === "1";
471
+ if (debug) {
472
+ console.log(
473
+ `\n[kimi-coding] Uploading ${filename} to ${uploadUrl} (${(buffer.length / 1024 / 1024).toFixed(2)} MB)`,
474
+ );
475
+ }
476
+
477
+ try {
478
+ const response = await fetch(uploadUrl, {
479
+ method: "POST",
480
+ headers: { Authorization: `Bearer ${apiKey}`, ...getCommonHeaders() },
481
+ body: formData,
482
+ });
483
+ if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
484
+ const fileObj = (await response.json()) as { id?: string };
485
+ if (!fileObj.id) throw new Error("missing file id");
486
+ const fileUrl = `ms://${fileObj.id}`;
487
+ if (debug) console.log(`[kimi-coding] Upload success: ${fileUrl}`);
488
+ return fileUrl;
489
+ } catch (err) {
490
+ console.error("[kimi-coding] Upload failed:", err);
491
+ return null;
492
+ }
493
+ }
494
+
495
+ // =============================================================================
496
+ // Payload file transformers (pure given an Uploader)
497
+ // =============================================================================
498
+ // These walk the provider-specific payload shape and replace inline base64
499
+ // image/video blocks with ms:// references returned by the injected uploader.
500
+ // They take an Uploader rather than an apiKey so they can be unit-tested with
501
+ // a fake uploader; all network I/O stays behind that boundary.
502
+
503
+ async function transformOpenAIPayloadFiles(payload: JsonRecord, upload: Uploader): Promise<void> {
504
+ if (!Array.isArray(payload.messages)) return;
505
+ const cache = new Map<string, string>();
506
+
507
+ for (const message of payload.messages) {
508
+ if (!isRecord(message) || !Array.isArray(message.content)) continue;
509
+
510
+ for (const block of message.content) {
511
+ if (!isRecord(block)) continue;
512
+ const key =
513
+ block.type === "image_url" ? "image_url" : block.type === "video_url" ? "video_url" : null;
514
+ if (!key) continue;
515
+
516
+ const field = block[key];
517
+ const urlValue =
518
+ typeof field === "string"
519
+ ? field
520
+ : isRecord(field) && typeof field.url === "string"
521
+ ? field.url
522
+ : null;
523
+ if (!urlValue || urlValue.startsWith("ms://")) continue;
524
+
525
+ const parsed = parseDataUrl(urlValue);
526
+ if (!parsed) continue;
527
+
528
+ const uploaded = cache.get(urlValue) ?? (await upload(parsed.mimeType, parsed.data));
529
+ if (!uploaded) continue;
530
+ cache.set(urlValue, uploaded);
531
+
532
+ block[key] =
533
+ typeof field === "string" ? uploaded : { ...(field as JsonRecord), url: uploaded };
534
+ }
535
+ }
536
+ }
537
+
538
+ async function transformAnthropicPayloadFiles(
539
+ payload: JsonRecord,
540
+ upload: Uploader,
541
+ ): Promise<void> {
542
+ if (!Array.isArray(payload.messages)) return;
543
+ const cache = new Map<string, string>();
544
+
545
+ const transformImageBlock = async (block: unknown): Promise<unknown> => {
546
+ if (!isRecord(block) || block.type !== "image") return block;
547
+ const source = block.source;
548
+ if (!isRecord(source) || source.type !== "base64") return block;
549
+ const mediaType = source.media_type;
550
+ const data = source.data;
551
+ if (typeof mediaType !== "string" || typeof data !== "string") return block;
552
+
553
+ const cacheKey = `${mediaType}:${data}`;
554
+ const uploaded = cache.get(cacheKey) ?? (await upload(mediaType, data));
555
+ if (!uploaded) return block;
556
+ cache.set(cacheKey, uploaded);
557
+
558
+ const next: JsonRecord = { type: "image", source: { type: "url", url: uploaded } };
559
+ if (block.cache_control !== undefined) next.cache_control = block.cache_control;
560
+ return next;
561
+ };
562
+
563
+ for (const message of payload.messages) {
564
+ if (!isRecord(message) || !Array.isArray(message.content)) continue;
565
+
566
+ for (let i = 0; i < message.content.length; i++) {
567
+ const block = message.content[i];
568
+ if (isRecord(block) && block.type === "tool_result" && Array.isArray(block.content)) {
569
+ for (let j = 0; j < block.content.length; j++) {
570
+ block.content[j] = await transformImageBlock(block.content[j]);
571
+ }
572
+ continue;
573
+ }
574
+ message.content[i] = await transformImageBlock(block);
575
+ }
576
+ }
577
+ }
578
+
579
+ // =============================================================================
580
+ // Payload mutation pipeline
581
+ // =============================================================================
582
+ // Applies all Kimi-specific mutations to a provider payload in place.
583
+ // Pure given its context: no process.env / fs / network access of its own —
584
+ // every side effect enters via ctx.upload or pre-read values in ctx.
585
+ // This makes the five steps below testable with fixture payloads.
586
+
587
+ async function applyKimiPayloadMutations(
588
+ payload: JsonRecord,
589
+ ctx: KimiPayloadContext,
590
+ ): Promise<void> {
591
+ // 1. Map unsupported roles: Kimi does not recognize "developer" (OpenAI-specific).
592
+ if (Array.isArray(payload.messages)) {
593
+ payload.messages = payload.messages.map((msg) =>
594
+ isRecord(msg) && msg.role === "developer" ? { ...msg, role: "system" } : msg,
595
+ );
596
+ }
597
+
598
+ // 2. File upload dispatch (protocol-specific).
599
+ if (ctx.upload) {
600
+ if (ctx.api === "openai-completions") {
601
+ await transformOpenAIPayloadFiles(payload, ctx.upload);
602
+ } else if (ctx.api === "anthropic-messages") {
603
+ await transformAnthropicPayloadFiles(payload, ctx.upload);
604
+ }
605
+ }
606
+
607
+ // 3. prompt_cache_key injection. Respect any key already on the payload,
608
+ // otherwise fall back to the caller-provided cacheKey (sessionId or
609
+ // explicit options.prompt_cache_key override). Skipped entirely when
610
+ // cacheRetention is "none" (via options.cacheRetention or
611
+ // PI_CACHE_RETENTION) so callers can truly disable caching — otherwise
612
+ // Kimi's native session cache would still fire even if pi-ai's
613
+ // Anthropic-style cache_control markers are omitted.
614
+ if (ctx.cacheRetention !== "none") {
615
+ const existing = payload.prompt_cache_key;
616
+ const resolved = (typeof existing === "string" && existing) || ctx.cacheKey;
617
+ if (resolved) payload.prompt_cache_key = resolved;
618
+ }
619
+
620
+ // 4. Env-level hyperparameter overrides (pre-parsed into numbers by caller).
621
+ const { temperature, topP, maxTokens } = ctx.envOverrides;
622
+ if (temperature !== undefined) payload.temperature = temperature;
623
+ if (topP !== undefined) payload.top_p = topP;
624
+ if (maxTokens !== undefined) payload.max_tokens = maxTokens;
625
+
626
+ // 5. Reasoning effort mapping.
627
+ if (ctx.reasoning) {
628
+ const mapped = mapThinkingLevel(ctx.reasoning);
629
+ if (mapped) {
630
+ payload.reasoning_effort = mapped.effort;
631
+ const extraBody = isRecord(payload.extra_body) ? payload.extra_body : {};
632
+ extraBody.thinking = { type: mapped.enabled ? "enabled" : "disabled" };
633
+ payload.extra_body = extraBody;
634
+ }
635
+ }
636
+ }
637
+
638
+ // =============================================================================
639
+ // Event stream filter: suppress Kimi "(Empty response: ...)" text blocks
640
+ // =============================================================================
641
+ // The Kimi API wraps thinking-only responses (no text content) into a text
642
+ // block like: (Empty response: {'content': [{'type': 'thinking', ...}]}).
643
+ // This leaks internal state to the user. We buffer text_start/text_delta
644
+ // events and drop the whole block if text_end starts with the marker.
645
+ // Pure async generator — no closure dependencies, testable with synthetic
646
+ // event arrays.
647
+
648
+ async function* filterEmptyResponseStream(
649
+ upstream: AsyncIterable<AssistantMessageEvent>,
650
+ ): AsyncIterable<AssistantMessageEvent> {
651
+ const suppressedIndices = new Set<number>();
652
+ let textBuffer: AssistantMessageEvent[] = [];
653
+ let bufferingIndex: number | null = null;
654
+
655
+ for await (const event of upstream) {
656
+ // Start buffering when a new text block begins.
657
+ if (event.type === "text_start") {
658
+ bufferingIndex = event.contentIndex;
659
+ textBuffer = [event];
660
+ continue;
661
+ }
662
+
663
+ // Accumulate text deltas + detect the empty-response marker on text_end.
664
+ if (
665
+ bufferingIndex !== null &&
666
+ "contentIndex" in event &&
667
+ event.contentIndex === bufferingIndex
668
+ ) {
669
+ if (event.type === "text_delta") {
670
+ textBuffer.push(event);
671
+ continue;
672
+ }
673
+ if (event.type === "text_end") {
674
+ if (event.content.startsWith(EMPTY_RESPONSE_PREFIX)) {
675
+ // Suppress entire text block. Do NOT splice the message content
676
+ // array: it is a shared reference into session state, and mutating
677
+ // it would shift subsequent contentIndex values, corrupting the
678
+ // stream.
679
+ suppressedIndices.add(bufferingIndex);
680
+ } else {
681
+ // Legitimate text block — flush buffered events + end event.
682
+ for (const buffered of textBuffer) yield buffered;
683
+ yield event;
684
+ }
685
+ textBuffer = [];
686
+ bufferingIndex = null;
687
+ continue;
688
+ }
689
+ }
690
+
691
+ // Skip any event targeting an already-suppressed content index.
692
+ if ("contentIndex" in event && suppressedIndices.has(event.contentIndex)) {
693
+ continue;
694
+ }
695
+
696
+ // Clean suppressed blocks out of the final message.
697
+ if (event.type === "done" && suppressedIndices.size > 0) {
698
+ event.message.content = event.message.content.filter(
699
+ (block) =>
700
+ !(
701
+ block.type === "text" &&
702
+ typeof block.text === "string" &&
703
+ block.text.startsWith(EMPTY_RESPONSE_PREFIX)
704
+ ),
705
+ );
706
+ }
707
+
708
+ yield event;
709
+ }
710
+ }
711
+
712
+ // =============================================================================
713
+ // Stream wrapper: orchestrates payload mutation + event filter
714
+ // =============================================================================
715
+ // Reads every side-effect source (process.env, options, apiKey) at the top
716
+ // and hands a plain KimiPayloadContext to applyKimiPayloadMutations. The only
717
+ // thing this function itself "does" is wire SDK streaming + filter + error
718
+ // fallback; the actual logic lives in the pure units above.
339
719
 
340
720
  function streamSimpleKimi(
341
- model: Model<"anthropic-messages">,
342
- context: Context,
343
- options?: SimpleStreamOptions,
721
+ model: Model<"anthropic-messages" | "openai-completions">,
722
+ context: Context,
723
+ options?: SimpleStreamOptions,
344
724
  ): AssistantMessageEventStream {
345
- const upstream = streamSimpleAnthropic(model, context, options);
346
- const filtered = new AssistantMessageEventStream();
347
-
348
- // Buffer text block events so we can suppress the entire block if it
349
- // turns out to be a Kimi "(Empty response: ...)" wrapper.
350
- const suppressedIndices = new Set<number>();
351
- let textBuffer: AssistantMessageEvent[] = [];
352
- let bufferingIndex: number | null = null;
353
-
354
- void (async () => {
355
- try {
356
- for await (const event of upstream) {
357
- // Start buffering when a new text block begins
358
- if (event.type === "text_start") {
359
- bufferingIndex = event.contentIndex;
360
- textBuffer = [event];
361
- continue;
362
- }
363
-
364
- // Accumulate text deltas while buffering
365
- if (bufferingIndex !== null && "contentIndex" in event && event.contentIndex === bufferingIndex) {
366
- if (event.type === "text_delta") {
367
- textBuffer.push(event);
368
- continue;
369
- }
370
- if (event.type === "text_end") {
371
- if (event.content.startsWith(EMPTY_RESPONSE_PREFIX)) {
372
- // Suppress entire text block — discard buffered events.
373
- // Do NOT splice content array: it is a shared reference
374
- // into session state, and mutating it shifts subsequent
375
- // contentIndex values, corrupting the stream.
376
- suppressedIndices.add(bufferingIndex);
377
- } else {
378
- // Legitimate text block — flush buffer + end event
379
- for (const buffered of textBuffer) filtered.push(buffered);
380
- filtered.push(event);
381
- }
382
- textBuffer = [];
383
- bufferingIndex = null;
384
- continue;
385
- }
386
- }
387
-
388
- // Skip events for already-suppressed indices
389
- if ("contentIndex" in event && suppressedIndices.has(event.contentIndex)) {
390
- continue;
391
- }
392
-
393
- // Clean suppressed blocks from the final message
394
- if (event.type === "done" && suppressedIndices.size > 0) {
395
- event.message.content = event.message.content.filter(
396
- (block) => !(block.type === "text" && typeof block.text === "string" && block.text.startsWith(EMPTY_RESPONSE_PREFIX)),
397
- );
398
- }
399
-
400
- filtered.push(event);
401
- }
402
- } catch (err) {
403
- filtered.push({
404
- type: "error",
405
- reason: "error",
406
- error: { content: [], stopReason: "error", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 } },
407
- } as AssistantMessageEvent & { type: "error" });
408
- }
409
- })();
410
-
411
- return filtered;
725
+ const filtered = new AssistantMessageEventStream();
726
+ const apiKey = options?.apiKey || process.env.KIMI_API_KEY || "";
727
+
728
+ const cacheKeyOverride = (
729
+ options as (SimpleStreamOptions & { prompt_cache_key?: unknown }) | undefined
730
+ )?.prompt_cache_key;
731
+ const cacheKey = (typeof cacheKeyOverride === "string" && cacheKeyOverride) || options?.sessionId;
732
+ const cacheRetention = resolveCacheRetention(options?.cacheRetention);
733
+ const envOverrides = readEnvOverrides();
734
+ const upload: Uploader | undefined = apiKey
735
+ ? (mimeType, data) => uploadKimiFile(apiKey, mimeType, data)
736
+ : undefined;
737
+
738
+ const originalOnPayload = options?.onPayload;
739
+ const patchedOptions: SimpleStreamOptions = {
740
+ ...options,
741
+ onPayload: async (payload, modelData) => {
742
+ let nextPayload: unknown = payload;
743
+
744
+ if (isRecord(nextPayload)) {
745
+ await applyKimiPayloadMutations(nextPayload, {
746
+ api: model.api,
747
+ upload,
748
+ cacheKey,
749
+ cacheRetention,
750
+ reasoning: options?.reasoning,
751
+ envOverrides,
752
+ });
753
+ }
754
+
755
+ if (originalOnPayload) {
756
+ const res = await originalOnPayload(nextPayload, modelData);
757
+ if (res !== undefined) nextPayload = res;
758
+ }
759
+
760
+ return nextPayload;
761
+ },
762
+ };
763
+
764
+ void (async () => {
765
+ const upstream =
766
+ model.api === "openai-completions"
767
+ ? streamSimpleOpenAICompletions(
768
+ model as Model<"openai-completions">,
769
+ context,
770
+ patchedOptions,
771
+ )
772
+ : streamSimpleAnthropic(model as Model<"anthropic-messages">, context, patchedOptions);
773
+
774
+ try {
775
+ for await (const event of filterEmptyResponseStream(upstream)) {
776
+ filtered.push(event);
777
+ }
778
+ } catch (err) {
779
+ console.error("[kimi-coding] stream error:", err);
780
+ filtered.push({
781
+ type: "error",
782
+ reason: "error",
783
+ error: {
784
+ content: [],
785
+ stopReason: "error",
786
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 },
787
+ },
788
+ } as AssistantMessageEvent & { type: "error" });
789
+ }
790
+ })();
791
+
792
+ return filtered;
412
793
  }
413
794
 
414
795
  // =============================================================================
@@ -416,49 +797,49 @@ function streamSimpleKimi(
416
797
  // =============================================================================
417
798
 
418
799
  export default function (pi: ExtensionAPI) {
419
- pi.registerProvider("kimi-coding", {
420
- baseUrl: getBaseUrl(),
421
- apiKey: "KIMI_API_KEY",
422
- api: "anthropic-messages",
423
- streamSimple: streamSimpleKimi,
424
-
425
- headers: getCommonHeaders(),
426
-
427
- models: [
428
- {
429
- id: "kimi-code",
430
- name: "Kimi Code (powered by kimi-k2.5)",
431
- reasoning: true,
432
- input: ["text", "image"],
433
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
434
- contextWindow: 262144,
435
- maxTokens: 32768,
436
- },
437
- {
438
- id: "kimi-k2.5",
439
- name: "Kimi K2.5",
440
- reasoning: true,
441
- input: ["text", "image"],
442
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
443
- contextWindow: 262144,
444
- maxTokens: 32768,
445
- },
446
- {
447
- id: "kimi-k2-thinking-turbo",
448
- name: "Kimi K2 Thinking Turbo",
449
- reasoning: true,
450
- input: ["text"],
451
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
452
- contextWindow: 262144,
453
- maxTokens: 32768,
454
- },
455
- ],
456
-
457
- oauth: {
458
- name: "Kimi Code (OAuth)",
459
- login: loginKimiCode,
460
- refreshToken: refreshKimiCodeToken,
461
- getApiKey: (cred) => cred.access,
462
- },
463
- });
800
+ pi.registerProvider("kimi-coding", {
801
+ baseUrl: getBaseUrl(),
802
+ apiKey: "KIMI_API_KEY",
803
+ api: PROTOCOL,
804
+ streamSimple: streamSimpleKimi,
805
+
806
+ headers: getCommonHeaders(),
807
+
808
+ models: [
809
+ {
810
+ id: "kimi-code",
811
+ name: "Kimi Code (powered by kimi-k2.5)",
812
+ reasoning: true,
813
+ input: ["text", "image"],
814
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
815
+ contextWindow: 262144,
816
+ maxTokens: 32000,
817
+ },
818
+ {
819
+ id: "kimi-k2.5",
820
+ name: "Kimi K2.5",
821
+ reasoning: true,
822
+ input: ["text", "image"],
823
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
824
+ contextWindow: 262144,
825
+ maxTokens: 32000,
826
+ },
827
+ {
828
+ id: "kimi-k2-thinking-turbo",
829
+ name: "Kimi K2 Thinking Turbo",
830
+ reasoning: true,
831
+ input: ["text"],
832
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
833
+ contextWindow: 262144,
834
+ maxTokens: 32000,
835
+ },
836
+ ],
837
+
838
+ oauth: {
839
+ name: "Kimi Code (OAuth)",
840
+ login: loginKimiCode,
841
+ refreshToken: refreshKimiCodeToken,
842
+ getApiKey: (cred) => cred.access,
843
+ },
844
+ });
464
845
  }