pi-provider-kimi-code 0.6.12 → 0.6.13
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 +25 -7
- package/index.ts +27 -0
- package/package.json +6 -5
- package/src/config.ts +33 -1
- package/src/constants.ts +86 -9
- package/src/models.ts +15 -1
- package/src/oauth.ts +42 -1
- package/src/payload.ts +266 -1
- package/src/settings-ui.ts +20 -0
- package/src/stream.ts +1 -0
- package/src/tools/common.ts +2 -3
- package/src/usage.ts +116 -7
package/README.md
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/pi-provider-kimi-code)
|
|
4
4
|
[](./LICENSE)
|
|
5
5
|
|
|
6
|
-
> **Kimi Code extension for Pi — use K3, K2.
|
|
6
|
+
> **Kimi Code extension for Pi — use K3, K2.8 Code, and HighSpeed models with your Kimi Code Plan.**
|
|
7
|
+
>
|
|
8
|
+
> Why you need it: Pi v0.82.0+ ships a built-in `kimi-coding` provider for model discovery, reasoning controls, and OAuth login, but it does not provide this extension's Kimi Code integrations. Without this extension, Pi and the official `kimi-code` CLI log in separately (a login in one does not authenticate the other), large inline images blow up requests or fail because nothing uploads them through Kimi's Files API, Moonshot's server-side tools (`moonshot_search`, `moonshot_fetch`, `kimi_datasource`) are unavailable, Pi's tool schemas regularly exceed Moonshot's 15 KB per-tool limit and get rejected, and accounts on the wrong side of the mainland-cn / global split (kimi.com vs kimi.ai) fail with 401 because the built-in provider has no region switching. This extension closes those gaps — see [Why this exists](#why-this-exists) below for details.
|
|
7
9
|
|
|
8
10
|
## Why this exists
|
|
9
11
|
|
|
@@ -12,7 +14,8 @@ Use the built-in provider if you only want to chat with Kimi models. Install thi
|
|
|
12
14
|
- **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.
|
|
13
15
|
- **Send files the Kimi way.** Large inline images go through Kimi's Files API and become `ms://` references instead of huge base64 payloads.
|
|
14
16
|
- **Use Kimi-native tools.** `moonshot_search`, `moonshot_fetch`, and `kimi_datasource` are opt-in tools that call Moonshot's server-side services.
|
|
15
|
-
- **
|
|
17
|
+
- **Meet Kimi's official schema requirements.** 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 adapts Pi's tool schemas to Kimi's requirements by automatically deduplicating them with `$ref`/`$defs` before sending, so subagents and other extensions don't break.
|
|
18
|
+
- **Switch regions.** Kimi runs two managed sides (`mainland-cn` on kimi.com, `global` on kimi.ai). The extension carries the region selection through API endpoints, OAuth, and `/login`, so a kimi.ai account no longer dies with a 401 against the default `.com` endpoints.
|
|
16
19
|
- **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.
|
|
17
20
|
|
|
18
21
|
## What this package adds
|
|
@@ -72,12 +75,25 @@ Pi's built-in `kimi-coding` provider already supports OAuth login via `/login`.
|
|
|
72
75
|
- **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.
|
|
73
76
|
- **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.
|
|
74
77
|
- **Supports the legacy `~/.kimi` path.** Read-only. Set `KIMI_SHARE_DIR` to override it.
|
|
78
|
+
- **Follows your region.** Managed endpoints follow the selected region (`mainland-cn` default, `global` for kimi.ai accounts) for API, OAuth, and `/login`. See [Region selection](#region-selection).
|
|
75
79
|
- **`KIMI_API_KEY` fallback.** For CI or key-based access:
|
|
76
80
|
|
|
77
81
|
```bash
|
|
78
82
|
KIMI_API_KEY=sk-... pi
|
|
79
83
|
```
|
|
80
84
|
|
|
85
|
+
### Region selection
|
|
86
|
+
|
|
87
|
+
Kimi runs two managed sides: `api.kimi.com` / `auth.kimi.com` (mainland China, default) and `api.kimi.ai` / `auth.kimi.ai` (global). If your account lives on the kimi.ai side, a global OAuth session or API key against the default `.com` endpoints fails with 401.
|
|
88
|
+
|
|
89
|
+
Precedence, lowest to highest:
|
|
90
|
+
|
|
91
|
+
1. Built-in default (`mainland-cn`)
|
|
92
|
+
2. `KIMI_CODE_REGION` env var (`mainland-cn` or `global`)
|
|
93
|
+
3. `region` in merged home/project `config.json` (`"mainland-cn"` / `"global"` / `null` = auto; project overrides home); the **Region** row in `/kimi-settings` edits this config
|
|
94
|
+
|
|
95
|
+
`KIMI_CODE_BASE_URL` / `KIMI_CODE_OAUTH_HOST` overrides always win, and unknown values fall back to `mainland-cn`. `/login kimi-coding` follows the selected region end to end. See [docs/ENV.md](docs/ENV.md) for details.
|
|
96
|
+
|
|
81
97
|
## Models (legacy provider registration)
|
|
82
98
|
|
|
83
99
|
> **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.
|
|
@@ -90,9 +106,9 @@ When this extension does register models, the official catalog determines what i
|
|
|
90
106
|
| `kimi-coding/kimi-for-coding-highspeed` | Kimi for Coding High Speed | Official catalog |
|
|
91
107
|
| `kimi-coding/k3` | Kimi K3 | Official catalog |
|
|
92
108
|
|
|
93
|
-
Kimi's official catalog at `https://api.kimi.com/coding/v1/models` is authoritative for model availability and context windows.
|
|
109
|
+
Kimi's official catalog at `https://api.kimi.com/coding/v1/models` is authoritative for model availability and context windows. Since the K2.8 Preview rollout (2026-09-11), the `kimi-for-coding` ID is upgraded in place — same model ID, new engine, no config change needed. K2.8 advertises up to 1M tokens of context across all membership tiers; the catalog reports the exact window for your account.
|
|
94
110
|
|
|
95
|
-
|
|
111
|
+
When discovery is unavailable, the extension uses the fallback models and defaults below (conservative pre-discovery values):
|
|
96
112
|
|
|
97
113
|
- Context window: `256k` tokens
|
|
98
114
|
- Max output: `32k` tokens
|
|
@@ -113,7 +129,7 @@ Inside Pi, run:
|
|
|
113
129
|
/kimi-settings
|
|
114
130
|
```
|
|
115
131
|
|
|
116
|
-
That command shows your signed-in account (via Kimi's `/me` endpoint), the current server-side model name (e.g. "K2.
|
|
132
|
+
That command shows your signed-in account (via Kimi's `/me` endpoint), the current server-side model name (e.g. "K2.8 Code High Speed"), your Kimi quota and Extra Usage balance, the effective region, and lets you edit the home or project config. Changes apply to the active session tool set.
|
|
117
133
|
|
|
118
134
|
Configurable settings include protocol mode, upload threshold, and per-tool enable/collapse.
|
|
119
135
|
|
|
@@ -137,6 +153,7 @@ Project config overrides home config with a deep merge. Missing files or missing
|
|
|
137
153
|
```json
|
|
138
154
|
{
|
|
139
155
|
"protocol": "openai",
|
|
156
|
+
"region": "global",
|
|
140
157
|
"uploads": { "thresholdBytes": 1048576 },
|
|
141
158
|
"tools": {
|
|
142
159
|
"moonshot_search": { "enabled": true, "default_collapsed": true },
|
|
@@ -184,6 +201,7 @@ Most users do not need environment variables. Two are worth knowing:
|
|
|
184
201
|
|
|
185
202
|
- `KIMI_API_KEY` — static API key for CI or key-based access.
|
|
186
203
|
- `KIMI_CODE_PROTOCOL` — `openai` by default; set to `anthropic` for Anthropic-compatible requests, or `responses` for the OpenAI Responses API.
|
|
204
|
+
- `KIMI_CODE_REGION` — `mainland-cn` (default) or `global`; selects the managed API/OAuth endpoints for kimi.com vs kimi.ai accounts.
|
|
187
205
|
|
|
188
206
|
Tools, protocol, and upload threshold are all configurable through `/kimi-settings` or JSON config files.
|
|
189
207
|
|
|
@@ -219,7 +237,7 @@ The login flow always prints a verification URL. Copy it into a browser manually
|
|
|
219
237
|
|
|
220
238
|
### "Access denied" or subscription errors after a successful login
|
|
221
239
|
|
|
222
|
-
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.
|
|
240
|
+
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. A 401 right after login can also mean a region mismatch — accounts registered on the kimi.ai side must run with `KIMI_CODE_REGION=global` (or the matching `region` config / `/kimi-settings` row).
|
|
223
241
|
|
|
224
242
|
### Tools do not show up or return errors
|
|
225
243
|
|
|
@@ -227,7 +245,7 @@ Run `/kimi-settings` and check whether the tool is enabled in the home or projec
|
|
|
227
245
|
|
|
228
246
|
### Large images fail with a payload error
|
|
229
247
|
|
|
230
|
-
This extension uploads images over `KIMI_CODE_UPLOAD_THRESHOLD_BYTES` (default 1 MB) to Kimi's Files API and references them as `ms://`. Set `KIMI_CODE_DEBUG=1` to see upload decisions in the provider logs.
|
|
248
|
+
This extension uploads images over `KIMI_CODE_UPLOAD_THRESHOLD_BYTES` (default 1 MB) to Kimi's Files API and references them as `ms://`. Independently of that, a per-request inline media budget (~20 MB of base64 on the wire) drops oversized inline images and videos from the payload and leaves a media-kind-specific placeholder, such as `[image omitted: dropped to fit the request media budget]` or `[video omitted: dropped to fit the request media budget]`, so the model knows content was removed. Set `KIMI_CODE_DEBUG=1` to see upload and budget decisions in the provider logs.
|
|
231
249
|
|
|
232
250
|
### Prompt cache never seems to hit
|
|
233
251
|
|
package/index.ts
CHANGED
|
@@ -46,8 +46,11 @@ import {
|
|
|
46
46
|
KIMI_WIRE_PROTOCOLS,
|
|
47
47
|
PROVIDER_ID,
|
|
48
48
|
PROVIDER_VERSION,
|
|
49
|
+
type KimiRegion,
|
|
50
|
+
applyKimiRegionEnvBridge,
|
|
49
51
|
getBaseUrl,
|
|
50
52
|
getKimiApiType,
|
|
53
|
+
setKimiRegionOverride,
|
|
51
54
|
} from "./src/constants.ts";
|
|
52
55
|
import {
|
|
53
56
|
type KimiOAuthCredentials,
|
|
@@ -74,6 +77,7 @@ import {
|
|
|
74
77
|
type KimiConfigScope,
|
|
75
78
|
buildSettingsTheme,
|
|
76
79
|
formatByteSize,
|
|
80
|
+
formatRegionValue,
|
|
77
81
|
formatScopeDescription,
|
|
78
82
|
parseByteSizeInput,
|
|
79
83
|
} from "./src/settings-ui.ts";
|
|
@@ -139,6 +143,12 @@ function reloadEffectiveKimiRuntimeConfig(
|
|
|
139
143
|
state.cwd = cwd;
|
|
140
144
|
state.config = config;
|
|
141
145
|
state.projectTrusted = projectTrusted;
|
|
146
|
+
// Push the effective region (project/home JSON wins over the env capture;
|
|
147
|
+
// env KIMI_CODE_REGION already sits in the merged config via the env layer)
|
|
148
|
+
// and re-bridge pi core's OAuth host so /login follows settings changes
|
|
149
|
+
// without a restart.
|
|
150
|
+
setKimiRegionOverride(config.region);
|
|
151
|
+
applyKimiRegionEnvBridge();
|
|
142
152
|
setStoreResolvedKimiConfig({
|
|
143
153
|
model: resolveKimiModelConfig(
|
|
144
154
|
config.model,
|
|
@@ -237,6 +247,7 @@ async function openSettingsMenu(
|
|
|
237
247
|
list.updateValue(toolName, formatToolMenuValue(toolName));
|
|
238
248
|
}
|
|
239
249
|
list.updateValue("protocol", drafts[scope].protocol);
|
|
250
|
+
list.updateValue("region", formatRegionValue(drafts[scope].region));
|
|
240
251
|
list.updateValue("uploadThreshold", formatByteSize(drafts[scope].uploads.thresholdBytes));
|
|
241
252
|
};
|
|
242
253
|
|
|
@@ -269,6 +280,12 @@ async function openSettingsMenu(
|
|
|
269
280
|
save();
|
|
270
281
|
return;
|
|
271
282
|
}
|
|
283
|
+
if (id === "region") {
|
|
284
|
+
drafts[scope].region = newValue === "auto" ? null : (newValue as KimiRegion);
|
|
285
|
+
list.updateValue(id, newValue);
|
|
286
|
+
save();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
272
289
|
if (id === "uploadThreshold") {
|
|
273
290
|
const bytes = parseByteSizeInput(newValue);
|
|
274
291
|
if (bytes === undefined || bytes <= 0) {
|
|
@@ -343,6 +360,13 @@ async function openSettingsMenu(
|
|
|
343
360
|
currentValue: drafts[scope].protocol,
|
|
344
361
|
values: ["openai", "anthropic", "responses"],
|
|
345
362
|
});
|
|
363
|
+
items.push({
|
|
364
|
+
id: "region",
|
|
365
|
+
label: "Region",
|
|
366
|
+
description: "Managed deployment region (auto follows KIMI_CODE_REGION / default)",
|
|
367
|
+
currentValue: formatRegionValue(drafts[scope].region),
|
|
368
|
+
values: ["auto", "mainland-cn", "global"],
|
|
369
|
+
});
|
|
346
370
|
items.push({
|
|
347
371
|
id: "uploadThreshold",
|
|
348
372
|
label: "Upload threshold",
|
|
@@ -654,6 +678,9 @@ function startModelDiscovery(pi: ExtensionAPI, state: KimiRuntimeState): void {
|
|
|
654
678
|
|
|
655
679
|
export function KimiCode(overrides?: KimiCodeConfigPatch): ExtensionFactory {
|
|
656
680
|
return async (pi: ExtensionAPI) => {
|
|
681
|
+
// Region handling (setKimiRegionOverride + the OAuth host bridge) happens
|
|
682
|
+
// in reloadEffectiveKimiRuntimeConfig, which runs just below and on every
|
|
683
|
+
// /kimi-settings save.
|
|
657
684
|
const cwd = process.cwd();
|
|
658
685
|
const config = loadKimiCodeConfig(
|
|
659
686
|
{ cwd, home: os.homedir(), includeProject: false },
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-provider-kimi-code",
|
|
3
|
-
"version": "0.6.
|
|
4
|
-
"description": "Kimi Code models (K3, K2.
|
|
3
|
+
"version": "0.6.13",
|
|
4
|
+
"description": "Kimi Code models (K3, K2.8, HighSpeed) in Pi: OAuth shared login with kimi-code CLI, file uploads, and Kimi-native tools",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-provider",
|
|
7
7
|
"coding-agent",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"kimi-k2.5",
|
|
16
16
|
"kimi-k2.6",
|
|
17
17
|
"kimi-k2.7",
|
|
18
|
+
"kimi-k2.8",
|
|
18
19
|
"kimi-k3",
|
|
19
20
|
"llm",
|
|
20
21
|
"llm-provider",
|
|
@@ -57,9 +58,9 @@
|
|
|
57
58
|
"proper-lockfile": "^4.1.2"
|
|
58
59
|
},
|
|
59
60
|
"devDependencies": {
|
|
60
|
-
"@earendil-works/pi-ai": "^0.
|
|
61
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
62
|
-
"@earendil-works/pi-tui": "^0.
|
|
61
|
+
"@earendil-works/pi-ai": "^0.99.2",
|
|
62
|
+
"@earendil-works/pi-coding-agent": "^0.99.2",
|
|
63
|
+
"@earendil-works/pi-tui": "^0.99.2",
|
|
63
64
|
"@j178/prek": "^0.5.2",
|
|
64
65
|
"@types/node": "^24.0.0",
|
|
65
66
|
"@types/proper-lockfile": "^4.1.4",
|
package/src/config.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
|
|
4
|
-
import { PROVIDER_ID, type KimiWireProtocol } from "./constants.ts";
|
|
4
|
+
import { PROVIDER_ID, type KimiRegion, type KimiWireProtocol } from "./constants.ts";
|
|
5
5
|
|
|
6
6
|
export const KIMI_TOOL_NAMES = ["moonshot_search", "moonshot_fetch", "kimi_datasource"] as const;
|
|
7
7
|
|
|
@@ -47,6 +47,13 @@ export interface KimiCodeConfig {
|
|
|
47
47
|
tools: Record<KimiToolName, { enabled: boolean; default_collapsed: boolean }>;
|
|
48
48
|
uploads: { thresholdBytes: number };
|
|
49
49
|
protocol: KimiWireProtocol;
|
|
50
|
+
/**
|
|
51
|
+
* Managed region for the default endpoints. `null` means "not specified
|
|
52
|
+
* here" — the module-load `KIMI_CODE_REGION` env capture (or the
|
|
53
|
+
* mainland-cn default) then decides. An explicit value from project/home
|
|
54
|
+
* config wins over the env capture once pushed via setKimiRegionOverride.
|
|
55
|
+
*/
|
|
56
|
+
region: KimiRegion | null;
|
|
50
57
|
}
|
|
51
58
|
|
|
52
59
|
export type KimiCodeConfigPatch = Partial<{
|
|
@@ -62,6 +69,7 @@ export type KimiCodeConfigPatch = Partial<{
|
|
|
62
69
|
tools: Partial<Record<KimiToolName, Partial<{ enabled: unknown; default_collapsed: unknown }>>>;
|
|
63
70
|
uploads: Partial<{ thresholdBytes: unknown }>;
|
|
64
71
|
protocol: unknown;
|
|
72
|
+
region: unknown;
|
|
65
73
|
}>;
|
|
66
74
|
|
|
67
75
|
export interface KimiCodeConfigSources {
|
|
@@ -81,6 +89,7 @@ export interface KimiCodeConfigSources {
|
|
|
81
89
|
tools: Record<KimiToolName, { enabled: KimiConfigSource; default_collapsed: KimiConfigSource }>;
|
|
82
90
|
uploads: { thresholdBytes: KimiConfigSource };
|
|
83
91
|
protocol: KimiConfigSource;
|
|
92
|
+
region: KimiConfigSource;
|
|
84
93
|
}
|
|
85
94
|
|
|
86
95
|
export interface LoadKimiCodeConfigOptions {
|
|
@@ -132,6 +141,7 @@ export const DEFAULT_KIMI_CODE_CONFIG: KimiCodeConfig = {
|
|
|
132
141
|
tools: makeDefaultTools(),
|
|
133
142
|
uploads: { thresholdBytes: 1048576 },
|
|
134
143
|
protocol: "openai",
|
|
144
|
+
region: null,
|
|
135
145
|
};
|
|
136
146
|
|
|
137
147
|
let runtimeOverride: KimiCodeConfigPatch = {};
|
|
@@ -257,6 +267,12 @@ function envConfigPatch(env: NodeJS.ProcessEnv): KimiCodeConfigPatch {
|
|
|
257
267
|
const protocol = env.KIMI_CODE_PROTOCOL?.trim();
|
|
258
268
|
if (protocol) patch.protocol = protocol;
|
|
259
269
|
|
|
270
|
+
// KIMI_CODE_REGION intentionally does NOT enter the merged config: the env
|
|
271
|
+
// acts as a fallback below the config.json layers (documented precedence:
|
|
272
|
+
// explicit config > env > built-in default), and an invalid value must not
|
|
273
|
+
// crash startup via strict validation — currentKimiRegion() falls back to
|
|
274
|
+
// the module-load capture instead.
|
|
275
|
+
|
|
260
276
|
const capabilities = env.KIMI_MODEL_CAPABILITIES;
|
|
261
277
|
if (capabilities) {
|
|
262
278
|
const caps = new Set(
|
|
@@ -323,6 +339,20 @@ function requireProtocol(
|
|
|
323
339
|
);
|
|
324
340
|
}
|
|
325
341
|
|
|
342
|
+
function requireRegionOrNull(
|
|
343
|
+
raw: unknown,
|
|
344
|
+
configPath: string,
|
|
345
|
+
pointer: string,
|
|
346
|
+
): KimiCodeConfig["region"] {
|
|
347
|
+
if (raw === null) return null;
|
|
348
|
+
if (raw === "mainland-cn" || raw === "global") return raw;
|
|
349
|
+
return fail(
|
|
350
|
+
configPath,
|
|
351
|
+
pointer,
|
|
352
|
+
`expected "mainland-cn" | "global" | null, got ${JSON.stringify(raw)}`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
326
356
|
function requireInputArray(raw: unknown, configPath: string, pointer: string): KimiInputModality[] {
|
|
327
357
|
const valid = new Set(["text", "image", "video"]);
|
|
328
358
|
if (!Array.isArray(raw)) return fail(configPath, pointer, `expected an array, got ${typeof raw}`);
|
|
@@ -438,6 +468,7 @@ export function validateKimiCodeConfig(
|
|
|
438
468
|
),
|
|
439
469
|
},
|
|
440
470
|
protocol: requireProtocol(config.protocol, configPath, "/protocol"),
|
|
471
|
+
region: requireRegionOrNull(config.region, configPath, "/region"),
|
|
441
472
|
};
|
|
442
473
|
}
|
|
443
474
|
|
|
@@ -487,6 +518,7 @@ function buildSources(
|
|
|
487
518
|
tools,
|
|
488
519
|
uploads: { thresholdBytes: sourceForPath(layers, ["uploads", "thresholdBytes"]) },
|
|
489
520
|
protocol: sourceForPath(layers, ["protocol"]),
|
|
521
|
+
region: sourceForPath(layers, ["region"]),
|
|
490
522
|
};
|
|
491
523
|
}
|
|
492
524
|
|
package/src/constants.ts
CHANGED
|
@@ -7,7 +7,82 @@ import os from "node:os";
|
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
|
|
9
9
|
export const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
|
|
10
|
-
|
|
10
|
+
|
|
11
|
+
// Region profiles mirror upstream kimi-code's packages/oauth region.ts: a
|
|
12
|
+
// region is a bundle of the managed API base origin and the OAuth host. The
|
|
13
|
+
// region only picks the DEFAULT endpoints — explicit env overrides
|
|
14
|
+
// (KIMI_CODE_BASE_URL / KIMI_CODE_OAUTH_HOST) always win. Global users with
|
|
15
|
+
// kimi.ai credentials otherwise hit 401s against the hardcoded .com defaults
|
|
16
|
+
// (issue #77).
|
|
17
|
+
export type KimiRegion = "mainland-cn" | "global";
|
|
18
|
+
|
|
19
|
+
export const KIMI_REGION_PROFILES: Record<KimiRegion, { apiBase: string; oauthHost: string }> = {
|
|
20
|
+
"mainland-cn": { apiBase: "https://api.kimi.com", oauthHost: "https://auth.kimi.com" },
|
|
21
|
+
global: { apiBase: "https://api.kimi.ai", oauthHost: "https://auth.kimi.ai" },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function parseKimiRegion(value: string | undefined): KimiRegion {
|
|
25
|
+
if (value === "global" || value === "mainland-cn") return value;
|
|
26
|
+
return "mainland-cn";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const ENV_KIMI_CODE_REGION: KimiRegion = parseKimiRegion(process.env.KIMI_CODE_REGION);
|
|
30
|
+
|
|
31
|
+
// Effective region pushed from the plugin config (project/home JSON via
|
|
32
|
+
// /kimi-settings), taking precedence over the module-load env capture so a
|
|
33
|
+
// settings change applies without a restart. `null` means "config did not
|
|
34
|
+
// specify" and falls back to ENV_KIMI_CODE_REGION.
|
|
35
|
+
let resolvedRegionOverride: KimiRegion | null = null;
|
|
36
|
+
|
|
37
|
+
export function setKimiRegionOverride(region: KimiRegion | null): void {
|
|
38
|
+
resolvedRegionOverride = region;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function currentKimiRegion(): KimiRegion {
|
|
42
|
+
return resolvedRegionOverride ?? ENV_KIMI_CODE_REGION;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Tracks a KIMI_CODE_OAUTH_HOST value injected by the bridge so a re-apply
|
|
46
|
+
// (e.g. region switched back to mainland in /kimi-settings) can retract it
|
|
47
|
+
// without clobbering a value the user configured in the meantime.
|
|
48
|
+
let regionBridgeOwnedValue: string | undefined;
|
|
49
|
+
|
|
50
|
+
// Bridge the selected region into pi core's built-in Kimi OAuth: pi-ai reads
|
|
51
|
+
// KIMI_CODE_OAUTH_HOST at call time, so filling it makes /login follow the
|
|
52
|
+
// configured region even when the login runs through pi's own kimi-coding
|
|
53
|
+
// auth path instead of this extension's. Only fills the variable when the
|
|
54
|
+
// region is non-default and the user has not configured an explicit override.
|
|
55
|
+
export function applyKimiRegionEnvBridge(
|
|
56
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
57
|
+
region: KimiRegion = currentKimiRegion(),
|
|
58
|
+
): boolean {
|
|
59
|
+
if (regionBridgeOwnedValue !== undefined && env.KIMI_CODE_OAUTH_HOST === regionBridgeOwnedValue) {
|
|
60
|
+
delete env.KIMI_CODE_OAUTH_HOST;
|
|
61
|
+
}
|
|
62
|
+
regionBridgeOwnedValue = undefined;
|
|
63
|
+
// pi core's OAuth reads only KIMI_CODE_OAUTH_HOST; mirror the documented
|
|
64
|
+
// KIMI_OAUTH_HOST alias into it so the alias keeps working on the built-in
|
|
65
|
+
// login path. The alias stays the source of truth.
|
|
66
|
+
if (!env.KIMI_CODE_OAUTH_HOST && env.KIMI_OAUTH_HOST?.trim()) {
|
|
67
|
+
env.KIMI_CODE_OAUTH_HOST = env.KIMI_OAUTH_HOST.trim();
|
|
68
|
+
regionBridgeOwnedValue = env.KIMI_CODE_OAUTH_HOST;
|
|
69
|
+
}
|
|
70
|
+
if (region === "mainland-cn") return false;
|
|
71
|
+
if (env.KIMI_CODE_OAUTH_HOST || env.KIMI_OAUTH_HOST) return false;
|
|
72
|
+
env.KIMI_CODE_OAUTH_HOST = KIMI_REGION_PROFILES[region].oauthHost;
|
|
73
|
+
regionBridgeOwnedValue = env.KIMI_CODE_OAUTH_HOST;
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Normalize any managed base URL to its `/v1` form. Single implementation
|
|
78
|
+
* shared by the tool endpoints (src/tools/common.ts), the usage/account URLs
|
|
79
|
+
* (src/usage.ts), and the credential probe (src/oauth.ts). */
|
|
80
|
+
export function normalizeKimiBaseV1(baseUrl: string): string {
|
|
81
|
+
const base = baseUrl.replace(/\/+$/, "");
|
|
82
|
+
return base.endsWith("/v1") ? base : `${base}/v1`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const DEFAULT_OAUTH_HOST = KIMI_REGION_PROFILES[ENV_KIMI_CODE_REGION].oauthHost;
|
|
11
86
|
|
|
12
87
|
export const KIMI_WIRE_PROTOCOLS = ["openai", "anthropic", "responses"] as const;
|
|
13
88
|
export type KimiWireProtocol = (typeof KIMI_WIRE_PROTOCOLS)[number];
|
|
@@ -48,15 +123,17 @@ export function getKimiApiType(protocol: KimiWireProtocol): KimiApiType {
|
|
|
48
123
|
// built-in anthropic-messages / openai-completions / openai-responses handlers.
|
|
49
124
|
export const KIMI_API_TYPE = getKimiApiType(ENV_KIMI_CODE_PROTOCOL);
|
|
50
125
|
|
|
51
|
-
export function getDefaultBaseUrl(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
126
|
+
export function getDefaultBaseUrl(
|
|
127
|
+
protocol: KimiWireProtocol,
|
|
128
|
+
region: KimiRegion = currentKimiRegion(),
|
|
129
|
+
): string {
|
|
130
|
+
const apiBase = KIMI_REGION_PROFILES[region].apiBase;
|
|
131
|
+
return protocol === "anthropic" ? `${apiBase}/coding` : `${apiBase}/coding/v1`;
|
|
55
132
|
}
|
|
56
133
|
|
|
57
134
|
export const DEFAULT_BASE_URL = getDefaultBaseUrl(ENV_KIMI_CODE_PROTOCOL);
|
|
58
135
|
|
|
59
|
-
export const PROVIDER_VERSION = "0.6.
|
|
136
|
+
export const PROVIDER_VERSION = "0.6.13";
|
|
60
137
|
|
|
61
138
|
export const KIMI_CODE_USER_AGENT = `pi-provider-kimi-code/${PROVIDER_VERSION}`;
|
|
62
139
|
export const KIMI_PLATFORM = "pi";
|
|
@@ -79,9 +156,9 @@ export const PROVIDER_ID = "kimi-coding";
|
|
|
79
156
|
export const KIMI_GLOBAL_FALLBACK_SOURCE_ID = "pi-provider-kimi-code-global-fallback";
|
|
80
157
|
|
|
81
158
|
export function getOAuthHost(): string {
|
|
82
|
-
const value =
|
|
83
|
-
|
|
84
|
-
return
|
|
159
|
+
const value = process.env.KIMI_CODE_OAUTH_HOST || process.env.KIMI_OAUTH_HOST;
|
|
160
|
+
if (value?.trim()) return value.trim();
|
|
161
|
+
return KIMI_REGION_PROFILES[currentKimiRegion()].oauthHost;
|
|
85
162
|
}
|
|
86
163
|
|
|
87
164
|
export function getBaseUrl(protocol: KimiWireProtocol = ENV_KIMI_CODE_PROTOCOL): string {
|
package/src/models.ts
CHANGED
|
@@ -99,6 +99,15 @@ function resolveModelCost(
|
|
|
99
99
|
// per-request cap still wins (options.maxTokens ?? model.maxTokens). Reconcile
|
|
100
100
|
// wherever contextWindow is authored so the two stay in lockstep.
|
|
101
101
|
//
|
|
102
|
+
// Wire note (upstream kimi-code #4091): the OpenAI wires now omit the
|
|
103
|
+
// completion cap entirely unless one is explicitly configured —
|
|
104
|
+
// applyKimiPayloadMutations drops the pi-ai-seeded cap when the model cap is
|
|
105
|
+
// window-tracked, keeping it only for Anthropic /messages (which requires
|
|
106
|
+
// max_tokens, where the window-clamped value matches upstream's
|
|
107
|
+
// kimiUnsetCompletionTokens). The window-tracked maxTokens therefore feeds
|
|
108
|
+
// pi-ai internals (thinking-budget math) and the Anthropic wire, not the
|
|
109
|
+
// OpenAI payloads.
|
|
110
|
+
//
|
|
102
111
|
// "Explicitly configured" means any value other than the built-in default:
|
|
103
112
|
// ensureKimiCodeConfig materializes the full default config (including
|
|
104
113
|
// maxTokens) into the home config file, so config-source tracking cannot
|
|
@@ -246,10 +255,15 @@ function getModelsUrl(protocol?: KimiWireProtocol): string {
|
|
|
246
255
|
return buildModelsUrl(getBaseUrl(protocol));
|
|
247
256
|
}
|
|
248
257
|
|
|
258
|
+
// Both official managed deployments serve /coding/v1/models; global (kimi.ai)
|
|
259
|
+
// users get model discovery too, not just mainland (.com).
|
|
249
260
|
export function isOfficialKimiModelsUrl(url: string): boolean {
|
|
250
261
|
try {
|
|
251
262
|
const parsed = new URL(url);
|
|
252
|
-
return
|
|
263
|
+
return (
|
|
264
|
+
(parsed.origin === "https://api.kimi.com" || parsed.origin === "https://api.kimi.ai") &&
|
|
265
|
+
parsed.pathname === "/coding/v1/models"
|
|
266
|
+
);
|
|
253
267
|
} catch {
|
|
254
268
|
return false;
|
|
255
269
|
}
|
package/src/oauth.ts
CHANGED
|
@@ -155,7 +155,14 @@ async function lockStoredOAuthCredential(
|
|
|
155
155
|
};
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
import {
|
|
158
|
+
import {
|
|
159
|
+
CLIENT_ID,
|
|
160
|
+
PROVIDER_ID,
|
|
161
|
+
RETRYABLE_REFRESH_STATUSES,
|
|
162
|
+
getBaseUrl,
|
|
163
|
+
getOAuthHost,
|
|
164
|
+
normalizeKimiBaseV1,
|
|
165
|
+
} from "./constants.ts";
|
|
159
166
|
import { getCommonHeaders } from "./device.ts";
|
|
160
167
|
import { type KimiOAuthCredentials, discoverKimiModelMetadata } from "./models.ts";
|
|
161
168
|
|
|
@@ -435,6 +442,25 @@ async function lockKimiCodeCredentials(signal?: AbortSignal): Promise<LockedKimi
|
|
|
435
442
|
};
|
|
436
443
|
}
|
|
437
444
|
|
|
445
|
+
// Tokens are region-bound, but the kimi-code CLI credential file does not
|
|
446
|
+
// record which OAuth host minted them — so a fresh credential reused across
|
|
447
|
+
// regions (a mainland CLI login while KIMI_CODE_REGION=global, or vice versa)
|
|
448
|
+
// would 401 every request. Probe the current region's managed API before
|
|
449
|
+
// reuse. Only an explicit 401 disproves usability: network failures must not
|
|
450
|
+
// force an unnecessary re-login. Exported for tests.
|
|
451
|
+
export async function isKimiCredentialRegionValid(accessToken: string): Promise<boolean> {
|
|
452
|
+
const meUrl = `${normalizeKimiBaseV1(getBaseUrl())}/me`;
|
|
453
|
+
try {
|
|
454
|
+
const response = await fetch(meUrl, {
|
|
455
|
+
headers: { ...getCommonHeaders(), Authorization: `Bearer ${accessToken}` },
|
|
456
|
+
signal: AbortSignal.timeout(5_000),
|
|
457
|
+
});
|
|
458
|
+
return response.status !== 401;
|
|
459
|
+
} catch {
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
438
464
|
async function tryReuseKimiCliCredentials(
|
|
439
465
|
callbacks: OAuthLoginCallbacks,
|
|
440
466
|
): Promise<KimiOAuthCredentials | null> {
|
|
@@ -449,6 +475,12 @@ async function tryReuseKimiCliCredentials(
|
|
|
449
475
|
callbacks.onProgress?.("Found existing Kimi credentials, reusing them.");
|
|
450
476
|
|
|
451
477
|
if (stillFresh) {
|
|
478
|
+
if (!(await isKimiCredentialRegionValid(data.access_token!))) {
|
|
479
|
+
callbacks.onProgress?.(
|
|
480
|
+
"Existing Kimi credentials are not valid for the configured region, starting a fresh login.",
|
|
481
|
+
);
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
452
484
|
const extras = await discoverKimiModelMetadata(data.access_token!);
|
|
453
485
|
return {
|
|
454
486
|
access: data.access_token!,
|
|
@@ -461,6 +493,15 @@ async function tryReuseKimiCliCredentials(
|
|
|
461
493
|
callbacks.onProgress?.("Kimi access token expired, refreshing.");
|
|
462
494
|
try {
|
|
463
495
|
const token = await refreshAccessToken(data.refresh_token!);
|
|
496
|
+
// Probe before persisting: a token that fails the region check must not
|
|
497
|
+
// be written into the shared CLI credential file, or a later CLI-side
|
|
498
|
+
// reuse (or the next /login) would pick up a token that 401s.
|
|
499
|
+
if (!(await isKimiCredentialRegionValid(token.access_token))) {
|
|
500
|
+
callbacks.onProgress?.(
|
|
501
|
+
"Refreshed Kimi credentials are not valid for the configured region, starting a fresh login.",
|
|
502
|
+
);
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
464
505
|
const expiresMs = Date.now() + token.expires_in * 1000;
|
|
465
506
|
writeKimiCodeCredentials(token.access_token, token.refresh_token, expiresMs);
|
|
466
507
|
const extras = await discoverKimiModelMetadata(token.access_token);
|
package/src/payload.ts
CHANGED
|
@@ -37,6 +37,8 @@ export interface KimiPayloadContext {
|
|
|
37
37
|
cacheKey?: string;
|
|
38
38
|
cacheRetention: CacheRetention;
|
|
39
39
|
reasoning?: ThinkingLevel;
|
|
40
|
+
/** Per-request cap from the caller (pi options.maxTokens); wins over the omission policy. */
|
|
41
|
+
requestMaxTokens?: number;
|
|
40
42
|
modelConfig: KimiResolvedModelConfig;
|
|
41
43
|
}
|
|
42
44
|
|
|
@@ -264,6 +266,155 @@ async function transformOpenAIPayloadFiles(
|
|
|
264
266
|
}
|
|
265
267
|
}
|
|
266
268
|
|
|
269
|
+
// -----------------------------------------------------------------------------
|
|
270
|
+
// Per-request inline media budget (upstream kimi-code #3784,
|
|
271
|
+
// mediaResolverService.applyMediaBudget): accumulated inline media count
|
|
272
|
+
// against a 20 MB per-request base64 wire-size budget (not decoded media
|
|
273
|
+
// bytes); when exceeded, the oldest items are dropped until the total is under
|
|
274
|
+
// the 10 MB base64 wire-size low-water mark and replaced with an omission
|
|
275
|
+
// placeholder. OpenAI data URLs count the full data: URL string, including its
|
|
276
|
+
// small prefix overhead, while Anthropic base64 blocks count the source data
|
|
277
|
+
// string. Uploaded ms:// references are tiny and never counted. Media is
|
|
278
|
+
// deduped by content hash so a repeated image counts once and every occurrence
|
|
279
|
+
// is dropped together.
|
|
280
|
+
// -----------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
const REQUEST_MEDIA_BUDGET_BYTES = 20 * 1024 * 1024;
|
|
283
|
+
const REQUEST_MEDIA_BUDGET_LOW_BYTES = 10 * 1024 * 1024;
|
|
284
|
+
|
|
285
|
+
interface InlineMediaBudgetEntry {
|
|
286
|
+
kind: "image" | "video";
|
|
287
|
+
key: string;
|
|
288
|
+
bytes: number;
|
|
289
|
+
/** Indices into messages[...].content[...] (a third index addresses a tool_result's content). */
|
|
290
|
+
path: number[];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function inlineMediaKey(kind: string, payload: string): string {
|
|
294
|
+
return createHash("sha256").update(kind).update("\0").update(payload).digest("hex");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function collectOpenAIInlineMedia(payload: JsonRecord): InlineMediaBudgetEntry[] {
|
|
298
|
+
const entries: InlineMediaBudgetEntry[] = [];
|
|
299
|
+
if (!Array.isArray(payload.messages)) return entries;
|
|
300
|
+
for (const [messageIndex, message] of payload.messages.entries()) {
|
|
301
|
+
if (!isRecord(message) || !Array.isArray(message.content)) continue;
|
|
302
|
+
for (const [blockIndex, block] of (message.content as unknown[]).entries()) {
|
|
303
|
+
if (!isRecord(block)) continue;
|
|
304
|
+
const field =
|
|
305
|
+
block.type === "image_url" ? "image_url" : block.type === "video_url" ? "video_url" : null;
|
|
306
|
+
if (!field) continue;
|
|
307
|
+
const kind = block.type === "image_url" ? "image" : "video";
|
|
308
|
+
const value = block[field];
|
|
309
|
+
const url =
|
|
310
|
+
typeof value === "string"
|
|
311
|
+
? value
|
|
312
|
+
: isRecord(value) && typeof value.url === "string"
|
|
313
|
+
? value.url
|
|
314
|
+
: null;
|
|
315
|
+
if (!url || !url.startsWith("data:")) continue;
|
|
316
|
+
entries.push({
|
|
317
|
+
kind,
|
|
318
|
+
key: inlineMediaKey(kind, url),
|
|
319
|
+
bytes: url.length,
|
|
320
|
+
path: [messageIndex, blockIndex],
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return entries;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function collectAnthropicInlineMedia(payload: JsonRecord): InlineMediaBudgetEntry[] {
|
|
328
|
+
const entries: InlineMediaBudgetEntry[] = [];
|
|
329
|
+
if (!Array.isArray(payload.messages)) return entries;
|
|
330
|
+
const collect = (messageIndex: number, path: number[], block: unknown): void => {
|
|
331
|
+
if (!isRecord(block)) return;
|
|
332
|
+
const kind = block.type === "image" || block.type === "video" ? block.type : null;
|
|
333
|
+
if (
|
|
334
|
+
kind &&
|
|
335
|
+
isRecord(block.source) &&
|
|
336
|
+
block.source.type === "base64" &&
|
|
337
|
+
typeof block.source.media_type === "string" &&
|
|
338
|
+
typeof block.source.data === "string"
|
|
339
|
+
) {
|
|
340
|
+
const data = block.source.data;
|
|
341
|
+
entries.push({
|
|
342
|
+
kind,
|
|
343
|
+
key: inlineMediaKey(kind, `${block.source.media_type}\0${data}`),
|
|
344
|
+
bytes: data.length,
|
|
345
|
+
path: [messageIndex, ...path],
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
for (const [messageIndex, message] of payload.messages.entries()) {
|
|
350
|
+
if (!isRecord(message) || !Array.isArray(message.content)) continue;
|
|
351
|
+
for (const [blockIndex, block] of (message.content as unknown[]).entries()) {
|
|
352
|
+
if (isRecord(block) && block.type === "tool_result" && Array.isArray(block.content)) {
|
|
353
|
+
for (const [nestedIndex, nested] of (block.content as unknown[]).entries()) {
|
|
354
|
+
collect(messageIndex, [blockIndex, nestedIndex], nested);
|
|
355
|
+
}
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
collect(messageIndex, [blockIndex], block);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return entries;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function applyInlineMediaBudget(
|
|
365
|
+
payload: JsonRecord,
|
|
366
|
+
api: "anthropic-messages" | "openai-completions",
|
|
367
|
+
budgetBytes: number = REQUEST_MEDIA_BUDGET_BYTES,
|
|
368
|
+
lowBytes: number = REQUEST_MEDIA_BUDGET_LOW_BYTES,
|
|
369
|
+
): boolean {
|
|
370
|
+
const entries =
|
|
371
|
+
api === "anthropic-messages"
|
|
372
|
+
? collectAnthropicInlineMedia(payload)
|
|
373
|
+
: collectOpenAIInlineMedia(payload);
|
|
374
|
+
if (entries.length === 0) return false;
|
|
375
|
+
|
|
376
|
+
// Deduplicate by content: repeated media counts once, drops hit every copy.
|
|
377
|
+
const bytesByKey = new Map<string, number>();
|
|
378
|
+
for (const entry of entries) {
|
|
379
|
+
if (!bytesByKey.has(entry.key)) bytesByKey.set(entry.key, entry.bytes);
|
|
380
|
+
}
|
|
381
|
+
let total = 0;
|
|
382
|
+
for (const bytes of bytesByKey.values()) total += bytes;
|
|
383
|
+
if (total <= budgetBytes) return false;
|
|
384
|
+
|
|
385
|
+
const dropped = new Set<string>();
|
|
386
|
+
for (const [key, bytes] of bytesByKey) {
|
|
387
|
+
if (total <= lowBytes) break;
|
|
388
|
+
if (bytes === 0) continue;
|
|
389
|
+
dropped.add(key);
|
|
390
|
+
total -= bytes;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const messages = payload.messages as unknown[];
|
|
394
|
+
let droppedCount = 0;
|
|
395
|
+
for (const entry of entries) {
|
|
396
|
+
if (!dropped.has(entry.key)) continue;
|
|
397
|
+
const message = messages[entry.path[0]] as JsonRecord;
|
|
398
|
+
const content = message.content as unknown[];
|
|
399
|
+
const placeholder = {
|
|
400
|
+
type: "text",
|
|
401
|
+
text: `[${entry.kind} omitted: dropped to fit the request media budget]`,
|
|
402
|
+
};
|
|
403
|
+
if (entry.path.length === 2) {
|
|
404
|
+
content[entry.path[1]] = placeholder;
|
|
405
|
+
} else {
|
|
406
|
+
const toolResult = content[entry.path[1]] as JsonRecord;
|
|
407
|
+
(toolResult.content as unknown[])[entry.path[2]] = placeholder;
|
|
408
|
+
}
|
|
409
|
+
droppedCount += 1;
|
|
410
|
+
}
|
|
411
|
+
console.warn(
|
|
412
|
+
`[kimi-coding] inline media exceeded the ${String(budgetBytes / (1024 * 1024))} MB ` +
|
|
413
|
+
`per-request budget; ${String(droppedCount)} media item(s) omitted`,
|
|
414
|
+
);
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
|
|
267
418
|
function isEffectivelyEmptyOpenAIContent(content: unknown): boolean {
|
|
268
419
|
if (typeof content === "string") return content.trim() === "";
|
|
269
420
|
if (!Array.isArray(content)) return false;
|
|
@@ -472,6 +623,75 @@ async function transformAnthropicPayloadFiles(
|
|
|
472
623
|
|
|
473
624
|
const RESPONSES_EFFORTS = new Set(["low", "high", "max"]);
|
|
474
625
|
|
|
626
|
+
// Empty/whitespace-only text parts in the Responses `input` array. Assistant
|
|
627
|
+
// turns that produced no text (reasoning + tool calls only) replay as
|
|
628
|
+
// output_text:"" and upstream 400s on text non-emptiness; see issue #78.
|
|
629
|
+
const RESPONSES_TEXT_PART_TYPES = new Set(["output_text", "text", "input_text"]);
|
|
630
|
+
|
|
631
|
+
function isEmptyTextPart(part: unknown): boolean {
|
|
632
|
+
return (
|
|
633
|
+
isRecord(part) &&
|
|
634
|
+
typeof part.type === "string" &&
|
|
635
|
+
RESPONSES_TEXT_PART_TYPES.has(part.type) &&
|
|
636
|
+
(typeof part.text !== "string" || part.text.trim() === "")
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
export function stripEmptyResponsesTextParts(payload: JsonRecord): boolean {
|
|
641
|
+
if (!Array.isArray(payload.input)) return false;
|
|
642
|
+
let changed = false;
|
|
643
|
+
const input: unknown[] = [];
|
|
644
|
+
for (const item of payload.input) {
|
|
645
|
+
if (!isRecord(item)) {
|
|
646
|
+
input.push(item);
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
// Typed non-message items (function_call, reasoning, ...) pass through.
|
|
650
|
+
if (typeof item.type === "string" && item.type !== "message") {
|
|
651
|
+
input.push(item);
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
const role = item.role;
|
|
655
|
+
const content = item.content;
|
|
656
|
+
const isAssistantLike = role === "assistant" || role == null;
|
|
657
|
+
if (typeof content === "string") {
|
|
658
|
+
if (isAssistantLike && content.trim() === "") {
|
|
659
|
+
changed = true;
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
input.push(item);
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
if (!Array.isArray(content)) {
|
|
666
|
+
// A message item with missing/non-array content carries nothing to
|
|
667
|
+
// render; drop assistant-shaped ones instead of replaying a
|
|
668
|
+
// contentless message (the upstream 400 path this fix targets).
|
|
669
|
+
if (isAssistantLike && (content === undefined || content === null)) {
|
|
670
|
+
changed = true;
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
input.push(item);
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
const next = content.filter((part) => !isEmptyTextPart(part));
|
|
677
|
+
// An assistant message with nothing left — either filtered here or
|
|
678
|
+
// arriving empty — carries no information; drop it rather than send a
|
|
679
|
+
// contentless message. User/system messages keep their content.
|
|
680
|
+
if (next.length === 0 && isAssistantLike) {
|
|
681
|
+
changed = true;
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
if (next.length === content.length) {
|
|
685
|
+
input.push(item);
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
changed = true;
|
|
689
|
+
input.push({ ...item, content: next });
|
|
690
|
+
}
|
|
691
|
+
if (changed) payload.input = input;
|
|
692
|
+
return changed;
|
|
693
|
+
}
|
|
694
|
+
|
|
475
695
|
function responsesSupportedEfforts(ctx: KimiPayloadContext): string[] {
|
|
476
696
|
const advertised = ctx.modelConfig.supportEfforts;
|
|
477
697
|
if (advertised?.length) return advertised.filter((effort) => RESPONSES_EFFORTS.has(effort));
|
|
@@ -547,6 +767,19 @@ export async function applyKimiPayloadMutations(
|
|
|
547
767
|
);
|
|
548
768
|
}
|
|
549
769
|
|
|
770
|
+
// 1b. Responses wire: drop empty text parts replayed from text-less
|
|
771
|
+
// assistant turns (issue #78). pi-ai's Responses serializer keeps empty
|
|
772
|
+
// output_text blocks — unlike its Chat Completions serializer, which
|
|
773
|
+
// filters them — so a reasoning/tool-only turn is replayed as
|
|
774
|
+
// {role:"assistant", content:[{type:"output_text", text:""}]} on every
|
|
775
|
+
// subsequent request, and upstream endpoints reject the whole payload with
|
|
776
|
+
// a 400-class text-non-emptiness error. function_call / reasoning / all
|
|
777
|
+
// other part types are untouched; an assistant message left with no
|
|
778
|
+
// content is dropped entirely.
|
|
779
|
+
if (ctx.api === "openai-responses") {
|
|
780
|
+
stripEmptyResponsesTextParts(payload);
|
|
781
|
+
}
|
|
782
|
+
|
|
550
783
|
// 2. File upload dispatch (protocol-specific).
|
|
551
784
|
if (ctx.upload) {
|
|
552
785
|
const uploadCache = ctx.uploadCacheScope ? uploadedFileCache : new Map<string, string>();
|
|
@@ -557,6 +790,11 @@ export async function applyKimiPayloadMutations(
|
|
|
557
790
|
await transformAnthropicPayloadFiles(payload, ctx.upload, uploadCache, uploadCacheScope);
|
|
558
791
|
}
|
|
559
792
|
}
|
|
793
|
+
if (ctx.api === "openai-completions" || ctx.api === "anthropic-messages") {
|
|
794
|
+
// Runs after the upload transforms so media already promoted to ms://
|
|
795
|
+
// references (tiny URLs) are never counted against the budget.
|
|
796
|
+
applyInlineMediaBudget(payload, ctx.api);
|
|
797
|
+
}
|
|
560
798
|
if (ctx.api === "openai-completions") {
|
|
561
799
|
normalizeOpenAIAssistantToolCalls(payload);
|
|
562
800
|
}
|
|
@@ -590,8 +828,12 @@ export async function applyKimiPayloadMutations(
|
|
|
590
828
|
|
|
591
829
|
// 5. Spread extra_body into the top-level payload before normalization and
|
|
592
830
|
// config caps. Top-level fields retain precedence over extra_body.
|
|
831
|
+
let extraBodyHadCap = false;
|
|
593
832
|
if (isRecord(payload.extra_body)) {
|
|
594
833
|
const extraBody = payload.extra_body as JsonRecord;
|
|
834
|
+
extraBodyHadCap = ["max_tokens", "max_completion_tokens", "max_output_tokens"].some(
|
|
835
|
+
(key) => typeof extraBody[key] === "number",
|
|
836
|
+
);
|
|
595
837
|
delete payload.extra_body;
|
|
596
838
|
for (const [key, value] of Object.entries(extraBody)) {
|
|
597
839
|
if (payload[key] === undefined) {
|
|
@@ -620,6 +862,29 @@ export async function applyKimiPayloadMutations(
|
|
|
620
862
|
}
|
|
621
863
|
|
|
622
864
|
const generation = ctx.modelConfig.generation;
|
|
865
|
+
// Output cap policy (mirrors upstream kimi-code #4091): omit the completion
|
|
866
|
+
// cap on the OpenAI wires unless explicitly configured. pi-ai seeds a cap
|
|
867
|
+
// clamped to the context window (clampMaxTokensToContext), and forwarding
|
|
868
|
+
// that value breaks strict serving stacks (e.g. bare vLLM) that reject a
|
|
869
|
+
// cap above the model's real output limit with repeated 400s. A cap counts
|
|
870
|
+
// as explicit when generation.maxCompletionTokens is configured
|
|
871
|
+
// (KIMI_MODEL_MAX_COMPLETION_TOKENS / config generation.maxCompletionTokens),
|
|
872
|
+
// model.maxTokens holds a non-window value, or the caller supplies a
|
|
873
|
+
// per-request cap through options.maxTokens / extra_body. Anthropic /messages requires
|
|
874
|
+
// max_tokens, so that wire keeps pi-ai's window-clamped cap — the same
|
|
875
|
+
// window - usedContextTokens value upstream fills in when unset.
|
|
876
|
+
if (ctx.api === "openai-completions" || ctx.api === "openai-responses") {
|
|
877
|
+
const capKey = ctx.api === "openai-responses" ? "max_output_tokens" : "max_completion_tokens";
|
|
878
|
+
const explicitModelCap =
|
|
879
|
+
typeof ctx.modelConfig.maxTokens === "number" &&
|
|
880
|
+
ctx.modelConfig.maxTokens > 0 &&
|
|
881
|
+
ctx.modelConfig.maxTokens !== ctx.modelConfig.contextWindow;
|
|
882
|
+
const explicitRequestCap =
|
|
883
|
+
extraBodyHadCap || (typeof ctx.requestMaxTokens === "number" && ctx.requestMaxTokens > 0);
|
|
884
|
+
if (generation.maxCompletionTokens === undefined && !explicitModelCap && !explicitRequestCap) {
|
|
885
|
+
delete payload[capKey];
|
|
886
|
+
}
|
|
887
|
+
}
|
|
623
888
|
// Official kimi-code sends temperature/top_p only when explicitly configured
|
|
624
889
|
// (env KIMI_MODEL_TEMPERATURE / KIMI_MODEL_TOP_P → generation.*); otherwise it
|
|
625
890
|
// omits them and lets the server apply its own defaults. Mirror that exactly:
|
|
@@ -720,7 +985,7 @@ export async function applyKimiPayloadMutations(
|
|
|
720
985
|
}
|
|
721
986
|
}
|
|
722
987
|
|
|
723
|
-
// 8.
|
|
988
|
+
// 8. Kimi coding endpoint constraints: the server rejects tool_choice "required" /
|
|
724
989
|
// function-specific when thinking is enabled (always-on). Responses only
|
|
725
990
|
// accepts auto. temperature/top_p are handled in step 6 — omitted unless
|
|
726
991
|
// explicitly configured, matching the official kimi-code client rather
|
package/src/settings-ui.ts
CHANGED
|
@@ -58,6 +58,13 @@ export function setProtocol(
|
|
|
58
58
|
return { ...config, protocol };
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
export function setRegion(
|
|
62
|
+
config: KimiCodeConfig,
|
|
63
|
+
region: KimiCodeConfig["region"],
|
|
64
|
+
): KimiCodeConfig {
|
|
65
|
+
return { ...config, region };
|
|
66
|
+
}
|
|
67
|
+
|
|
61
68
|
export function setUploadThreshold(config: KimiCodeConfig, thresholdBytes: number): KimiCodeConfig {
|
|
62
69
|
return { ...config, uploads: { ...config.uploads, thresholdBytes } };
|
|
63
70
|
}
|
|
@@ -73,6 +80,7 @@ export function buildKimiMainTitle(
|
|
|
73
80
|
"",
|
|
74
81
|
`Model: ${modelName}`,
|
|
75
82
|
`Protocol: ${config.protocol} (${sources.protocol})`,
|
|
83
|
+
`Region: ${formatRegionValue(config.region)} (${sources.region})`,
|
|
76
84
|
`Upload threshold: ${formatByteSize(config.uploads.thresholdBytes)} (${sources.uploads.thresholdBytes})`,
|
|
77
85
|
"",
|
|
78
86
|
"Effective tools:",
|
|
@@ -138,6 +146,18 @@ export function toolMenuItem(config: KimiCodeConfig, toolName: KimiToolName): st
|
|
|
138
146
|
return `${toolName} -> ${formatToolStatus(config, toolName)}`;
|
|
139
147
|
}
|
|
140
148
|
|
|
149
|
+
export function formatRegionValue(region: KimiCodeConfig["region"]): string {
|
|
150
|
+
return region ?? "auto";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function formatRegionStatus(config: KimiCodeConfig): string {
|
|
154
|
+
return `region: ${formatRegionValue(config.region)}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function regionMenuItem(config: KimiCodeConfig): string {
|
|
158
|
+
return `Region -> ${formatRegionValue(config.region)}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
141
161
|
export function protocolMenuItem(config: KimiCodeConfig): string {
|
|
142
162
|
return `Protocol -> ${config.protocol}`;
|
|
143
163
|
}
|
package/src/stream.ts
CHANGED
package/src/tools/common.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
3
|
|
|
4
|
-
import { PROVIDER_ID, getBaseUrl } from "../constants.ts";
|
|
4
|
+
import { PROVIDER_ID, getBaseUrl, normalizeKimiBaseV1 } from "../constants.ts";
|
|
5
5
|
import { getKimiProviderHeaders } from "../device.ts";
|
|
6
6
|
import {
|
|
7
7
|
KIMI_LOGIN_REQUIRED_MESSAGE,
|
|
@@ -57,8 +57,7 @@ export function buildTimeoutSignal(signal: AbortSignal | undefined): {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
export function getKimiBaseV1(): string {
|
|
60
|
-
|
|
61
|
-
return base.endsWith("/v1") ? base : `${base}/v1`;
|
|
60
|
+
return normalizeKimiBaseV1(getBaseUrl());
|
|
62
61
|
}
|
|
63
62
|
|
|
64
63
|
export function getKimiDatasourceUrl(): string {
|
package/src/usage.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PROVIDER_ID, getBaseUrl } from "./constants.ts";
|
|
1
|
+
import { PROVIDER_ID, getBaseUrl, normalizeKimiBaseV1 } from "./constants.ts";
|
|
2
2
|
import { getKimiProviderHeaders } from "./device.ts";
|
|
3
3
|
import { readStoredOAuthCredential, refreshKimiAuthToken } from "./oauth.ts";
|
|
4
4
|
|
|
@@ -51,6 +51,13 @@ function toNumber(value: unknown): number | null {
|
|
|
51
51
|
return Number.isFinite(number) ? number : null;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
function toOptionalNumber(value: unknown): number | null {
|
|
55
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
56
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
57
|
+
const number = Number(value);
|
|
58
|
+
return Number.isFinite(number) ? number : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
54
61
|
function toStringValue(value: unknown): string | undefined {
|
|
55
62
|
if (typeof value === "string" && value.trim()) return value;
|
|
56
63
|
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
@@ -141,6 +148,85 @@ function formatExtraUsage(info: BoosterWalletInfo): string[] {
|
|
|
141
148
|
return lines;
|
|
142
149
|
}
|
|
143
150
|
|
|
151
|
+
// Quota model (upstream kimi-code #3787): the platform /usages payload now
|
|
152
|
+
// serves per-window used ratios under `usages` instead of absolute used/limit
|
|
153
|
+
// rows. Keys are snake_case (`limit_5h`, `limit_7d`, `limit_month_total`,
|
|
154
|
+
// `limit_month_code`); each entry carries `used_ratio` (0..1, sometimes a
|
|
155
|
+
// decimal string) and an optional `reset_time`. Rows are presence-driven —
|
|
156
|
+
// the backend omits windows the plan does not have. The legacy `usage` /
|
|
157
|
+
// `limits` rows are still served alongside during the transition, but the
|
|
158
|
+
// legacy wire is gone from kap-server, so quota rows win when present.
|
|
159
|
+
interface QuotaEntry {
|
|
160
|
+
usedRatio: number;
|
|
161
|
+
resetTime?: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const QUOTA_WINDOWS = [
|
|
165
|
+
["limit_5h", "5h limit"],
|
|
166
|
+
["limit_7d", "Weekly limit"],
|
|
167
|
+
["limit_month_total", "Monthly limit"],
|
|
168
|
+
] as const;
|
|
169
|
+
|
|
170
|
+
const QUOTA_MONTH_CODE_KEY = "limit_month_code";
|
|
171
|
+
|
|
172
|
+
function isRecordValue(value: unknown): value is Record<string, unknown> {
|
|
173
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function toRatio(value: unknown): number | undefined {
|
|
177
|
+
if (typeof value === "number") {
|
|
178
|
+
return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : undefined;
|
|
179
|
+
}
|
|
180
|
+
if (typeof value !== "string" || !value.trim()) return undefined;
|
|
181
|
+
const parsed = Number(value);
|
|
182
|
+
return Number.isFinite(parsed) ? Math.max(0, Math.min(1, parsed)) : undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function parseQuotaEntry(value: unknown): QuotaEntry | undefined {
|
|
186
|
+
if (!isRecordValue(value)) return undefined;
|
|
187
|
+
const usedRatio = toRatio(value.used_ratio);
|
|
188
|
+
if (usedRatio === undefined) return undefined;
|
|
189
|
+
const resetTime = toStringValue(value.reset_time);
|
|
190
|
+
return {
|
|
191
|
+
usedRatio,
|
|
192
|
+
...(resetTime ? { resetTime } : {}),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function parseQuotaWindows(record: Record<string, unknown>): Map<string, QuotaEntry> {
|
|
197
|
+
const windows = new Map<string, QuotaEntry>();
|
|
198
|
+
const usages = record.usages;
|
|
199
|
+
if (!isRecordValue(usages)) return windows;
|
|
200
|
+
for (const key of [...QUOTA_WINDOWS.map(([key]) => key), QUOTA_MONTH_CODE_KEY]) {
|
|
201
|
+
const entry = parseQuotaEntry(usages[key]);
|
|
202
|
+
if (entry) windows.set(key, entry);
|
|
203
|
+
}
|
|
204
|
+
return windows;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function formatQuotaRow(label: string, entry: QuotaEntry, options: UsageFormatOptions): string {
|
|
208
|
+
const percent = Math.min(100, Math.max(0, Math.ceil(entry.usedRatio * 100)));
|
|
209
|
+
const lines = [label, `${quotaBar(entry.usedRatio * 100, 100)} ${percent}% used`];
|
|
210
|
+
const reset = formatResetTime(entry.resetTime, options);
|
|
211
|
+
if (reset) lines.push(`Resets ${reset}`);
|
|
212
|
+
return lines.join("\n");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// The kimi/code split of the monthly quota on the new plan: `code` is the
|
|
216
|
+
// code-typed share of the monthly total as served, `kimi` the remainder
|
|
217
|
+
// (mirrors upstream monthlyBreakdown in apps/kimi-code usage-format).
|
|
218
|
+
function formatMonthlyBreakdown(
|
|
219
|
+
total: QuotaEntry,
|
|
220
|
+
windows: Map<string, QuotaEntry>,
|
|
221
|
+
): string | null {
|
|
222
|
+
const code = windows.get(QUOTA_MONTH_CODE_KEY);
|
|
223
|
+
if (!code) return null;
|
|
224
|
+
const codePercent = Math.min(100, Math.max(0, Math.ceil(code.usedRatio * 100)));
|
|
225
|
+
const totalPercent = Math.min(100, Math.max(0, Math.ceil(total.usedRatio * 100)));
|
|
226
|
+
const kimiPercent = Math.max(0, totalPercent - codePercent);
|
|
227
|
+
return `kimi ${kimiPercent}% · code ${codePercent}%`;
|
|
228
|
+
}
|
|
229
|
+
|
|
144
230
|
export function parseUsageRow(value: unknown, fallbackLabel: string): UsageRow | null {
|
|
145
231
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
146
232
|
const record = value as Record<string, unknown>;
|
|
@@ -164,11 +250,28 @@ export function parseUsageSummary(payload: unknown, options: UsageFormatOptions
|
|
|
164
250
|
|
|
165
251
|
const record = payload as Record<string, unknown>;
|
|
166
252
|
const lines: string[] = [];
|
|
167
|
-
|
|
253
|
+
|
|
254
|
+
const quotaWindows = parseQuotaWindows(record);
|
|
255
|
+
// A code-split entry without a monthly total renders no row of its own; it
|
|
256
|
+
// only feeds the breakdown line, so it must not suppress the legacy rows.
|
|
257
|
+
const hasQuotaRows = QUOTA_WINDOWS.some(([key]) => quotaWindows.has(key));
|
|
258
|
+
if (hasQuotaRows) {
|
|
259
|
+
for (const [key, label] of QUOTA_WINDOWS) {
|
|
260
|
+
const entry = quotaWindows.get(key);
|
|
261
|
+
if (!entry) continue;
|
|
262
|
+
if (lines.length > 0) lines.push("");
|
|
263
|
+
lines.push(...formatQuotaRow(label, entry, options).split("\n"));
|
|
264
|
+
const breakdown =
|
|
265
|
+
key === "limit_month_total" ? formatMonthlyBreakdown(entry, quotaWindows) : null;
|
|
266
|
+
if (breakdown) lines.push(breakdown);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const summary = hasQuotaRows ? null : parseUsageRow(record.usage, "Current week");
|
|
168
271
|
if (summary)
|
|
169
272
|
lines.push(formatUsageRow({ ...summary, label: normalizeUsageLabel(summary.label) }, options));
|
|
170
273
|
|
|
171
|
-
if (Array.isArray(record.limits)) {
|
|
274
|
+
if (!hasQuotaRows && Array.isArray(record.limits)) {
|
|
172
275
|
for (const [index, item] of record.limits.entries()) {
|
|
173
276
|
const itemRecord =
|
|
174
277
|
typeof item === "object" && item !== null && !Array.isArray(item)
|
|
@@ -307,8 +410,7 @@ function partialBlock(value: number): string {
|
|
|
307
410
|
}
|
|
308
411
|
|
|
309
412
|
export function buildKimiUsageUrl(baseUrl = getBaseUrl("openai")): string {
|
|
310
|
-
|
|
311
|
-
return normalized.endsWith("/v1") ? `${normalized}/usages` : `${normalized}/v1/usages`;
|
|
413
|
+
return `${normalizeKimiBaseV1(baseUrl)}/usages`;
|
|
312
414
|
}
|
|
313
415
|
|
|
314
416
|
function fetchKimiUsage(token: string, signal: AbortSignal): Promise<Response> {
|
|
@@ -383,6 +485,12 @@ export interface KimiUserInfo {
|
|
|
383
485
|
userLevel: number;
|
|
384
486
|
userLevelName: string;
|
|
385
487
|
email?: string;
|
|
488
|
+
/**
|
|
489
|
+
* Plan generation from the managed /me profile (upstream #3921). upstream
|
|
490
|
+
* gates feedback surveys and plan-dependent surfaces by it; the monthly
|
|
491
|
+
* kimi/code quota split only exists on newer goods versions.
|
|
492
|
+
*/
|
|
493
|
+
goodsVersion?: number;
|
|
386
494
|
}
|
|
387
495
|
|
|
388
496
|
export function parseKimiUserInfo(payload: unknown): KimiUserInfo | null {
|
|
@@ -391,12 +499,14 @@ export function parseKimiUserInfo(payload: unknown): KimiUserInfo | null {
|
|
|
391
499
|
const userId = toStringValue(record.user_id);
|
|
392
500
|
if (!userId) return null;
|
|
393
501
|
const email = toStringValue(record.email);
|
|
502
|
+
const goodsVersion = toOptionalNumber(record.goods_version);
|
|
394
503
|
return {
|
|
395
504
|
userId,
|
|
396
505
|
nickname: toStringValue(record.nickname) ?? "",
|
|
397
506
|
userLevel: toNumber(record.user_level) ?? 0,
|
|
398
507
|
userLevelName: toStringValue(record.user_level_name) ?? "",
|
|
399
508
|
...(email ? { email } : {}),
|
|
509
|
+
...(goodsVersion !== null ? { goodsVersion } : {}),
|
|
400
510
|
};
|
|
401
511
|
}
|
|
402
512
|
|
|
@@ -412,8 +522,7 @@ export function formatKimiUserInfo(info: KimiUserInfo): string {
|
|
|
412
522
|
}
|
|
413
523
|
|
|
414
524
|
export function buildKimiUserInfoUrl(baseUrl = getBaseUrl("openai")): string {
|
|
415
|
-
|
|
416
|
-
return normalized.endsWith("/v1") ? `${normalized}/me` : `${normalized}/v1/me`;
|
|
525
|
+
return `${normalizeKimiBaseV1(baseUrl)}/me`;
|
|
417
526
|
}
|
|
418
527
|
|
|
419
528
|
function fetchKimiUserInfo(token: string, signal: AbortSignal): Promise<Response> {
|