pi-provider-kimi-code 0.6.6 → 0.6.8

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 CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  Pi already has a basic `kimi-coding` provider. This extension is for the parts that start to matter once Kimi Code becomes part of your real Pi workflow: account login reuse, file uploads, tool schema compatibility, live model metadata, measured cache behavior, and API-tested parameter handling.
9
9
 
10
- > **Kimi K3 supported.** The provider combines Kimi's live `/v1/models` catalog with your membership level from `/usages`, exposing only eligible models and advertising the correct K3 context limit. It refreshes this metadata at startup and through `/kimi-settings`; OAuth login and token refresh also update the model catalog.
10
+ > **Kimi K3 supported.** The provider reads Kimi's live `/v1/models` catalog as the source of truth for model availability and context limits. It refreshes the catalog at startup and through `/kimi-settings`; OAuth login and token refresh also update it.
11
11
 
12
12
  ## Why this exists
13
13
 
@@ -28,7 +28,7 @@ Kimi's API surface goes beyond the LLM itself. It includes file uploads, web sea
28
28
  - Kimi account login in Pi, plus `KIMI_API_KEY` for CI or pay-per-token use.
29
29
  - `kimi-code` credential reuse from `~/.kimi-code/credentials/kimi-code.json`, with read-only support for the legacy `~/.kimi` path.
30
30
  - Kimi Files API uploads for large inline images.
31
- - Live model metadata combined with membership-aware K3 and HighSpeed availability.
31
+ - Live model metadata from Kimi's official catalog, including server-advertised model availability and context limits.
32
32
  - OpenAI-compatible mode by default, Anthropic-compatible mode on request.
33
33
  - K2.7 and K3 reasoning level mapping for Pi's reasoning controls.
34
34
  - Tool schema dedup to stay under Moonshot's 15 KB per-tool-schema limit.
@@ -108,15 +108,15 @@ KIMI_API_KEY=sk-... pi
108
108
 
109
109
  ## Models
110
110
 
111
- This provider publishes three Pi model IDs:
111
+ The official catalog determines the models this provider publishes. The following IDs are the fallback set when catalog discovery is unavailable:
112
112
 
113
- | Pi model ID | Upstream model | Access |
114
- | --------------------------------------- | ------------------------ | ------------------------------------------------------ |
115
- | `kimi-coding/kimi-for-coding` | Kimi K2.7 Code | All Kimi Code members |
116
- | `kimi-coding/kimi-for-coding-highspeed` | Kimi K2.7 Code HighSpeed | Allegretto and above |
117
- | `kimi-coding/k3` | Kimi K3 | Moderato: 256K context; Allegretto and above: up to 1M |
113
+ | Pi model ID | Default name | Availability and context |
114
+ | --------------------------------------- | ------------------------ | ------------------------ |
115
+ | `kimi-coding/kimi-for-coding` | Kimi K2.7 Code | Official catalog |
116
+ | `kimi-coding/kimi-for-coding-highspeed` | Kimi K2.7 Code HighSpeed | Official catalog |
117
+ | `kimi-coding/k3` | Kimi K3 | Official catalog |
118
118
 
119
- Plans below Moderato cannot use K3. The provider reads the current membership level from `/usages` and combines it with `/models`: server catalog availability remains authoritative, while known plan limits can only hide unavailable selections or lower K3's advertised context window. Unknown or unavailable membership data falls back to the server catalog instead of guessing.
119
+ Kimi's official catalog at `https://api.kimi.com/coding/v1/models` is authoritative for model availability and context windows. When discovery is unavailable, the extension uses the fallback models and defaults below.
120
120
 
121
121
  Select a model inside Pi:
122
122
 
@@ -126,7 +126,7 @@ Select a model inside Pi:
126
126
  /model kimi-coding/k3
