pi-provider-kimi-code 0.6.13 → 0.6.14

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/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ ## [0.6.14] - 2026-11-15
4
+
5
+ ### Changed
6
+
7
+ - Upgraded `@earendil-works/pi-ai` / `pi-coding-agent` / `pi-tui` to `^1.5.0`. This resolves all npm audit findings (undici, minimatch, brace-expansion advisories) and picks up pi's streaming and rendering performance fixes.
8
+ - Tool parameter schemas (`moonshot_search`, `moonshot_fetch`, `kimi_datasource`) now use `@earendil-works/pi-schema` builders instead of TypeBox, following pi 1.5.0's migration. The generated JSON Schema is unchanged.
9
+
10
+ ### Security
11
+
12
+ - Device-code login now rejects verification URLs that are not `http:`/`https:`, mirroring pi 1.5.0's fix for built-in OAuth providers. A malicious or buggy authorization server can no longer hand pi a `file:` or custom-scheme URL to open.
13
+
14
+ ### Added
15
+
16
+ - The `/kimi-coding` settings dialog now reports pi as blocked on user input (OSC 7501 program status) while it is open, so supporting terminals show the correct waiting state.
17
+ - This changelog: pi 1.5.0 shows extension update notes on the next launch after an update.
18
+ - The `/kimi-settings` dialog now exposes **Keep thinking** (`model.thinkingKeep`: `default` / `all` / `last` / `none`), controlling the `thinking.keep` value sent to the Kimi API — i.e. how much reasoning history the server retains across turns. The wire and config plumbing already existed; this makes it adjustable without editing JSON.
19
+ - `kimi_datasource` now syncs its data-source catalog from the official plugin marketplace (`/coding/v1/plugins/marketplace`) with a lazy 24h disk cache (`~/.kimi-code/kimi-datasource-catalog.json`). The bundled snapshot moves from 12 to 35 sources (adds `china_*`, `who`/`fao`/`oecd`/`fred`, `chembl`/`pubchem`/`pdb`/`uniprot` and other science sources, `dongcai`/`cls`/`finance_research`), `data_source_name` is no longer a closed enum so server-side additions are never rejected by a stale client schema, and unknown names trigger one forced catalog refresh before being rejected with the current source list.
20
+ - Model discovery now prefers the unified `/models` schema (`limit.context`, `reasoning.type`/`reasoning.effort.valid`/`default`, `modalities.input`) that the official kimi-code client switched to in #4209, keeping the legacy flat fields (`context_length`, `supports_reasoning`, `think_efforts`, ...) as fallback while the server still sends both.
21
+ - Kimi's hard quota exhaustion (HTTP 429 with `exceeded_current_quota_error`) is now rewritten into actionable guidance — it is not a transient rate limit and retries will keep failing — pointing at `/kimi-settings` for usage, mirroring upstream kimi-code #4477/#4703. Plain transient 429s are untouched.
package/index.ts CHANGED
@@ -224,204 +224,225 @@ async function openSettingsMenu(
224
224
  let scope: KimiConfigScope = projectTrusted ? "project" : "home";
225
225
  let dirty = false;
226
226
 
227
- await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
228
- const settingsTheme = buildSettingsTheme(theme);
229
-
230
- let list: SettingsList;
231
-
232
- const toolLabel = (toolName: KimiToolName) => {
233
- if (toolName === "moonshot_search") return "Search tool";
234
- if (toolName === "moonshot_fetch") return "Fetch tool";
235
- return "Real-world data API";
236
- };
237
-
238
- const formatToolMenuValue = (toolName: KimiToolName) => {
239
- const tool = drafts[scope].tools[toolName];
240
- if (!tool.enabled) return "disabled";
241
- return tool.default_collapsed ? "enabled without preview" : "enabled with preview";
242
- };
243
-
244
- const refreshDisplays = () => {
245
- scopeItem.description = formatScopeDescription(scope, ctx.cwd);
246
- for (const toolName of KIMI_TOOL_NAMES) {
247
- list.updateValue(toolName, formatToolMenuValue(toolName));
248
- }
249
- list.updateValue("protocol", drafts[scope].protocol);
250
- list.updateValue("region", formatRegionValue(drafts[scope].region));
251
- list.updateValue("uploadThreshold", formatByteSize(drafts[scope].uploads.thresholdBytes));
252
- };
253
-
254
- const save = () => {
255
- if (scope === "project" && !projectTrusted) {
256
- ctx.ui.notify("Project config cannot be saved until the project is trusted.", "warning");
257
- return;
258
- }
259
- try {
260
- saveScopeKimiCodeConfig(scope, ctx.cwd, drafts[scope]);
261
- applyEffectiveKimiRuntimeConfig(pi, state, ctx.cwd, {
262
- updateActiveTools: true,
263
- projectTrusted: scope === "project" ? true : projectTrusted,
264
- });
265
- dirty = true;
266
- } catch (error: unknown) {
267
- ctx.ui.notify((error as Error).message, "error");
268
- }
269
- };
270
-
271
- const onChange = (id: string, newValue: string) => {
272
- if (id === "scope") {
273
- scope = newValue as KimiConfigScope;
274
- refreshDisplays();
275
- return;
276
- }
277
- if (id === "protocol") {
278
- drafts[scope].protocol = newValue as KimiCodeConfig["protocol"];
279
- list.updateValue(id, newValue);
280
- save();
281
- return;
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
- }
289
- if (id === "uploadThreshold") {
290
- const bytes = parseByteSizeInput(newValue);
291
- if (bytes === undefined || bytes <= 0) {
292
- ctx.ui.notify("Upload threshold must be a positive size, for example 2 MiB", "error");
227
+ await ctx.ui.custom<void>(
228
+ (tui, theme, _keybindings, done) => {
229
+ const settingsTheme = buildSettingsTheme(theme);
230
+
231
+ let list: SettingsList;
232
+
233
+ const toolLabel = (toolName: KimiToolName) => {
234
+ if (toolName === "moonshot_search") return "Search tool";
235
+ if (toolName === "moonshot_fetch") return "Fetch tool";
236
+ return "Real-world data API";
237
+ };
238
+
239
+ const formatToolMenuValue = (toolName: KimiToolName) => {
240
+ const tool = drafts[scope].tools[toolName];
241
+ if (!tool.enabled) return "disabled";
242
+ return tool.default_collapsed ? "enabled without preview" : "enabled with preview";
243
+ };
244
+
245
+ const refreshDisplays = () => {
246
+ scopeItem.description = formatScopeDescription(scope, ctx.cwd);
247
+ for (const toolName of KIMI_TOOL_NAMES) {
248
+ list.updateValue(toolName, formatToolMenuValue(toolName));
249
+ }
250
+ list.updateValue("protocol", drafts[scope].protocol);
251
+ list.updateValue("region", formatRegionValue(drafts[scope].region));
252
+ list.updateValue("uploadThreshold", formatByteSize(drafts[scope].uploads.thresholdBytes));
253
+ list.updateValue("thinkingKeep", drafts[scope].model.thinkingKeep ?? "default");
254
+ };
255
+
256
+ const save = () => {
257
+ if (scope === "project" && !projectTrusted) {
258
+ ctx.ui.notify("Project config cannot be saved until the project is trusted.", "warning");
293
259
  return;
294
260
  }
295
- drafts[scope].uploads.thresholdBytes = bytes;
296
- list.updateValue(id, formatByteSize(bytes));
297
- save();
298
- return;
299
- }
300
- const toolMatch = /^(?<tool>.+):(?<field>enabled|collapsed)$/.exec(id);
301
- if (toolMatch?.groups) {
302
- const toolName = toolMatch.groups.tool as KimiToolName;
303
- const field = toolMatch.groups.field as "enabled" | "collapsed";
304
- if (field === "enabled") {
305
- drafts[scope].tools[toolName].enabled = newValue === "true";
306
- } else {
307
- drafts[scope].tools[toolName].default_collapsed = newValue !== "true";
261
+ try {
262
+ saveScopeKimiCodeConfig(scope, ctx.cwd, drafts[scope]);
263
+ applyEffectiveKimiRuntimeConfig(pi, state, ctx.cwd, {
264
+ updateActiveTools: true,
265
+ projectTrusted: scope === "project" ? true : projectTrusted,
266
+ });
267
+ dirty = true;
268
+ } catch (error: unknown) {
269
+ ctx.ui.notify((error as Error).message, "error");
308
270
  }
309
- list.updateValue(toolName, formatToolMenuValue(toolName));
310
- save();
311
- }
312
- };
313
-
314
- const scopeItem: SettingItem = {
315
- id: "scope",
316
- label: "Config scope",
317
- description: projectTrusted
318
- ? formatScopeDescription(scope, ctx.cwd)
319
- : "Project config disabled until the project is trusted; editing home config only",
320
- currentValue: scope,
321
- values: projectTrusted ? ["project", "home"] : ["home"],
322
- };
271
+ };
323
272
 
324
- const items: SettingItem[] = [scopeItem];
325
- for (const toolName of KIMI_TOOL_NAMES) {
326
- items.push({
327
- id: toolName,
328
- label: toolLabel(toolName),
329
- description: `Configure ${toolName} registration and preview defaults`,
330
- currentValue: formatToolMenuValue(toolName),
331
- submenu: (_current, submenuDone) =>
332
- new SettingsList(
333
- [
334
- {
335
- id: `${toolName}:enabled`,
336
- label: "Enabled",
337
- description: `Register ${toolName} at session start`,
338
- currentValue: String(drafts[scope].tools[toolName].enabled),
339
- values: ["true", "false"],
340
- },
341
- {
342
- id: `${toolName}:collapsed`,
343
- label: "Show preview",
344
- description: `Show ${toolName} result previews by default`,
345
- currentValue: String(!drafts[scope].tools[toolName].default_collapsed),
346
- values: ["true", "false"],
347
- },
348
- ],
349
- 2,
350
- settingsTheme,
351
- onChange,
352
- () => submenuDone(),
353
- ),
354
- });
355
- }
356
- items.push({
357
- id: "protocol",
358
- label: "Protocol",
359
- description: "API protocol for Kimi requests",
360
- currentValue: drafts[scope].protocol,
361
- values: ["openai", "anthropic", "responses"],
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
- });
370
- items.push({
371
- id: "uploadThreshold",
372
- label: "Upload threshold",
373
- description: "Max size for inline file uploads",
374
- currentValue: formatByteSize(drafts[scope].uploads.thresholdBytes),
375
- submenu: (_current, submenuDone) => {
376
- const input = new Input();
377
- input.setValue(formatByteSize(drafts[scope].uploads.thresholdBytes));
378
- input.onSubmit = (value) => {
379
- const bytes = parseByteSizeInput(value);
273
+ const onChange = (id: string, newValue: string) => {
274
+ if (id === "scope") {
275
+ scope = newValue as KimiConfigScope;
276
+ refreshDisplays();
277
+ return;
278
+ }
279
+ if (id === "protocol") {
280
+ drafts[scope].protocol = newValue as KimiCodeConfig["protocol"];
281
+ list.updateValue(id, newValue);
282
+ save();
283
+ return;
284
+ }
285
+ if (id === "region") {
286
+ drafts[scope].region = newValue === "auto" ? null : (newValue as KimiRegion);
287
+ list.updateValue(id, newValue);
288
+ save();
289
+ return;
290
+ }
291
+ if (id === "uploadThreshold") {
292
+ const bytes = parseByteSizeInput(newValue);
380
293
  if (bytes === undefined || bytes <= 0) {
381
294
  ctx.ui.notify("Upload threshold must be a positive size, for example 2 MiB", "error");
382
- submenuDone();
383
295
  return;
384
296
  }
385
- submenuDone(formatByteSize(bytes));
386
- };
387
- input.onEscape = () => submenuDone();
388
- return input;
389
- },
390
- });
297
+ drafts[scope].uploads.thresholdBytes = bytes;
298
+ list.updateValue(id, formatByteSize(bytes));
299
+ save();
300
+ return;
301
+ }
302
+ if (id === "thinkingKeep") {
303
+ drafts[scope].model.thinkingKeep =
304
+ newValue === "default" ? null : (newValue as KimiCodeConfig["model"]["thinkingKeep"]);
305
+ list.updateValue(id, newValue);
306
+ save();
307
+ return;
308
+ }
309
+ const toolMatch = /^(?<tool>.+):(?<field>enabled|collapsed)$/.exec(id);
310
+ if (toolMatch?.groups) {
311
+ const toolName = toolMatch.groups.tool as KimiToolName;
312
+ const field = toolMatch.groups.field as "enabled" | "collapsed";
313
+ if (field === "enabled") {
314
+ drafts[scope].tools[toolName].enabled = newValue === "true";
315
+ } else {
316
+ drafts[scope].tools[toolName].default_collapsed = newValue !== "true";
317
+ }
318
+ list.updateValue(toolName, formatToolMenuValue(toolName));
319
+ save();
320
+ }
321
+ };
322
+
323
+ const scopeItem: SettingItem = {
324
+ id: "scope",
325
+ label: "Config scope",
326
+ description: projectTrusted
327
+ ? formatScopeDescription(scope, ctx.cwd)
328
+ : "Project config disabled until the project is trusted; editing home config only",
329
+ currentValue: scope,
330
+ values: projectTrusted ? ["project", "home"] : ["home"],
331
+ };
332
+
333
+ const items: SettingItem[] = [scopeItem];
334
+ for (const toolName of KIMI_TOOL_NAMES) {
335
+ items.push({
336
+ id: toolName,
337
+ label: toolLabel(toolName),
338
+ description: `Configure ${toolName} registration and preview defaults`,
339
+ currentValue: formatToolMenuValue(toolName),
340
+ submenu: (_current, submenuDone) =>
341
+ new SettingsList(
342
+ [
343
+ {
344
+ id: `${toolName}:enabled`,
345
+ label: "Enabled",
346
+ description: `Register ${toolName} at session start`,
347
+ currentValue: String(drafts[scope].tools[toolName].enabled),
348
+ values: ["true", "false"],
349
+ },
350
+ {
351
+ id: `${toolName}:collapsed`,
352
+ label: "Show preview",
353
+ description: `Show ${toolName} result previews by default`,
354
+ currentValue: String(!drafts[scope].tools[toolName].default_collapsed),
355
+ values: ["true", "false"],
356
+ },
357
+ ],
358
+ 2,
359
+ settingsTheme,
360
+ onChange,
361
+ () => submenuDone(),
362
+ ),
363
+ });
364
+ }
365
+ items.push({
366
+ id: "protocol",
367
+ label: "Protocol",
368
+ description: "API protocol for Kimi requests",
369
+ currentValue: drafts[scope].protocol,
370
+ values: ["openai", "anthropic", "responses"],
371
+ });
372
+ items.push({
373
+ id: "region",
374
+ label: "Region",
375
+ description: "Managed deployment region (auto follows KIMI_CODE_REGION / default)",
376
+ currentValue: formatRegionValue(drafts[scope].region),
377
+ values: ["auto", "mainland-cn", "global"],
378
+ });
379
+ items.push({
380
+ id: "thinkingKeep",
381
+ label: "Keep thinking",
382
+ description:
383
+ "thinking.keep sent to the Kimi API: how much reasoning history the server should retain across turns",
384
+ currentValue: drafts[scope].model.thinkingKeep ?? "default",
385
+ values: ["default", "all", "last", "none"],
386
+ });
387
+ items.push({
388
+ id: "uploadThreshold",
389
+ label: "Upload threshold",
390
+ description: "Max size for inline file uploads",
391
+ currentValue: formatByteSize(drafts[scope].uploads.thresholdBytes),
392
+ submenu: (_current, submenuDone) => {
393
+ const input = new Input();
394
+ input.setValue(formatByteSize(drafts[scope].uploads.thresholdBytes));
395
+ input.onSubmit = (value) => {
396
+ const bytes = parseByteSizeInput(value);
397
+ if (bytes === undefined || bytes <= 0) {
398
+ ctx.ui.notify("Upload threshold must be a positive size, for example 2 MiB", "error");
399
+ submenuDone();
400
+ return;
401
+ }
402
+ submenuDone(formatByteSize(bytes));
403
+ };
404
+ input.onEscape = () => submenuDone();
405
+ return input;
406
+ },
407
+ });
391
408
 
392
- list = new SettingsList(items, items.length, settingsTheme, onChange, () => done(), {
393
- enableSearch: true,
394
- });
409
+ list = new SettingsList(items, items.length, settingsTheme, onChange, () => done(), {
410
+ enableSearch: true,
411
+ });
395
412
 
396
- return {
397
- items,
398
- onChange,
399
- render(width: number) {
400
- const usageLines = usage.split("\n").map((line) => truncateToWidth(` ${line}`, width));
401
- return [
402
- truncateToWidth(
403
- theme.fg("accent", theme.bold(`Kimi settings (provider v${PROVIDER_VERSION})`)),
404
- width,
405
- ),
406
- "",
407
- truncateToWidth(theme.fg("accent", theme.bold("Kimi account")), width),
408
- truncateToWidth(` ${account}`, width),
409
- "",
410
- truncateToWidth(theme.fg("accent", theme.bold("Kimi usage")), width),
411
- ...usageLines,
412
- "",
413
- ...list.render(width),
414
- ];
415
- },
416
- handleInput(data: string) {
417
- list.handleInput?.(data);
418
- tui.requestRender();
419
- },
420
- invalidate() {
421
- list.invalidate();
422
- },
423
- };
424
- });
413
+ return {
414
+ items,
415
+ onChange,
416
+ render(width: number) {
417
+ const usageLines = usage.split("\n").map((line) => truncateToWidth(` ${line}`, width));
418
+ return [
419
+ truncateToWidth(
420
+ theme.fg("accent", theme.bold(`Kimi settings (provider v${PROVIDER_VERSION})`)),
421
+ width,
422
+ ),
423
+ "",
424
+ truncateToWidth(theme.fg("accent", theme.bold("Kimi account")), width),
425
+ truncateToWidth(` ${account}`, width),
426
+ "",
427
+ truncateToWidth(theme.fg("accent", theme.bold("Kimi usage")), width),
428
+ ...usageLines,
429
+ "",
430
+ ...list.render(width),
431
+ ];
432
+ },
433
+ handleInput(data: string) {
434
+ list.handleInput?.(data);
435
+ tui.requestRender();
436
+ },
437
+ invalidate() {
438
+ list.invalidate();
439
+ },
440
+ };
441
+ },
442
+ {
443
+ blocked: { kind: "question", message: "Kimi settings" },
444
+ },
445
+ );
425
446
 
426
447
  if (dirty) await ctx.reload();
427
448
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-provider-kimi-code",
3
- "version": "0.6.13",
3
+ "version": "0.6.14",
4
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",
@@ -41,7 +41,8 @@
41
41
  "index.ts",
42
42
  "src",
43
43
  "package.json",
44
- "README.md"
44
+ "README.md",
45
+ "CHANGELOG.md"
45
46
  ],
