pi-provider-kimi-code 0.6.7 → 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/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,11 +41,16 @@ import {
38
41
  saveProjectKimiCodeConfig,
39
42
  type KimiToolName,
40
43
  } from "./src/config.ts";
41
- import { PROVIDER_ID, PROVIDER_VERSION, getBaseUrl, getKimiApiType } from "./src/constants.ts";
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,
45
- applyKimiMembershipLimitsToModel,
46
54
  buildKimiModelFromConfig,
47
55
  applyKimiOAuthExtrasToModel,
48
56
  KIMI_CODING_HIGHSPEED_MODEL_ID,
@@ -51,10 +59,15 @@ import {
51
59
  KIMI_MODEL_CATALOG_VERSION,
52
60
  discoverKimiModelMetadata,
53
61
  getKimiModelMetadata,
54
- isKimiModelAvailableForMembership,
62
+ hasKimiModelMetadata,
55
63
  resolveKimiModelConfig,
56
64
  } from "./src/models.ts";
57
- import { getKimiApiKey, loginKimiCode, refreshKimiCodeToken } from "./src/oauth.ts";
65
+ import {
66
+ getKimiApiKey,
67
+ loginKimiCode,
68
+ refreshKimiAuthToken,
69
+ refreshKimiCodeToken,
70
+ } from "./src/oauth.ts";
58
71
  import { isKimiProjectConfigApproved } from "./src/project-trust.ts";
