pi-provider-kimi-code 0.1.8 → 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 +78 -9
  2. package/index.ts +547 -352
  3. package/package.json +20 -11
package/README.md CHANGED
@@ -38,11 +38,11 @@ KIMI_API_KEY=sk-... pi
38
38
 
39
39
  ## Models
40
40
 
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 768 |
44
- | `kimi-k2.5` | Kimi K2.5 | yes | text, image | 262 144 | 32 768 |
45
- | `kimi-k2-thinking-turbo` | Kimi K2 Thinking Turbo | yes | text | 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 |
46
46
 
47
47
  Select a model inside pi:
48
48
 
@@ -52,16 +52,85 @@ Select a model inside pi:
52
52
 
53
53
  ## Environment Overrides
54
54
 
55
- - `KIMI_CODE_BASE_URL` — override the default API base URL (`https://api.kimi.com/coding`)
55
+ - `KIMI_CODE_BASE_URL` — override the default API base URL (default depends on protocol; see below)
56
56
  - `KIMI_CODE_OAUTH_HOST` — override the OAuth host
57
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.
58
121
 
59
122
  ## How It Works
60
123
 
61
- - Registers provider `kimi-coding` with base URL `https://api.kimi.com/coding`
62
- - Uses `api: "anthropic-messages"` — Kimi's API is wire-compatible with the Anthropic Messages format
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`
63
128
  - Adds a small `streamSimple` wrapper to suppress Kimi's leaked `(Empty response: ...)` placeholder blocks
64
- - Sends the same `KimiCLI/1.28.0` + `X-Msh-*` headers as current `kimi-cli`
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`
65
134
  - Persists a stable device ID at `~/.pi/providers/kimi-coding/device_id`
66
135
  - OAuth uses [RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628) device authorization grant against `https://auth.kimi.com`
67
136
  - Zero dependencies — types from `@mariozechner/pi-ai` and `@mariozechner/pi-coding-agent` are provided by the pi runtime
package/index.ts CHANGED
@@ -9,12 +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";
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
+ Context,
22
+ Model,
23
+ SimpleStreamOptions,
24
+ } from "@mariozechner/pi-ai";
25
+ import {
26
+ streamSimpleAnthropic,
27
+ streamSimpleOpenAICompletions,
28
+ AssistantMessageEventStream,
29
+ } from "@mariozechner/pi-ai";
18
30
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
19
31
 
20
32
  // =============================================================================
@@ -23,8 +35,13 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
23
35
 
24
36
  const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
25
37
  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";
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";
28
45
  const KIMI_CLI_USER_AGENT = `KimiCLI/${KIMI_CLI_VERSION}`;
29
46
  const KIMI_PLATFORM = "kimi_cli";
30
47
  const DEVICE_ID_PATH = join(os.homedir(), ".pi", "providers", "kimi-coding", "device_id");
@@ -34,99 +51,114 @@ const DEVICE_ID_PATH = join(os.homedir(), ".pi", "providers", "kimi-coding", "de
34
51
  // =============================================================================
35
52
 
36
53
  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;
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;
39
57
  }
40
58
 
41
59
  function getBaseUrl(): string {
42
- const value = process.env.KIMI_CODE_BASE_URL || DEFAULT_BASE_URL;
43
- return value.trim() || DEFAULT_BASE_URL;
60
+ const value = process.env.KIMI_CODE_BASE_URL || DEFAULT_BASE_URL;
61
+ return value.trim() || DEFAULT_BASE_URL;
44
62
  }
45
63
 
46
64
  function createDeviceId(): string {
47
- return randomBytes(16).toString("hex");
65
+ return randomBytes(16).toString("hex");
48
66
  }
49
67
 
50
68
  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
- }
69
+ try {
70
+ chmodSync(path, 0o600);
71
+ } catch {
72
+ // Ignore chmod failures on platforms/filesystems that do not support it.
73
+ }
56
74
  }
57
75
 
58
76
  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
- }
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
+ }
66
84
  }
67
85
 
68
86
  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
- }
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
+ }
76
102
  }
77
103
 
78
104
  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}`;
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}`;
89
117
  }
90
118
 
91
119
  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;
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;
98
128
  }
99
129
 
100
130
  const DEVICE_MODEL = getDeviceModel();
101
131
  let DEVICE_ID: string | null = null;
102
132
 
103
133
  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;
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;
117
147
  }
118
148
 
119
149
  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>;
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>;
130
162
  }
131
163
 
132
164
  // =============================================================================
@@ -134,127 +166,127 @@ function getCommonHeaders(): Record<string, string> {
134
166
  // =============================================================================
135
167
 
136
168
  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;
169
+ user_code: string;
170
+ device_code: string;
171
+ verification_uri: string;
172
+ verification_uri_complete: string;
173
+ expires_in: number;
174
+ interval: number;
143
175
  }
144
176
 
145
177
  interface TokenResponse {
146
- access_token: string;
147
- refresh_token: string;
148
- expires_in: number;
149
- scope: string;
150
- token_type: string;
178
+ access_token: string;
179
+ refresh_token: string;
180
+ expires_in: number;
181
+ scope: string;
182
+ token_type: string;
151
183
  }
152
184
 
153
185
  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
- };
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
+ };
191
223
  }
192
224
 
193
225
  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}`);
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}`);
228
260
  }
229
261
 
230
262
  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;
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;
258
290
  }
259
291
 
260
292
  // =============================================================================
@@ -262,70 +294,70 @@ async function refreshAccessToken(refreshToken: string): Promise<TokenResponse>
262
294
  // =============================================================================
263
295
 
264
296
  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
- }
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
+ }
320
352
  }
321
353
 
322
354
  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
- };
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
+ };
329
361
  }
330
362
 
331
363
  // =============================================================================
@@ -337,78 +369,241 @@ async function refreshKimiCodeToken(credentials: OAuthCredentials): Promise<OAut
337
369
 
338
370
  const EMPTY_RESPONSE_PREFIX = "(Empty response:";
339
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
+
340
450
  function streamSimpleKimi(
341
- model: Model<"anthropic-messages">,
342
- context: Context,
343
- options?: SimpleStreamOptions,
451
+ model: Model<"anthropic-messages" | "openai-completions">,
452
+ context: Context,
453
+ options?: SimpleStreamOptions,
344
454
  ): 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;
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;
412
607
  }
413
608
 
414
609
  // =============================================================================
@@ -416,49 +611,49 @@ function streamSimpleKimi(
416
611
  // =============================================================================
417
612
 
418
613
  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
- });
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
+ });
464
659
  }
package/package.json CHANGED
@@ -1,30 +1,39 @@
1
1
  {
2
2
  "name": "pi-provider-kimi-code",
3
- "version": "0.1.8",
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": [