pi-provider-kimi-code 0.1.7 → 0.1.9

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 +86 -7
  2. package/index.ts +570 -314
  3. package/package.json +20 -11
package/README.md CHANGED
@@ -38,21 +38,100 @@ KIMI_API_KEY=sk-... pi
38
38
 
39
39
  ## Models
40
40
 
41
- | ID | Name | Reasoning | Input | Context | Max Output |
42
- |--------------------|------------------|-----------|-------------|----------|------------|
43
- | `kimi-k2-thinking` | Kimi K2 Thinking | yes | text | 262 144 | 32 768 |
44
- | `k2p5` | Kimi K2.5 | yes | text, image | 262 144 | 32 768 |
41
+ | ID | Name | Reasoning | Input | Context | Max Output |
42
+ | ------------------------ | -------------------------------- | --------- | ----------- | ------- | ---------- |
43
+ | `kimi-code` | Kimi Code (powered by kimi-k2.5) | yes | text, image | 262 144 | 32 000 |
44
+ | `kimi-k2.5` | Kimi K2.5 | yes | text, image | 262 144 | 32 000 |
45
+ | `kimi-k2-thinking-turbo` | Kimi K2 Thinking Turbo | yes | text | 262 144 | 32 000 |
45
46
 
46
47
  Select a model inside pi:
47
48
 
48
49
  ```
49
- /model kimi-coding/kimi-k2-thinking
50
+ /model kimi-coding/kimi-code
50
51
  ```
51
52
 