46
47
  "type": "module",
47
48
  "scripts": {
@@ -55,23 +56,24 @@
55
56
  "pre-commit": "prek run"
56
57
  },
57
58
  "dependencies": {
59
+ "@earendil-works/pi-schema": "^1.5.0",
58
60
  "proper-lockfile": "^4.1.2"
59
61
  },
60
62
  "devDependencies": {
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",
64
- "@j178/prek": "^0.5.2",
65
- "@types/node": "^24.0.0",
63
+ "@earendil-works/pi-ai": "^1.5.0",
64
+ "@earendil-works/pi-coding-agent": "^1.5.0",
65
+ "@earendil-works/pi-tui": "^1.5.0",
66
+ "@j178/prek": "^0.5.10",
67
+ "@types/node": "^24.19.2",
66
68
  "@types/proper-lockfile": "^4.1.4",
67
- "oxfmt": "^0.67.0",
68
- "oxlint": "^1.82.0",
69
+ "oxfmt": "^0.77.0",
70
+ "oxlint": "^1.92.0",
69
71
  "typescript": "^5.9.0"
70
72
  },
71
73
  "peerDependencies": {
72
- "@earendil-works/pi-ai": "*",
73
- "@earendil-works/pi-coding-agent": "*",
74
- "@earendil-works/pi-tui": "*"
74
+ "@earendil-works/pi-ai": ">=1.5.0",
75
+ "@earendil-works/pi-coding-agent": ">=1.5.0",
76
+ "@earendil-works/pi-tui": ">=1.5.0"
75
77
  },
