pi-provider-kimi-code 0.1.9 → 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 +93 -84
  2. package/index.ts +377 -191
  3. package/package.json +29 -5
package/README.md CHANGED
@@ -1,16 +1,38 @@
1
- # pi-provider-kimi-code
2
-
1
+ # Pi extension for Kimi Code
3
2
  [![npm](https://img.shields.io/npm/v/pi-provider-kimi-code)](https://www.npmjs.com/package/pi-provider-kimi-code)
3
+ [![license](https://img.shields.io/npm/l/pi-provider-kimi-code)](./LICENSE)
4
+
5
+ **Reuse your [Kimi Code Membership](https://www.kimi.com/code/docs/en/) inside [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent)** — no separate API credits, no second billing dashboard. Every request draws from your Kimi Code membership quota (the 5-hour token bucket) instead of billing per-token on the Moonshot Open Platform.
6
+
7
+ It's **"Claude Code for Kimi"** — log in with your Kimi account, with `KIMI_API_KEY` still supported as a fallback for CI. You get Kimi K2.5, Kimi K2 Thinking Turbo, and the umbrella `kimi-code` model with a 262K-token context window, automatic prompt caching, automatic large-image and video upload, and compatibility with both Anthropic-style and OpenAI-style clients.
8
+
9
+ ## Who is this for?
10
+
11
+ - You already pay for a **[Kimi Code Membership](https://www.kimi.com/code/docs/en/)** and want to use it inside `pi-coding-agent` instead of the official `kimi-cli` — see [MoonshotAI/kimi-cli#757](https://github.com/MoonshotAI/kimi-cli/issues/757) for the canonical feature request this extension answers.
12
+ - You want **"Claude Code for Kimi"**: log in with your Kimi account instead of buying separate API credits. (`KIMI_API_KEY` is also supported as a fallback for CI.)
13
+ - You're in the [pi / pi-mono](https://github.com/badlogic/pi-mono) ecosystem and want **Kimi K2.5** or **Kimi K2 Thinking Turbo** as a pi provider.
14
+
15
+ Pay-per-token via `KIMI_API_KEY` also works if you just want to try Kimi in CI or without a subscription.
16
+
17
+ ## Features
4
18
 
5
- A [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) custom provider extension that adds [Kimi Code](https://kimi.com) models with OAuth device-code login.
19
+ - **Browser login with your Kimi account** — reuse your Kimi Code Membership without buying separate API credits. Credentials are stored locally and refreshed automatically. `KIMI_API_KEY` is also accepted for pay-per-token or CI use.
20
+ - **262K-token context window** on all three registered models.
21
+ - **Automatic prompt caching** — binds Kimi's prompt cache to your pi session so repeated calls hit the cache cheaply (observed TTL ~5-10 minutes). Honors pi's `PI_CACHE_RETENTION=none` if you want to disable caching entirely.
22
+ - **Automatic large-image and video upload** — images over 1 MB and all videos are uploaded to Kimi ahead of time, so you don't hit inline payload limits.
23
+ - **Works with both Anthropic- and OpenAI-compatible modes** — use whichever one your pi setup expects.
24
+ - **Stream cleaning** — Kimi occasionally leaks placeholder blocks into the stream during thinking phases; this extension catches and hides them so your pi UI stays clean.
25
+ - **Zero dependencies, zero build step** — the extension loads directly as TypeScript, nothing to compile.
6
26
 
7
27
  ## Install
8
28
 
29
+ From npm:
30
+
9
31
  ```bash
10
32
  pi install npm:pi-provider-kimi-code
11
33
  ```
12
34
 
13
- Or load without installing:
35
+ Or load a local checkout without installing:
14
36
 
15
37
  ```bash
16
38
  pi -e /path/to/pi-provider-kimi-code
@@ -18,19 +40,19 @@ pi -e /path/to/pi-provider-kimi-code
18
40
 
19
41
  ## Authentication
20
42
 
21
- ### OAuth (recommended)
43
+ ### Browser login (recommended)
22
44
 
23
- Inside pi, run:
45
+ Inside `pi`, run:
24
46
 
25
47
  ```
26
48
  /login kimi-coding
27
49
  ```
28
50
 
29
- This starts the device-code flow — a browser window opens, you authorize, and credentials are stored automatically.
51
+ A browser tab opens, you sign into your Kimi account, and credentials are stored at `~/.pi/agent/auth.json`. Tokens refresh automatically.
30
52
 
31
- ### API Key
53
+ ### API key
32
54
 
33
- Set the `KIMI_API_KEY` environment variable:
55
+ For CI or pay-per-token use, set `KIMI_API_KEY`:
34
56
 
35
57
  ```bash
36
58
  KIMI_API_KEY=sk-... pi
@@ -44,101 +66,88 @@ KIMI_API_KEY=sk-... pi
44
66
  | `kimi-k2.5` | Kimi K2.5 | yes | text, image | 262 144 | 32 000 |
45
67
  | `kimi-k2-thinking-turbo` | Kimi K2 Thinking Turbo | yes | text | 262 144 | 32 000 |
46
68
 
47
- Select a model inside pi:
69
+ Select a model inside `pi`:
48
70
 
49
71
  ```
50
72
  /model kimi-coding/kimi-code
51
73
  ```
52
74
 
53
- ## Environment Overrides
75
+ ## Environment variables
54
76
 
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)
77
+ | Variable | Description |
78
+ | ---------------------------------- | ------------------------------------------------------ |
79
+ | `KIMI_API_KEY` | Static API key (alternative to browser login) |
80
+ | `KIMI_CODE_BASE_URL` | Override the API base URL |
81
+ | `KIMI_CODE_OAUTH_HOST` | Override the OAuth host |
82
+ | `KIMI_CODE_PROTOCOL` | `anthropic` (default) or `openai` |
83
+ | `KIMI_CODE_UPLOAD_THRESHOLD_BYTES` | Image auto-upload threshold, default `1048576` (1 MB) |
84
+ | `KIMI_CODE_DEBUG` | Set to `1` to print provider-side debug logs |
65
85
 
66
- ## Prompt Caching
86
+ Full list with defaults and the test-script variables: [docs/ENV.md](docs/ENV.md).
67
87
 
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:
88
+ ## FAQ
69
89
 
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.
90
+ ### How is this different from the official `kimi-cli`?
73
91
 
74
- Cache usage is easiest to verify on the Anthropic-compatible endpoint, which returns `cache_read_input_tokens` / `cache_creation_input_tokens` fields.
92
+ `kimi-cli` is Moonshot's own terminal agent. It's a full CLI — it replaces pi, you can't use it as a pi provider. This extension is the bridge: it lets you keep pi as your harness and point it at your Kimi Code Membership for inference, reusing the same login flow `kimi-cli` v1.3+ ships.
75
93
 
76
- ## OpenAI Compatibility Mode
94
+ ### Do I need a paid Kimi subscription?
77
95
 
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:
96
+ For browser login, yes — whatever your Moonshot account is entitled to is what the provider can access. Without a plan you'll hit rate limits quickly. For pay-per-token usage, set `KIMI_API_KEY` instead.
79
97
 
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
98
+ ### Where does my data go?
84
99
 
85
- ## End-to-End Test Script
100
+ Requests go only to `api.kimi.com` (Moonshot's servers). Login credentials are stored locally at `~/.pi/agent/auth.json`, readable only by your user. Nothing is uploaded to any third party.
86
101
 
87
- This repo includes a tracked smoke-test script:
102
+ ### Is this affiliated with Moonshot AI?
88
103
 
89
- ```bash
90
- KIMI_API_KEY=sk-... ./test_e2e.sh
91
- ```
104
+ No. This is an independent extension. The login flow is derived from the public implementation in the open-source [`kimi-cli`](https://github.com/MoonshotAI/kimi-cli) repository.
105
+
106
+ ### Which pi version does this work with?
107
+
108
+ Any recent [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent).
109
+
110
+ ### Why are there two protocol modes?
111
+
112
+ Kimi's coding endpoint speaks both Anthropic and OpenAI dialects. Anthropic mode has better visibility into cache hits, so it's the default. Switch via `KIMI_CODE_PROTOCOL=openai` if something in your pi setup prefers the OpenAI path.
113
+
114
+ ## Troubleshooting
115
+
116
+ ### `pi` reports `fetch failed` even though `curl` works
117
+
118
+ pi runs on Node's `fetch` / undici stack, which handles `http_proxy` / `https_proxy` / `all_proxy` differently from `curl`. Verify those variables in the pi process's environment. The bundled smoke-test script `scripts/test_e2e.sh` prints the effective proxy-related environment for easier debugging.
119
+
120
+ ### `/login kimi-coding` prints a device code but the browser never opens
121
+
122
+ The login flow always prints a verification URL — opening it is the terminal's job. If your terminal or OS blocks auto-open, copy the URL and paste it into a browser manually.
123
+
124
+ ### "Access denied" or subscription errors after a successful login
125
+
126
+ Your Moonshot account needs an active Kimi Code subscription for the provider to do anything useful. If the same account works in `kimi-cli`, re-run `/login kimi-coding` to refresh credentials.
127
+
128
+ ### Large images fail with a payload error
129
+
130
+ This extension uploads images over `KIMI_CODE_UPLOAD_THRESHOLD_BYTES` (default 1 MB) to Kimi's Files API and references them as `ms://`. Videos always upload. Set `KIMI_CODE_DEBUG=1` to see upload decisions in the provider logs.
131
+
132
+ ### Prompt cache never seems to hit
133
+
134
+ The cache is keyed off your pi session, which changes between sessions. Long-lived sessions get cache hits within a few minutes; cold sessions won't. Also check `PI_CACHE_RETENTION` — if it's set to `none`, this extension intentionally skips cache-key injection. For deterministic measurement, run the cache-TTL probe with a fixed key — see [docs/TESTING.md](docs/TESTING.md).
135
+
136
+ ### OpenAI-compatible tools complain about a `developer` role
137
+
138
+ In OpenAI mode this extension maps the `developer` role to `system` (Kimi's Coding endpoint does not recognize `developer`). If something in your toolchain expects `developer` to round-trip, use Anthropic mode instead.
139
+
140
+ ## References
92
141
 
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
-
122
- ## How It Works
123
-
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`
135
- - OAuth uses [RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628) device authorization grant against `https://auth.kimi.com`
136
- - Zero dependencies — types from `@mariozechner/pi-ai` and `@mariozechner/pi-coding-agent` are provided by the pi runtime
137
- - Zero build step — pi loads TypeScript directly via jiti
142
+ - Upstream harness: [badlogic/pi-mono](https://github.com/badlogic/pi-mono) · [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent)
143
+ - Upstream login implementation and feature request: [MoonshotAI/kimi-cli](https://github.com/MoonshotAI/kimi-cli) · [kimi-cli#757](https://github.com/MoonshotAI/kimi-cli/issues/757)
144
+ - Environment variables: [docs/ENV.md](docs/ENV.md)
145
+ - Testing guide: [docs/TESTING.md](docs/TESTING.md)
146
+ - Architecture notes: [docs/architecture.md](docs/architecture.md)
138
147
 
139
148
  ## Credits
140
149
 
141
- This extension is based on the OAuth implementation from [kimi-cli](https://github.com/MoonshotAI/kimi-cli) by Moonshot AI.
150
+ Based on the login implementation from [`kimi-cli`](https://github.com/MoonshotAI/kimi-cli) by Moonshot AI. Built as an extension for [`pi-coding-agent`](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) by [@badlogic](https://github.com/badlogic).
142
151
 
143
152
  ## License
144
153
 
package/index.ts CHANGED
@@ -18,9 +18,11 @@ import type {
18
18
  OAuthCredentials,
19
19
  OAuthLoginCallbacks,
20
20
  AssistantMessageEvent,
21
+ CacheRetention,
21
22
  Context,
22
23
  Model,
23
24
  SimpleStreamOptions,
25
+ ThinkingLevel,
24
26
  } from "@mariozechner/pi-ai";
25
27
  import {
26
28
  streamSimpleAnthropic,
@@ -361,13 +363,39 @@ async function refreshKimiCodeToken(credentials: OAuthCredentials): Promise<OAut
361
363
  }
362
364
 
363
365
  // =============================================================================
364
- // Stream wrapper: strip "(Empty response: ...)" text blocks from Kimi API
366
+ // Payload / stream helpers: types + pure utilities
365
367
  // =============================================================================
366
- // The Kimi API wraps thinking-only responses (no text content) into a text
367
- // block like: (Empty response: {'content': [{'type': 'thinking', ...}]})
368
- // This leaks internal state to the user. We detect and suppress such blocks.
369
368
 
370
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
+ }
371
399
 
372
400
  function mapThinkingLevel(level?: string): { effort: string | null; enabled: boolean } | undefined {
373
401
  if (!level) return undefined;
@@ -378,215 +406,373 @@ function mapThinkingLevel(level?: string): { effort: string | null; enabled: boo
378
406
  return undefined;
379
407
  }
380
408
 
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
- }
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]);
439
571
  }
440
- newContent.push(block);
572
+ continue;
441
573
  }
442
- transformedMessages.push({ ...message, content: newContent });
443
- } else {
444
- transformedMessages.push(message);
574
+ message.content[i] = await transformImageBlock(block);
445
575
  }
446
576
  }
447
- return { ...context, messages: transformedMessages };
448
577
  }
449
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.
719
+
450
720
  function streamSimpleKimi(
451
721
  model: Model<"anthropic-messages" | "openai-completions">,
452
722
  context: Context,
453
723
  options?: SimpleStreamOptions,
454
724
  ): AssistantMessageEventStream {
455
- // Pre-process context files: upload large images/videos to Kimi API
456
725
  const filtered = new AssistantMessageEventStream();
457
726
  const apiKey = options?.apiKey || process.env.KIMI_API_KEY || "";
458
727
 
459
- void (async () => {
460
- let finalContext = context;
461
- try {
462
- if (apiKey) {
463
- finalContext = await transformContextFiles(context, apiKey);
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
+ });
464
753
  }
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
754
 
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
- }
755
+ if (originalOnPayload) {
756
+ const res = await originalOnPayload(nextPayload, modelData);
757
+ if (res !== undefined) nextPayload = res;
758
+ }
497
759
 
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
- };
760
+ return nextPayload;
761
+ },
762
+ };
524
763
 
764
+ void (async () => {
525
765
  const upstream =
526
766
  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;
767
+ ? streamSimpleOpenAICompletions(
768
+ model as Model<"openai-completions">,
769
+ context,
770
+ patchedOptions,
771
+ )
772
+ : streamSimpleAnthropic(model as Model<"anthropic-messages">, context, patchedOptions);
535
773
 
536
774
  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
-
775
+ for await (const event of filterEmptyResponseStream(upstream)) {
590
776
  filtered.push(event);
591
777
  }
592
778
  } catch (err) {
package/package.json CHANGED
@@ -1,20 +1,40 @@
1
1
  {
2
2
  "name": "pi-provider-kimi-code",
3
- "version": "0.1.9",
4
- "description": "pi custom provider extension for Kimi Code models with OAuth device-code login",
3
+ "version": "0.2.0",
4
+ "description": "Pi extension for Kimi Code — reuse your kimi.com Membership in pi-coding-agent (K2.5, K2 Thinking Turbo)",
5
5
  "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "pi-provider",
9
+ "pi",
10
+ "pi-coding-agent",
11
+ "pi-mono",
6
12
  "coding-agent",
13
+ "ai-provider",
14
+ "llm",
15
+ "llm-provider",
7
16
  "kimi",
8
17
  "kimi-code",
9
- "llm",
10
- "pi",
11
- "pi-extension"
18
+ "kimi-for-coding",
19
+ "kimi-coder",
20
+ "kimi-k2",
21
+ "kimi-k2.5",
22
+ "kimi-k2-thinking",
23
+ "moonshot",
24
+ "moonshotai",
25
+ "oauth",
26
+ "device-code",
27
+ "subscription"
12
28
  ],
13
29
  "license": "MIT",
14
30
  "repository": {
15
31
  "type": "git",
16
32
  "url": "https://github.com/Leechael/pi-provider-kimi-code"
17
33
  },
34
+ "bugs": {
35
+ "url": "https://github.com/Leechael/pi-provider-kimi-code/issues"
36
+ },
37
+ "author": "Leechael",
18
38
  "files": [
19
39
  "index.ts",
20
40
  "package.json",
@@ -30,6 +50,10 @@
30
50
  "format:check": "oxfmt . --check",
31
51
  "pre-commit": "prek run"
32
52
  },
53
+ "peerDependencies": {
54
+ "@mariozechner/pi-coding-agent": "*",
55
+ "@mariozechner/pi-ai": "*"
56
+ },
33
57
  "devDependencies": {
34
58
  "@j178/prek": "^0.3.8",
35
59
  "oxfmt": "^0.44.0",