59
72
  import {
60
73
  type KimiConfigScope,
@@ -70,8 +83,8 @@ import { buildKimiDatasourceTool } from "./src/tools/datasource.ts";
70
83
  interface KimiRuntimeState {
71
84
  cwd: string;
72
85
  config: KimiCodeConfig;
86
+ fallbackSourceId: string;
73
87
  modelExtras: KimiOAuthExtras;
74
- membershipLevel: string | null;
75
88
  projectTrusted: boolean;
76
89
  overrides?: KimiCodeConfigPatch;
77
90
  }
@@ -143,15 +156,30 @@ function applyEffectiveKimiRuntimeConfig(
143
156
  return config;
144
157
  }
145
158
 
146
- async function refreshModelExtras(state: KimiRuntimeState): Promise<boolean> {
147
- const token = getKimiUsageToken();
159
+ async function refreshModelExtras(
160
+ state: KimiRuntimeState,
161
+ token = getKimiUsageToken(),
162
+ ): Promise<boolean> {
148
163
  if (!token) return false;
149
- const extras = await discoverKimiModelMetadata(token, state.config.protocol);
164
+ const extras = await discoverKimiModelMetadata(token, state.config.protocol, {
165
+ refreshAccessToken: refreshKimiAuthToken,
166
+ });
150
167
  if (Object.keys(extras).length === 0) return false;
151
168
  Object.assign(state.modelExtras, extras);
152
169
  return true;
153
170
  }
154
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
+
155
183
  async function openSettingsMenu(
156
184
  pi: ExtensionAPI,
157
185
  ctx: ExtensionCommandContext,
@@ -167,10 +195,7 @@ async function openSettingsMenu(
167
195
  if (!modelsRefreshed && refreshedToken && refreshedToken !== modelDiscoveryToken) {
168
196
  modelsRefreshed = await refreshModelExtras(state);
169
197
  }
170
- const refreshedMembershipLevel = usageSnapshot.membershipLevel ?? state.membershipLevel;
171
- const membershipChanged = state.membershipLevel !== refreshedMembershipLevel;
172
- state.membershipLevel = refreshedMembershipLevel;
173
- if (modelsRefreshed || membershipChanged) registerKimiProvider(pi, state);
198
+ if (modelsRefreshed) await registerKimiProvider(pi, state);
174
199
  const usage = usageSnapshot.summary;
175
200
 
176
201
  const projectTrusted = await isKimiProjectConfigApproved(ctx, ctx.cwd);
@@ -382,74 +407,125 @@ function saveScopeKimiCodeConfig(
382
407
  function filterAvailableKimiModels<T extends { id: string }>(
383
408
  models: T[],
384
409
  extras: KimiOAuthExtras,
385
- membershipLevel: string | null,
386
410
  ): T[] {
387
411
  const available =
388
412
  extras.modelCatalogVersion === KIMI_MODEL_CATALOG_VERSION && extras.modelCatalog
389
413
  ? new Set(Object.keys(extras.modelCatalog))
390
414
  : null;
391
- return models.filter(
392
- (model) =>
393
- (!available || available.has(model.id)) &&
394
- isKimiModelAvailableForMembership(model.id, membershipLevel) !== false,
395
- );
415
+ return models.filter((model) => !available || available.has(model.id));
396
416
  }
397
417
 
398
- function registerKimiProvider(pi: ExtensionAPI, state: KimiRuntimeState): void {
399
- const standardModel = applyKimiMembershipLimitsToModel(
400
- applyKimiOAuthExtrasToModel(
401
- buildKimiModelFromConfig(state.config.model),
402
- getKimiModelMetadata(state.modelExtras, KIMI_CODING_MODEL_ID),
403
- ),
404
- state.membershipLevel,
405
- );
406
- const highSpeedModel = applyKimiMembershipLimitsToModel(
407
- applyKimiOAuthExtrasToModel(
408
- buildKimiModelFromConfig(state.config.model, KIMI_CODING_HIGHSPEED_MODEL_ID),
409
- getKimiModelMetadata(state.modelExtras, KIMI_CODING_HIGHSPEED_MODEL_ID),
410
- ),
411
- state.membershipLevel,
412
- );
413
- const k3Model = applyKimiMembershipLimitsToModel(
418
+ function catalogModelIds(extras: KimiOAuthExtras): string[] {
419
+ if (extras.modelCatalogVersion === KIMI_MODEL_CATALOG_VERSION && extras.modelCatalog) {
420
+ return Object.keys(extras.modelCatalog);
421
+ }
422
+ return [KIMI_CODING_MODEL_ID, KIMI_CODING_HIGHSPEED_MODEL_ID, KIMI_K3_MODEL_ID];
423
+ }
424
+
425
+ function buildKimiCatalogModels(state: KimiRuntimeState) {
426
+ return catalogModelIds(state.modelExtras).map((modelId) =>
414
427
  applyKimiOAuthExtrasToModel(
415
- buildKimiModelFromConfig(state.config.model, KIMI_K3_MODEL_ID),
416
- getKimiModelMetadata(state.modelExtras, KIMI_K3_MODEL_ID),
428
+ buildKimiModelFromConfig(state.config.model, modelId),
429
+ getKimiModelMetadata(state.modelExtras, modelId),
430
+ state.config.model.reasoningMap,
417
431
  ),
418
- state.membershipLevel,
419
432
  );
433
+ }
420
434
 
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);
421
513
  pi.registerProvider(PROVIDER_ID, {
422
514
  baseUrl: getBaseUrl(state.config.protocol),
423
515
  apiKey: "$KIMI_API_KEY",
424
516
  api: getKimiApiType(state.config.protocol),
425
517
  streamSimple: streamSimpleKimi,
426
518
 
427
- models: filterAvailableKimiModels(
428
- [standardModel, highSpeedModel, k3Model],
429
- state.modelExtras,
430
- state.membershipLevel,
431
- ),
519
+ models: filterAvailableKimiModels(buildKimiCatalogModels(state), state.modelExtras),
432
520
 
433
521
  oauth: {
434
522
  name: "Kimi Code (OAuth)",
435
523
  login: async (callbacks) => {
436
524
  const credentials = await loginKimiCode(callbacks);
437
- const usage = await fetchKimiUsageSnapshot({
438
- timeoutMs: 2500,
439
- token: credentials.access,
440
- refreshOnUnauthorized: false,
441
- });
442
- state.membershipLevel = usage.membershipLevel;
443
525
  return credentials;
444
526
  },
445
527
  refreshToken: async (credentials) => {
446
528
  const refreshed = await refreshKimiCodeToken(credentials);
447
- const usage = await fetchKimiUsageSnapshot({
448
- timeoutMs: 2500,
449
- token: refreshed.access,
450
- refreshOnUnauthorized: false,
451
- });
452
- state.membershipLevel = usage.membershipLevel;
453
529
  return refreshed;
454
530
  },
455
531
  getApiKey: getKimiApiKey,
@@ -460,32 +536,102 @@ function registerKimiProvider(pi: ExtensionAPI, state: KimiRuntimeState): void {
460
536
  // request payload by streamSimpleKimi.
461
537
  modifyModels: (models, cred) => {
462
538
  const extras = cred as KimiOAuthCredentials;
463
- state.modelExtras = extras;
539
+ // Retain previously discovered extras when this credential carries
540
+ // none (legacy format or failed discovery): applying empty metadata
541
+ // would strip thinking levels the startup discovery already found.
542
+ if (hasKimiModelMetadata(extras)) state.modelExtras = extras;
464
543
  reloadEffectiveKimiRuntimeConfig(state, state.cwd, state.projectTrusted);
465
544
  const available =
466
545
  extras.modelCatalogVersion === KIMI_MODEL_CATALOG_VERSION && extras.modelCatalog
467
546
  ? new Set(Object.keys(extras.modelCatalog))
468
547
  : null;
469
- return models
470
- .filter(
471
- (model) =>
472
- model.provider !== PROVIDER_ID ||
473
- ((!available || available.has(model.id)) &&
474
- isKimiModelAvailableForMembership(model.id, state.membershipLevel) !== false),
475
- )
476
- .map((model) =>
477
- model.provider === PROVIDER_ID
478
- ? applyKimiMembershipLimitsToModel(
479
- applyKimiOAuthExtrasToModel(model, getKimiModelMetadata(extras, model.id)),
480
- state.membershipLevel,
481
- )
482
- : model,
548
+ if (!available) {
549
+ return [
550
+ ...models.filter((model) => model.provider !== PROVIDER_ID),
551
+ ...models
552
+ .filter((model) => model.provider === PROVIDER_ID)
553
+ .map((model) => {
554
+ const metadata = getKimiModelMetadata(extras, model.id);
555
+ if (!hasKimiModelMetadata(metadata)) return model;
556
+ return applyKimiOAuthExtrasToModel(
557
+ model,
558
+ metadata,
559
+ state.config.model.reasoningMap,
560
+ );
561
+ }),
562
+ ];
563
+ }
564
+ const catalogModels = catalogModelIds(extras).map((modelId) => {
565
+ const existing = models.find(
566
+ (model) => model.provider === PROVIDER_ID && model.id === modelId,
483
567
  );
568
+ return applyKimiOAuthExtrasToModel(
569
+ existing ?? {
570
+ ...buildKimiModelFromConfig(state.config.model, modelId),
571
+ api: getKimiApiType(state.config.protocol),
572
+ provider: PROVIDER_ID,
573
+ baseUrl: getBaseUrl(state.config.protocol),
574
+ },
575
+ getKimiModelMetadata(extras, modelId),
576
+ state.config.model.reasoningMap,
577
+ );
578
+ });
579
+ let insertedCatalog = false;
580
+ return models.flatMap((model) => {
581
+ if (model.provider !== PROVIDER_ID) return [model];
582
+ if (insertedCatalog) return [];
583
+ insertedCatalog = true;
584
+ return catalogModels;
585
+ });
484
586
  },
485
587
  },
486
588
  });
487
589
  }
488
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
+
489
635
  export function KimiCode(overrides?: KimiCodeConfigPatch): ExtensionFactory {
490
636
  return async (pi: ExtensionAPI) => {
491
637
  const cwd = process.cwd();
@@ -493,30 +639,22 @@ export function KimiCode(overrides?: KimiCodeConfigPatch): ExtensionFactory {
493
639
  { cwd, home: os.homedir(), includeProject: false },
494
640
  overrides,
495
641
  );
496
- const discoveryToken = getKimiUsageToken();
497
- const [initialDiscovery, usageSnapshot] = await Promise.all([
498
- discoveryToken ? discoverKimiModelMetadata(discoveryToken, config.protocol) : {},
499
- fetchKimiUsageSnapshot({ timeoutMs: 2500 }),
500
- ]);
501
- let discovered = initialDiscovery;
502
- const refreshedToken = getKimiUsageToken();
503
- if (
504
- Object.keys(discovered).length === 0 &&
505
- refreshedToken &&
506
- refreshedToken !== discoveryToken
507
- ) {
508
- discovered = await discoverKimiModelMetadata(refreshedToken, config.protocol);
509
- }
510
642
  const state: KimiRuntimeState = {
511
643
  cwd,
512
644
  config,
513
- modelExtras: discovered,
514
- membershipLevel: usageSnapshot.membershipLevel,
645
+ fallbackSourceId: createKimiGlobalFallbackSourceId(),
646
+ modelExtras: {},
515
647
  projectTrusted: false,
516
648
  overrides,
517
649
  };
518
650
  reloadEffectiveKimiRuntimeConfig(state, cwd, false);
519
- registerKimiProvider(pi, state);
651
+ liveFallbackSources.add(state.fallbackSourceId);
652
+ try {
653
+ await registerKimiProvider(pi, state);
654
+ } catch (error) {
655
+ liveFallbackSources.delete(state.fallbackSourceId);
656
+ throw error;
657
+ }
520
658
 
521
659
  registerConfiguredMoonshotTools(pi, state.config, { updateActiveTools: false });
522
660
 
@@ -526,7 +664,11 @@ export function KimiCode(overrides?: KimiCodeConfigPatch): ExtensionFactory {
526
664
  updateActiveTools: true,
527
665
  projectTrusted,
528
666
  });
529
- registerKimiProvider(pi, state);
667
+ await registerKimiProvider(pi, state);
668
+ });
669
+
670
+ pi.on("session_shutdown", async () => {
671
+ await unregisterKimiGlobalApiFallback(state.fallbackSourceId);
530
672
  });
531
673
 
532
674
  pi.registerCommand("kimi-settings", {
@@ -539,6 +681,8 @@ export function KimiCode(overrides?: KimiCodeConfigPatch): ExtensionFactory {
539
681
  await openSettingsMenu(pi, ctx, state);
540
682
  },
541
683
  });
684
+
685
+ startModelDiscovery(pi, state);
542
686
  };
543
687
  }
544
688
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-provider-kimi-code",
3
- "version": "0.6.7",
4
- "description": "Use Kimi Code in Pi without losing the Kimi-specific bits",
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.80.9",
57
- "@earendil-works/pi-coding-agent": "^0.80.9",
58
- "@earendil-works/pi-tui": "^0.80.9",
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.7";
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.26.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, exec) they support.
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
- function macOsProductVersion(): string | undefined {
101
- try {
102
- const version = execFileSync("/usr/bin/sw_vers", ["-productVersion"], {
103
- encoding: "utf-8",
104
- timeout: 1000,
105
- }).trim();
106
- return version.length > 0 ? version : undefined;
107
- } catch {
108
- return undefined;
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
- function getDeviceModel(): string {
113
- return computeDeviceModel({
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: process.platform === "darwin" ? macOsProductVersion() : undefined,
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": DEVICE_MODEL,
219
+ "X-Msh-Device-Model": getDeviceModel(),
178
220
  "X-Msh-Os-Version": getOsVersion(),
179
221
  "X-Msh-Device-Id": getStableDeviceId(),
180
222
  };