53
+ ## Environment Overrides
54
+
55
+ - `KIMI_CODE_BASE_URL` — override the default API base URL (default depends on protocol; see below)
56
+ - `KIMI_CODE_OAUTH_HOST` — override the OAuth host
57
+ - `KIMI_OAUTH_HOST` — fallback OAuth host override for compatibility
58
+ - `KIMI_CODE_PROTOCOL` — choose protocol mode:
59
+ - unset / `anthropic` → `anthropic-messages` via `/coding/v1/messages`
60
+ - `openai` → `openai-completions` via `/coding/v1/chat/completions`
61
+ - `KIMI_MODEL_TEMPERATURE` — force temperature on outbound requests
62
+ - `KIMI_MODEL_TOP_P` — force top-p on outbound requests
63
+ - `KIMI_MODEL_MAX_TOKENS` — force max tokens on outbound requests
64
+ - `KIMI_CODE_DEBUG=1` — print provider-side debug logs (request metadata, file upload logs)
65
+
66
+ ## Prompt Caching
67
+
68
+ Kimi Code API requires a `prompt_cache_key` field at the payload root for prompt caching. The pi framework separately injects Anthropic-style `cache_control: { type: "ephemeral" }` markers. This extension bridges both:
69
+
70
+ 1. **Session key injection**: Extracts your current pi `sessionId` and injects it as `prompt_cache_key` on every request.
71
+ 2. **TTL**: Approximately **5-10 minutes**. Consistently hits at 300s, borderline at 600s, misses at 900s. Measure in your environment with `KIMI_E2E_ONLY_CACHE=1` (see [E2E test script](#end-to-end-test-script)).
72
+ 3. **Manual override**: Set `prompt_cache_key` explicitly in the payload or options to override the automatic session-based key.
73
+
74
+ Cache usage is easiest to verify on the Anthropic-compatible endpoint, which returns `cache_read_input_tokens` / `cache_creation_input_tokens` fields.
75
+
76
+ ## OpenAI Compatibility Mode
77
+
78
+ Set `KIMI_CODE_PROTOCOL=openai` to switch the provider to Kimi Coding's OpenAI-compatible chat completions endpoint. Internally this extension uses `openai-completions` (not `openai-responses`) and applies Kimi-specific compatibility overrides:
79
+
80
+ - Maps `developer` role to `system` (Kimi does not recognize the OpenAI `developer` role)
81
+ - Disables `store`
82
+ - Uses `max_tokens`
83
+ - Sends `KimiCLI/*` + `X-Msh-*` agent headers required by the Coding endpoint
84
+
85
+ ## End-to-End Test Script
86
+
87
+ This repo includes a tracked smoke-test script:
88
+
89
+ ```bash
90
+ KIMI_API_KEY=sk-... ./test_e2e.sh
91
+ ```
92
+
93
+ By default it explicitly targets `--model kimi-coding/kimi-code`, disables extension discovery with `-ne`, and turns on provider debug logging.
94
+
95
+ What it does:
96
+
97
+ 1. Runs a `pi -ne -e <extension-dir>` smoke test in Anthropic mode.
98
+ 2. Runs the same smoke test in OpenAI mode.
99
+ 3. Runs a high-thinking smoke test (saves full JSONL, extracts thinking + answer + usage).
100
+ 4. Runs a prompt-cache TTL probe and prints a conclusion on observed cache lifetime.
101
+ 5. Uploads a watermarked image to `/files`, then asks the model to read back the watermark via `ms://` reference.
102
+ 6. Sends a `prompt_cache_key` request directly to verify cache key injection.
103
+
104
+ Useful environment variables for the test script:
105
+
106
+ | Variable | Default | Description |
107
+ | -------------------------- | ----------------------- | --------------------------------------- |
108
+ | `KIMI_API_KEY` | (required) | API key (or pass as positional arg) |
109
+ | `KIMI_CODE_DEBUG` | `1` | Provider debug logs |
110
+ | `KIMI_E2E_VERBOSE` | `1` | Command and environment diagnostics |
111
+ | `KIMI_E2E_MODEL` | `kimi-coding/kimi-code` | Model for `pi` smoke tests |
112
+ | `KIMI_E2E_CACHE_INTERVALS` | `60,300` | Absolute seconds from warmup per probe |
113
+ | `KIMI_E2E_CACHE_KEY` | auto | Override cache key for TTL probe |
114
+ | `KIMI_E2E_CACHE_REPEAT` | `2000` | Long-text repeat count for cache warmup |
115
+ | `KIMI_E2E_SKIP_CACHE` | `0` | Set `1` to skip the cache TTL phase |
116
+ | `KIMI_E2E_ONLY_CACHE` | `0` | Set `1` to run only the cache TTL test |
117
+
118
+ ### Proxy / networking note
119
+
120
+ If `curl` can reach Kimi but `pi` reports `fetch failed`, check your `http_proxy` / `https_proxy` / `all_proxy` environment. `pi` runs on Node's `fetch` / undici stack, which may behave differently from `curl`; the test script prints the effective proxy-related environment for easier debugging.
121
+
52
122
  ## How It Works
53
123
 
54
- - Registers provider `kimi-coding` with base URL `https://api.kimi.com/coding`
55
- - Uses `api: "anthropic-messages"` — Kimi's API is wire-compatible with the Anthropic Messages format, so no custom streaming logic is needed
124
+ - Registers provider `kimi-coding` with Kimi's API endpoint
125
+ - Supports two protocol modes:
126
+ - `anthropic-messages` (default)
127
+ - `openai-completions` when `KIMI_CODE_PROTOCOL=openai`
128
+ - Adds a small `streamSimple` wrapper to suppress Kimi's leaked `(Empty response: ...)` placeholder blocks
129
+ - Injects `prompt_cache_key` automatically so Kimi's cache works with pi sessions
130
+ - Maps pi thinking levels to Kimi-native `reasoning_effort` + `extra_body.thinking`
131
+ - Pre-uploads large image / video attachments to Kimi Files API (returns `ms://` references)
132
+ - Maps `developer` role to `system` for OpenAI protocol compatibility
133
+ - Sends the same `KimiCLI/1.30.0` + `X-Msh-*` headers as current `kimi-cli`
134
+ - Persists a stable device ID at `~/.pi/providers/kimi-coding/device_id`
56
135
  - OAuth uses [RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628) device authorization grant against `https://auth.kimi.com`
57
136
  - Zero dependencies — types from `@mariozechner/pi-ai` and `@mariozechner/pi-coding-agent` are provided by the pi runtime
58
137
  - Zero build step — pi loads TypeScript directly via jiti
package/index.ts CHANGED
@@ -9,10 +9,24 @@
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";
14
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
15
  import os from "node:os";
14
- import type { OAuthCredentials, OAuthLoginCallbacks, AssistantMessageEvent, Context, Model, SimpleStreamOptions } from "@mariozechner/pi-ai";
15
- import { streamSimpleAnthropic, AssistantMessageEventStream } from "@mariozechner/pi-ai";
16
+ import { dirname, join } from "node:path";
17
+ import type {
18
+ OAuthCredentials,
19
+ OAuthLoginCallbacks,
20
+ AssistantMessageEvent,
21
+ Context,
22
+ Model,
23
+ SimpleStreamOptions,
24
+ } from "@mariozechner/pi-ai";
25
+ import {
26
+ streamSimpleAnthropic,
27
+ streamSimpleOpenAICompletions,
28
+ AssistantMessageEventStream,
29
+ } from "@mariozechner/pi-ai";
16
30
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
17
31
 
18
32
  // =============================================================================
@@ -20,52 +34,131 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
20
34
  // =============================================================================
21
35
 
22
36
  const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
23
- const OAUTH_HOST = "https://auth.kimi.com";
24
- const KIMI_CLI_USER_AGENT = "KimiCLI/1.0.0";
37
+ const DEFAULT_OAUTH_HOST = "https://auth.kimi.com";
38
+ const PROTOCOL =
39
+ process.env.KIMI_CODE_PROTOCOL === "openai" ? "openai-completions" : "anthropic-messages";
40
+ const DEFAULT_BASE_URL =
41
+ PROTOCOL === "openai-completions"
42
+ ? "https://api.kimi.com/coding/v1"
43
+ : "https://api.kimi.com/coding";
44
+ const KIMI_CLI_VERSION = "1.30.0";
45
+ const KIMI_CLI_USER_AGENT = `KimiCLI/${KIMI_CLI_VERSION}`;
25
46
  const KIMI_PLATFORM = "kimi_cli";
26
- const KIMI_CLI_VERSION = "1.0.0";
47
+ const DEVICE_ID_PATH = join(os.homedir(), ".pi", "providers", "kimi-coding", "device_id");
27
48
 
28
49
  // =============================================================================
29
50
  // Device identification
30
51
  // =============================================================================
31
52
 
53
+ function getOAuthHost(): string {
54
+ const value =
55
+ process.env.KIMI_CODE_OAUTH_HOST || process.env.KIMI_OAUTH_HOST || DEFAULT_OAUTH_HOST;
56
+ return value.trim() || DEFAULT_OAUTH_HOST;
57
+ }
58
+
59
+ function getBaseUrl(): string {
60
+ const value = process.env.KIMI_CODE_BASE_URL || DEFAULT_BASE_URL;
61
+ return value.trim() || DEFAULT_BASE_URL;
62
+ }
63
+
32
64
  function createDeviceId(): string {
33
- return randomBytes(16).toString("hex");
65
+ return randomBytes(16).toString("hex");
66
+ }
67
+
68
+ function ensurePrivateFile(path: string): void {
69
+ try {
70
+ chmodSync(path, 0o600);
71
+ } catch {
72
+ // Ignore chmod failures on platforms/filesystems that do not support it.
73
+ }
74
+ }
75
+
76
+ function readPersistedDeviceId(): string | null {
77
+ try {
78
+ if (!existsSync(DEVICE_ID_PATH)) return null;
79
+ const deviceId = readFileSync(DEVICE_ID_PATH, "utf8").trim();
80
+ return deviceId || null;
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ function persistDeviceId(deviceId: string): void {
87
+ try {
88
+ mkdirSync(dirname(DEVICE_ID_PATH), { recursive: true });
89
+ writeFileSync(DEVICE_ID_PATH, deviceId, "utf8");
90
+ ensurePrivateFile(DEVICE_ID_PATH);
91
+ } catch {
92
+ // Ignore persistence failures and fall back to the in-memory device id.
93
+ }
94
+ }
95
+
96
+ function getMacOSVersion(): string {
97
+ try {
98
+ return execSync("sw_vers -productVersion", { encoding: "utf8" }).trim();
99
+ } catch {
100
+ return os.release();
101
+ }
34
102
  }
35
103
 
36
104
  function getDeviceModel(): string {
37
- const platform = process.platform;
38
- const arch = os.machine() || process.arch;
39
- const release = os.release();
40
- if (platform === "darwin") {
41
- return release && arch ? `macOS ${release} ${arch}` : `macOS ${arch}`;
42
- }
43
- if (platform === "win32") {
44
- return release && arch ? `Windows ${release} ${arch}` : `Windows ${arch}`;
45
- }
46
- return release && arch ? `${platform} ${release} ${arch}` : `${platform} ${arch}`;
105
+ const platform = process.platform;
106
+ const arch = os.machine() || process.arch;
107
+ if (platform === "darwin") {
108
+ const version = getMacOSVersion();
109
+ return version && arch ? `macOS ${version} ${arch}` : `macOS ${arch}`;
110
+ }
111
+ if (platform === "win32") {
112
+ const release = os.release();
113
+ return release && arch ? `Windows ${release} ${arch}` : `Windows ${arch}`;
114
+ }
115
+ const release = os.release();
116
+ return release && arch ? `${platform} ${release} ${arch}` : `${platform} ${arch}`;
117
+ }
118
+
119
+ function asciiHeaderValue(value: string, fallback = "unknown"): string {
120
+ const trimmed = value.trim();
121
+ /* oxlint-disable-next-line no-control-regex */
122
+ if (/^[\x00-\x7F]*$/.test(trimmed)) {
123
+ return trimmed;
124
+ }
125
+ /* oxlint-disable-next-line no-control-regex */
126
+ const sanitized = trimmed.replace(/[^\x00-\x7F]/g, "").trim();
127
+ return sanitized || fallback;
47
128
  }
48
129
 
49
130
  const DEVICE_MODEL = getDeviceModel();
50
131
  let DEVICE_ID: string | null = null;
51
132
 
52
133
  function getStableDeviceId(): string {
53
- if (!DEVICE_ID) {
54
- DEVICE_ID = createDeviceId();
55
- }
56
- return DEVICE_ID;
134
+ if (DEVICE_ID) {
135
+ return DEVICE_ID;
136
+ }
137
+
138
+ const persisted = readPersistedDeviceId();
139
+ if (persisted) {
140
+ DEVICE_ID = persisted;
141
+ return DEVICE_ID;
142
+ }
143
+
144
+ DEVICE_ID = createDeviceId();
145
+ persistDeviceId(DEVICE_ID);
146
+ return DEVICE_ID;
57
147
  }
58
148
 
59
149
  function getCommonHeaders(): Record<string, string> {
60
- return {
61
- "User-Agent": KIMI_CLI_USER_AGENT,
62
- "X-Msh-Platform": KIMI_PLATFORM,
63
- "X-Msh-Version": KIMI_CLI_VERSION,
64
- "X-Msh-Device-Name": os.hostname(),
65
- "X-Msh-Device-Model": DEVICE_MODEL,
66
- "X-Msh-Os-Version": os.release(),
67
- "X-Msh-Device-Id": getStableDeviceId(),
68
- };
150
+ const headers = {
151
+ "User-Agent": KIMI_CLI_USER_AGENT,
152
+ "X-Msh-Platform": KIMI_PLATFORM,
153
+ "X-Msh-Version": KIMI_CLI_VERSION,
154
+ "X-Msh-Device-Name": os.hostname(),
155
+ "X-Msh-Device-Model": DEVICE_MODEL,
156
+ "X-Msh-Os-Version": os.release(),
157
+ "X-Msh-Device-Id": getStableDeviceId(),
158
+ };
159
+ return Object.fromEntries(
160
+ Object.entries(headers).map(([key, value]) => [key, asciiHeaderValue(value)]),
161
+ ) as Record<string, string>;
69
162
  }
70
163
 
71
164
  // =============================================================================
@@ -73,127 +166,127 @@ function getCommonHeaders(): Record<string, string> {
73
166
  // =============================================================================
74
167
 
75
168
  interface DeviceAuthorization {
76
- user_code: string;
77
- device_code: string;
78
- verification_uri: string;
79
- verification_uri_complete: string;
80
- expires_in: number;
81
- interval: number;
169
+ user_code: string;
170
+ device_code: string;
171
+ verification_uri: string;
172
+ verification_uri_complete: string;
173
+ expires_in: number;
174
+ interval: number;
82
175
  }
83
176
 
84
177
  interface TokenResponse {
85
- access_token: string;
86
- refresh_token: string;
87
- expires_in: number;
88
- scope: string;
89
- token_type: string;
178
+ access_token: string;
179
+ refresh_token: string;
180
+ expires_in: number;
181
+ scope: string;
182
+ token_type: string;
90
183
  }
91
184
 
92
185
  async function requestDeviceAuthorization(): Promise<DeviceAuthorization> {
93
- const response = await fetch(`${OAUTH_HOST}/api/oauth/device_authorization`, {
94
- method: "POST",
95
- headers: {
96
- "Content-Type": "application/x-www-form-urlencoded",
97
- ...getCommonHeaders(),
98
- },
99
- body: new URLSearchParams({
100
- client_id: CLIENT_ID,
101
- }),
102
- });
103
-
104
- if (!response.ok) {
105
- const text = await response.text().catch(() => "");
106
- throw new Error(`Device authorization failed: ${response.status} ${text}`);
107
- }
108
-
109
- const data = (await response.json()) as {
110
- user_code?: string;
111
- device_code?: string;
112
- verification_uri?: string;
113
- verification_uri_complete?: string;
114
- expires_in?: number;
115
- interval?: number;
116
- };
117
-
118
- if (!data.user_code || !data.device_code || !data.verification_uri_complete) {
119
- throw new Error("Invalid device authorization response");
120
- }
121
-
122
- return {
123
- user_code: data.user_code,
124
- device_code: data.device_code,
125
- verification_uri: data.verification_uri || "",
126
- verification_uri_complete: data.verification_uri_complete,
127
- expires_in: data.expires_in || 1800,
128
- interval: data.interval || 5,
129
- };
186
+ const response = await fetch(`${getOAuthHost()}/api/oauth/device_authorization`, {
187
+ method: "POST",
188
+ headers: {
189
+ "Content-Type": "application/x-www-form-urlencoded",
190
+ ...getCommonHeaders(),
191
+ },
192
+ body: new URLSearchParams({
193
+ client_id: CLIENT_ID,
194
+ }),
195
+ });
196
+
197
+ if (!response.ok) {
198
+ const text = await response.text().catch(() => "");
199
+ throw new Error(`Device authorization failed: ${response.status} ${text}`);
200
+ }
201
+
202
+ const data = (await response.json()) as {
203
+ user_code?: string;
204
+ device_code?: string;
205
+ verification_uri?: string;
206
+ verification_uri_complete?: string;
207
+ expires_in?: number;
208
+ interval?: number;
209
+ };
210
+
211
+ if (!data.user_code || !data.device_code || !data.verification_uri_complete) {
212
+ throw new Error("Invalid device authorization response");
213
+ }
214
+
215
+ return {
216
+ user_code: data.user_code,
217
+ device_code: data.device_code,
218
+ verification_uri: data.verification_uri || "",
219
+ verification_uri_complete: data.verification_uri_complete,
220
+ expires_in: data.expires_in || 1800,
221
+ interval: data.interval || 5,
222
+ };
130
223
  }
131
224
 
132
225
  async function requestDeviceToken(auth: DeviceAuthorization): Promise<TokenResponse | null> {
133
- const response = await fetch(`${OAUTH_HOST}/api/oauth/token`, {
134
- method: "POST",
135
- headers: {
136
- "Content-Type": "application/x-www-form-urlencoded",
137
- ...getCommonHeaders(),
138
- },
139
- body: new URLSearchParams({
140
- client_id: CLIENT_ID,
141
- device_code: auth.device_code,
142
- grant_type: "urn:ietf:params:oauth:grant-type:device_code",
143
- }),
144
- });
145
-
146
- if (response.status === 200) {
147
- const data = (await response.json()) as TokenResponse;
148
- if (data.access_token && data.refresh_token) {
149
- return data;
150
- }
151
- throw new Error("Token response missing required fields");
152
- }
153
-
154
- if (response.status === 400) {
155
- const data = (await response.json()) as { error?: string; error_description?: string };
156
- if (data.error === "authorization_pending") {
157
- return null; // Still waiting for user
158
- }
159
- if (data.error === "expired_token") {
160
- throw new Error("expired_token");
161
- }
162
- throw new Error(`Token request failed: ${data.error_description || data.error || "unknown"}`);
163
- }
164
-
165
- const text = await response.text().catch(() => "");
166
- throw new Error(`Token request failed: ${response.status} ${text}`);
226
+ const response = await fetch(`${getOAuthHost()}/api/oauth/token`, {
227
+ method: "POST",
228
+ headers: {
229
+ "Content-Type": "application/x-www-form-urlencoded",
230
+ ...getCommonHeaders(),
231
+ },
232
+ body: new URLSearchParams({
233
+ client_id: CLIENT_ID,
234
+ device_code: auth.device_code,
235
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
236
+ }),
237
+ });
238
+
239
+ if (response.status === 200) {
240
+ const data = (await response.json()) as TokenResponse;
241
+ if (data.access_token && data.refresh_token) {
242
+ return data;
243
+ }
244
+ throw new Error("Token response missing required fields");
245
+ }
246
+
247
+ if (response.status === 400) {
248
+ const data = (await response.json()) as { error?: string; error_description?: string };
249
+ if (data.error === "authorization_pending") {
250
+ return null;
251
+ }
252
+ if (data.error === "expired_token") {
253
+ throw new Error("expired_token");
254
+ }
255
+ throw new Error(`Token request failed: ${data.error_description || data.error || "unknown"}`);
256
+ }
257
+
258
+ const text = await response.text().catch(() => "");
259
+ throw new Error(`Token request failed: ${response.status} ${text}`);
167
260
  }
168
261
 
169
262
  async function refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
170
- const response = await fetch(`${OAUTH_HOST}/api/oauth/token`, {
171
- method: "POST",
172
- headers: {
173
- "Content-Type": "application/x-www-form-urlencoded",
174
- ...getCommonHeaders(),
175
- },
176
- body: new URLSearchParams({
177
- client_id: CLIENT_ID,
178
- grant_type: "refresh_token",
179
- refresh_token: refreshToken,
180
- }),
181
- });
182
-
183
- if (!response.ok) {
184
- const text = await response.text().catch(() => "");
185
- if (response.status === 401 || response.status === 403) {
186
- throw new Error(`Token refresh unauthorized: ${text}`);
187
- }
188
- throw new Error(`Token refresh failed: ${response.status} ${text}`);
189
- }
190
-
191
- const data = (await response.json()) as TokenResponse;
192
- if (!data.access_token || !data.refresh_token) {
193
- throw new Error("Token refresh response missing required fields");
194
- }
195
-
196
- return data;
263
+ const response = await fetch(`${getOAuthHost()}/api/oauth/token`, {
264
+ method: "POST",
265
+ headers: {
266
+ "Content-Type": "application/x-www-form-urlencoded",
267
+ ...getCommonHeaders(),
268
+ },
269
+ body: new URLSearchParams({
270
+ client_id: CLIENT_ID,
271
+ grant_type: "refresh_token",
272
+ refresh_token: refreshToken,
273
+ }),
274
+ });
275
+
276
+ if (!response.ok) {
277
+ const text = await response.text().catch(() => "");
278
+ if (response.status === 401 || response.status === 403) {
279
+ throw new Error(`Token refresh unauthorized: ${text}`);
280
+ }
281
+ throw new Error(`Token refresh failed: ${response.status} ${text}`);
282
+ }
283
+
284
+ const data = (await response.json()) as TokenResponse;
285
+ if (!data.access_token || !data.refresh_token) {
286
+ throw new Error("Token refresh response missing required fields");
287
+ }
288
+
289
+ return data;
197
290
  }
198
291
 
199
292
  // =============================================================================
@@ -201,70 +294,70 @@ async function refreshAccessToken(refreshToken: string): Promise<TokenResponse>
201
294
  // =============================================================================
202
295
 
203
296
  async function loginKimiCode(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
204
- // Keep trying until we get a token (handles expired device codes)
205
- while (true) {
206
- const auth = await requestDeviceAuthorization();
207
-
208
- callbacks.onAuth({
209
- url: auth.verification_uri_complete,
210
- instructions: `Please visit the URL to authorize. Your code: ${auth.user_code}`,
211
- });
212
-
213
- const interval = Math.max(auth.interval, 1) * 1000;
214
- const expiresAt = Date.now() + auth.expires_in * 1000;
215
-
216
- let token: TokenResponse | null = null;
217
- let printedWaiting = false;
218
-
219
- while (Date.now() < expiresAt) {
220
- try {
221
- token = await requestDeviceToken(auth);
222
- if (token) break;
223
- } catch (error) {
224
- if (error instanceof Error && error.message === "expired_token") {
225
- // Device code expired, restart the flow
226
- if (callbacks.onProgress) {
227
- callbacks.onProgress("Device code expired, restarting...");
228
- }
229
- break;
230
- }
231
- throw error;
232
- }
233
-
234
- if (!printedWaiting) {
235
- if (callbacks.onProgress) {
236
- callbacks.onProgress("Waiting for authorization...");
237
- }
238
- printedWaiting = true;
239
- }
240
-
241
- // Check for abort
242
- if (callbacks.signal?.aborted) {
243
- throw new Error("Authorization aborted");
244
- }
245
-
246
- await new Promise((resolve) => setTimeout(resolve, interval));
247
- }
248
-
249
- if (token) {
250
- return {
251
- access: token.access_token,
252
- refresh: token.refresh_token,
253
- expires: Date.now() + token.expires_in * 1000,
254
- };
255
- }
256
-
257
- // If we get here without a token, the device code expired - loop will retry
258
- }
297
+ // Keep trying until we get a token (handles expired device codes)
298
+ while (true) {
299
+ const auth = await requestDeviceAuthorization();
300
+
301
+ callbacks.onAuth({
302
+ url: auth.verification_uri_complete,
303
+ instructions: `Please visit the URL to authorize. Your code: ${auth.user_code}`,
304
+ });
305
+
306
+ const interval = Math.max(auth.interval, 1) * 1000;
307
+ const expiresAt = Date.now() + auth.expires_in * 1000;
308
+
309
+ let token: TokenResponse | null = null;
310
+ let printedWaiting = false;
311
+
312
+ while (Date.now() < expiresAt) {
313
+ try {
314
+ token = await requestDeviceToken(auth);
315
+ if (token) break;
316
+ } catch (error) {
317
+ if (error instanceof Error && error.message === "expired_token") {
318
+ // Device code expired, restart the flow
319
+ if (callbacks.onProgress) {
320
+ callbacks.onProgress("Device code expired, restarting...");
321
+ }
322
+ break;
323
+ }
324
+ throw error;
325
+ }
326
+
327
+ if (!printedWaiting) {
328
+ if (callbacks.onProgress) {
329
+ callbacks.onProgress("Waiting for authorization...");
330
+ }
331
+ printedWaiting = true;
332
+ }
333
+
334
+ // Check for abort
335
+ if (callbacks.signal?.aborted) {
336
+ throw new Error("Authorization aborted");
337
+ }
338
+
339
+ await new Promise((resolve) => setTimeout(resolve, interval));
340
+ }
341
+
342
+ if (token) {
343
+ return {
344
+ access: token.access_token,
345
+ refresh: token.refresh_token,
346
+ expires: Date.now() + token.expires_in * 1000,
347
+ };
348
+ }
349
+
350
+ // If we get here without a token, the device code expired - loop will retry
351
+ }
259
352
  }
260
353
 
261
354
  async function refreshKimiCodeToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
262
- const token = await refreshAccessToken(credentials.refresh);
263
- return {
264
- access: token.access_token,
265
- refresh: token.refresh_token,
266
- expires: Date.now() + token.expires_in * 1000,
267
- };
355
+ const token = await refreshAccessToken(credentials.refresh);
356
+ return {
357
+ access: token.access_token,
358
+ refresh: token.refresh_token,
359
+ expires: Date.now() + token.expires_in * 1000,
360
+ };
268
361
  }
269
362
 
270
363
  // =============================================================================
@@ -276,78 +369,241 @@ async function refreshKimiCodeToken(credentials: OAuthCredentials): Promise<OAut
276
369
 
277
370
  const EMPTY_RESPONSE_PREFIX = "(Empty response:";
278
371
 
372
+ function mapThinkingLevel(level?: string): { effort: string | null; enabled: boolean } | undefined {
373
+ if (!level) return undefined;
374
+ if (level === "none" || level === "off") return { effort: null, enabled: false };
375
+ if (level === "minimal" || level === "low") return { effort: "low", enabled: true };
376
+ if (level === "medium") return { effort: "medium", enabled: true };
377
+ if (level === "high" || level === "xhigh") return { effort: "high", enabled: true };
378
+ return undefined;
379
+ }
380
+
381
+ async function transformContextFiles(context: Context, apiKey: string): Promise<Context> {
382
+ const transformedMessages = [];
383
+ for (const message of context.messages) {
384
+ if (message.role === "user" && Array.isArray(message.content)) {
385
+ const newContent = [];
386
+ for (const block of message.content) {
387
+ if (block.type === "image") {
388
+ // If the image is extremely large or if it's actually a video masquerading as an image,
389
+ // upload it to Kimi's /v1/files endpoint.
390
+ // For Kimi, images > 5MB or videos should be uploaded. We'll use 5MB threshold.
391
+ const buffer = Buffer.from(block.data, "base64");
392
+ if (buffer.length > 5 * 1024 * 1024 || block.mimeType.startsWith("video/")) {
393
+ const formData = new FormData();
394
+ const filename = block.mimeType.startsWith("video/") ? "upload.mp4" : "upload.png";
395
+ formData.append("file", new Blob([buffer], { type: block.mimeType }), filename);
396
+ formData.append("purpose", block.mimeType.startsWith("video/") ? "video" : "image");
397
+
398
+ const filesBaseUrl = (
399
+ process.env.KIMI_CODE_BASE_URL || "https://api.kimi.com/coding/v1"
400
+ ).replace(/\/$/, "");
401
+ const uploadUrl = `${filesBaseUrl}/files`;
402
+ if (process.env.KIMI_CODE_DEBUG === "1") {
403
+ console.log(
404
+ `\n[kimi-coding] Uploading ${filename} to ${uploadUrl} (size: ${(buffer.length / 1024 / 1024).toFixed(2)} MB)...`,
405
+ );
406
+ }
407
+ const response = await fetch(uploadUrl, {
408
+ method: "POST",
409
+ headers: {
410
+ Authorization: `Bearer ${apiKey}`,
411
+ ...getCommonHeaders(),
412
+ },
413
+ body: formData,
414
+ });
415
+
416
+ if (response.ok) {
417
+ const fileObj = (await response.json()) as { id?: string; url?: string };
418
+ const fileUrl = `ms://${fileObj.id}`;
419
+ if (process.env.KIMI_CODE_DEBUG === "1") {
420
+ console.log(`[kimi-coding] Upload success. File URL: ${fileUrl}`);
421
+ }
422
+ // Replace the block with Kimi's specific reference
423
+ if (block.mimeType.startsWith("video/")) {
424
+ newContent.push({
425
+ type: "video_url" as any,
426
+ video_url: { url: fileUrl },
427
+ });
428
+ } else {
429
+ newContent.push({
430
+ type: "image_url" as any,
431
+ image_url: { url: fileUrl },
432
+ });
433
+ }
434
+ continue;
435
+ } else {
436
+ console.error("Failed to upload file to Kimi API", await response.text());
437
+ }
438
+ }
439
+ }
440
+ newContent.push(block);
441
+ }
442
+ transformedMessages.push({ ...message, content: newContent });
443
+ } else {
444
+ transformedMessages.push(message);
445
+ }
446
+ }
447
+ return { ...context, messages: transformedMessages };
448
+ }
449
+
279
450
  function streamSimpleKimi(
280
- model: Model<"anthropic-messages">,
281
- context: Context,
282
- options?: SimpleStreamOptions,
451
+ model: Model<"anthropic-messages" | "openai-completions">,
452
+ context: Context,
453
+ options?: SimpleStreamOptions,
283
454
  ): AssistantMessageEventStream {
284
- const upstream = streamSimpleAnthropic(model, context, options);
285
- const filtered = new AssistantMessageEventStream();
286
-
287
- // Buffer text block events so we can suppress the entire block if it
288
- // turns out to be a Kimi "(Empty response: ...)" wrapper.
289
- const suppressedIndices = new Set<number>();
290
- let textBuffer: AssistantMessageEvent[] = [];
291
- let bufferingIndex: number | null = null;
292
-
293
- void (async () => {
294
- try {
295
- for await (const event of upstream) {
296
- // Start buffering when a new text block begins
297
- if (event.type === "text_start") {
298
- bufferingIndex = event.contentIndex;
299
- textBuffer = [event];
300
- continue;
301
- }
302
-
303
- // Accumulate text deltas while buffering
304
- if (bufferingIndex !== null && "contentIndex" in event && event.contentIndex === bufferingIndex) {
305
- if (event.type === "text_delta") {
306
- textBuffer.push(event);
307
- continue;
308
- }
309
- if (event.type === "text_end") {
310
- if (event.content.startsWith(EMPTY_RESPONSE_PREFIX)) {
311
- // Suppress entire text block — discard buffered events.
312
- // Do NOT splice content array: it is a shared reference
313
- // into session state, and mutating it shifts subsequent
314
- // contentIndex values, corrupting the stream.
315
- suppressedIndices.add(bufferingIndex);
316
- } else {
317
- // Legitimate text block — flush buffer + end event
318
- for (const buffered of textBuffer) filtered.push(buffered);
319
- filtered.push(event);
320
- }
321
- textBuffer = [];
322
- bufferingIndex = null;
323
- continue;
324
- }
325
- }
326
-
327
- // Skip events for already-suppressed indices
328
- if ("contentIndex" in event && suppressedIndices.has(event.contentIndex)) {
329
- continue;
330
- }
331
-
332
- // Clean suppressed blocks from the final message
333
- if (event.type === "done" && suppressedIndices.size > 0) {
334
- event.message.content = event.message.content.filter(
335
- (block) => !(block.type === "text" && typeof block.text === "string" && block.text.startsWith(EMPTY_RESPONSE_PREFIX)),
336
- );
337
- }
338
-
339
- filtered.push(event);
340
- }
341
- } catch (err) {
342
- filtered.push({
343
- type: "error",
344
- reason: "error",
345
- error: { content: [], stopReason: "error", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 } },
346
- } as AssistantMessageEvent & { type: "error" });
347
- }
348
- })();
349
-
350
- return filtered;
455
+ // Pre-process context files: upload large images/videos to Kimi API
456
+ const filtered = new AssistantMessageEventStream();
457
+ const apiKey = options?.apiKey || process.env.KIMI_API_KEY || "";
458
+
459
+ void (async () => {
460
+ let finalContext = context;
461
+ try {
462
+ if (apiKey) {
463
+ finalContext = await transformContextFiles(context, apiKey);
464
+ }
465
+ } catch (e) {
466
+ console.error("Error transforming context files", e);
467
+ }
468
+
469
+ // Intercept the payload to inject Kimi's proprietary prompt_cache_key
470
+ // because Kimi's Anthropic compatibility endpoint requires it alongside cache_control.
471
+ const originalOnPayload = options?.onPayload;
472
+ const patchedOptions: SimpleStreamOptions = {
473
+ ...options,
474
+ onPayload: async (payload: any, modelData) => {
475
+ let nextPayload = payload;
476
+ if (originalOnPayload) {
477
+ const res = await originalOnPayload(payload, modelData);
478
+ if (res !== undefined) nextPayload = res;
479
+ }
480
+
481
+ if (nextPayload && typeof nextPayload === "object") {
482
+ // Map unsupported roles: Kimi does not recognize "developer" (OpenAI-specific).
483
+ if (Array.isArray(nextPayload.messages)) {
484
+ nextPayload.messages = nextPayload.messages.map((msg: any) =>
485
+ msg.role === "developer" ? { ...msg, role: "system" } : msg,
486
+ );
487
+ }
488
+
489
+ // Inject prompt_cache_key: allow explicit override, fallback to stable sessionId.
490
+ const cacheKey =
491
+ nextPayload.prompt_cache_key ||
492
+ (options as any)?.prompt_cache_key ||
493
+ options?.sessionId;
494
+ if (cacheKey) {
495
+ nextPayload = { ...nextPayload, prompt_cache_key: cacheKey };
496
+ }
497
+
498
+ // Environment overrides
499
+ const envTemp = process.env.KIMI_MODEL_TEMPERATURE;
500
+ if (envTemp) nextPayload.temperature = parseFloat(envTemp);
501
+
502
+ const envTopP = process.env.KIMI_MODEL_TOP_P;
503
+ if (envTopP) nextPayload.top_p = parseFloat(envTopP);
504
+
505
+ const envMaxTokens = process.env.KIMI_MODEL_MAX_TOKENS;
506
+ if (envMaxTokens) nextPayload.max_tokens = parseInt(envMaxTokens, 10);
507
+
508
+ // Reasoning effort mapping
509
+ const reasoningLevel = (options as any)?.reasoning;
510
+ if (reasoningLevel) {
511
+ const mapped = mapThinkingLevel(reasoningLevel);
512
+ if (mapped) {
513
+ nextPayload.reasoning_effort = mapped.effort;
514
+ nextPayload.extra_body = nextPayload.extra_body || {};
515
+ nextPayload.extra_body.thinking = {
516
+ type: mapped.enabled ? "enabled" : "disabled",
517
+ };
518
+ }
519
+ }
520
+ }
521
+ return nextPayload;
522
+ },
523
+ };
524
+
525
+ const upstream =
526
+ model.api === "openai-completions"
527
+ ? streamSimpleOpenAICompletions(model as any, finalContext, patchedOptions)
528
+ : streamSimpleAnthropic(model as any, finalContext, patchedOptions);
529
+
530
+ // Buffer text block events so we can suppress the entire block if it
531
+ // turns out to be a Kimi "(Empty response: ...)" wrapper.
532
+ const suppressedIndices = new Set<number>();
533
+ let textBuffer: AssistantMessageEvent[] = [];
534
+ let bufferingIndex: number | null = null;
535
+
536
+ try {
537
+ for await (const event of upstream) {
538
+ // Start buffering when a new text block begins
539
+ if (event.type === "text_start") {
540
+ bufferingIndex = event.contentIndex;
541
+ textBuffer = [event];
542
+ continue;
543
+ }
544
+
545
+ // Accumulate text deltas while buffering
546
+ if (
547
+ bufferingIndex !== null &&
548
+ "contentIndex" in event &&
549
+ event.contentIndex === bufferingIndex
550
+ ) {
551
+ if (event.type === "text_delta") {
552
+ textBuffer.push(event);
553
+ continue;
554
+ }
555
+ if (event.type === "text_end") {
556
+ if (event.content.startsWith(EMPTY_RESPONSE_PREFIX)) {
557
+ // Suppress entire text block — discard buffered events.
558
+ // Do NOT splice content array: it is a shared reference
559
+ // into session state, and mutating it shifts subsequent
560
+ // contentIndex values, corrupting the stream.
561
+ suppressedIndices.add(bufferingIndex);
562
+ } else {
563
+ // Legitimate text block — flush buffer + end event
564
+ for (const buffered of textBuffer) filtered.push(buffered);
565
+ filtered.push(event);
566
+ }
567
+ textBuffer = [];
568
+ bufferingIndex = null;
569
+ continue;
570
+ }
571
+ }
572
+
573
+ // Skip events for already-suppressed indices
574
+ if ("contentIndex" in event && suppressedIndices.has(event.contentIndex)) {
575
+ continue;
576
+ }
577
+
578
+ // Clean suppressed blocks from the final message
579
+ if (event.type === "done" && suppressedIndices.size > 0) {
580
+ event.message.content = event.message.content.filter(
581
+ (block) =>
582
+ !(
583
+ block.type === "text" &&
584
+ typeof block.text === "string" &&
585
+ block.text.startsWith(EMPTY_RESPONSE_PREFIX)
586
+ ),
587
+ );
588
+ }
589
+
590
+ filtered.push(event);
591
+ }
592
+ } catch (err) {
593
+ console.error("[kimi-coding] stream error:", err);
594
+ filtered.push({
595
+ type: "error",
596
+ reason: "error",
597
+ error: {
598
+ content: [],
599
+ stopReason: "error",
600
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 },
601
+ },
602
+ } as AssistantMessageEvent & { type: "error" });
603
+ }
604
+ })();
605
+
606
+ return filtered;
351
607
  }
352
608
 
353
609
  // =============================================================================
@@ -355,49 +611,49 @@ function streamSimpleKimi(
355
611
  // =============================================================================
356
612
 
357
613
  export default function (pi: ExtensionAPI) {
358
- pi.registerProvider("kimi-coding", {
359
- baseUrl: "https://api.kimi.com/coding",
360
- apiKey: "KIMI_API_KEY",
361
- api: "anthropic-messages",
362
- streamSimple: streamSimpleKimi,
363
-
364
- headers: getCommonHeaders(),
365
-
366
- models: [
367
- {
368
- id: "kimi-code",
369
- name: "Kimi Code (powered by kimi-k2.5)",
370
- reasoning: true,
371
- input: ["text", "image"],
372
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
373
- contextWindow: 262144,
374
- maxTokens: 32768,
375
- },
376
- {
377
- id: "kimi-k2.5",
378
- name: "Kimi K2.5",
379
- reasoning: true,
380
- input: ["text", "image"],
381
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
382
- contextWindow: 262144,
383
- maxTokens: 32768,
384
- },
385
- {
386
- id: "kimi-k2-thinking-turbo",
387
- name: "Kimi K2 Thinking Turbo",
388
- reasoning: true,
389
- input: ["text"],
390
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
391
- contextWindow: 262144,
392
- maxTokens: 32768,
393
- },
394
- ],
395
-
396
- oauth: {
397
- name: "Kimi Code (OAuth)",
398
- login: loginKimiCode,
399
- refreshToken: refreshKimiCodeToken,
400
- getApiKey: (cred) => cred.access,
401
- },
402
- });
614
+ pi.registerProvider("kimi-coding", {
615
+ baseUrl: getBaseUrl(),
616
+ apiKey: "KIMI_API_KEY",
617
+ api: PROTOCOL,
618
+ streamSimple: streamSimpleKimi,
619
+
620
+ headers: getCommonHeaders(),
621
+
622
+ models: [
623
+ {
624
+ id: "kimi-code",
625
+ name: "Kimi Code (powered by kimi-k2.5)",
626
+ reasoning: true,
627
+ input: ["text", "image"],
628
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
629
+ contextWindow: 262144,
630
+ maxTokens: 32000,
631
+ },
632
+ {
633
+ id: "kimi-k2.5",
634
+ name: "Kimi K2.5",
635
+ reasoning: true,
636
+ input: ["text", "image"],
637
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
638
+ contextWindow: 262144,
639
+ maxTokens: 32000,
640
+ },
641
+ {
642
+ id: "kimi-k2-thinking-turbo",
643
+ name: "Kimi K2 Thinking Turbo",
644
+ reasoning: true,
645
+ input: ["text"],
646
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
647
+ contextWindow: 262144,
648
+ maxTokens: 32000,
649
+ },
650
+ ],
651
+
652
+ oauth: {
653
+ name: "Kimi Code (OAuth)",
654
+ login: loginKimiCode,
655
+ refreshToken: refreshKimiCodeToken,
656
+ getApiKey: (cred) => cred.access,
657
+ },
658
+ });
403
659
  }
package/package.json CHANGED
@@ -1,30 +1,39 @@
1
1
  {
2
2
  "name": "pi-provider-kimi-code",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "pi custom provider extension for Kimi Code models with OAuth device-code login",
5
- "type": "module",
6
- "license": "MIT",
7
- "repository": {
8
- "type": "git",
9
- "url": "https://github.com/Leechael/pi-provider-kimi-code"
10
- },
11
5
  "keywords": [
12
- "pi",
13
- "pi-extension",
6
+ "coding-agent",
14
7
  "kimi",
15
8
  "kimi-code",
16
9
  "llm",
17
- "coding-agent"
10
+ "pi",
11
+ "pi-extension"
18
12
  ],
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/Leechael/pi-provider-kimi-code"
17
+ },
19
18
  "files": [
20
19
  "index.ts",
21
20
  "package.json",
22
21
  "README.md"
23
22
  ],
23
+ "type": "module",
24
24
  "scripts": {
25
25
  "clean": "echo 'nothing to clean'",
26
26
  "build": "echo 'nothing to build'",
27
- "check": "echo 'nothing to check'"
27
+ "check": "echo 'nothing to check'",
28
+ "lint": "oxlint .",
29
+ "format": "oxfmt . --write",
30
+ "format:check": "oxfmt . --check",
31
+ "pre-commit": "prek run"
32
+ },
33
+ "devDependencies": {
34
+ "@j178/prek": "^0.3.8",
35
+ "oxfmt": "^0.44.0",
36
+ "oxlint": "^1.59.0"
28
37
  },
29
38
  "pi": {
30
39
  "extensions": [