pi-provider-kimi-code 0.6.8 → 0.6.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.
- package/README.md +40 -103
- package/index.ts +174 -23
- package/package.json +5 -5
- package/src/constants.ts +6 -2
- package/src/device.ts +59 -17
- package/src/models.ts +61 -6
- package/src/oauth.ts +7 -1
- package/src/payload.ts +154 -40
- package/src/stream.ts +32 -4
package/README.md
CHANGED
|
@@ -3,40 +3,35 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/pi-provider-kimi-code)
|
|
4
4
|
[](./LICENSE)
|
|
5
5
|
|
|
6
|
-
**
|
|
6
|
+
> **Kimi Code harness extension for Pi.** Pi v0.82.0 ships a built-in `kimi-coding` provider that handles the LLM layer — K3, adaptive thinking, and a generated model catalog — plus native OAuth sign-in via `/login`. This extension brings the rest of the Kimi Code surface into Pi: shared login state with the official `kimi-code` CLI, Kimi Files API uploads, and Kimi-native tools such as `moonshot_search`, `moonshot_fetch`, and `kimi_datasource`.
|
|
7
7
|
|
|
8
|
-
Pi
|
|
8
|
+
Pi's built-in `kimi-coding` provider handles the LLM. This extension covers the rest: shared login state with the official CLI, file uploads through Kimi's Files API, and Kimi-native tools. It also keeps a legacy provider registration so existing model sessions still load. New users should use Pi's built-in model provider.
|
|
9
9
|
|
|
10
|
-
> **
|
|
10
|
+
> **Live model catalog (legacy).** For backward compatibility, the extension still reads Kimi's live `/v1/models` catalog when it has credentials. New Pi users should rely on the built-in provider's generated catalog instead.
|
|
11
11
|
|
|
12
12
|
## Why this exists
|
|
13
13
|
|
|
14
|
-
Pi is a small harness you adapt to your own workflow. Kimi Code is Moonshot's official coding agent.
|
|
14
|
+
Pi is a small harness you adapt to your own workflow. Kimi Code is Moonshot's official coding agent. Pi now ships a built-in `kimi-coding` provider that covers the LLM, including OAuth login. This extension is for the parts of Kimi Code that Pi's built-in provider does not cover: the account session, file uploads, and Kimi's own tools/datasource APIs.
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
Use the built-in provider if you only want to chat with Kimi models. Install this extension when you also want the full Kimi Code surface inside Pi:
|
|
17
17
|
|
|
18
|
-
- **
|
|
18
|
+
- **Share a session with the official `kimi-code` CLI.** Reuse an existing `kimi-code` login, or sync Pi's OAuth login back to `kimi-code` so both CLIs stay authenticated.
|
|
19
19
|
- **Send files the Kimi way.** Large inline images go through Kimi's Files API and become `ms://` references instead of huge base64 payloads.
|
|
20
|
-
- **
|
|
20
|
+
- **Use Kimi-native tools.** `moonshot_search`, `moonshot_fetch`, and `kimi_datasource` are opt-in tools that call Moonshot's server-side services.
|
|
21
21
|
- **Keep Pi's tools working.** Moonshot's API rejects tool schemas over 15 KB — a limit that Pi's extension ecosystem regularly hits ([#16](https://github.com/Leechael/pi-provider-kimi-code/issues/16), [#21](https://github.com/Leechael/pi-provider-kimi-code/issues/21)). This extension automatically deduplicates schemas with `$ref`/`$defs` before sending, so subagents and other extensions don't break.
|
|
22
|
-
- **
|
|
23
|
-
- **Turn on Kimi-native tools when you want them.** `moonshot_search`, `moonshot_fetch`, and `kimi_datasource` are opt-in, configurable per user or per project.
|
|
24
|
-
- **Embed in your own build.** `KimiCode()` factory lets you ship Kimi Code support inside a custom Pi agent with programmatic config overrides — no file-based extension path needed.
|
|
22
|
+
- **Embed in your own build.** The `KimiCode()` factory lets you ship Kimi Code support inside a custom Pi agent with programmatic config overrides — no file-based extension path needed.
|
|
25
23
|
|
|
26
24
|
## What this package adds
|
|
27
25
|
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
33
|
-
-
|
|
34
|
-
-
|
|
35
|
-
-
|
|
36
|
-
-
|
|
37
|
-
- Optional `moonshot_search`, `moonshot_fetch`, and unified datasource `kimi_datasource` tools via `/kimi-settings`.
|
|
38
|
-
- Programmatic `KimiCode()` factory for embedding in custom Pi builds.
|
|
39
|
-
- No build step; Pi loads the TypeScript extension directly.
|
|
26
|
+
- **Credential reuse with `kimi-code`.** Reads `~/.kimi-code/credentials/kimi-code.json` and syncs refreshed OAuth tokens back to it.
|
|
27
|
+
- **Kimi account login in Pi.** `/login kimi-coding` stores credentials in `~/.pi/agent/auth.json`; tokens refresh automatically.
|
|
28
|
+
- **`KIMI_API_KEY` fallback.** For CI or key-based access.
|
|
29
|
+
- **Kimi Files API uploads.** Uploads large inline images and references them as `ms://` instead of inline base64.
|
|
30
|
+
- **Optional Kimi-native tools.** `moonshot_search`, `moonshot_fetch`, and `kimi_datasource` via `/kimi-settings`.
|
|
31
|
+
- **Tool schema dedup.** Collapses repeated `$ref`/`$defs` structures to stay under Moonshot's 15 KB per-tool limit.
|
|
32
|
+
- **Legacy provider registration.** Keeps existing model sessions loading; new users should use Pi's built-in provider.
|
|
33
|
+
- **Programmatic `KimiCode()` factory.** Embed Kimi Code in a custom Pi build with config overrides.
|
|
34
|
+
- **No build step.** Pi loads the TypeScript extension directly.
|
|
40
35
|
|
|
41
36
|
## Install
|
|
42
37
|
|
|
@@ -76,39 +71,24 @@ main(process.argv.slice(2), {
|
|
|
76
71
|
|
|
77
72
|
`KimiCode()` with no arguments behaves identically to the file-based extension. Pass a `KimiCodeConfigPatch` to override defaults (protocol, upload threshold, tools, model parameters). See [docs/programmatic-usage.md](docs/programmatic-usage.md) for the full API.
|
|
78
73
|
|
|
79
|
-
##
|
|
74
|
+
## Authentication
|
|
80
75
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
```text
|
|
84
|
-
/login kimi-coding
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
A browser tab opens, you sign into your Kimi account, and Pi stores the credential at `~/.pi/agent/auth.json`. Tokens refresh automatically. The extension also syncs refreshed credentials to the current `kimi-code` credential file:
|
|
88
|
-
|
|
89
|
-
```text
|
|
90
|
-
~/.kimi-code/credentials/kimi-code.json
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
If you already use `kimi-code`, the extension can reuse that session. Set `KIMI_CODE_HOME` if its home directory lives somewhere else.
|
|
76
|
+
Pi's built-in `kimi-coding` provider already supports OAuth login via `/login`. This extension handles the credential layer around that login:
|
|
94
77
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
Legacy credentials are read-only. Set `KIMI_SHARE_DIR` to override the legacy `~/.kimi` directory.
|
|
102
|
-
|
|
103
|
-
For CI or pay-per-token use, set `KIMI_API_KEY` instead:
|
|
78
|
+
- **Syncs refreshed tokens back to `kimi-code`.** Pi stores credentials at `~/.pi/agent/auth.json`; this extension keeps the official CLI's credential file (`~/.kimi-code/credentials/kimi-code.json`) up to date so both stay authenticated.
|
|
79
|
+
- **Reuses existing `kimi-code` sessions.** If you already use the official CLI, the extension reads its session directly. Set `KIMI_CODE_HOME` if its home directory lives somewhere else.
|
|
80
|
+
- **Supports the legacy `~/.kimi` path.** Read-only. Set `KIMI_SHARE_DIR` to override it.
|
|
81
|
+
- **`KIMI_API_KEY` fallback.** For CI or key-based access:
|
|
104
82
|
|
|
105
83
|
```bash
|
|
106
84
|
KIMI_API_KEY=sk-... pi
|
|
107
85
|
```
|
|
108
86
|
|
|
109
|
-
## Models
|
|
87
|
+
## Models (legacy provider registration)
|
|
88
|
+
|
|
89
|
+
> **New users:** Use Pi's built-in `kimi-coding` provider for model discovery, reasoning controls, and OAuth login. This extension keeps its own model registration only for backward compatibility with existing sessions.
|
|
110
90
|
|
|
111
|
-
|
|
91
|
+
When this extension does register models, the official catalog determines what it publishes. The following IDs are the fallback set when catalog discovery is unavailable:
|
|
112
92
|
|
|
113
93
|
| Pi model ID | Default name | Availability and context |
|
|
114
94
|
| --------------------------------------- | ------------------------ | ------------------------ |
|
|
@@ -118,16 +98,6 @@ The official catalog determines the models this provider publishes. The followin
|
|
|
118
98
|
|
|
119
99
|
Kimi's official catalog at `https://api.kimi.com/coding/v1/models` is authoritative for model availability and context windows. When discovery is unavailable, the extension uses the fallback models and defaults below.
|
|
120
100
|
|
|
121
|
-
Select a model inside Pi:
|
|
122
|
-
|
|
123
|
-
```text
|
|
124
|
-
/model kimi-coding/kimi-for-coding
|
|
125
|
-
/model kimi-coding/kimi-for-coding-highspeed
|
|
126
|
-
/model kimi-coding/k3
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
Kimi keeps Coding models behind aliases. Rather than hardcoding a stale model list, this extension reads the official catalog at startup, when credentials change, and through `/kimi-settings`. If your account is on a newer rollout (e.g., Kimi K2.7) or internal test, Pi can pick up the latest model IDs, names, and context sizes without waiting for a package release.
|
|
130
|
-
|
|
131
101
|
Fallback values:
|
|
132
102
|
|
|
133
103
|
- Context window: `256k` tokens
|
|
@@ -141,7 +111,7 @@ Switching models or thinking effort invalidates Kimi's existing context cache. S
|
|
|
141
111
|
|
|
142
112
|
## Optional tools
|
|
143
113
|
|
|
144
|
-
Kimi
|
|
114
|
+
Kimi Code has server-side capabilities that this extension exposes as opt-in tools. All tools are off by default. Enable them individually through `/kimi-settings` or JSON config.
|
|
145
115
|
|
|
146
116
|
Inside Pi, run:
|
|
147
117
|
|
|
@@ -213,8 +183,8 @@ If you already use another search or fetch tool, pick one path for a session. Ov
|
|
|
213
183
|
|
|
214
184
|
Most users do not need environment variables. Two are worth knowing:
|
|
215
185
|
|
|
216
|
-
- `KIMI_API_KEY` — static API key for CI or
|
|
217
|
-
- `KIMI_CODE_PROTOCOL` — `openai` by default; set to `anthropic`
|
|
186
|
+
- `KIMI_API_KEY` — static API key for CI or key-based access.
|
|
187
|
+
- `KIMI_CODE_PROTOCOL` — `openai` by default; set to `anthropic` for Anthropic-compatible requests.
|
|
218
188
|
|
|
219
189
|
Tools, protocol, and upload threshold are all configurable through `/kimi-settings` or JSON config files.
|
|
220
190
|
|
|
@@ -224,30 +194,15 @@ The full env list, including base URL overrides, `kimi-code` path overrides, upl
|
|
|
224
194
|
|
|
225
195
|
### Kimi Code vs Pi
|
|
226
196
|
|
|
227
|
-
`kimi-code` is Moonshot's official terminal agent. Pi is the harness you adapt to your own workflow
|
|
197
|
+
`kimi-code` is Moonshot's official terminal agent. Pi is the harness you adapt to your own workflow. Pi's built-in `kimi-coding` provider handles the LLM; this extension adds the rest of the Kimi Code surface to Pi.
|
|
228
198
|
|
|
229
199
|
### Cache behavior
|
|
230
200
|
|
|
231
|
-
Kimi's cache is content-based
|
|
232
|
-
|
|
233
|
-
See [docs/caching.md](docs/caching.md) for the measured behavior: TTL, cross-protocol cache reuse, 256-token prefix alignment, device-id sharing, and the cases that invalidate cache.
|
|
201
|
+
Kimi's cache is content-based and keyed purely by prompt prefix. `prompt_cache_key`, Anthropic `cache_control` markers, and device-id headers are ignored. Cache benefits accrue within a session, not across cold starts. See [docs/caching.md](docs/caching.md) for measured behavior.
|
|
234
202
|
|
|
235
203
|
### Protocol modes
|
|
236
204
|
|
|
237
|
-
Kimi's coding endpoint speaks both Anthropic and OpenAI dialects. This extension defaults to OpenAI-compatible mode.
|
|
238
|
-
|
|
239
|
-
`KIMI_CODE_PROTOCOL` supports:
|
|
240
|
-
|
|
241
|
-
- `openai` — default, uses `/coding/v1/chat/completions`
|
|
242
|
-
- `anthropic` — uses `/coding/v1/messages`
|
|
243
|
-
|
|
244
|
-
Use Anthropic mode if your Pi setup depends on Anthropic-style request or tool semantics.
|
|
245
|
-
|
|
246
|
-
### Why OpenAI-compatible mode is the default
|
|
247
|
-
|
|
248
|
-
The Anthropic-compatible protocol has an awkward tool-result shape: tool output is wrapped inside a `role: "user"` message. That is valid Anthropic format, but Kimi can sometimes read it like a real user message and continue from the wrong premise in multi-turn tool loops.
|
|
249
|
-
|
|
250
|
-
The OpenAI-compatible protocol uses a dedicated `role: "tool"` message, so the boundary is clearer. That is why this extension now defaults to `openai`. The Anthropic path stays available for setups that need it. See [issue #5](https://github.com/Leechael/pi-provider-kimi-code/issues/5) for details.
|
|
205
|
+
Kimi's coding endpoint speaks both Anthropic and OpenAI dialects. This extension defaults to OpenAI-compatible mode (`/coding/v1/chat/completions`) because it uses a dedicated `role: "tool"` message, which keeps tool results separate from user messages in multi-turn loops. Anthropic mode (`/coding/v1/messages`) is available if your setup depends on Anthropic-style semantics. See [issue #5](https://github.com/Leechael/pi-provider-kimi-code/issues/5) for details.
|
|
251
206
|
|
|
252
207
|
### Is this affiliated with Moonshot AI?
|
|
253
208
|
|
|
@@ -257,25 +212,19 @@ No. This is an independent extension. The login flow is derived from the public
|
|
|
257
212
|
|
|
258
213
|
### `pi` reports `fetch failed` even though `curl` works
|
|
259
214
|
|
|
260
|
-
Pi
|
|
215
|
+
Pi uses Node's `fetch` / undici stack, which handles `http_proxy` / `https_proxy` / `all_proxy` differently from `curl`. Check the proxy variables in the Pi process's environment. `scripts/test_e2e.sh` prints the effective proxy settings for debugging.
|
|
261
216
|
|
|
262
217
|
### `/login kimi-coding` prints a device code but the browser never opens
|
|
263
218
|
|
|
264
|
-
The login flow always prints a verification URL.
|
|
219
|
+
The login flow always prints a verification URL. Copy it into a browser manually if your terminal or OS blocks auto-open.
|
|
265
220
|
|
|
266
221
|
### "Access denied" or subscription errors after a successful login
|
|
267
222
|
|
|
268
|
-
Your Moonshot account needs an active Kimi Code Plan
|
|
269
|
-
|
|
270
|
-
### Tools do not show up
|
|
271
|
-
|
|
272
|
-
Run `/kimi-settings` and check whether the tool is enabled in the home or project config. All tools also require `/login kimi-coding`; `KIMI_API_KEY` is not enough.
|
|
273
|
-
|
|
274
|
-
If you changed the JSON config by hand, reload it through `/kimi-settings` or start a new Pi session.
|
|
223
|
+
Your Moonshot account needs an active Kimi Code Plan. If the same account works in `kimi-code`, re-run `/login kimi-coding` to refresh credentials.
|
|
275
224
|
|
|
276
|
-
### Tools
|
|
225
|
+
### Tools do not show up or return errors
|
|
277
226
|
|
|
278
|
-
|
|
227
|
+
Run `/kimi-settings` and check whether the tool is enabled in the home or project config. All tools require OAuth login; `KIMI_API_KEY` is not enough. Subscription or whitelist errors come from the upstream Moonshot service.
|
|
279
228
|
|
|
280
229
|
### Large images fail with a payload error
|
|
281
230
|
|
|
@@ -283,19 +232,11 @@ This extension uploads images over `KIMI_CODE_UPLOAD_THRESHOLD_BYTES` (default 1
|
|
|
283
232
|
|
|
284
233
|
### Prompt cache never seems to hit
|
|
285
234
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
- On the Anthropic-compat endpoint, the `system` prompt or the `tools` array changed (e.g. an extension changed the tool set mid-session). Either fully invalidates the cache — see [docs/caching.md](docs/caching.md), Finding 11. OpenAI-compat endpoint not yet measured for this scenario.
|
|
289
|
-
- An `image_url` reference (`ms://<file_id>`) changed between turns. Same rule — see Finding 17.
|
|
290
|
-
- You're comparing across two cold `pi` invocations, not within a single session. Cache benefits accrue **within** a session; Pi's system/tools assembly drifts between sessions. See Finding 18.
|
|
291
|
-
- A timestamp, request ID, or randomized header is being interpolated into the prompt.
|
|
292
|
-
- The first ~256 tokens of the prompt differ between turns.
|
|
293
|
-
|
|
294
|
-
`PI_CACHE_RETENTION=none` skips `prompt_cache_key` injection but **does not** disable Kimi's caching (the cache is unconditional). For deterministic measurement, run a focused suite under `scripts/e2e/cache/`, starting with `scripts/e2e/cache/ttl.sh`. See [docs/caching.md](docs/caching.md) for full mechanics.
|
|
235
|
+
Cache benefits accrue within a single session. If `cache_read_input_tokens` (Anthropic) or `cached_tokens` (OpenAI) stays at `0`, something in the prompt is varying between turns — a changed system prompt, tool set, `ms://` reference, or timestamp. See [docs/caching.md](docs/caching.md) for the full list of invalidation causes and measurement scripts.
|
|
295
236
|
|
|
296
237
|
### OpenAI-compatible tools complain about a `developer` role
|
|
297
238
|
|
|
298
|
-
In OpenAI mode this extension maps
|
|
239
|
+
In OpenAI mode this extension maps `developer` to `system` because Kimi's Coding endpoint does not recognize `developer`. Use Anthropic mode if your toolchain expects `developer` to round-trip.
|
|
299
240
|
|
|
300
241
|
## References
|
|
301
242
|
|
|
@@ -306,10 +247,6 @@ In OpenAI mode this extension maps the `developer` role to `system` (Kimi's Codi
|
|
|
306
247
|
- Cache behavior: [docs/caching.md](docs/caching.md)
|
|
307
248
|
- Architecture notes: [docs/architecture.md](docs/architecture.md)
|
|
308
249
|
|
|
309
|
-
## Credits
|
|
310
|
-
|
|
311
|
-
Based on the login implementation from Kimi Code by Moonshot AI. Pi was originally created by [@badlogic](https://github.com/badlogic).
|
|
312
|
-
|
|
313
250
|
## License
|
|
314
251
|
|
|
315
252
|
MIT
|
package/index.ts
CHANGED
|
@@ -27,6 +27,9 @@ import {
|
|
|
27
27
|
import { Input, SettingsList, type SettingItem, truncateToWidth } from "@earendil-works/pi-tui";
|
|
28
28
|
import os from "node:os";
|
|
29
29
|
|
|
30
|
+
// pi-ai <=0.79 has no "./compat"
|
|
31
|
+
const piAiCompatModule = "@earendil-works/pi-ai/compat";
|
|
32
|
+
|
|
30
33
|
import {
|
|
31
34
|
type KimiCodeConfig,
|
|
32
35
|
type KimiCodeConfigPatch,
|
|
@@ -38,7 +41,13 @@ import {
|
|
|
38
41
|
saveProjectKimiCodeConfig,
|
|
39
42
|
type KimiToolName,
|
|
40
43
|
} from "./src/config.ts";
|
|
41
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
KIMI_GLOBAL_FALLBACK_SOURCE_ID,
|
|
46
|
+
PROVIDER_ID,
|
|
47
|
+
PROVIDER_VERSION,
|
|
48
|
+
getBaseUrl,
|
|
49
|
+
getKimiApiType,
|
|
50
|
+
} from "./src/constants.ts";
|
|
42
51
|
import {
|
|
43
52
|
type KimiOAuthCredentials,
|
|
44
53
|
type KimiOAuthExtras,
|
|
@@ -53,7 +62,12 @@ import {
|
|
|
53
62
|
hasKimiModelMetadata,
|
|
54
63
|
resolveKimiModelConfig,
|
|
55
64
|
} from "./src/models.ts";
|
|
56
|
-
import {
|
|
65
|
+
import {
|
|
66
|
+
getKimiApiKey,
|
|
67
|
+
loginKimiCode,
|
|
68
|
+
refreshKimiAuthToken,
|
|
69
|
+
refreshKimiCodeToken,
|
|
70
|
+
} from "./src/oauth.ts";
|
|
57
71
|
import { isKimiProjectConfigApproved } from "./src/project-trust.ts";
|
|
58
72
|
import {
|
|
59
73
|
type KimiConfigScope,
|
|
@@ -69,6 +83,7 @@ import { buildKimiDatasourceTool } from "./src/tools/datasource.ts";
|
|
|
69
83
|
interface KimiRuntimeState {
|
|
70
84
|
cwd: string;
|
|
71
85
|
config: KimiCodeConfig;
|
|
86
|
+
fallbackSourceId: string;
|
|
72
87
|
modelExtras: KimiOAuthExtras;
|
|
73
88
|
projectTrusted: boolean;
|
|
74
89
|
overrides?: KimiCodeConfigPatch;
|
|
@@ -141,15 +156,30 @@ function applyEffectiveKimiRuntimeConfig(
|
|
|
141
156
|
return config;
|
|
142
157
|
}
|
|
143
158
|
|
|
144
|
-
async function refreshModelExtras(
|
|
145
|
-
|
|
159
|
+
async function refreshModelExtras(
|
|
160
|
+
state: KimiRuntimeState,
|
|
161
|
+
token = getKimiUsageToken(),
|
|
162
|
+
): Promise<boolean> {
|
|
146
163
|
if (!token) return false;
|
|
147
|
-
const extras = await discoverKimiModelMetadata(token, state.config.protocol
|
|
164
|
+
const extras = await discoverKimiModelMetadata(token, state.config.protocol, {
|
|
165
|
+
refreshAccessToken: refreshKimiAuthToken,
|
|
166
|
+
});
|
|
148
167
|
if (Object.keys(extras).length === 0) return false;
|
|
149
168
|
Object.assign(state.modelExtras, extras);
|
|
150
169
|
return true;
|
|
151
170
|
}
|
|
152
171
|
|
|
172
|
+
// Discovery can lose a race with a concurrent OAuth refresh: the token it
|
|
173
|
+
// started with may already be dead by the time the request goes out. Retry
|
|
174
|
+
// once, but only when the stored token actually changed underneath us.
|
|
175
|
+
async function discoverModelExtras(state: KimiRuntimeState): Promise<boolean> {
|
|
176
|
+
const token = getKimiUsageToken();
|
|
177
|
+
if (await refreshModelExtras(state, token)) return true;
|
|
178
|
+
const refreshedToken = getKimiUsageToken();
|
|
179
|
+
if (!refreshedToken || refreshedToken === token) return false;
|
|
180
|
+
return refreshModelExtras(state, refreshedToken);
|
|
181
|
+
}
|
|
182
|
+
|
|
153
183
|
async function openSettingsMenu(
|
|
154
184
|
pi: ExtensionAPI,
|
|
155
185
|
ctx: ExtensionCommandContext,
|
|
@@ -165,7 +195,7 @@ async function openSettingsMenu(
|
|
|
165
195
|
if (!modelsRefreshed && refreshedToken && refreshedToken !== modelDiscoveryToken) {
|
|
166
196
|
modelsRefreshed = await refreshModelExtras(state);
|
|
167
197
|
}
|
|
168
|
-
if (modelsRefreshed) registerKimiProvider(pi, state);
|
|
198
|
+
if (modelsRefreshed) await registerKimiProvider(pi, state);
|
|
169
199
|
const usage = usageSnapshot.summary;
|
|
170
200
|
|
|
171
201
|
const projectTrusted = await isKimiProjectConfigApproved(ctx, ctx.cwd);
|
|
@@ -402,7 +432,84 @@ function buildKimiCatalogModels(state: KimiRuntimeState) {
|
|
|
402
432
|
);
|
|
403
433
|
}
|
|
404
434
|
|
|
405
|
-
|
|
435
|
+
// Node reports a missing subpath export (pi-ai <=0.79) and a missing package
|
|
436
|
+
// with these codes. Both mean "this pi-ai has no compat layer", which is an
|
|
437
|
+
// expected configuration rather than a failure worth reporting: the fallback
|
|
438
|
+
// simply does not exist there.
|
|
439
|
+
const MODULE_UNAVAILABLE_CODES = new Set(["ERR_MODULE_NOT_FOUND", "ERR_PACKAGE_PATH_NOT_EXPORTED"]);
|
|
440
|
+
|
|
441
|
+
// Every api id this extension can hand out, not only the one for the currently
|
|
442
|
+
// configured protocol: sessions persist the api id that was current when the
|
|
443
|
+
// model was resolved, so a session created before a protocol switch still
|
|
444
|
+
// carries the other id and would keep crashing. streamSimpleKimi routes on the
|
|
445
|
+
// model's wireProtocol / the resolved runtime config and never on model.api, so
|
|
446
|
+
// both ids dispatch identically.
|
|
447
|
+
const KIMI_FALLBACK_APIS = [getKimiApiType("openai"), getKimiApiType("anthropic")];
|
|
448
|
+
|
|
449
|
+
type PiAiApiRegistry = {
|
|
450
|
+
registerApiProvider: (
|
|
451
|
+
provider: {
|
|
452
|
+
api: string;
|
|
453
|
+
stream: typeof streamSimpleKimi;
|
|
454
|
+
streamSimple: typeof streamSimpleKimi;
|
|
455
|
+
},
|
|
456
|
+
sourceId: string,
|
|
457
|
+
) => void;
|
|
458
|
+
unregisterApiProviders: (sourceId: string) => void;
|
|
459
|
+
getApiProvider: (api: string) => unknown;
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
async function loadPiAiApiRegistry(action: string): Promise<PiAiApiRegistry | null> {
|
|
463
|
+
try {
|
|
464
|
+
return (await import(piAiCompatModule)) as PiAiApiRegistry;
|
|
465
|
+
} catch (error: unknown) {
|
|
466
|
+
const code = (error as { code?: unknown } | undefined)?.code;
|
|
467
|
+
if (typeof code === "string" && MODULE_UNAVAILABLE_CODES.has(code)) return null;
|
|
468
|
+
console.error(
|
|
469
|
+
`[pi-provider-kimi-code] failed to ${action} the global api fallback:`,
|
|
470
|
+
error instanceof Error ? error.message : error,
|
|
471
|
+
);
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
let nextFallbackSourceId = 0;
|
|
477
|
+
const liveFallbackSources = new Set<string>();
|
|
478
|
+
|
|
479
|
+
function createKimiGlobalFallbackSourceId(): string {
|
|
480
|
+
nextFallbackSourceId += 1;
|
|
481
|
+
return `${KIMI_GLOBAL_FALLBACK_SOURCE_ID}:${nextFallbackSourceId}`;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function registerKimiGlobalApiFallback(sourceId: string): Promise<void> {
|
|
485
|
+
if (!liveFallbackSources.has(sourceId)) return;
|
|
486
|
+
const registry = await loadPiAiApiRegistry("register");
|
|
487
|
+
if (!registry) return;
|
|
488
|
+
for (const api of KIMI_FALLBACK_APIS) {
|
|
489
|
+
// Never displace an entry someone else owns: this is a shared,
|
|
490
|
+
// process-wide table and the fallback only exists to fill a hole, not to
|
|
491
|
+
// win a conflict. pi-ai installs its own builtins under the same rule.
|
|
492
|
+
if (registry.getApiProvider(api)) continue;
|
|
493
|
+
registry.registerApiProvider(
|
|
494
|
+
{ api, stream: streamSimpleKimi, streamSimple: streamSimpleKimi },
|
|
495
|
+
sourceId,
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// The registry outlives each session. Remove only the entries owned by the
|
|
501
|
+
// retiring runtime, then hand any holes to another live runtime.
|
|
502
|
+
async function unregisterKimiGlobalApiFallback(sourceId: string): Promise<void> {
|
|
503
|
+
if (!liveFallbackSources.delete(sourceId)) return;
|
|
504
|
+
const registry = await loadPiAiApiRegistry("unregister");
|
|
505
|
+
if (!registry) return;
|
|
506
|
+
registry.unregisterApiProviders(sourceId);
|
|
507
|
+
const successor = liveFallbackSources.values().next().value;
|
|
508
|
+
if (successor) await registerKimiGlobalApiFallback(successor);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function registerKimiProvider(pi: ExtensionAPI, state: KimiRuntimeState): Promise<void> {
|
|
512
|
+
await registerKimiGlobalApiFallback(state.fallbackSourceId);
|
|
406
513
|
pi.registerProvider(PROVIDER_ID, {
|
|
407
514
|
baseUrl: getBaseUrl(state.config.protocol),
|
|
408
515
|
apiKey: "$KIMI_API_KEY",
|
|
@@ -481,6 +588,50 @@ function registerKimiProvider(pi: ExtensionAPI, state: KimiRuntimeState): void {
|
|
|
481
588
|
});
|
|
482
589
|
}
|
|
483
590
|
|
|
591
|
+
// What pi throws from any `pi` method once the extension runtime behind it has
|
|
592
|
+
// been retired, which is what a /reload does. Same wording on every version
|
|
593
|
+
// this extension supports (0.78.1 through 0.82.0); if it ever changes, the
|
|
594
|
+
// worst case is that a reload race gets logged instead of ignored.
|
|
595
|
+
const STALE_EXTENSION_RUNTIME_PREFIX = "This extension ctx is stale";
|
|
596
|
+
|
|
597
|
+
let pendingModelDiscovery: Promise<void> = Promise.resolve();
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Resolves once the discovery started by the most recent extension load has
|
|
601
|
+
* settled. Nothing in the runtime waits for it; it exists so tests can observe
|
|
602
|
+
* the deferred registration without polling.
|
|
603
|
+
*/
|
|
604
|
+
export function kimiModelDiscoverySettled(): Promise<void> {
|
|
605
|
+
return pendingModelDiscovery;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// pi's extension loader awaits this factory before it builds the session, and
|
|
609
|
+
// the TUI only draws its first frame after that returns, so a network round
|
|
610
|
+
// trip here is a stall the user sees on every launch and every /reload. The
|
|
611
|
+
// stored OAuth credential already carries the last known catalog (pi replays it
|
|
612
|
+
// through oauth.modifyModels), so register what the config describes right away
|
|
613
|
+
// and fold the fresh metadata in when it lands.
|
|
614
|
+
function startModelDiscovery(pi: ExtensionAPI, state: KimiRuntimeState): void {
|
|
615
|
+
pendingModelDiscovery = discoverModelExtras(state)
|
|
616
|
+
.then((discovered) => {
|
|
617
|
+
if (!discovered) return;
|
|
618
|
+
reloadEffectiveKimiRuntimeConfig(state, state.cwd, state.projectTrusted);
|
|
619
|
+
return registerKimiProvider(pi, state);
|
|
620
|
+
})
|
|
621
|
+
.catch((error: unknown) => {
|
|
622
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
623
|
+
// A /reload retires this extension runtime while discovery may still be
|
|
624
|
+
// in flight, and registering against a retired runtime throws. The
|
|
625
|
+
// reloaded factory runs its own discovery, so there is nothing to redo.
|
|
626
|
+
if (message.startsWith(STALE_EXTENSION_RUNTIME_PREFIX)) return;
|
|
627
|
+
// Anything else used to surface as an extension load failure, back when
|
|
628
|
+
// this ran inside the factory. Deferring it must not make it invisible:
|
|
629
|
+
// the provider stays registered with the metadata it already had, and
|
|
630
|
+
// the user needs to know why it stopped refreshing.
|
|
631
|
+
console.error("[kimi-coding] deferred model discovery failed:", message);
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
484
635
|
export function KimiCode(overrides?: KimiCodeConfigPatch): ExtensionFactory {
|
|
485
636
|
return async (pi: ExtensionAPI) => {
|
|
486
637
|
const cwd = process.cwd();
|
|
@@ -488,28 +639,22 @@ export function KimiCode(overrides?: KimiCodeConfigPatch): ExtensionFactory {
|
|
|
488
639
|
{ cwd, home: os.homedir(), includeProject: false },
|
|
489
640
|
overrides,
|
|
490
641
|
);
|
|
491
|
-
const discoveryToken = getKimiUsageToken();
|
|
492
|
-
const initialDiscovery = discoveryToken
|
|
493
|
-
? await discoverKimiModelMetadata(discoveryToken, config.protocol)
|
|
494
|
-
: {};
|
|
495
|
-
let discovered = initialDiscovery;
|
|
496
|
-
const refreshedToken = getKimiUsageToken();
|
|
497
|
-
if (
|
|
498
|
-
Object.keys(discovered).length === 0 &&
|
|
499
|
-
refreshedToken &&
|
|
500
|
-
refreshedToken !== discoveryToken
|
|
501
|
-
) {
|
|
502
|
-
discovered = await discoverKimiModelMetadata(refreshedToken, config.protocol);
|
|
503
|
-
}
|
|
504
642
|
const state: KimiRuntimeState = {
|
|
505
643
|
cwd,
|
|
506
644
|
config,
|
|
507
|
-
|
|
645
|
+
fallbackSourceId: createKimiGlobalFallbackSourceId(),
|
|
646
|
+
modelExtras: {},
|
|
508
647
|
projectTrusted: false,
|
|
509
648
|
overrides,
|
|
510
649
|
};
|
|
511
650
|
reloadEffectiveKimiRuntimeConfig(state, cwd, false);
|
|
512
|
-
|
|
651
|
+
liveFallbackSources.add(state.fallbackSourceId);
|
|
652
|
+
try {
|
|
653
|
+
await registerKimiProvider(pi, state);
|
|
654
|
+
} catch (error) {
|
|
655
|
+
liveFallbackSources.delete(state.fallbackSourceId);
|
|
656
|
+
throw error;
|
|
657
|
+
}
|
|
513
658
|
|
|
514
659
|
registerConfiguredMoonshotTools(pi, state.config, { updateActiveTools: false });
|
|
515
660
|
|
|
@@ -519,7 +664,11 @@ export function KimiCode(overrides?: KimiCodeConfigPatch): ExtensionFactory {
|
|
|
519
664
|
updateActiveTools: true,
|
|
520
665
|
projectTrusted,
|
|
521
666
|
});
|
|
522
|
-
registerKimiProvider(pi, state);
|
|
667
|
+
await registerKimiProvider(pi, state);
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
pi.on("session_shutdown", async () => {
|
|
671
|
+
await unregisterKimiGlobalApiFallback(state.fallbackSourceId);
|
|
523
672
|
});
|
|
524
673
|
|
|
525
674
|
pi.registerCommand("kimi-settings", {
|
|
@@ -532,6 +681,8 @@ export function KimiCode(overrides?: KimiCodeConfigPatch): ExtensionFactory {
|
|
|
532
681
|
await openSettingsMenu(pi, ctx, state);
|
|
533
682
|
},
|
|
534
683
|
});
|
|
684
|
+
|
|
685
|
+
startModelDiscovery(pi, state);
|
|
535
686
|
};
|
|
536
687
|
}
|
|
537
688
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-provider-kimi-code",
|
|
3
|
-
"version": "0.6.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.6.9",
|
|
4
|
+
"description": "Kimi Code harness extension for Pi: shared login state, file uploads, and Kimi-native tools",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-provider",
|
|
7
7
|
"coding-agent",
|
|
@@ -53,9 +53,9 @@
|
|
|
53
53
|
"proper-lockfile": "^4.1.2"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
|
-
"@earendil-works/pi-ai": "^0.
|
|
57
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
58
|
-
"@earendil-works/pi-tui": "^0.
|
|
56
|
+
"@earendil-works/pi-ai": "^0.82.0",
|
|
57
|
+
"@earendil-works/pi-coding-agent": "^0.82.0",
|
|
58
|
+
"@earendil-works/pi-tui": "^0.82.0",
|
|
59
59
|
"@j178/prek": "^0.4.4",
|
|
60
60
|
"@types/node": "^24.0.0",
|
|
61
61
|
"@types/proper-lockfile": "^4.1.4",
|
package/src/constants.ts
CHANGED
|
@@ -41,12 +41,12 @@ export function getDefaultBaseUrl(protocol: KimiWireProtocol): string {
|
|
|
41
41
|
|
|
42
42
|
export const DEFAULT_BASE_URL = getDefaultBaseUrl(ENV_KIMI_CODE_PROTOCOL);
|
|
43
43
|
|
|
44
|
-
export const PROVIDER_VERSION = "0.6.
|
|
44
|
+
export const PROVIDER_VERSION = "0.6.9";
|
|
45
45
|
|
|
46
46
|
// Upstream kimi-code CLI version — used in User-Agent and X-Msh-Version
|
|
47
47
|
// headers to match the official client's identity. Update this when
|
|
48
48
|
// syncing with https://github.com/MoonshotAI/kimi-code.
|
|
49
|
-
export const KIMI_UPSTREAM_VERSION = "0.
|
|
49
|
+
export const KIMI_UPSTREAM_VERSION = "0.29.1";
|
|
50
50
|
export const KIMI_CODE_USER_AGENT = `kimi-code-cli/${KIMI_UPSTREAM_VERSION}`;
|
|
51
51
|
export const KIMI_PLATFORM = "kimi_code_cli";
|
|
52
52
|
|
|
@@ -63,6 +63,10 @@ export const RETRYABLE_REFRESH_STATUSES = new Set([429, 500, 502, 503, 504]);
|
|
|
63
63
|
|
|
64
64
|
export const PROVIDER_ID = "kimi-coding";
|
|
65
65
|
|
|
66
|
+
// Owner tag for the entries this extension adds to pi-ai's global api-provider
|
|
67
|
+
// registry, so they can be identified and removed as a group.
|
|
68
|
+
export const KIMI_GLOBAL_FALLBACK_SOURCE_ID = "pi-provider-kimi-code-global-fallback";
|
|
69
|
+
|
|
66
70
|
export function getOAuthHost(): string {
|
|
67
71
|
const value =
|
|
68
72
|
process.env.KIMI_CODE_OAUTH_HOST || process.env.KIMI_OAUTH_HOST || DEFAULT_OAUTH_HOST;
|
package/src/device.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Device identification: device-id persistence, Kimi Code-compatible request
|
|
2
2
|
// headers, and the cross-platform device-model string. Pure helpers live near
|
|
3
|
-
// the side-effecting one-shots (file IO
|
|
3
|
+
// the side-effecting one-shots (file IO) they support.
|
|
4
4
|
|
|
5
|
-
import { execFileSync } from "node:child_process";
|
|
6
5
|
import { randomBytes } from "node:crypto";
|
|
7
6
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { readFile } from "node:fs/promises";
|
|
8
8
|
import os from "node:os";
|
|
9
9
|
import { dirname } from "node:path";
|
|
10
10
|
|
|
@@ -97,25 +97,68 @@ export function computeDeviceModel(input: DeviceModelInput): string {
|
|
|
97
97
|
return `${system} ${arch}`.trim();
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
100
|
+
// Where macOS keeps its product version. Upstream Kimi Code reads it through
|
|
101
|
+
// Python's platform.mac_ver(), which parses this same file, so it is the same
|
|
102
|
+
// source of truth as `sw_vers -productVersion` without the process spawn: a
|
|
103
|
+
// spawn costs ~10ms of blocked event loop, and pi pays it again on every
|
|
104
|
+
// /reload because it re-evaluates extension modules.
|
|
105
|
+
const MAC_PRODUCT_VERSION_PATH = "/System/Library/CoreServices/SystemVersion.plist";
|
|
106
|
+
|
|
107
|
+
// Exposed for tests. The plist is a flat XML property list, so the version sits
|
|
108
|
+
// in the <string> immediately after the ProductVersion key.
|
|
109
|
+
export function parseMacProductVersion(plist: string): string | undefined {
|
|
110
|
+
const match = /<key>ProductVersion<\/key>\s*<string>([^<]*)<\/string>/.exec(plist);
|
|
111
|
+
return match?.[1].trim() || undefined;
|
|
110
112
|
}
|
|
111
113
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
+
let DEVICE_MODEL: string | undefined;
|
|
115
|
+
|
|
116
|
+
function setDeviceModel(macVersion: string | undefined): string {
|
|
117
|
+
DEVICE_MODEL ??= computeDeviceModel({
|
|
114
118
|
platform: process.platform,
|
|
115
119
|
release: os.release(),
|
|
116
120
|
arch: os.machine() || process.arch,
|
|
117
|
-
macVersion
|
|
121
|
+
macVersion,
|
|
118
122
|
});
|
|
123
|
+
return DEVICE_MODEL;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function loadMacDeviceModel(): Promise<void> {
|
|
127
|
+
try {
|
|
128
|
+
setDeviceModel(parseMacProductVersion(await readFile(MAC_PRODUCT_VERSION_PATH, "utf-8")));
|
|
129
|
+
} catch {
|
|
130
|
+
setDeviceModel(undefined);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Settles once the module-load lookup has produced a device model. Nothing in
|
|
136
|
+
* the provider waits for it — headers stay synchronous — but tests use it to
|
|
137
|
+
* exercise the async path without racing the fallback below.
|
|
138
|
+
*/
|
|
139
|
+
export const deviceModelReady: Promise<void> = (() => {
|
|
140
|
+
if (process.platform !== "darwin") {
|
|
141
|
+
// Nothing to read: every other platform derives the model from os.release().
|
|
142
|
+
setDeviceModel(undefined);
|
|
143
|
+
return Promise.resolve();
|
|
144
|
+
}
|
|
145
|
+
// Read the version off the event loop at module load so the value is usually
|
|
146
|
+
// in place before anything asks for a header.
|
|
147
|
+
return loadMacDeviceModel();
|
|
148
|
+
})();
|
|
149
|
+
|
|
150
|
+
function getDeviceModel(): string {
|
|
151
|
+
if (DEVICE_MODEL !== undefined) return DEVICE_MODEL;
|
|
152
|
+
// A header was needed before the async read landed. Read the same file
|
|
153
|
+
// synchronously rather than reporting the kernel release, which would put a
|
|
154
|
+
// different device model on the wire than every later request. This costs
|
|
155
|
+
// ~0.02ms against the ~10ms a `sw_vers` subprocess used to cost, and both
|
|
156
|
+
// paths memoize into the same slot.
|
|
157
|
+
try {
|
|
158
|
+
return setDeviceModel(parseMacProductVersion(readFileSync(MAC_PRODUCT_VERSION_PATH, "utf-8")));
|
|
159
|
+
} catch {
|
|
160
|
+
return setDeviceModel(undefined);
|
|
161
|
+
}
|
|
119
162
|
}
|
|
120
163
|
|
|
121
164
|
export function getOsVersion(): string {
|
|
@@ -133,7 +176,6 @@ export function asciiHeaderValue(value: string, fallback = "unknown"): string {
|
|
|
133
176
|
return sanitized || fallback;
|
|
134
177
|
}
|
|
135
178
|
|
|
136
|
-
const DEVICE_MODEL = getDeviceModel();
|
|
137
179
|
let DEVICE_ID: string | null = null;
|
|
138
180
|
|
|
139
181
|
function getStableDeviceId(): string {
|
|
@@ -174,7 +216,7 @@ export function getCommonHeaders(): Record<string, string> {
|
|
|
174
216
|
"X-Msh-Platform": KIMI_PLATFORM,
|
|
175
217
|
"X-Msh-Version": KIMI_UPSTREAM_VERSION,
|
|
176
218
|
"X-Msh-Device-Name": os.hostname(),
|
|
177
|
-
"X-Msh-Device-Model":
|
|
219
|
+
"X-Msh-Device-Model": getDeviceModel(),
|
|
178
220
|
"X-Msh-Os-Version": getOsVersion(),
|
|
179
221
|
"X-Msh-Device-Id": getStableDeviceId(),
|
|
180
222
|
};
|
package/src/models.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
ModelConfig,
|
|
9
9
|
ModelReasoningMap,
|
|
10
10
|
} from "./config.ts";
|
|
11
|
+
import { DEFAULT_KIMI_CODE_CONFIG } from "./config.ts";
|
|
11
12
|
|
|
12
13
|
import { type KimiWireProtocol, getBaseUrl } from "./constants.ts";
|
|
13
14
|
import { getKimiProviderHeaders } from "./device.ts";
|
|
@@ -36,6 +37,10 @@ const DEFAULT_DISCOVERY_TIMEOUT_MS = 2500;
|
|
|
36
37
|
|
|
37
38
|
export interface DiscoverKimiModelMetadataOptions {
|
|
38
39
|
timeoutMs?: number;
|
|
40
|
+
// 401 recovery for runtime callers (extension startup, settings menu).
|
|
41
|
+
// oauth.ts's login/refresh flows must NOT pass this: they call discovery
|
|
42
|
+
// while holding the OAuth lock, and refreshKimiAuthToken would re-enter it.
|
|
43
|
+
refreshAccessToken?: (currentToken: string) => Promise<string | null>;
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
function mergeInputModalities(
|
|
@@ -84,6 +89,36 @@ function resolveModelCost(
|
|
|
84
89
|
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
85
90
|
}
|
|
86
91
|
|
|
92
|
+
// Policy: a model's output cap tracks its context window unless the user
|
|
93
|
+
// explicitly configured model.maxTokens. pi-ai clamps the request cap to
|
|
94
|
+
// contextWindow - used - 4096 (clampMaxTokensToContext in
|
|
95
|
+
// @earendil-works/pi-ai), so setting maxTokens to the window yields the official
|
|
96
|
+
// kimi-code behavior (computeCompletionBudgetCap: maxCtx, clamped to
|
|
97
|
+
// maxCtx - usedContext) instead of a fixed small cap that truncates long
|
|
98
|
+
// max-effort reasoning (reasoning_content counts toward the cap). A deliberate
|
|
99
|
+
// per-request cap still wins (options.maxTokens ?? model.maxTokens). Reconcile
|
|
100
|
+
// wherever contextWindow is authored so the two stay in lockstep.
|
|
101
|
+
//
|
|
102
|
+
// "Explicitly configured" means any value other than the built-in default:
|
|
103
|
+
// ensureKimiCodeConfig materializes the full default config (including
|
|
104
|
+
// maxTokens) into the home config file, so config-source tracking cannot
|
|
105
|
+
// distinguish a deliberate 32000 from a materialized default. A user who
|
|
106
|
+
// explicitly sets the default value gets the window cap — indistinguishable
|
|
107
|
+
// from the materialized-default case, and the intended new behavior anyway.
|
|
108
|
+
const DEFAULT_MODEL_MAX_TOKENS = DEFAULT_KIMI_CODE_CONFIG.model.maxTokens;
|
|
109
|
+
|
|
110
|
+
// True while the cap is "unset": still the built-in default, or already
|
|
111
|
+
// tracking the context window (set by an earlier withWindowCappedMaxTokens
|
|
112
|
+
// call, before discovery grew the window).
|
|
113
|
+
function isWindowTrackedMaxTokens(model: Model<Api>): boolean {
|
|
114
|
+
return model.maxTokens === DEFAULT_MODEL_MAX_TOKENS || model.maxTokens === model.contextWindow;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function withWindowCappedMaxTokens<M extends Model<Api>>(model: M): M {
|
|
118
|
+
if (!isWindowTrackedMaxTokens(model)) return model;
|
|
119
|
+
return { ...model, maxTokens: model.contextWindow };
|
|
120
|
+
}
|
|
121
|
+
|
|
87
122
|
export function buildKimiModelFromConfig(
|
|
88
123
|
config: ModelConfig,
|
|
89
124
|
modelId = KIMI_CODING_MODEL_ID,
|
|
@@ -97,7 +132,7 @@ export function buildKimiModelFromConfig(
|
|
|
97
132
|
: modelId === KIMI_CODING_MODEL_ID
|
|
98
133
|
? "Kimi for Coding"
|
|
99
134
|
: modelId;
|
|
100
|
-
return {
|
|
135
|
+
return withWindowCappedMaxTokens({
|
|
101
136
|
id: modelId,
|
|
102
137
|
name,
|
|
103
138
|
reasoning: config.reasoning,
|
|
@@ -105,7 +140,7 @@ export function buildKimiModelFromConfig(
|
|
|
105
140
|
cost: { ...resolveModelCost(modelId) },
|
|
106
141
|
contextWindow: config.contextWindow,
|
|
107
142
|
maxTokens: config.maxTokens,
|
|
108
|
-
} as Model<Api
|
|
143
|
+
} as Model<Api>);
|
|
109
144
|
}
|
|
110
145
|
|
|
111
146
|
export function resolveKimiModelConfig(
|
|
@@ -225,15 +260,30 @@ export async function discoverKimiModelMetadata(
|
|
|
225
260
|
const controller = new AbortController();
|
|
226
261
|
const timeout =
|
|
227
262
|
timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs).unref() : undefined;
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
signal
|
|
263
|
+
const requestModels = (token: string, signal: AbortSignal) =>
|
|
264
|
+
fetch(modelsUrl, {
|
|
265
|
+
signal,
|
|
231
266
|
headers: {
|
|
232
267
|
...getKimiProviderHeaders(),
|
|
233
|
-
Authorization: `Bearer ${
|
|
268
|
+
Authorization: `Bearer ${token}`,
|
|
234
269
|
Accept: "application/json",
|
|
235
270
|
},
|
|
236
271
|
});
|
|
272
|
+
try {
|
|
273
|
+
let response = await requestModels(accessToken, controller.signal);
|
|
274
|
+
if (response.status === 401 && options.refreshAccessToken) {
|
|
275
|
+
const refreshed = await options.refreshAccessToken(accessToken);
|
|
276
|
+
if (refreshed && refreshed !== accessToken) {
|
|
277
|
+
const retryController = new AbortController();
|
|
278
|
+
const retryTimeout =
|
|
279
|
+
timeoutMs > 0 ? setTimeout(() => retryController.abort(), timeoutMs).unref() : undefined;
|
|
280
|
+
try {
|
|
281
|
+
response = await requestModels(refreshed, retryController.signal);
|
|
282
|
+
} finally {
|
|
283
|
+
if (retryTimeout) clearTimeout(retryTimeout);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
237
287
|
if (!response.ok) return {};
|
|
238
288
|
const json = (await response.json()) as { data?: unknown };
|
|
239
289
|
const list = Array.isArray(json.data) ? (json.data as KimiServerModel[]) : [];
|
|
@@ -306,6 +356,9 @@ export function applyKimiOAuthExtrasToModel(
|
|
|
306
356
|
extras: KimiModelMetadata,
|
|
307
357
|
reasoningMap?: ModelReasoningMap,
|
|
308
358
|
): Model<Api> {
|
|
359
|
+
// Capture before extras grow the window: a window-tracked cap must follow
|
|
360
|
+
// the new window, an explicit cap must survive unchanged.
|
|
361
|
+
const windowTracked = isWindowTrackedMaxTokens(model);
|
|
309
362
|
const next: Model<Api> & {
|
|
310
363
|
wireModelId?: string;
|
|
311
364
|
supportsThinkingType?: "only" | "no" | "both";
|
|
@@ -347,5 +400,7 @@ export function applyKimiOAuthExtrasToModel(
|
|
|
347
400
|
if (thinkingLevelMap) next.thinkingLevelMap = thinkingLevelMap;
|
|
348
401
|
else delete next.thinkingLevelMap;
|
|
349
402
|
}
|
|
403
|
+
// Reconcile the output cap with the (possibly discovery-updated) window.
|
|
404
|
+
if (windowTracked) next.maxTokens = next.contextWindow;
|
|
350
405
|
return next;
|
|
351
406
|
}
|
package/src/oauth.ts
CHANGED
|
@@ -658,7 +658,13 @@ export function isKimiAuthErrorMessage(message: unknown): boolean {
|
|
|
658
658
|
/\b401\b/.test(text) ||
|
|
659
659
|
text.includes("unauthorized") ||
|
|
660
660
|
text.includes("incorrect api key") ||
|
|
661
|
-
text.includes("invalid api key")
|
|
661
|
+
text.includes("invalid api key") ||
|
|
662
|
+
// Kimi's production 401 body: "The API Key appears to be invalid or may
|
|
663
|
+
// have expired..." with type "invalid_authentication_error". Neither
|
|
664
|
+
// contains the substrings above, so without these the digit fallback is
|
|
665
|
+
// the only match and vanishes when a transport strips the status prefix.
|
|
666
|
+
text.includes("api key appears to be invalid") ||
|
|
667
|
+
text.includes("invalid_authentication_error")
|
|
662
668
|
);
|
|
663
669
|
}
|
|
664
670
|
|
package/src/payload.ts
CHANGED
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
// per-protocol message transforms, OpenAI tool-call / tool-schema normalizers,
|
|
3
3
|
// and the top-level applyKimiPayloadMutations that orchestrates them.
|
|
4
4
|
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
|
|
5
7
|
import type { CacheRetention, ThinkingLevel } from "@earendil-works/pi-ai";
|
|
6
8
|
import type { KimiResolvedModelConfig, ModelReasoningEntry } from "./config.ts";
|
|
7
9
|
|
|
8
10
|
import { getBaseUrl } from "./constants.ts";
|
|
9
11
|
import { getKimiProviderHeaders } from "./device.ts";
|
|
12
|
+
import { refreshKimiAuthToken } from "./oauth.ts";
|
|
10
13
|
import { optimizeToolSchemas } from "./schema-dedup.ts";
|
|
11
14
|
|
|
12
15
|
// =============================================================================
|
|
@@ -30,6 +33,7 @@ export function resolveCacheRetention(value?: CacheRetention): CacheRetention {
|
|
|
30
33
|
export interface KimiPayloadContext {
|
|
31
34
|
api: "anthropic-messages" | "openai-completions";
|
|
32
35
|
upload?: Uploader;
|
|
36
|
+
uploadCacheScope?: string;
|
|
33
37
|
cacheKey?: string;
|
|
34
38
|
cacheRetention: CacheRetention;
|
|
35
39
|
reasoning?: ThinkingLevel;
|
|
@@ -80,6 +84,16 @@ function getUploadFilename(mimeType: string): string {
|
|
|
80
84
|
"image/png": "upload.png",
|
|
81
85
|
"image/gif": "upload.gif",
|
|
82
86
|
"image/webp": "upload.webp",
|
|
87
|
+
// Video extensions mirror upstream kimi-code's MIME_TO_EXT
|
|
88
|
+
// (packages/kosong/src/providers/kimi-files.ts).
|
|
89
|
+
"video/mp4": "upload.mp4",
|
|
90
|
+
"video/mpeg": "upload.mpeg",
|
|
91
|
+
"video/quicktime": "upload.mov",
|
|
92
|
+
"video/webm": "upload.webm",
|
|
93
|
+
"video/x-matroska": "upload.mkv",
|
|
94
|
+
"video/x-msvideo": "upload.avi",
|
|
95
|
+
"video/x-flv": "upload.flv",
|
|
96
|
+
"video/3gpp": "upload.3gp",
|
|
83
97
|
};
|
|
84
98
|
return map[mimeType] ?? "upload.bin";
|
|
85
99
|
}
|
|
@@ -88,22 +102,34 @@ function getUploadFilename(mimeType: string): string {
|
|
|
88
102
|
// File upload (I/O edge)
|
|
89
103
|
// =============================================================================
|
|
90
104
|
|
|
105
|
+
export interface UploadKimiFileDeps {
|
|
106
|
+
fetch?: typeof fetch;
|
|
107
|
+
refreshAccessToken?: (currentToken: string) => Promise<string | null>;
|
|
108
|
+
}
|
|
109
|
+
|
|
91
110
|
export async function uploadKimiFile(
|
|
92
111
|
apiKey: string,
|
|
93
112
|
mimeType: string,
|
|
94
113
|
data: string,
|
|
95
114
|
thresholdBytes?: number,
|
|
115
|
+
deps?: UploadKimiFileDeps,
|
|
96
116
|
): Promise<string | null> {
|
|
117
|
+
const fetchImpl = deps?.fetch ?? fetch;
|
|
118
|
+
const refreshAccessToken = deps?.refreshAccessToken ?? refreshKimiAuthToken;
|
|
97
119
|
const buffer = Buffer.from(data, "base64");
|
|
98
|
-
|
|
120
|
+
const isVideo = mimeType.startsWith("video/");
|
|
121
|
+
if (!mimeType.startsWith("image/") && !isVideo) return null;
|
|
99
122
|
const threshold =
|
|
100
123
|
thresholdBytes ?? parseInlineUploadThreshold(process.env.KIMI_CODE_UPLOAD_THRESHOLD_BYTES);
|
|
101
|
-
|
|
124
|
+
// The inline threshold applies to images only: the Kimi API has no inline
|
|
125
|
+
// video path (upstream kimi-code uploads every video via /files), so videos
|
|
126
|
+
// always upload.
|
|
127
|
+
if (!isVideo && buffer.length <= threshold) return null;
|
|
102
128
|
|
|
103
129
|
const filename = getUploadFilename(mimeType);
|
|
104
130
|
const formData = new FormData();
|
|
105
131
|
formData.append("file", new Blob([buffer], { type: mimeType }), filename);
|
|
106
|
-
formData.append("purpose", "image");
|
|
132
|
+
formData.append("purpose", isVideo ? "video" : "image");
|
|
107
133
|
|
|
108
134
|
const uploadUrl = `${deriveFilesBaseUrl(getBaseUrl())}/files`;
|
|
109
135
|
const debug = process.env.KIMI_CODE_DEBUG === "1";
|
|
@@ -113,13 +139,31 @@ export async function uploadKimiFile(
|
|
|
113
139
|
);
|
|
114
140
|
}
|
|
115
141
|
|
|
116
|
-
|
|
117
|
-
|
|
142
|
+
const postUpload = (token: string) =>
|
|
143
|
+
fetchImpl(uploadUrl, {
|
|
118
144
|
method: "POST",
|
|
119
|
-
headers: { ...getKimiProviderHeaders(), Authorization: `Bearer ${
|
|
145
|
+
headers: { ...getKimiProviderHeaders(), Authorization: `Bearer ${token}` },
|
|
120
146
|
body: formData,
|
|
121
147
|
});
|
|
122
|
-
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
let response = await postUpload(apiKey);
|
|
151
|
+
// Kimi access tokens are short-lived and invalidated server-side as soon
|
|
152
|
+
// as any peer process rotates them, so a 401 here usually means our key
|
|
153
|
+
// snapshot went stale mid-session, not that login is broken. Mirror the
|
|
154
|
+
// chat-stream recovery: force one refresh and retry once.
|
|
155
|
+
let responseText: string | undefined;
|
|
156
|
+
if (response.status === 401) {
|
|
157
|
+
responseText = await response.text();
|
|
158
|
+
const refreshed = await refreshAccessToken(apiKey);
|
|
159
|
+
if (refreshed && refreshed !== apiKey) {
|
|
160
|
+
console.error("[kimi-coding] upload got 401, retrying with refreshed token");
|
|
161
|
+
response = await postUpload(refreshed);
|
|
162
|
+
responseText = undefined;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (!response.ok)
|
|
166
|
+
throw new Error(`${response.status} ${responseText ?? (await response.text())}`);
|
|
123
167
|
const fileObj = (await response.json()) as { id?: string };
|
|
124
168
|
if (!fileObj.id) throw new Error("missing file id");
|
|
125
169
|
const fileUrl = `ms://${fileObj.id}`;
|
|
@@ -132,24 +176,69 @@ export async function uploadKimiFile(
|
|
|
132
176
|
}
|
|
133
177
|
|
|
134
178
|
// =============================================================================
|
|
135
|
-
// Payload file transformers
|
|
179
|
+
// Payload file transformers
|
|
136
180
|
//
|
|
137
181
|
// These walk the provider-specific payload shape and replace inline base64
|
|
138
182
|
// image blocks with ms:// references returned by the injected uploader. They
|
|
139
183
|
// take an Uploader rather than an apiKey so they can be unit-tested with a
|
|
140
184
|
// fake uploader; all network I/O stays behind that boundary.
|
|
185
|
+
//
|
|
186
|
+
// Successful uploads are remembered in a module-level cache shared across
|
|
187
|
+
// requests: payloads are rebuilt from session context every request, so
|
|
188
|
+
// without it a conversation's images would re-upload on every turn. Keys are
|
|
189
|
+
// content hashes rather than the data URLs themselves so the cache does not
|
|
190
|
+
// retain every image's base64 payload in memory. Failures are not cached and
|
|
191
|
+
// retry on the next request.
|
|
141
192
|
// =============================================================================
|
|
142
193
|
|
|
143
|
-
|
|
194
|
+
const MAX_UPLOADED_FILE_CACHE_ENTRIES = 512;
|
|
195
|
+
const uploadedFileCache = new Map<string, string>();
|
|
196
|
+
|
|
197
|
+
function uploadedFileCacheKey(cacheScope: string, mimeType: string, data: string): string {
|
|
198
|
+
return createHash("sha256")
|
|
199
|
+
.update(cacheScope)
|
|
200
|
+
.update("\0")
|
|
201
|
+
.update(mimeType)
|
|
202
|
+
.update("\0")
|
|
203
|
+
.update(data)
|
|
204
|
+
.digest("hex");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function rememberUploadedFile(
|
|
208
|
+
uploadCache: Map<string, string>,
|
|
209
|
+
cacheKey: string,
|
|
210
|
+
url: string,
|
|
211
|
+
): void {
|
|
212
|
+
if (
|
|
213
|
+
uploadCache === uploadedFileCache &&
|
|
214
|
+
!uploadCache.has(cacheKey) &&
|
|
215
|
+
uploadCache.size >= MAX_UPLOADED_FILE_CACHE_ENTRIES
|
|
216
|
+
) {
|
|
217
|
+
const oldest = uploadCache.keys().next().value;
|
|
218
|
+
if (oldest !== undefined) uploadCache.delete(oldest);
|
|
219
|
+
}
|
|
220
|
+
uploadCache.set(cacheKey, url);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function clearKimiUploadedFileCache(): void {
|
|
224
|
+
uploadedFileCache.clear();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function transformOpenAIPayloadFiles(
|
|
228
|
+
payload: JsonRecord,
|
|
229
|
+
upload: Uploader,
|
|
230
|
+
uploadCache: Map<string, string>,
|
|
231
|
+
uploadCacheScope: string,
|
|
232
|
+
): Promise<void> {
|
|
144
233
|
if (!Array.isArray(payload.messages)) return;
|
|
145
|
-
const cache = new Map<string, string>();
|
|
146
234
|
|
|
147
235
|
for (const message of payload.messages) {
|
|
148
236
|
if (!isRecord(message) || !Array.isArray(message.content)) continue;
|
|
149
237
|
|
|
150
238
|
for (const block of message.content) {
|
|
151
239
|
if (!isRecord(block)) continue;
|
|
152
|
-
const key =
|
|
240
|
+
const key =
|
|
241
|
+
block.type === "image_url" ? "image_url" : block.type === "video_url" ? "video_url" : null;
|
|
153
242
|
if (!key) continue;
|
|
154
243
|
|
|
155
244
|
const field = block[key];
|
|
@@ -164,9 +253,10 @@ async function transformOpenAIPayloadFiles(payload: JsonRecord, upload: Uploader
|
|
|
164
253
|
const parsed = parseDataUrl(urlValue);
|
|
165
254
|
if (!parsed) continue;
|
|
166
255
|
|
|
167
|
-
const
|
|
256
|
+
const cacheKey = uploadedFileCacheKey(uploadCacheScope, parsed.mimeType, parsed.data);
|
|
257
|
+
const uploaded = uploadCache.get(cacheKey) ?? (await upload(parsed.mimeType, parsed.data));
|
|
168
258
|
if (!uploaded) continue;
|
|
169
|
-
|
|
259
|
+
rememberUploadedFile(uploadCache, cacheKey, uploaded);
|
|
170
260
|
|
|
171
261
|
block[key] =
|
|
172
262
|
typeof field === "string" ? uploaded : { ...(field as JsonRecord), url: uploaded };
|
|
@@ -330,9 +420,10 @@ function normalizeOpenAIToolSchemas(payload: JsonRecord): void {
|
|
|
330
420
|
async function transformAnthropicPayloadFiles(
|
|
331
421
|
payload: JsonRecord,
|
|
332
422
|
upload: Uploader,
|
|
423
|
+
uploadCache: Map<string, string>,
|
|
424
|
+
uploadCacheScope: string,
|
|
333
425
|
): Promise<void> {
|
|
334
426
|
if (!Array.isArray(payload.messages)) return;
|
|
335
|
-
const cache = new Map<string, string>();
|
|
336
427
|
|
|
337
428
|
const transformImageBlock = async (block: unknown): Promise<unknown> => {
|
|
338
429
|
if (!isRecord(block) || block.type !== "image") return block;
|
|
@@ -342,10 +433,10 @@ async function transformAnthropicPayloadFiles(
|
|
|
342
433
|
const data = source.data;
|
|
343
434
|
if (typeof mediaType !== "string" || typeof data !== "string") return block;
|
|
344
435
|
|
|
345
|
-
const cacheKey =
|
|
346
|
-
const uploaded =
|
|
436
|
+
const cacheKey = uploadedFileCacheKey(uploadCacheScope, mediaType, data);
|
|
437
|
+
const uploaded = uploadCache.get(cacheKey) ?? (await upload(mediaType, data));
|
|
347
438
|
if (!uploaded) return block;
|
|
348
|
-
|
|
439
|
+
rememberUploadedFile(uploadCache, cacheKey, uploaded);
|
|
349
440
|
|
|
350
441
|
const next: JsonRecord = { type: "image", source: { type: "url", url: uploaded } };
|
|
351
442
|
if (block.cache_control !== undefined) next.cache_control = block.cache_control;
|
|
@@ -390,10 +481,12 @@ export async function applyKimiPayloadMutations(
|
|
|
390
481
|
|
|
391
482
|
// 2. File upload dispatch (protocol-specific).
|
|
392
483
|
if (ctx.upload) {
|
|
484
|
+
const uploadCache = ctx.uploadCacheScope ? uploadedFileCache : new Map<string, string>();
|
|
485
|
+
const uploadCacheScope = ctx.uploadCacheScope ?? "request";
|
|
393
486
|
if (ctx.api === "openai-completions") {
|
|
394
|
-
await transformOpenAIPayloadFiles(payload, ctx.upload);
|
|
487
|
+
await transformOpenAIPayloadFiles(payload, ctx.upload, uploadCache, uploadCacheScope);
|
|
395
488
|
} else if (ctx.api === "anthropic-messages") {
|
|
396
|
-
await transformAnthropicPayloadFiles(payload, ctx.upload);
|
|
489
|
+
await transformAnthropicPayloadFiles(payload, ctx.upload, uploadCache, uploadCacheScope);
|
|
397
490
|
}
|
|
398
491
|
}
|
|
399
492
|
if (ctx.api === "openai-completions") {
|
|
@@ -447,8 +540,17 @@ export async function applyKimiPayloadMutations(
|
|
|
447
540
|
}
|
|
448
541
|
|
|
449
542
|
const generation = ctx.modelConfig.generation;
|
|
543
|
+
// Official kimi-code sends temperature/top_p only when explicitly configured
|
|
544
|
+
// (env KIMI_MODEL_TEMPERATURE / KIMI_MODEL_TOP_P → generation.*); otherwise it
|
|
545
|
+
// omits them and lets the server apply its own defaults. Mirror that exactly:
|
|
546
|
+
// drop any value pi-ai seeded by default when the user hasn't configured one
|
|
547
|
+
// (the official client does not pin these to 1.0 / 0.95).
|
|
450
548
|
if (generation.temperature !== undefined) payload.temperature = generation.temperature;
|
|
549
|
+
else delete payload.temperature;
|
|
451
550
|
if (generation.topP !== undefined) payload.top_p = generation.topP;
|
|
551
|
+
else delete payload.top_p;
|
|
552
|
+
// Output cap: an explicit cap (KIMI_MODEL_MAX_COMPLETION_TOKENS / config →
|
|
553
|
+
// generation.maxCompletionTokens) is honored as a hardCap on the request value.
|
|
452
554
|
if (generation.maxCompletionTokens !== undefined) {
|
|
453
555
|
const maxTokensKey = ctx.api === "anthropic-messages" ? "max_tokens" : "max_completion_tokens";
|
|
454
556
|
const currentMaxTokens = payload[maxTokensKey];
|
|
@@ -469,22 +571,39 @@ export async function applyKimiPayloadMutations(
|
|
|
469
571
|
const mapped = resolveReasoningForLevel(resolvedReasoning, ctx.modelConfig);
|
|
470
572
|
if (mapped) {
|
|
471
573
|
const oldThinking = isRecord(payload.thinking) ? payload.thinking : {};
|
|
472
|
-
const thinking: JsonRecord = {
|
|
473
|
-
...oldThinking,
|
|
474
|
-
type: mapped.enabled ? "enabled" : "disabled",
|
|
475
|
-
};
|
|
476
|
-
delete thinking.effort;
|
|
477
|
-
if (!mapped.enabled) delete thinking.keep;
|
|
478
574
|
const effort = ctx.reasoning
|
|
479
575
|
? mapped.effort
|
|
480
576
|
: (ctx.modelConfig.defaultEffort ?? mapped.effort);
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
577
|
+
const effortSupported =
|
|
578
|
+
effort !== null && ctx.modelConfig.supportEfforts?.includes(effort) === true;
|
|
579
|
+
if (ctx.api === "anthropic-messages" && oldThinking.type === "adaptive") {
|
|
580
|
+
// pi-ai >=0.82 builds adaptive thinking for models carrying
|
|
581
|
+
// compat.forceAdaptiveThinking (streamSimpleKimi sets it on the
|
|
582
|
+
// anthropic runtime model). Keep the adaptive shape — effort lives in
|
|
583
|
+
// top-level output_config there, not inside thinking — and only
|
|
584
|
+
// replace it with an explicit disable when the level maps to off.
|
|
585
|
+
if (mapped.enabled) {
|
|
586
|
+
if (effortSupported) payload.output_config = { effort };
|
|
587
|
+
else delete payload.output_config;
|
|
588
|
+
} else {
|
|
589
|
+
payload.thinking = { type: "disabled" };
|
|
590
|
+
delete payload.output_config;
|
|
591
|
+
}
|
|
592
|
+
} else {
|
|
593
|
+
const thinking: JsonRecord = {
|
|
594
|
+
...oldThinking,
|
|
595
|
+
type: mapped.enabled ? "enabled" : "disabled",
|
|
596
|
+
};
|
|
597
|
+
delete thinking.effort;
|
|
598
|
+
if (!mapped.enabled) delete thinking.keep;
|
|
599
|
+
if (mapped.enabled && effortSupported) {
|
|
600
|
+
thinking.effort = effort;
|
|
601
|
+
}
|
|
602
|
+
if (mapped.enabled && ctx.modelConfig.thinkingKeep) {
|
|
603
|
+
thinking.keep = ctx.modelConfig.thinkingKeep;
|
|
604
|
+
}
|
|
605
|
+
payload.thinking = thinking;
|
|
486
606
|
}
|
|
487
|
-
payload.thinking = thinking;
|
|
488
607
|
}
|
|
489
608
|
}
|
|
490
609
|
|
|
@@ -510,15 +629,10 @@ export async function applyKimiPayloadMutations(
|
|
|
510
629
|
}
|
|
511
630
|
}
|
|
512
631
|
|
|
513
|
-
// 8. K2.7 Code API constraints: the server rejects
|
|
514
|
-
//
|
|
515
|
-
//
|
|
516
|
-
|
|
517
|
-
delete payload.temperature;
|
|
518
|
-
}
|
|
519
|
-
if (payload.top_p !== undefined && payload.top_p !== 0.95) {
|
|
520
|
-
delete payload.top_p;
|
|
521
|
-
}
|
|
632
|
+
// 8. K2.7 Code API constraints: the server rejects tool_choice "required" /
|
|
633
|
+
// function-specific when thinking is enabled (always-on). temperature/top_p
|
|
634
|
+
// are handled in step 6 — omitted unless explicitly configured, matching the
|
|
635
|
+
// official kimi-code client rather than pinned to 1.0/0.95.
|
|
522
636
|
if (payload.tool_choice !== undefined) {
|
|
523
637
|
const tc = payload.tool_choice;
|
|
524
638
|
const isAllowed =
|
package/src/stream.ts
CHANGED
|
@@ -58,9 +58,13 @@ import {
|
|
|
58
58
|
type KimiResolvedModelConfig,
|
|
59
59
|
} from "./config.ts";
|
|
60
60
|
|
|
61
|
-
import { ENV_KIMI_CODE_PROTOCOL, getApiProtocol, getBaseUrl } from "./constants.ts";
|
|
61
|
+
import { ENV_KIMI_CODE_PROTOCOL, PROVIDER_ID, getApiProtocol, getBaseUrl } from "./constants.ts";
|
|
62
62
|
import { getKimiProviderHeaders } from "./device.ts";
|
|
63
|
-
import {
|
|
63
|
+
import {
|
|
64
|
+
isKimiAuthErrorMessage,
|
|
65
|
+
readStoredOAuthCredential,
|
|
66
|
+
refreshKimiAuthToken,
|
|
67
|
+
} from "./oauth.ts";
|
|
64
68
|
import {
|
|
65
69
|
type Uploader,
|
|
66
70
|
applyKimiPayloadMutations,
|
|
@@ -190,11 +194,21 @@ export async function* filterEmptyResponseStream(
|
|
|
190
194
|
|
|
191
195
|
const KIMI_API_KEY_ENV_REFERENCES = new Set(["$KIMI_API_KEY", "${KIMI_API_KEY}"]);
|
|
192
196
|
|
|
197
|
+
// On the normal request path pi resolves the provider credential through its
|
|
198
|
+
// ModelRuntime and hands it to us. Callers that arrive through pi-ai's global
|
|
199
|
+
// api registry (a bare agentLoop() with no streamFn) have no ModelRuntime
|
|
200
|
+
// behind them and pass nothing, so read the same credential store pi itself
|
|
201
|
+
// would have read instead of sending an unauthenticated request. Kept last so
|
|
202
|
+
// an explicit key and the environment override keep their existing precedence.
|
|
203
|
+
function getStoredKimiAccessToken(): string {
|
|
204
|
+
return readStoredOAuthCredential(PROVIDER_ID)?.access?.trim() || "";
|
|
205
|
+
}
|
|
206
|
+
|
|
193
207
|
export function resolveKimiApiKey(apiKey: string | undefined): string {
|
|
194
208
|
if (apiKey !== undefined && KIMI_API_KEY_ENV_REFERENCES.has(apiKey)) {
|
|
195
|
-
return process.env.KIMI_API_KEY?.trim() ||
|
|
209
|
+
return process.env.KIMI_API_KEY?.trim() || getStoredKimiAccessToken();
|
|
196
210
|
}
|
|
197
|
-
return apiKey || process.env.KIMI_API_KEY ||
|
|
211
|
+
return apiKey || process.env.KIMI_API_KEY || getStoredKimiAccessToken();
|
|
198
212
|
}
|
|
199
213
|
|
|
200
214
|
// SimpleStreamOptions.headers is Record<string, string> on pi <=0.79 and
|
|
@@ -285,6 +299,7 @@ export function streamSimpleKimi(
|
|
|
285
299
|
await applyKimiPayloadMutations(nextPayload, {
|
|
286
300
|
api: apiProtocol,
|
|
287
301
|
upload,
|
|
302
|
+
uploadCacheScope: apiKey ? `${getBaseUrl(wireProtocol)}:${apiKey}` : undefined,
|
|
288
303
|
cacheKey,
|
|
289
304
|
cacheRetention,
|
|
290
305
|
reasoning: options?.reasoning,
|
|
@@ -323,6 +338,19 @@ export function streamSimpleKimi(
|
|
|
323
338
|
...model,
|
|
324
339
|
api: apiProtocol,
|
|
325
340
|
baseUrl: getBaseUrl(wireProtocol),
|
|
341
|
+
// Kimi's anthropic-protocol endpoint speaks adaptive thinking and
|
|
342
|
+
// rejects/ignores the interleaved-thinking beta header; pi-ai >=0.82
|
|
343
|
+
// keys both behaviors off these compat flags (its own built-in
|
|
344
|
+
// kimi-coding models carry them in generated metadata).
|
|
345
|
+
...(wireProtocol === "anthropic"
|
|
346
|
+
? {
|
|
347
|
+
compat: {
|
|
348
|
+
...(model as { compat?: Record<string, unknown> }).compat,
|
|
349
|
+
forceAdaptiveThinking: true,
|
|
350
|
+
allowEmptySignature: true,
|
|
351
|
+
},
|
|
352
|
+
}
|
|
353
|
+
: {}),
|
|
326
354
|
} as Model<Api>;
|
|
327
355
|
const upstream =
|
|
328
356
|
wireProtocol === "openai"
|