76
78
  "pi": {
77
79
  "extensions": [
package/src/constants.ts CHANGED
@@ -133,7 +133,7 @@ export function getDefaultBaseUrl(
133
133
 
134
134
  export const DEFAULT_BASE_URL = getDefaultBaseUrl(ENV_KIMI_CODE_PROTOCOL);
135
135
 
136
- export const PROVIDER_VERSION = "0.6.13";
136
+ export const PROVIDER_VERSION = "0.6.14";
137
137
 
138
138
  export const KIMI_CODE_USER_AGENT = `pi-provider-kimi-code/${PROVIDER_VERSION}`;
139
139
  export const KIMI_PLATFORM = "pi";
package/src/errors.ts ADDED
@@ -0,0 +1,31 @@
1
+ // Kimi error-message classification, mirroring upstream kimi-code #4477 /
2
+ // #4703: quota exhaustion is a hard limit with explicit non-retryability, not
3
+ // a transient rate limit — say so instead of letting the raw 429 body read
4
+ // like something a retry would fix.
5
+
6
+ const QUOTA_ERROR_CODE = "exceeded_current_quota_error";
7
+
8
+ /**
9
+ * Detect Kimi's structured quota error in a flattened error message
10
+ * (status line + JSON body). Both markers are required to avoid matching
11
+ * unrelated 429 rate limits, which ARE transient.
12
+ */
13
+ export function isKimiQuotaExhaustedMessage(message: unknown): boolean {
14
+ const text = String(message ?? "").toLowerCase();
15
+ return text.includes(QUOTA_ERROR_CODE) && /\b429\b/.test(text);
16
+ }
17
+
18
+ /**
19
+ * Rewrite a raw upstream error into actionable text. Returns the input
20
+ * unchanged when nothing applies.
21
+ */
22
+ export function improveKimiErrorMessage(message: string): string {
23
+ if (!isKimiQuotaExhaustedMessage(message)) return message;
24
+ return (
25
+ "Kimi Code quota exhausted (HTTP 429, exceeded_current_quota_error). " +
26
+ "This is a hard quota limit, not a transient rate limit — retrying will " +
27
+ "keep failing until the quota window resets. Run /kimi-settings to check " +
28
+ "your current usage. " +
29
+ `Original error: ${message}`
30
+ );
31
+ }
package/src/models.ts CHANGED
@@ -184,6 +184,13 @@ interface KimiServerModel {
184
184
  supports_thinking_type?: unknown;
185
185
  protocol?: unknown;
186
186
  think_efforts?: unknown;
187
+ // Unified schema (upstream kimi-code #4209). The server currently sends
188
+ // both shapes; the official client already reads ONLY these fields, so
189
+ // prefer them and treat the flat legacy fields above as fallback.
190
+ limit?: unknown;
191
+ reasoning?: unknown;
192
+ modalities?: unknown;
193
+ tools?: unknown;
187
194
  }
188
195
 
189
196
  function parseSupportsThinkingType(value: unknown): "only" | "no" | "both" | undefined {
@@ -221,28 +228,89 @@ function parseThinkEfforts(value: unknown): {
221
228
  };
222
229
  }
223
230
 
231
+ // Unified `reasoning` object: { type: "only" | "both" | "none", effort: {
232
+ // valid: [...], default } }. A missing object means the legacy flat fields
233
+ // decide; a present object always wins (type "none" clears a stale thinking
234
+ // default, matching upstream's three-state mapping to internal "no").
235
+ function parseUnifiedReasoning(value: unknown): {
236
+ readonly matched: boolean;
237
+ readonly supportsThinkingType?: "only" | "no" | "both";
238
+ readonly supportsReasoning?: boolean;
239
+ readonly supportEfforts?: string[];
240
+ readonly defaultEffort?: string;
241
+ } {
242
+ if (!isRecord(value)) return { matched: false };
243
+ const rawType = value.type;
244
+ const supportsThinkingType =
245
+ rawType === "only" || rawType === "both" ? rawType : rawType === "none" ? "no" : undefined;
246
+ const effort = isRecord(value.effort) ? value.effort : undefined;
247
+ const supportEfforts = Array.isArray(effort?.valid)
248
+ ? effort.valid.filter((e): e is string => typeof e === "string" && !!e)
249
+ : undefined;
250
+ const rawDefault =
251
+ typeof effort?.default === "string" && effort.default ? effort.default : undefined;
252
+ // A default not listed in valid is dropped, same as upstream.
253
+ const defaultEffort =
254
+ rawDefault && supportEfforts && !supportEfforts.includes(rawDefault) ? undefined : rawDefault;
255
+ return {
256
+ matched: true,
257
+ ...(supportsThinkingType ? { supportsThinkingType } : {}),
258
+ supportsReasoning: supportsThinkingType !== undefined && supportsThinkingType !== "no",
259
+ ...(supportEfforts && supportEfforts.length > 0 ? { supportEfforts } : {}),
260
+ ...(defaultEffort ? { defaultEffort } : {}),
261
+ };
262
+ }
263
+
264
+ function parseInputModalities(value: unknown): {
265
+ supportsImageIn?: boolean;
266
+ supportsVideoIn?: boolean;
267
+ } {
268
+ if (!isRecord(value) || !Array.isArray(value.input)) return {};
269
+ const input = value.input;
270
+ return {
271
+ supportsImageIn: input.includes("image"),
272
+ supportsVideoIn: input.includes("video"),
273
+ };
274
+ }
275
+
224
276
  function parseKimiModelMetadata(model: KimiServerModel): KimiModelMetadata | undefined {
225
277
  if (typeof model.id !== "string" || !model.id) return undefined;
226
278
  const metadata: KimiModelMetadata = { wireModelId: model.id };
227
279
  if (typeof model.display_name === "string") metadata.modelDisplay = model.display_name;
228
- if (typeof model.context_length === "number" && model.context_length > 0) {
280
+ // Unified limit.context wins over legacy context_length.
281
+ const limit = isRecord(model.limit) ? model.limit : undefined;
282
+ if (typeof limit?.context === "number" && limit.context > 0) {
283
+ metadata.contextLength = limit.context;
284
+ } else if (typeof model.context_length === "number" && model.context_length > 0) {
229
285
  metadata.contextLength = model.context_length;
230
286
  }
231
- const thinkingType = parseSupportsThinkingType(model.supports_thinking_type);
232
- if (thinkingType) {
233
- metadata.supportsThinkingType = thinkingType;
234
- } else if (typeof model.supports_reasoning === "boolean") {
235
- metadata.supportsReasoning = model.supports_reasoning;
236
- }
237
- if (typeof model.supports_image_in === "boolean") {
238
- metadata.supportsImageIn = model.supports_image_in;
287
+ const unifiedReasoning = parseUnifiedReasoning(model.reasoning);
288
+ if (unifiedReasoning.matched) {
289
+ const { matched: _, ...fields } = unifiedReasoning;
290
+ Object.assign(metadata, fields);
291
+ } else {
292
+ const thinkingType = parseSupportsThinkingType(model.supports_thinking_type);
293
+ if (thinkingType) {
294
+ metadata.supportsThinkingType = thinkingType;
295
+ } else if (typeof model.supports_reasoning === "boolean") {
296
+ metadata.supportsReasoning = model.supports_reasoning;
297
+ }
298
+ Object.assign(metadata, parseThinkEfforts(model.think_efforts));
239
299
  }
240
- if (typeof model.supports_video_in === "boolean") {
241
- metadata.supportsVideoIn = model.supports_video_in;
300
+ const modalities = parseInputModalities(model.modalities);
301
+ if (modalities.supportsImageIn !== undefined) {
302
+ metadata.supportsImageIn = modalities.supportsImageIn;
303
+ metadata.supportsVideoIn = modalities.supportsVideoIn;
304
+ } else {
305
+ if (typeof model.supports_image_in === "boolean") {
306
+ metadata.supportsImageIn = model.supports_image_in;
307
+ }
308
+ if (typeof model.supports_video_in === "boolean") {
309
+ metadata.supportsVideoIn = model.supports_video_in;
310
+ }
242
311
  }
243
312
  const protocol = parseKimiCatalogProtocol(model.protocol);
244
313
  if (protocol) metadata.protocol = protocol;
245
- Object.assign(metadata, parseThinkEfforts(model.think_efforts));
246
314
  return metadata;
247
315
  }
248
316