127
127
  ```
128
128
 
129
- Kimi keeps Coding models behind aliases. Rather than hardcoding a stale model list, this extension asks Kimi for the current model info when you log in or refresh. If your account is on a newer rollout (e.g., Kimi K2.7) or internal test, Pi can pick up the latest model name and context size without waiting for a package release.
129
+ Kimi keeps Coding models behind aliases. Rather than hardcoding a stale model list, this extension reads the official catalog at startup, when credentials change, and through `/kimi-settings`. If your account is on a newer rollout (e.g., Kimi K2.7) or internal test, Pi can pick up the latest model IDs, names, and context sizes without waiting for a package release.
130
130
 
131
131
  Fallback values:
132
132
 
@@ -135,7 +135,7 @@ Fallback values:
135
135
  - Input: text and image
136
136
  - Reasoning: enabled
137
137
 
138
- The provider maps Pi's reasoning levels to Kimi's top-level `thinking` parameter. K3 currently advertises only `max`; `max` and `xhigh` map to `max`, `high` and `medium` map to `high`, and `low` and `minimal` map to `low`. It sends `thinking.effort` only when `/models` advertises the mapped value. The mapping refreshes automatically on credential refresh. Opening `/kimi-settings` also re-discovers the latest model and membership metadata.
138
+ The provider maps Pi's reasoning levels to Kimi's top-level `thinking` parameter. It sends `thinking.effort` only when the official catalog advertises the mapped value. The mapping refreshes automatically on credential refresh. Opening `/kimi-settings` also re-discovers the latest model metadata.
139
139
 
140
140
  Switching models or thinking effort invalidates Kimi's existing context cache. Start a new session when switching to avoid re-prefilling a long conversation and consuming extra quota.
141
141
 
package/index.ts CHANGED
@@ -42,7 +42,6 @@ import { PROVIDER_ID, PROVIDER_VERSION, getBaseUrl, getKimiApiType } from "./src
42
42
  import {
43
43
  type KimiOAuthCredentials,
44
44
  type KimiOAuthExtras,
45
- applyKimiMembershipLimitsToModel,
46
45
  buildKimiModelFromConfig,
47
46
  applyKimiOAuthExtrasToModel,
48
47
  KIMI_CODING_HIGHSPEED_MODEL_ID,
@@ -51,7 +50,7 @@ import {
51
50
  KIMI_MODEL_CATALOG_VERSION,
52
51
  discoverKimiModelMetadata,
53
52
  getKimiModelMetadata,
54
- isKimiModelAvailableForMembership,
53
+ hasKimiModelMetadata,
55
54
  resolveKimiModelConfig,
56
55
  } from "./src/models.ts";
57
56
  import { getKimiApiKey, loginKimiCode, refreshKimiCodeToken } from "./src/oauth.ts";
@@ -71,7 +70,6 @@ interface KimiRuntimeState {
71
70
  cwd: string;
72
71
  config: KimiCodeConfig;
73
72
  modelExtras: KimiOAuthExtras;
74
- membershipLevel: string | null;
75
73
  projectTrusted: boolean;
76
74
  overrides?: KimiCodeConfigPatch;
77
75
  }
@@ -167,10 +165,7 @@ async function openSettingsMenu(
167
165
  if (!modelsRefreshed && refreshedToken && refreshedToken !== modelDiscoveryToken) {
168
166
  modelsRefreshed = await refreshModelExtras(state);
169
167
  }
170
- const refreshedMembershipLevel = usageSnapshot.membershipLevel ?? state.membershipLevel;
171
- const membershipChanged = state.membershipLevel !== refreshedMembershipLevel;
172
- state.membershipLevel = refreshedMembershipLevel;
173
- if (modelsRefreshed || membershipChanged) registerKimiProvider(pi, state);
168
+ if (modelsRefreshed) registerKimiProvider(pi, state);
174
169
  const usage = usageSnapshot.summary;
175
170
 
176
171
  const projectTrusted = await isKimiProjectConfigApproved(ctx, ctx.cwd);
@@ -382,74 +377,48 @@ function saveScopeKimiCodeConfig(
382
377
  function filterAvailableKimiModels<T extends { id: string }>(
383
378
  models: T[],
384
379
  extras: KimiOAuthExtras,
385
- membershipLevel: string | null,
386
380
  ): T[] {
387
381
  const available =
388
382
  extras.modelCatalogVersion === KIMI_MODEL_CATALOG_VERSION && extras.modelCatalog
389
383
  ? new Set(Object.keys(extras.modelCatalog))
390
384
  : null;
391
- return models.filter(
392
- (model) =>
393
- (!available || available.has(model.id)) &&
394
- isKimiModelAvailableForMembership(model.id, membershipLevel) !== false,
395
- );
385
+ return models.filter((model) => !available || available.has(model.id));
396
386
  }
397
387
 
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(
388
+ function catalogModelIds(extras: KimiOAuthExtras): string[] {
389
+ if (extras.modelCatalogVersion === KIMI_MODEL_CATALOG_VERSION && extras.modelCatalog) {
390
+ return Object.keys(extras.modelCatalog);
391
+ }
392
+ return [KIMI_CODING_MODEL_ID, KIMI_CODING_HIGHSPEED_MODEL_ID, KIMI_K3_MODEL_ID];
393
+ }
394
+
395
+ function buildKimiCatalogModels(state: KimiRuntimeState) {
396
+ return catalogModelIds(state.modelExtras).map((modelId) =>
414
397
  applyKimiOAuthExtrasToModel(
415
- buildKimiModelFromConfig(state.config.model, KIMI_K3_MODEL_ID),
416
- getKimiModelMetadata(state.modelExtras, KIMI_K3_MODEL_ID),
398
+ buildKimiModelFromConfig(state.config.model, modelId),
399
+ getKimiModelMetadata(state.modelExtras, modelId),
400
+ state.config.model.reasoningMap,
417
401
  ),
418
- state.membershipLevel,
419
402
  );
403
+ }
420
404
 
405
+ function registerKimiProvider(pi: ExtensionAPI, state: KimiRuntimeState): void {
421
406
  pi.registerProvider(PROVIDER_ID, {
422
407
  baseUrl: getBaseUrl(state.config.protocol),
423
408
  apiKey: "$KIMI_API_KEY",
424
409
  api: getKimiApiType(state.config.protocol),
425
410
  streamSimple: streamSimpleKimi,
426
411
 
427
- models: filterAvailableKimiModels(
428
- [standardModel, highSpeedModel, k3Model],
429
- state.modelExtras,
430
- state.membershipLevel,
431
- ),
412
+ models: filterAvailableKimiModels(buildKimiCatalogModels(state), state.modelExtras),
432
413
 
433
414
  oauth: {
434
415
  name: "Kimi Code (OAuth)",
435
416
  login: async (callbacks) => {
436
417
  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
418
  return credentials;
444
419
  },
445
420
  refreshToken: async (credentials) => {
446
421
  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
422
  return refreshed;
454
423
  },
455
424
  getApiKey: getKimiApiKey,
@@ -460,27 +429,53 @@ function registerKimiProvider(pi: ExtensionAPI, state: KimiRuntimeState): void {
460
429
  // request payload by streamSimpleKimi.
461
430
  modifyModels: (models, cred) => {
462
431
  const extras = cred as KimiOAuthCredentials;
463
- state.modelExtras = extras;
432
+ // Retain previously discovered extras when this credential carries
433
+ // none (legacy format or failed discovery): applying empty metadata
434
+ // would strip thinking levels the startup discovery already found.
435
+ if (hasKimiModelMetadata(extras)) state.modelExtras = extras;
464
436
  reloadEffectiveKimiRuntimeConfig(state, state.cwd, state.projectTrusted);
465
437
  const available =
466
438
  extras.modelCatalogVersion === KIMI_MODEL_CATALOG_VERSION && extras.modelCatalog
467
439
  ? new Set(Object.keys(extras.modelCatalog))
468
440
  : 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,
441
+ if (!available) {
442
+ return [
443
+ ...models.filter((model) => model.provider !== PROVIDER_ID),
444
+ ...models
445
+ .filter((model) => model.provider === PROVIDER_ID)
446
+ .map((model) => {
447
+ const metadata = getKimiModelMetadata(extras, model.id);
448
+ if (!hasKimiModelMetadata(metadata)) return model;
449
+ return applyKimiOAuthExtrasToModel(
450
+ model,
451
+ metadata,
452
+ state.config.model.reasoningMap,
453
+ );
454
+ }),
455
+ ];
456
+ }
457
+ const catalogModels = catalogModelIds(extras).map((modelId) => {
458
+ const existing = models.find(
459
+ (model) => model.provider === PROVIDER_ID && model.id === modelId,
460
+ );
461
+ return applyKimiOAuthExtrasToModel(
462
+ existing ?? {
463
+ ...buildKimiModelFromConfig(state.config.model, modelId),
464
+ api: getKimiApiType(state.config.protocol),
465
+ provider: PROVIDER_ID,
466
+ baseUrl: getBaseUrl(state.config.protocol),
467
+ },
468
+ getKimiModelMetadata(extras, modelId),
469
+ state.config.model.reasoningMap,
483
470
  );
471
+ });
472
+ let insertedCatalog = false;
473
+ return models.flatMap((model) => {
474
+ if (model.provider !== PROVIDER_ID) return [model];
475
+ if (insertedCatalog) return [];
476
+ insertedCatalog = true;
477
+ return catalogModels;
478
+ });
484
479
  },
485
480
  },
486
481
  });
@@ -494,10 +489,9 @@ export function KimiCode(overrides?: KimiCodeConfigPatch): ExtensionFactory {
494
489
  overrides,
495
490
  );
496
491
  const discoveryToken = getKimiUsageToken();
497
- const [initialDiscovery, usageSnapshot] = await Promise.all([
498
- discoveryToken ? discoverKimiModelMetadata(discoveryToken, config.protocol) : {},
499
- fetchKimiUsageSnapshot({ timeoutMs: 2500 }),
500
- ]);
492
+ const initialDiscovery = discoveryToken
493
+ ? await discoverKimiModelMetadata(discoveryToken, config.protocol)
494
+ : {};
501
495
  let discovered = initialDiscovery;
502
496
  const refreshedToken = getKimiUsageToken();
503
497
  if (
@@ -511,7 +505,6 @@ export function KimiCode(overrides?: KimiCodeConfigPatch): ExtensionFactory {
511
505
  cwd,
512
506
  config,
513
507
  modelExtras: discovered,
514
- membershipLevel: usageSnapshot.membershipLevel,
515
508
  projectTrusted: false,
516
509
  overrides,
517
510
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-provider-kimi-code",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
4
4
  "description": "Use Kimi Code in Pi without losing the Kimi-specific bits",
5
5
  "keywords": [
6
6
  "ai-provider",
@@ -49,12 +49,16 @@
49
49
  "format:check": "oxfmt . --check",
50
50
  "pre-commit": "prek run"
51
51
  },
52
+ "dependencies": {
53
+ "proper-lockfile": "^4.1.2"
54
+ },
52
55
  "devDependencies": {
53
- "@earendil-works/pi-ai": "^0.79.10",
54
- "@earendil-works/pi-coding-agent": "^0.79.10",
55
- "@earendil-works/pi-tui": "^0.79.10",
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
59
  "@j178/prek": "^0.4.4",
57
60
  "@types/node": "^24.0.0",
61
+ "@types/proper-lockfile": "^4.1.4",
58
62
  "oxfmt": "^0.44.0",
59
63
  "oxlint": "^1.59.0",
60
64
  "typescript": "^5.9.0"
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.6";
44
+ export const PROVIDER_VERSION = "0.6.8";
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.28.0";
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
 
package/src/models.ts CHANGED
@@ -1,8 +1,13 @@
1
1
  // Model identity: discovery against the server's /v1/models endpoint, plus the
2
2
  // extras-merging helpers used by both registration and the OAuth modifyModels hook.
3
3
 
4
- import type { Api, Model, OAuthCredentials } from "@earendil-works/pi-ai";
5
- import type { KimiInputModality, KimiResolvedModelConfig, ModelConfig } from "./config.ts";
4
+ import type { Api, Model, OAuthCredentials, ThinkingLevelMap } from "@earendil-works/pi-ai";
5
+ import type {
6
+ KimiInputModality,
7
+ KimiResolvedModelConfig,
8
+ ModelConfig,
9
+ ModelReasoningMap,
10
+ } from "./config.ts";
6
11
 
7
12
  import { type KimiWireProtocol, getBaseUrl } from "./constants.ts";
8
13
  import { getKimiProviderHeaders } from "./device.ts";
@@ -50,62 +55,33 @@ function mergeInputModalities(
50
55
  return (["text", "image", "video"] as const).filter((modality) => next.has(modality));
51
56
  }
52
57
 
53
- // Pricing per million tokens in USD (CNY converted at ~7.25).
54
- // Source: https://platform.kimi.com/docs/pricing/chat-k27-code
55
- const COST_STANDARD = { input: 0.897, output: 3.724, cacheRead: 0.179, cacheWrite: 0.897 };
56
- const COST_HIGH_SPEED = { input: 1.793, output: 7.448, cacheRead: 0.359, cacheWrite: 1.793 };
58
+ // Pricing per million tokens in USD.
59
+ // Sources: https://www.kimi.com/resources/kimi-k2-7-code-pricing
60
+ // https://www.kimi.com/resources/kimi-k3-pricing
61
+ const COST_STANDARD = { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0.95 };
62
+ const COST_HIGH_SPEED = { input: 1.9, output: 8, cacheRead: 0.38, cacheWrite: 1.9 };
63
+ const COST_K3 = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3 };
57
64
 
58
65
  export const KIMI_CODING_MODEL_ID = "kimi-for-coding";
59
66
  export const KIMI_CODING_HIGHSPEED_MODEL_ID = "kimi-for-coding-highspeed";
60
67
  export const KIMI_K3_MODEL_ID = "k3";
61
68
  export const KIMI_MODEL_CATALOG_VERSION = 1;
62
69
 
63
- const KIMI_K3_MODERATO_CONTEXT_WINDOW = 262144;
64
- const KIMI_MEMBERSHIP_RANK: Readonly<Record<string, number>> = {
65
- LEVEL_FREE: 0,
66
- LEVEL_BASIC: 1,
67
- LEVEL_STANDARD: 2,
68
- LEVEL_INTERMEDIATE: 3,
69
- LEVEL_ADVANCED: 4,
70
- LEVEL_PREMIUM: 5,
71
- };
72
- const KIMI_MODERATO_RANK = KIMI_MEMBERSHIP_RANK.LEVEL_STANDARD;
73
- const KIMI_ALLEGRETTO_RANK = KIMI_MEMBERSHIP_RANK.LEVEL_INTERMEDIATE;
74
-
75
- export function isKimiModelAvailableForMembership(
70
+ function resolveModelCost(
76
71
  modelId: string,
77
- membershipLevel: string | null | undefined,
78
- ): boolean | undefined {
79
- if (!membershipLevel) return undefined;
80
- const rank = KIMI_MEMBERSHIP_RANK[membershipLevel];
81
- if (rank === undefined) return undefined;
82
- if (modelId === KIMI_K3_MODEL_ID) return rank >= KIMI_MODERATO_RANK;
83
- if (modelId === KIMI_CODING_HIGHSPEED_MODEL_ID) return rank >= KIMI_ALLEGRETTO_RANK;
84
- return true;
85
- }
86
-
87
- export function applyKimiMembershipLimitsToModel(
88
- model: Model<Api>,
89
- membershipLevel: string | null | undefined,
90
- ): Model<Api> {
91
- const rank = membershipLevel ? KIMI_MEMBERSHIP_RANK[membershipLevel] : undefined;
92
- if (model.id !== KIMI_K3_MODEL_ID || rank === undefined || rank >= KIMI_ALLEGRETTO_RANK) {
93
- return model;
94
- }
95
- return {
96
- ...model,
97
- contextWindow: Math.min(model.contextWindow, KIMI_K3_MODERATO_CONTEXT_WINDOW),
98
- };
99
- }
100
-
101
- function resolveModelCost(modelDisplay: string | undefined): {
72
+ modelDisplay?: string,
73
+ ): {
102
74
  input: number;
103
75
  output: number;
104
76
  cacheRead: number;
105
77
  cacheWrite: number;
106
78
  } {
107
- if (modelDisplay && /high\s*speed/i.test(modelDisplay)) return COST_HIGH_SPEED;
108
- return COST_STANDARD;
79
+ if (modelId === KIMI_K3_MODEL_ID) return COST_K3;
80
+ if (modelId === KIMI_CODING_MODEL_ID) return COST_STANDARD;
81
+ if (modelId === KIMI_CODING_HIGHSPEED_MODEL_ID || /high\s*speed/i.test(modelDisplay ?? "")) {
82
+ return COST_HIGH_SPEED;
83
+ }
84
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
109
85
  }
110
86
 
111
87
  export function buildKimiModelFromConfig(
@@ -118,13 +94,15 @@ export function buildKimiModelFromConfig(
118
94
  ? "Kimi K3"
119
95
  : isHighSpeed
120
96
  ? "Kimi for Coding High Speed"
121
- : "Kimi for Coding";
97
+ : modelId === KIMI_CODING_MODEL_ID
98
+ ? "Kimi for Coding"
99
+ : modelId;
122
100
  return {
123
101
  id: modelId,
124
102
  name,
125
103
  reasoning: config.reasoning,
126
104
  input: [...config.input] as unknown as ("text" | "image" | "video")[],
127
- cost: { ...(isHighSpeed ? COST_HIGH_SPEED : COST_STANDARD) },
105
+ cost: { ...resolveModelCost(modelId) },
128
106
  contextWindow: config.contextWindow,
129
107
  maxTokens: config.maxTokens,
130
108
  } as Model<Api>;
@@ -226,18 +204,29 @@ function getModelsUrl(protocol?: KimiWireProtocol): string {
226
204
  return buildModelsUrl(getBaseUrl(protocol));
227
205
  }
228
206
 
207
+ export function isOfficialKimiModelsUrl(url: string): boolean {
208
+ try {
209
+ const parsed = new URL(url);
210
+ return parsed.origin === "https://api.kimi.com" && parsed.pathname === "/coding/v1/models";
211
+ } catch {
212
+ return false;
213
+ }
214
+ }
215
+
229
216
  export async function discoverKimiModelMetadata(
230
217
  accessToken: string,
231
218
  protocol?: KimiWireProtocol,
232
219
  options: DiscoverKimiModelMetadataOptions = {},
233
220
  ): Promise<KimiOAuthExtras> {
234
221
  if (!accessToken) return {};
222
+ const modelsUrl = getModelsUrl(protocol);
223
+ if (!isOfficialKimiModelsUrl(modelsUrl)) return {};
235
224
  const timeoutMs = options.timeoutMs ?? DEFAULT_DISCOVERY_TIMEOUT_MS;
236
225
  const controller = new AbortController();
237
226
  const timeout =
238
227
  timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs).unref() : undefined;
239
228
  try {
240
- const response = await fetch(getModelsUrl(protocol), {
229
+ const response = await fetch(modelsUrl, {
241
230
  signal: controller.signal,
242
231
  headers: {
243
232
  ...getKimiProviderHeaders(),
@@ -248,16 +237,10 @@ export async function discoverKimiModelMetadata(
248
237
  if (!response.ok) return {};
249
238
  const json = (await response.json()) as { data?: unknown };
250
239
  const list = Array.isArray(json.data) ? (json.data as KimiServerModel[]) : [];
251
- const supportedIds = new Set([
252
- KIMI_CODING_MODEL_ID,
253
- KIMI_CODING_HIGHSPEED_MODEL_ID,
254
- KIMI_K3_MODEL_ID,
255
- ]);
256
240
  const modelCatalog: Record<string, KimiModelMetadata> = {};
257
241
  for (const model of list) {
258
- if (typeof model.id !== "string" || !supportedIds.has(model.id)) continue;
259
242
  const metadata = parseKimiModelMetadata(model);
260
- if (metadata) modelCatalog[model.id] = metadata;
243
+ if (metadata) modelCatalog[metadata.wireModelId!] = metadata;
261
244
  }
262
245
  const preferred = modelCatalog[KIMI_CODING_MODEL_ID];
263
246
  if (!preferred) {
@@ -279,9 +262,49 @@ export function getKimiModelMetadata(extras: KimiOAuthExtras, modelId: string):
279
262
  return modelId === KIMI_CODING_MODEL_ID ? extras : {};
280
263
  }
281
264
 
265
+ const KIMI_DISCOVERY_KEYS = [
266
+ "wireModelId",
267
+ "modelDisplay",
268
+ "contextLength",
269
+ "supportsReasoning",
270
+ "supportsImageIn",
271
+ "supportsVideoIn",
272
+ "supportsThinkingType",
273
+ "protocol",
274
+ "supportEfforts",
275
+ "defaultEffort",
276
+ "modelCatalog",
277
+ ] as const;
278
+
279
+ // Credentials always carry access/refresh/expires, so discovery presence must
280
+ // be checked on the metadata fields themselves. Empty means either a legacy
281
+ // pre-discovery credential or a failed discovery during login/refresh.
282
+ export function hasKimiModelMetadata(extras: KimiOAuthExtras): boolean {
283
+ return KIMI_DISCOVERY_KEYS.some((key) => extras[key] !== undefined);
284
+ }
285
+
286
+ export function buildKimiThinkingLevelMap(
287
+ reasoningMap: ModelReasoningMap,
288
+ extras: Pick<KimiModelMetadata, "supportEfforts" | "supportsThinkingType">,
289
+ ): ThinkingLevelMap | undefined {
290
+ if (!extras.supportEfforts?.length) return undefined;
291
+ const map = {
292
+ off: extras.supportsThinkingType === "only" ? null : "off",
293
+ } as ThinkingLevelMap & Record<string, string | null>;
294
+ for (const level of ["minimal", "low", "medium", "high", "xhigh", "max"] as const) {
295
+ const entry = reasoningMap[level];
296
+ map[level] =
297
+ entry?.enabled && entry.effort && extras.supportEfforts.includes(entry.effort)
298
+ ? entry.effort
299
+ : null;
300
+ }
301
+ return map;
302
+ }
303
+
282
304
  export function applyKimiOAuthExtrasToModel(
283
305
  model: Model<Api>,
284
306
  extras: KimiModelMetadata,
307
+ reasoningMap?: ModelReasoningMap,
285
308
  ): Model<Api> {
286
309
  const next: Model<Api> & {
287
310
  wireModelId?: string;
@@ -295,7 +318,7 @@ export function applyKimiOAuthExtrasToModel(
295
318
  model.id === KIMI_K3_MODEL_ID && /^k3$/i.test(extras.modelDisplay)
296
319
  ? "Kimi K3"
297
320
  : extras.modelDisplay;
298
- next.cost = resolveModelCost(extras.modelDisplay);
321
+ next.cost = resolveModelCost(model.id, extras.modelDisplay);
299
322
  }
300
323
  if (typeof extras.contextLength === "number" && extras.contextLength > 0) {
301
324
  next.contextWindow = extras.contextLength;
@@ -316,6 +339,13 @@ export function applyKimiOAuthExtrasToModel(
316
339
  }
317
340
  if (extras.protocol) next.wireProtocol = extras.protocol;
318
341
  if (extras.supportEfforts) next.supportEfforts = [...extras.supportEfforts];
342
+ else delete next.supportEfforts;
319
343
  if (extras.defaultEffort) next.defaultEffort = extras.defaultEffort;
344
+ else delete next.defaultEffort;
345
+ if (reasoningMap) {
346
+ const thinkingLevelMap = buildKimiThinkingLevelMap(reasoningMap, extras);
347
+ if (thinkingLevelMap) next.thinkingLevelMap = thinkingLevelMap;
348
+ else delete next.thinkingLevelMap;
349
+ }
320
350
  return next;
321
351
  }
package/src/oauth.ts CHANGED
@@ -2,13 +2,158 @@
2
2
  // reuse, the login / refresh handlers wired into pi's OAuth interface, and
3
3
  // the stream-level auth refresh used by the streaming handler.
4
4
 
5
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
6
  import os from "node:os";
7
7
  import { dirname, join } from "node:path";
8
8
 
9
+ import { lock as acquireFileLock } from "proper-lockfile";
10
+
9
11
  import type { OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai";
10
- import type { OAuthCredential } from "@earendil-works/pi-coding-agent";
11
- import { AuthStorage } from "@earendil-works/pi-coding-agent";
12
+ import * as piAgent from "@earendil-works/pi-coding-agent";
13
+
14
+ // Structural mirrors of pi's stored credential shapes. The exported names
15
+ // moved between pi versions (pi-coding-agent <=0.79 vs pi-ai >=0.80.8), so
16
+ // these are defined locally to type-check against either surface.
17
+ type StoredCredential = { type: string } & Record<string, unknown>;
18
+
19
+ export interface StoredOAuthCredential {
20
+ type: "oauth";
21
+ access: string;
22
+ refresh: string;
23
+ expires: number;
24
+ [key: string]: unknown;
25
+ }
26
+
27
+ // pi renamed its auth storage surface in 0.80.8: the exported AuthStorage
28
+ // class (sync get/set under a file lock) was replaced by readStoredCredential
29
+ // for one-off reads, with no exported write path. Detect whichever the host
30
+ // pi provides so the extension keeps loading on both sides of the rename.
31
+ const piAgentRuntime = piAgent as unknown as {
32
+ getAgentDir(): string;
33
+ readStoredCredential?(providerId: string): StoredCredential | undefined;
34
+ AuthStorage?: {
35
+ create(): {
36
+ get(provider: string): StoredCredential | undefined;
37
+ set(provider: string, credential: StoredCredential): void;
38
+ };
39
+ };
40
+ };
41
+
42
+ export function readStoredOAuthCredential(providerId: string): StoredOAuthCredential | null {
43
+ const credential = piAgentRuntime.readStoredCredential
44
+ ? piAgentRuntime.readStoredCredential(providerId)
45
+ : piAgentRuntime.AuthStorage?.create().get(providerId);
46
+ return credential?.type === "oauth" ? (credential as StoredOAuthCredential) : null;
47
+ }
48
+
49
+ // Mutual exclusion with pi's own credential writes: use the same library
50
+ // (proper-lockfile) on the same file pi's FileAuthStorageBackend locks, with
51
+ // the same options. The shared implementation keeps the lock mtime fresh
52
+ // while held, recovers stale locks from crashed processes, and retries for
53
+ // ~40s so a live holder running a slow network refresh inside the lock is
54
+ // waited out.
55
+ async function acquireSharedFileLock(
56
+ path: string,
57
+ signal: AbortSignal | undefined,
58
+ options: { maxRetries: number; stale: number; minTimeout: number; maxTimeout: number },
59
+ ): Promise<() => Promise<void>> {
60
+ for (let attempt = 0; ; attempt++) {
61
+ signal?.throwIfAborted();
62
+ try {
63
+ return await acquireFileLock(path, {
64
+ realpath: false,
65
+ stale: options.stale,
66
+ // Retry via the outer abortable sleep below. proper-lockfile's own
67
+ // retry loop is not signal-aware and would otherwise ignore an abort
68
+ // for the full configured retry window.
69
+ retries: 0,
70
+ });
71
+ } catch (error) {
72
+ const code =
73
+ typeof error === "object" && error !== null && "code" in error
74
+ ? String((error as { code?: unknown }).code)
75
+ : undefined;
76
+ if (code !== "ELOCKED" || attempt >= options.maxRetries) throw error;
77
+ const baseDelay = Math.min(options.minTimeout * 2 ** attempt, options.maxTimeout);
78
+ await sleep(baseDelay * (1 + Math.random()), signal);
79
+ }
80
+ }
81
+ }
82
+
83
+ async function acquireAuthFileLock(
84
+ authPath: string,
85
+ signal?: AbortSignal,
86
+ ): Promise<() => Promise<void>> {
87
+ return acquireSharedFileLock(authPath, signal, {
88
+ maxRetries: 10,
89
+ stale: 30_000,
90
+ minTimeout: 100,
91
+ maxTimeout: 10_000,
92
+ });
93
+ }
94
+
95
+ async function acquireKimiCodeRefreshLock(signal?: AbortSignal): Promise<() => Promise<void>> {
96
+ if (process.platform === "win32" || process.env.KIMI_DISABLE_OAUTH_LOCK === "1") {
97
+ return async () => {};
98
+ }
99
+ const target = join(
100
+ process.env.KIMI_CODE_HOME || join(os.homedir(), ".kimi-code"),
101
+ "oauth",
102
+ "kimi-code",
103
+ );
104
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
105
+ writeFileSync(target, "", { encoding: "utf-8", flag: "a" });
106
+ return acquireSharedFileLock(target, signal, {
107
+ maxRetries: 120,
108
+ stale: 5_000,
109
+ minTimeout: 500,
110
+ maxTimeout: 1_000,
111
+ });
112
+ }
113
+
114
+ interface LockedOAuthCredential {
115
+ credential: StoredOAuthCredential | null;
116
+ store(credential: StoredOAuthCredential): void;
117
+ release(): Promise<void>;
118
+ }
119
+
120
+ async function lockStoredOAuthCredential(
121
+ providerId: string,
122
+ signal?: AbortSignal,
123
+ ): Promise<LockedOAuthCredential> {
124
+ // Hold the same lock Pi uses across the complete read-refresh-write
125
+ // transaction. Kimi rotates refresh tokens, so locking only the write lets
126
+ // concurrent Pi processes exchange the same token twice.
127
+ const authPath = join(piAgentRuntime.getAgentDir(), "auth.json");
128
+ mkdirSync(dirname(authPath), { recursive: true, mode: 0o700 });
129
+ if (!existsSync(authPath)) {
130
+ writeFileSync(authPath, "{}", { encoding: "utf-8", mode: 0o600 });
131
+ chmodSync(authPath, 0o600);
132
+ }
133
+ const release = await acquireAuthFileLock(authPath, signal);
134
+ let data: Record<string, unknown> = {};
135
+ try {
136
+ const parsed = JSON.parse(readFileSync(authPath, "utf-8")) as unknown;
137
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
138
+ data = parsed as Record<string, unknown>;
139
+ }
140
+ } catch {
141
+ // Missing or unreadable auth.json — treat it as an empty store.
142
+ }
143
+ const stored = data[providerId] as StoredCredential | undefined;
144
+ return {
145
+ credential: stored?.type === "oauth" ? (stored as unknown as StoredOAuthCredential) : null,
146
+ store(credential) {
147
+ data[providerId] = credential;
148
+ writeFileSync(authPath, JSON.stringify(data, null, 2), {
149
+ encoding: "utf-8",
150
+ mode: 0o600,
151
+ });
152
+ chmodSync(authPath, 0o600);
153
+ },
154
+ release,
155
+ };
156
+ }
12
157
 
13
158
  import { CLIENT_ID, PROVIDER_ID, RETRYABLE_REFRESH_STATUSES, getOAuthHost } from "./constants.ts";
14
159
  import { getCommonHeaders } from "./device.ts";
@@ -258,14 +403,10 @@ function readKimiCliCredentials(): KimiCliCredentialsFile | null {
258
403
  return null;
259
404
  }
260
405
 
261
- function kimiCodeCredentialExists(): boolean {
262
- return existsSync(getKimiCodeCredentialPath());
263
- }
264
-
265
406
  function writeKimiCodeCredentials(access: string, refresh: string, expiresMs: number): void {
266
407
  const path = getKimiCodeCredentialPath();
267
408
  const dir = dirname(path);
268
- mkdirSync(dir, { recursive: true });
409
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
269
410
  const data: KimiCliCredentialsFile = {
270
411
  access_token: access,
271
412
  refresh_token: refresh,
@@ -274,7 +415,24 @@ function writeKimiCodeCredentials(access: string, refresh: string, expiresMs: nu
274
415
  token_type: "Bearer",
275
416
  expires_in: Math.max(0, Math.floor((expiresMs - Date.now()) / 1000)),
276
417
  };
277
- writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
418
+ writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
419
+ chmodSync(path, 0o600);
420
+ }
421
+
422
+ interface LockedKimiCodeCredentials {
423
+ credential: KimiCliCredentialsFile | null;
424
+ store(access: string, refresh: string, expiresMs: number): void;
425
+ release(): Promise<void>;
426
+ }
427
+
428
+ async function lockKimiCodeCredentials(signal?: AbortSignal): Promise<LockedKimiCodeCredentials> {
429
+ const release = await acquireKimiCodeRefreshLock(signal);
430
+ const credential = readKimiCliCredentials();
431
+ return {
432
+ credential,
433
+ store: writeKimiCodeCredentials,
434
+ release,
435
+ };
278
436
  }
279
437
 
280
438
  async function tryReuseKimiCliCredentials(
@@ -395,26 +553,83 @@ export async function loginKimiCode(callbacks: OAuthLoginCallbacks): Promise<Kim
395
553
  export async function refreshKimiCodeToken(
396
554
  credentials: OAuthCredentials,
397
555
  ): Promise<KimiOAuthCredentials> {
398
- const kimiCred = kimiCodeCredentialExists() ? readKimiCliCredentials() : null;
399
- let token: Awaited<ReturnType<typeof refreshAccessToken>>;
556
+ const kimiLocked = await lockKimiCodeCredentials();
400
557
  try {
401
- token = await refreshAccessToken(credentials.refresh);
402
- } catch (error) {
403
- if (kimiCred?.refresh_token && kimiCred.refresh_token !== credentials.refresh) {
404
- token = await refreshAccessToken(kimiCred.refresh_token);
405
- } else {
558
+ const initialKimiCred = kimiLocked.credential;
559
+ const initialKimiExpiresMs = (initialKimiCred?.expires_at ?? 0) * 1000;
560
+ const peerHasNewerValidAccess =
561
+ initialKimiCred?.access_token !== undefined &&
562
+ initialKimiCred.access_token !== credentials.access &&
563
+ Date.now() < initialKimiExpiresMs;
564
+ const activeRefreshToken = peerHasNewerValidAccess
565
+ ? initialKimiCred.refresh_token!
566
+ : credentials.refresh;
567
+ const activeAccessToken = peerHasNewerValidAccess
568
+ ? initialKimiCred.access_token!
569
+ : credentials.access;
570
+ if (peerHasNewerValidAccess && initialKimiCred.refresh_token !== credentials.refresh) {
571
+ const extras = await discoverKimiModelMetadata(activeAccessToken);
572
+ return {
573
+ access: activeAccessToken,
574
+ refresh: activeRefreshToken,
575
+ expires: initialKimiExpiresMs,
576
+ ...extras,
577
+ };
578
+ }
579
+ let token: Awaited<ReturnType<typeof refreshAccessToken>>;
580
+ try {
581
+ token = await refreshAccessToken(activeRefreshToken);
582
+ } catch (error) {
583
+ // Lock-disabled platforms (Windows / KIMI_DISABLE_OAUTH_LOCK=1) use
584
+ // storage re-read as the only coordinator. A peer may have rotated the
585
+ // shared sidecar while this process was exchanging the old refresh
586
+ // token; recover from that newer valid credential instead of surfacing
587
+ // the stale-token invalid_grant as a login failure.
588
+ await sleep(100);
589
+ const peerCredential = readKimiCliCredentials();
590
+ const peerAccess = peerCredential?.access_token;
591
+ const peerRefresh = peerCredential?.refresh_token;
592
+ const peerExpiresMs = (peerCredential?.expires_at ?? 0) * 1000;
593
+ if (
594
+ peerAccess !== undefined &&
595
+ peerRefresh !== undefined &&
596
+ peerAccess !== credentials.access &&
597
+ Date.now() < peerExpiresMs
598
+ ) {
599
+ const extras = await discoverKimiModelMetadata(peerAccess);
600
+ return {
601
+ access: peerAccess,
602
+ refresh: peerRefresh,
603
+ expires: peerExpiresMs,
604
+ ...extras,
605
+ };
606
+ }
406
607
  throw error;
407
608
  }
609
+ const expiresMs = Date.now() + token.expires_in * 1000;
610
+ const kimiCredentialAfterRefresh = readKimiCliCredentials();
611
+ const peerAccess = kimiCredentialAfterRefresh?.access_token;
612
+ const peerRefresh = kimiCredentialAfterRefresh?.refresh_token;
613
+ const peerExpiresMs = (kimiCredentialAfterRefresh?.expires_at ?? 0) * 1000;
614
+ const accessToken =
615
+ peerAccess !== undefined && peerAccess !== activeAccessToken && Date.now() < peerExpiresMs
616
+ ? peerAccess
617
+ : token.access_token;
618
+ const refreshToken = accessToken === peerAccess ? peerRefresh! : token.refresh_token;
619
+ const effectiveExpiresMs = accessToken === peerAccess ? peerExpiresMs : expiresMs;
620
+ if (accessToken !== peerAccess) {
621
+ kimiLocked.store(accessToken, refreshToken, effectiveExpiresMs);
622
+ }
623
+ const extras = await discoverKimiModelMetadata(accessToken);
624
+ return {
625
+ access: accessToken,
626
+ refresh: refreshToken,
627
+ expires: effectiveExpiresMs,
628
+ ...extras,
629
+ };
630
+ } finally {
631
+ await kimiLocked.release().catch(() => {});
408
632
  }
409
- const expiresMs = Date.now() + token.expires_in * 1000;
410
- writeKimiCodeCredentials(token.access_token, token.refresh_token, expiresMs);
411
- const extras = await discoverKimiModelMetadata(token.access_token);
412
- return {
413
- access: token.access_token,
414
- refresh: token.refresh_token,
415
- expires: expiresMs,
416
- ...extras,
417
- };
418
633
  }
419
634
 
420
635
  // =============================================================================
@@ -424,8 +639,8 @@ export async function refreshKimiCodeToken(
424
639
  // `expires` is in the past. If the server rotates/revokes the access token
425
640
  // before that (common with short-lived session tokens), every request keeps
426
641
  // returning 401. The streaming handler invokes refreshKimiAuthToken on the
427
- // first auth error to force a refresh through AuthStorage (which persists the
428
- // new credentials under a file lock), and retry once.
642
+ // first auth error to force a refresh and persist the new credentials, and
643
+ // retry once.
429
644
  // =============================================================================
430
645
 
431
646
  export function getKimiApiKey(credentials: OAuthCredentials): string {
@@ -451,75 +666,90 @@ export async function refreshKimiAuthToken(
451
666
  currentKey: string,
452
667
  options: { signal?: AbortSignal } = {},
453
668
  ): Promise<string | null> {
669
+ let locked: LockedOAuthCredential | undefined;
670
+ let kimiLocked: LockedKimiCodeCredentials | undefined;
454
671
  try {
455
- const kimiCred = readKimiCliCredentials();
456
- const storage = AuthStorage.create();
457
- const piCred = storage.get(PROVIDER_ID);
458
- const piOAuth = piCred?.type === "oauth" ? piCred : null;
672
+ locked = await lockStoredOAuthCredential(PROVIDER_ID, options.signal);
673
+ const piOAuth = locked.credential;
459
674
 
460
675
  if (piOAuth) {
461
676
  if (piOAuth.access !== currentKey && Date.now() < piOAuth.expires) {
462
- console.error("[kimi-coding] auth refresh: reusing newer on-disk token");
463
677
  return piOAuth.access;
464
678
  }
465
679
 
466
- console.error("[kimi-coding] auth refresh: requesting new access token");
467
680
  let refreshed: Awaited<ReturnType<typeof refreshAccessToken>>;
681
+ kimiLocked = await lockKimiCodeCredentials(options.signal);
468
682
  try {
469
683
  refreshed = await refreshAccessToken(piOAuth.refresh, { signal: options.signal });
470
684
  } catch (error) {
685
+ const kimiCred = readKimiCliCredentials();
471
686
  if (!kimiCred?.refresh_token || kimiCred.refresh_token === piOAuth.refresh) throw error;
472
687
  const kimiExpiresMs = (kimiCred.expires_at ?? 0) * 1000;
473
688
  if (kimiCred.access_token !== currentKey && Date.now() < kimiExpiresMs) {
474
- const recovered: OAuthCredential = {
689
+ const recovered: StoredOAuthCredential = {
475
690
  ...piOAuth,
476
691
  type: "oauth",
477
692
  access: kimiCred.access_token!,
478
693
  refresh: kimiCred.refresh_token,
479
694
  expires: kimiExpiresMs,
480
695
  };
481
- storage.set(PROVIDER_ID, recovered);
482
- console.error("[kimi-coding] auth refresh: recovered newer kimi-code token");
696
+ locked.store(recovered);
483
697
  return recovered.access;
484
698
  }
485
- console.error("[kimi-coding] auth refresh: pi token rejected, trying kimi-code token");
486
699
  refreshed = await refreshAccessToken(kimiCred.refresh_token, { signal: options.signal });
487
700
  }
488
701
 
489
702
  const newExpiresMs = Date.now() + refreshed.expires_in * 1000;
490
- const newCred: OAuthCredential = {
703
+ const kimiCredentialAfterRefresh = readKimiCliCredentials();
704
+ const peerAccess = kimiCredentialAfterRefresh?.access_token;
705
+ const peerExpiresMs = (kimiCredentialAfterRefresh?.expires_at ?? 0) * 1000;
706
+ const peerIsNewerValid =
707
+ peerAccess !== undefined && peerAccess !== currentKey && Date.now() < peerExpiresMs;
708
+ if (peerIsNewerValid) {
709
+ const peerCred: StoredOAuthCredential = {
710
+ ...piOAuth,
711
+ type: "oauth",
712
+ access: peerAccess,
713
+ refresh: kimiCredentialAfterRefresh!.refresh_token!,
714
+ expires: peerExpiresMs,
715
+ };
716
+ locked.store(peerCred);
717
+ return peerCred.access;
718
+ }
719
+ kimiLocked.store(refreshed.access_token, refreshed.refresh_token, newExpiresMs);
720
+ const newCred: StoredOAuthCredential = {
491
721
  ...piOAuth,
492
722
  type: "oauth",
493
723
  access: refreshed.access_token,
494
724
  refresh: refreshed.refresh_token,
495
725
  expires: newExpiresMs,
496
726
  };
497
- storage.set(PROVIDER_ID, newCred);
498
- writeKimiCodeCredentials(refreshed.access_token, refreshed.refresh_token, newExpiresMs);
499
- console.error("[kimi-coding] auth refresh: new token persisted");
727
+ locked.store(newCred);
500
728
  return newCred.access;
501
729
  }
502
730
 
731
+ kimiLocked = await lockKimiCodeCredentials(options.signal);
732
+ const kimiCred = kimiLocked.credential;
503
733
  if (!kimiCred) {
504
- console.error(
505
- `[kimi-coding] auth refresh skipped: no OAuth credentials for ${PROVIDER_ID} on disk`,
506
- );
507
734
  return null;
508
735
  }
509
736
 
510
737
  const kimiExpiresMs = (kimiCred.expires_at ?? 0) * 1000;
511
738
  if (kimiCred.access_token !== currentKey && Date.now() < kimiExpiresMs) {
512
- console.error("[kimi-coding] auth refresh: reusing newer kimi-code token");
513
739
  return kimiCred.access_token!;
514
740
  }
515
741
 
516
- console.error("[kimi-coding] auth refresh: requesting new access token via kimi-code");
517
742
  const refreshed = await refreshAccessToken(kimiCred.refresh_token!, {
518
743
  signal: options.signal,
519
744
  });
520
745
  const newExpiresMs = Date.now() + refreshed.expires_in * 1000;
521
- writeKimiCodeCredentials(refreshed.access_token, refreshed.refresh_token, newExpiresMs);
522
- console.error("[kimi-coding] auth refresh: new token persisted to kimi-code");
746
+ const kimiCredentialAfterRefresh = readKimiCliCredentials();
747
+ const peerAccess = kimiCredentialAfterRefresh?.access_token;
748
+ const peerExpiresMs = (kimiCredentialAfterRefresh?.expires_at ?? 0) * 1000;
749
+ if (peerAccess !== undefined && peerAccess !== currentKey && Date.now() < peerExpiresMs) {
750
+ return peerAccess;
751
+ }
752
+ kimiLocked.store(refreshed.access_token, refreshed.refresh_token, newExpiresMs);
523
753
  return refreshed.access_token;
524
754
  } catch (err) {
525
755
  if (options.signal?.aborted) return null;
@@ -529,5 +759,12 @@ export async function refreshKimiAuthToken(
529
759
  console.error("[kimi-coding] auth refresh failed:", err);
530
760
  }
531
761
  return null;
762
+ } finally {
763
+ if (kimiLocked) {
764
+ await kimiLocked.release().catch(() => {});
765
+ }
766
+ if (locked) {
767
+ await locked.release().catch(() => {});
768
+ }
532
769
  }
533
770
  }
package/src/stream.ts CHANGED
@@ -12,11 +12,46 @@ import type {
12
12
  Model,
13
13
  SimpleStreamOptions,
14
14
  } from "@earendil-works/pi-ai";
15
- import {
16
- createAssistantMessageEventStream,
17
- streamSimpleAnthropic,
18
- streamSimpleOpenAICompletions,
19
- } from "@earendil-works/pi-ai";
15
+ import * as piAi from "@earendil-works/pi-ai";
16
+ import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
17
+
18
+ type KimiStreamSimple = (
19
+ model: Model<Api>,
20
+ context: Context,
21
+ options?: SimpleStreamOptions,
22
+ ) => AssistantMessageEventStream;
23
+
24
+ // pi 0.80.8 unexported the global streamSimple* functions. Depending on how
25
+ // this module is loaded, the pi-ai root now offers one of three shapes:
26
+ // - pi extension loader (jiti/Bun): root aliases to the compat entrypoint,
27
+ // which exposes lazy per-API factories (anthropicMessagesApi et al.)
28
+ // - pi <=0.79: root only has the legacy streamSimple* globals
29
+ // - plain Node with pi-ai >=0.80.8 (tests, SDK use): root has neither, but
30
+ // lazyApi + a dynamic subpath import reaches the same implementation
31
+ // Detect whichever shape is present so the extension loads in all three.
32
+ const piAiRuntime = piAi as unknown as {
33
+ anthropicMessagesApi?: () => { streamSimple: KimiStreamSimple };
34
+ openAICompletionsApi?: () => { streamSimple: KimiStreamSimple };
35
+ streamSimpleAnthropic?: KimiStreamSimple;
36
+ streamSimpleOpenAICompletions?: KimiStreamSimple;
37
+ lazyApi?: (load: () => Promise<object>) => { streamSimple: KimiStreamSimple };
38
+ };
39
+
40
+ // The ./api/* subpath exports only exist on pi-ai >=0.80.8, so a literal
41
+ // specifier would fail type resolution against older pi-ai. Computed
42
+ // specifiers keep the imports invisible to TypeScript; they are only ever
43
+ // evaluated in plain-Node environments where the subpaths do resolve.
44
+ const anthropicMessagesModule = "@earendil-works/pi-ai/api/anthropic-messages";
45
+ const openAICompletionsModule = "@earendil-works/pi-ai/api/openai-completions";
46
+
47
+ const streamSimpleAnthropic: KimiStreamSimple =
48
+ piAiRuntime.anthropicMessagesApi?.().streamSimple ??
49
+ piAiRuntime.streamSimpleAnthropic ??
50
+ piAiRuntime.lazyApi?.(() => import(anthropicMessagesModule)).streamSimple!;
51
+ const streamSimpleOpenAICompletions: KimiStreamSimple =
52
+ piAiRuntime.openAICompletionsApi?.().streamSimple ??
53
+ piAiRuntime.streamSimpleOpenAICompletions ??
54
+ piAiRuntime.lazyApi?.(() => import(openAICompletionsModule)).streamSimple!;
20
55
  import {
21
56
  DEFAULT_KIMI_CODE_CONFIG,
22
57
  type KimiCodeConfig,
@@ -162,7 +197,16 @@ export function resolveKimiApiKey(apiKey: string | undefined): string {
162
197
  return apiKey || process.env.KIMI_API_KEY || "";
163
198
  }
164
199
 
165
- export function mergeKimiRequestHeaders(headers?: Record<string, string>): Record<string, string> {
200
+ // SimpleStreamOptions.headers is Record<string, string> on pi <=0.79 and
201
+ // ProviderHeaders (Record<string, string | null>, null = suppress the header
202
+ // when pi-ai assembles the request) on >=0.80.8. Accept and preserve both
203
+ // shapes; defined locally because ProviderHeaders is not exported on old pi.
204
+ type HeaderMap = Record<string, string | null>;
205
+
206
+ export function mergeKimiRequestHeaders(headers?: HeaderMap): HeaderMap {
207
+ // Caller values override our Kimi defaults. Nulls are retained rather than
208
+ // deleted so the suppression semantics reach pi-ai, which strips null
209
+ // entries (including its own defaults) when building the request.
166
210
  return { ...getKimiProviderHeaders(), ...headers };
167
211
  }
168
212
 
@@ -231,7 +275,9 @@ export function streamSimpleKimi(
231
275
  return {
232
276
  ...options,
233
277
  ...apiKeyOverride,
234
- headers: mergeKimiRequestHeaders(options?.headers),
278
+ // Cast to whatever headers shape the installed pi-ai declares:
279
+ // Record<string, string> on pi <=0.79, ProviderHeaders on >=0.80.8.
280
+ headers: mergeKimiRequestHeaders(options?.headers) as SimpleStreamOptions["headers"],
235
281
  onPayload: async (payload, modelData) => {
236
282
  let nextPayload: unknown = payload;
237
283
 
@@ -1,9 +1,13 @@
1
- import { AuthStorage, type AgentToolResult } from "@earendil-works/pi-coding-agent";
1
+ import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
2
2
  import { truncateToWidth } from "@earendil-works/pi-tui";
3
3
 
4
4
  import { PROVIDER_ID, getBaseUrl } from "../constants.ts";
5
5
  import { getKimiProviderHeaders } from "../device.ts";
6
- import { KIMI_LOGIN_REQUIRED_MESSAGE, refreshKimiAuthToken } from "../oauth.ts";
6
+ import {
7
+ KIMI_LOGIN_REQUIRED_MESSAGE,
8
+ readStoredOAuthCredential,
9
+ refreshKimiAuthToken,
10
+ } from "../oauth.ts";
7
11
 
8
12
  export const KIMI_TOOL_TIMEOUT_MS = 180_000;
9
13
 
@@ -19,9 +23,7 @@ export interface BuildKimiToolOptions {
19
23
  }
20
24
 
21
25
  function defaultGetAccessToken(): string | null {
22
- const credential = AuthStorage.create().get(PROVIDER_ID);
23
- if (!credential || credential.type !== "oauth") return null;
24
- return credential.access;
26
+ return readStoredOAuthCredential(PROVIDER_ID)?.access ?? null;
25
27
  }
26
28
 
27
29
  export function buildKimiToolDeps(options: BuildKimiToolOptions = {}): KimiToolDeps {
package/src/usage.ts CHANGED
@@ -1,17 +1,6 @@
1
- import { AuthStorage } from "@earendil-works/pi-coding-agent";
2
-
3
1
  import { PROVIDER_ID, getBaseUrl } from "./constants.ts";
4
2
  import { getKimiProviderHeaders } from "./device.ts";
5
- import { refreshKimiAuthToken } from "./oauth.ts";
6
-
7
- const MEMBERSHIP_LEVEL_NAMES: Record<string, string> = {
8
- LEVEL_FREE: "Free",
9
- LEVEL_BASIC: "Adagio",
10
- LEVEL_STANDARD: "Moderato",
11
- LEVEL_INTERMEDIATE: "Allegretto",
12
- LEVEL_ADVANCED: "Allegro",
13
- LEVEL_PREMIUM: "Vivace",
14
- };
3
+ import { readStoredOAuthCredential, refreshKimiAuthToken } from "./oauth.ts";
15
4
 
16
5
  const RESET_TIME_KEYS = [
17
6
  "resetTime",
@@ -38,7 +27,6 @@ export interface UsageFormatOptions {
38
27
 
39
28
  export interface KimiUsageSnapshot {
40
29
  summary: string;
41
- membershipLevel: string | null;
42
30
  }
43
31
 
44
32
  interface BoosterWalletInfo {
@@ -52,8 +40,8 @@ interface BoosterWalletInfo {
52
40
  const FIXED_POINT_CENTS = 1_000_000n;
53
41
 
54
42
  export function getKimiUsageToken(): string | null {
55
- const credential = AuthStorage.create().get(PROVIDER_ID);
56
- if (credential?.type === "oauth" && credential.access) return credential.access;
43
+ const credential = readStoredOAuthCredential(PROVIDER_ID);
44
+ if (credential?.access) return credential.access;
57
45
  const apiKey = process.env.KIMI_API_KEY?.trim();
58
46
  return apiKey || null;
59
47
  }
@@ -176,9 +164,6 @@ export function parseUsageSummary(payload: unknown, options: UsageFormatOptions
176
164
 
177
165
  const record = payload as Record<string, unknown>;
178
166
  const lines: string[] = [];
179
- const membership = parseMembership(record);
180
- if (membership) lines.push(membership, "");
181
-
182
167
  const summary = parseUsageRow(record.usage, "Current week");
183
168
  if (summary)
184
169
  lines.push(formatUsageRow({ ...summary, label: normalizeUsageLabel(summary.label) }, options));
@@ -237,24 +222,6 @@ function formatWindowLabel(value: unknown, fallbackLabel: string): string {
237
222
  return `Current ${minutes}m window`;
238
223
  }
239
224
 
240
- export function parseMembershipLevel(record: Record<string, unknown>): string | null {
241
- const user = record.user;
242
- if (typeof user !== "object" || user === null || Array.isArray(user)) return null;
243
- const membership = (user as Record<string, unknown>).membership;
244
- if (typeof membership !== "object" || membership === null || Array.isArray(membership)) {
245
- return null;
246
- }
247
- const level = (membership as Record<string, unknown>).level;
248
- return typeof level === "string" && level ? level : null;
249
- }
250
-
251
- export function parseMembership(record: Record<string, unknown>): string | null {
252
- const level = parseMembershipLevel(record);
253
- if (!level) return null;
254
- const name = MEMBERSHIP_LEVEL_NAMES[level];
255
- return name ? `Membership: ${name} (${level})` : `Membership: ${level}`;
256
- }
257
-
258
225
  export function formatUsageRow(row: UsageRow, options: UsageFormatOptions = {}): string {
259
226
  if (row.limit <= 0) return `${row.label}\n${row.used} used`;
260
227
  const used = Math.max(0, Math.min(row.used, row.limit));
@@ -358,10 +325,7 @@ export async function fetchKimiUsageSnapshot(
358
325
  ): Promise<KimiUsageSnapshot> {
359
326
  const token = options.token ?? getKimiUsageToken();
360
327
  if (!token) {
361
- return {
362
- summary: "Usage: missing credentials. Run /login kimi-coding.",
363
- membershipLevel: null,
364
- };
328
+ return { summary: "Usage: missing credentials. Run /login kimi-coding." };
365
329
  }
366
330
 
367
331
  const controller = new AbortController();
@@ -373,17 +337,13 @@ export async function fetchKimiUsageSnapshot(
373
337
  if (refreshed) response = await fetchKimiUsage(refreshed, controller.signal);
374
338
  }
375
339
  if (!response.ok) {
376
- return { summary: `Usage: fetch failed (${response.status})`, membershipLevel: null };
340
+ return { summary: `Usage: fetch failed (${response.status})` };
377
341
  }
378
342
  const payload = (await response.json()) as unknown;
379
- const membershipLevel =
380
- typeof payload === "object" && payload !== null && !Array.isArray(payload)
381
- ? parseMembershipLevel(payload as Record<string, unknown>)
382
- : null;
383
- return { summary: parseUsageSummary(payload), membershipLevel };
343
+ return { summary: parseUsageSummary(payload) };
384
344
  } catch (error) {
385
345
  const message = error instanceof Error ? error.message : String(error);
386
- return { summary: `Usage: fetch failed (${message})`, membershipLevel: null };
346
+ return { summary: `Usage: fetch failed (${message})` };
387
347
  } finally {
388
348
  clearTimeout(timeout);
389
349
  }