pi-provider-kimi-code 0.6.5 → 0.6.7

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 K2.7 supported.** The provider automatically discovers the current model from Kimi's `/v1/models` endpoint at login, refresh, and `/kimi-settings` time, so new rollouts (including K2.7) are picked up immediately without a package update.
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.
11
11
 
12
12
  ## Why this exists
13
13
 
@@ -19,20 +19,20 @@ Kimi's API surface goes beyond the LLM itself. It includes file uploads, web sea
19
19
  - **Send files the Kimi way.** Large inline images go through Kimi's Files API and become `ms://` references instead of huge base64 payloads.
20
20
  - **Know what the cache is doing.** Kimi caches by content prefix. This repo measures that behavior instead of pretending `prompt_cache_key` controls it.
21
21
  - **Keep Pi's tools working.** Moonshot's API rejects tool schemas over 15 KB — a limit that Pi's extension ecosystem regularly hits ([#16](https://github.com/Leechael/pi-provider-kimi-code/issues/16), [#21](https://github.com/Leechael/pi-provider-kimi-code/issues/21)). This extension automatically deduplicates schemas with `$ref`/`$defs` before sending, so subagents and other extensions don't break.
22
- - **Tested against the live API.** Thinking config, parameter constraints, protocol compatibility, and streaming behavior are all verified against Kimi's Coding endpoint — not assumed from docs. When the API rejects something (wrong `temperature`, unsupported `tool_choice`), the provider strips it before you see a 400.
22
+ - **Tested against the live API.** Thinking config, parameter constraints, protocol compatibility, and streaming behavior are all verified against Kimi's Coding endpoint — not assumed from docs. When the API rejects something (wrong `temperature`, unsupported `tool_choice`), the provider normalizes the request before you see a 400.
23
23
  - **Turn on Kimi-native tools when you want them.** `moonshot_search`, `moonshot_fetch`, and `kimi_datasource` are opt-in, configurable per user or per project.
24
24
  - **Embed in your own build.** `KimiCode()` factory lets you ship Kimi Code support inside a custom Pi agent with programmatic config overrides — no file-based extension path needed.
25
25
 
26
26
  ## What this package adds
27
27
 
28
28
  - Kimi account login in Pi, plus `KIMI_API_KEY` for CI or pay-per-token use.
29
- - `kimi-code` credential reuse from `~/.kimi/credentials/kimi-code.json`.
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 from Kimi at login, refresh, and `/kimi-settings` time.
31
+ - Live model metadata combined with membership-aware K3 and HighSpeed availability.
32
32
  - OpenAI-compatible mode by default, Anthropic-compatible mode on request.
33
- - Kimi K2.7 reasoning level mapping for Pi's reasoning controls.
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.
35
- - K2.7 parameter guard: strips `temperature`, `top_p`, and `tool_choice` values that the API rejects.
35
+ - K2.7 parameter guard: removes rejected `temperature` and `top_p` values and rewrites unsupported `tool_choice` values to `auto`.
36
36
  - Stream cleanup for Kimi's thinking-only placeholder text.
37
37
  - Optional `moonshot_search`, `moonshot_fetch`, and unified datasource `kimi_datasource` tools via `/kimi-settings`.
38
38
  - Programmatic `KimiCode()` factory for embedding in custom Pi builds.
@@ -84,15 +84,21 @@ Inside Pi, run:
84
84
  /login kimi-coding
85
85
  ```
86
86
 
87
- A browser tab opens, you sign into your Kimi account, and Pi stores the credential at `~/.pi/agent/auth.json`. Tokens refresh automatically.
87
+ A browser tab opens, you sign into your Kimi account, and Pi stores the credential at `~/.pi/agent/auth.json`. Tokens refresh automatically. The extension also syncs refreshed credentials to the current `kimi-code` credential file:
88
88
 
89
- If you already use `kimi-code`, this extension can reuse its credential file:
89
+ ```text
90
+ ~/.kimi-code/credentials/kimi-code.json
91
+ ```
92
+
93
+ If you already use `kimi-code`, the extension can reuse that session. Set `KIMI_CODE_HOME` if its home directory lives somewhere else.
94
+
95
+ The legacy credential path is also supported:
90
96
 
91
97
  ```text
92
98
  ~/.kimi/credentials/kimi-code.json
93
99
  ```
94
100
 
95
- The file is read-only from this extension's side. Set `KIMI_SHARE_DIR` if your `kimi-code` share directory lives somewhere else.
101
+ Legacy credentials are read-only. Set `KIMI_SHARE_DIR` to override the legacy `~/.kimi` directory.
96
102
 
97
103
  For CI or pay-per-token use, set `KIMI_API_KEY` instead:
98
104
 
@@ -100,20 +106,24 @@ For CI or pay-per-token use, set `KIMI_API_KEY` instead:
100
106
  KIMI_API_KEY=sk-... pi
101
107
  ```
102
108
 
103
- ## Model
109
+ ## Models
104
110
 
105
- This provider publishes two Pi model IDs:
111
+ This provider publishes three Pi model IDs:
106
112
 
107
- ```text
108
- kimi-coding/kimi-for-coding
109
- kimi-coding/kimi-for-coding-highspeed
110
- ```
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 |
111
118
 
112
- Select standard or highspeed mode inside Pi:
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.
120
+
121
+ Select a model inside Pi:
113
122
 
114
123
  ```text
115
124
  /model kimi-coding/kimi-for-coding
116
125
  /model kimi-coding/kimi-for-coding-highspeed
126
+ /model kimi-coding/k3
117
127
  ```
118
128
 
119
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.
@@ -125,7 +135,9 @@ Fallback values:
125
135
  - Input: text and image
126
136
  - Reasoning: enabled
127
137
 
128
- The provider maps Pi's reasoning levels to Kimi's top-level `thinking` parameter. It sends `thinking.effort` only when `/models` advertises supported effort values. The mapping refreshes automatically on credential refresh. Opening `/kimi-settings` also re-discovers the latest model metadata.
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.
139
+
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.
129
141
 
130
142
  ## Optional tools
131
143
 
@@ -143,8 +155,8 @@ Configurable settings include protocol mode, upload threshold, and per-tool enab
143
155
 
144
156
  Config files are JSON:
145
157
 
146
- - Home: `~/.pi/pi-provider-kimi-code.json`
147
- - Project override: `<cwd>/.pi/pi-provider-kimi-code.json`
158
+ - Home: `~/.pi/providers/kimi-coding/config.json`
159
+ - Project override: `<cwd>/.pi/providers/kimi-coding/config.json`
148
160
 
149
161
  Project config overrides home config with a deep merge. Missing files or missing keys mean all tools stay off.
150
162
 
@@ -296,7 +308,7 @@ In OpenAI mode this extension maps the `developer` role to `system` (Kimi's Codi
296
308
 
297
309
  ## Credits
298
310
 
299
- Based on the login implementation from Kimi Code by Moonshot AI. Built as a Pi extension by [@badlogic](https://github.com/badlogic).
311
+ Based on the login implementation from Kimi Code by Moonshot AI. Pi was originally created by [@badlogic](https://github.com/badlogic).
300
312
 
301
313
  ## License
302
314
 
package/index.ts CHANGED
@@ -42,12 +42,16 @@ import { PROVIDER_ID, PROVIDER_VERSION, getBaseUrl, getKimiApiType } from "./src
42
42
  import {
43
43
  type KimiOAuthCredentials,
44
44
  type KimiOAuthExtras,
45
+ applyKimiMembershipLimitsToModel,
45
46
  buildKimiModelFromConfig,
46
47
  applyKimiOAuthExtrasToModel,
47
48
  KIMI_CODING_HIGHSPEED_MODEL_ID,
48
49
  KIMI_CODING_MODEL_ID,
50
+ KIMI_K3_MODEL_ID,
51
+ KIMI_MODEL_CATALOG_VERSION,
49
52
  discoverKimiModelMetadata,
50
53
  getKimiModelMetadata,
54
+ isKimiModelAvailableForMembership,
51
55
  resolveKimiModelConfig,
52
56
  } from "./src/models.ts";
53
57
  import { getKimiApiKey, loginKimiCode, refreshKimiCodeToken } from "./src/oauth.ts";
@@ -60,13 +64,14 @@ import {
60
64
  parseByteSizeInput,
61
65
  } from "./src/settings-ui.ts";
62
66
  import { setStoreResolvedKimiConfig, streamSimpleKimi } from "./src/stream.ts";
63
- import { fetchKimiUsageSummary, getKimiUsageToken } from "./src/usage.ts";
67
+ import { fetchKimiUsageSnapshot, getKimiUsageToken } from "./src/usage.ts";
64
68
  import { buildMoonshotFetchTool, buildMoonshotSearchTool } from "./src/tools/moonshot.ts";
65
69
  import { buildKimiDatasourceTool } from "./src/tools/datasource.ts";
66
70
  interface KimiRuntimeState {
67
71
  cwd: string;
68
72
  config: KimiCodeConfig;
69
73
  modelExtras: KimiOAuthExtras;
74
+ membershipLevel: string | null;
70
75
  projectTrusted: boolean;
71
76
  overrides?: KimiCodeConfigPatch;
72
77
  }
@@ -152,11 +157,21 @@ async function openSettingsMenu(
152
157
  ctx: ExtensionCommandContext,
153
158
  state: KimiRuntimeState,
154
159
  ): Promise<void> {
155
- const [usage, modelsRefreshed] = await Promise.all([
156
- fetchKimiUsageSummary(),
160
+ const modelDiscoveryToken = getKimiUsageToken();
161
+ const [usageSnapshot, initialModelsRefreshed] = await Promise.all([
162
+ fetchKimiUsageSnapshot(),
157
163
  refreshModelExtras(state),
158
164
  ]);
159
- if (modelsRefreshed) registerKimiProvider(pi, state);
165
+ let modelsRefreshed = initialModelsRefreshed;
166
+ const refreshedToken = getKimiUsageToken();
167
+ if (!modelsRefreshed && refreshedToken && refreshedToken !== modelDiscoveryToken) {
168
+ modelsRefreshed = await refreshModelExtras(state);
169
+ }
170
+ const refreshedMembershipLevel = usageSnapshot.membershipLevel ?? state.membershipLevel;
171
+ const membershipChanged = state.membershipLevel !== refreshedMembershipLevel;
172
+ state.membershipLevel = refreshedMembershipLevel;
173
+ if (modelsRefreshed || membershipChanged) registerKimiProvider(pi, state);
174
+ const usage = usageSnapshot.summary;
160
175
 
161
176
  const projectTrusted = await isKimiProjectConfigApproved(ctx, ctx.cwd);
162
177
  const homeDraft = loadHomeKimiCodeConfig(os.homedir());
@@ -367,19 +382,40 @@ function saveScopeKimiCodeConfig(
367
382
  function filterAvailableKimiModels<T extends { id: string }>(
368
383
  models: T[],
369
384
  extras: KimiOAuthExtras,
385
+ membershipLevel: string | null,
370
386
  ): T[] {
371
- const available = extras.modelCatalog ? new Set(Object.keys(extras.modelCatalog)) : null;
372
- return available ? models.filter((model) => available.has(model.id)) : models;
387
+ const available =
388
+ extras.modelCatalogVersion === KIMI_MODEL_CATALOG_VERSION && extras.modelCatalog
389
+ ? new Set(Object.keys(extras.modelCatalog))
390
+ : null;
391
+ return models.filter(
392
+ (model) =>
393
+ (!available || available.has(model.id)) &&
394
+ isKimiModelAvailableForMembership(model.id, membershipLevel) !== false,
395
+ );
373
396
  }
374
397
 
375
398
  function registerKimiProvider(pi: ExtensionAPI, state: KimiRuntimeState): void {
376
- const standardModel = applyKimiOAuthExtrasToModel(
377
- buildKimiModelFromConfig(state.config.model),
378
- getKimiModelMetadata(state.modelExtras, KIMI_CODING_MODEL_ID),
399
+ const standardModel = applyKimiMembershipLimitsToModel(
400
+ applyKimiOAuthExtrasToModel(
401
+ buildKimiModelFromConfig(state.config.model),
402
+ getKimiModelMetadata(state.modelExtras, KIMI_CODING_MODEL_ID),
403
+ ),
404
+ state.membershipLevel,
379
405
  );
380
- const highSpeedModel = applyKimiOAuthExtrasToModel(
381
- buildKimiModelFromConfig(state.config.model, KIMI_CODING_HIGHSPEED_MODEL_ID),
382
- getKimiModelMetadata(state.modelExtras, KIMI_CODING_HIGHSPEED_MODEL_ID),
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(
414
+ applyKimiOAuthExtrasToModel(
415
+ buildKimiModelFromConfig(state.config.model, KIMI_K3_MODEL_ID),
416
+ getKimiModelMetadata(state.modelExtras, KIMI_K3_MODEL_ID),
417
+ ),
418
+ state.membershipLevel,
383
419
  );
384
420
 
385
421
  pi.registerProvider(PROVIDER_ID, {
@@ -388,12 +424,34 @@ function registerKimiProvider(pi: ExtensionAPI, state: KimiRuntimeState): void {
388
424
  api: getKimiApiType(state.config.protocol),
389
425
  streamSimple: streamSimpleKimi,
390
426
 
391
- models: filterAvailableKimiModels([standardModel, highSpeedModel], state.modelExtras),
427
+ models: filterAvailableKimiModels(
428
+ [standardModel, highSpeedModel, k3Model],
429
+ state.modelExtras,
430
+ state.membershipLevel,
431
+ ),
392
432
 
393
433
  oauth: {
394
434
  name: "Kimi Code (OAuth)",
395
- login: loginKimiCode,
396
- refreshToken: refreshKimiCodeToken,
435
+ login: async (callbacks) => {
436
+ 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
+ return credentials;
444
+ },
445
+ refreshToken: async (credentials) => {
446
+ 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
+ return refreshed;
454
+ },
397
455
  getApiKey: getKimiApiKey,
398
456
  // Reflect server-side model identity on the registered model after login
399
457
  // / refresh. We never rewrite the model id (pi-side `/model` selections
@@ -404,14 +462,23 @@ function registerKimiProvider(pi: ExtensionAPI, state: KimiRuntimeState): void {
404
462
  const extras = cred as KimiOAuthCredentials;
405
463
  state.modelExtras = extras;
406
464
  reloadEffectiveKimiRuntimeConfig(state, state.cwd, state.projectTrusted);
407
- const available = extras.modelCatalog ? new Set(Object.keys(extras.modelCatalog)) : null;
465
+ const available =
466
+ extras.modelCatalogVersion === KIMI_MODEL_CATALOG_VERSION && extras.modelCatalog
467
+ ? new Set(Object.keys(extras.modelCatalog))
468
+ : null;
408
469
  return models
409
470
  .filter(
410
- (model) => model.provider !== PROVIDER_ID || !available || available.has(model.id),
471
+ (model) =>
472
+ model.provider !== PROVIDER_ID ||
473
+ ((!available || available.has(model.id)) &&
474
+ isKimiModelAvailableForMembership(model.id, state.membershipLevel) !== false),
411
475
  )
412
476
  .map((model) =>
413
477
  model.provider === PROVIDER_ID
414
- ? applyKimiOAuthExtrasToModel(model, getKimiModelMetadata(extras, model.id))
478
+ ? applyKimiMembershipLimitsToModel(
479
+ applyKimiOAuthExtrasToModel(model, getKimiModelMetadata(extras, model.id)),
480
+ state.membershipLevel,
481
+ )
415
482
  : model,
416
483
  );
417
484
  },
@@ -427,13 +494,24 @@ export function KimiCode(overrides?: KimiCodeConfigPatch): ExtensionFactory {
427
494
  overrides,
428
495
  );
429
496
  const discoveryToken = getKimiUsageToken();
430
- const discovered = discoveryToken
431
- ? await discoverKimiModelMetadata(discoveryToken, config.protocol)
432
- : {};
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
+ }
433
510
  const state: KimiRuntimeState = {
434
511
  cwd,
435
512
  config,
436
513
  modelExtras: discovered,
514
+ membershipLevel: usageSnapshot.membershipLevel,
437
515
  projectTrusted: false,
438
516
  overrides,
439
517
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-provider-kimi-code",
3
- "version": "0.6.5",
3
+ "version": "0.6.7",
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/config.ts CHANGED
@@ -121,9 +121,10 @@ export const DEFAULT_KIMI_CODE_CONFIG: KimiCodeConfig = {
121
121
  off: { effort: null, enabled: false },
122
122
  minimal: { effort: "low", enabled: true },
123
123
  low: { effort: "low", enabled: true },
124
- medium: { effort: "medium", enabled: true },
124
+ medium: { effort: "high", enabled: true },
125
125
  high: { effort: "high", enabled: true },
126
- xhigh: { effort: "high", enabled: true },
126
+ xhigh: { effort: "max", enabled: true },
127
+ max: { effort: "max", enabled: true },
127
128
  },
128
129
  thinkingKeep: "all",
129
130
  generation: {},
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.5";
44
+ export const PROVIDER_VERSION = "0.6.7";
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
- // syncing with https://github.com/anthropics/kimi-code.
49
- export const KIMI_UPSTREAM_VERSION = "0.23.4";
48
+ // syncing with https://github.com/MoonshotAI/kimi-code.
49
+ export const KIMI_UPSTREAM_VERSION = "0.26.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
@@ -22,6 +22,7 @@ export interface KimiModelMetadata {
22
22
 
23
23
  export interface KimiOAuthExtras extends KimiModelMetadata {
24
24
  modelCatalog?: Record<string, KimiModelMetadata>;
25
+ modelCatalogVersion?: number;
25
26
  }
26
27
 
27
28
  export type KimiOAuthCredentials = OAuthCredentials & KimiOAuthExtras;
@@ -56,6 +57,46 @@ const COST_HIGH_SPEED = { input: 1.793, output: 7.448, cacheRead: 0.359, cacheWr
56
57
 
57
58
  export const KIMI_CODING_MODEL_ID = "kimi-for-coding";
58
59
  export const KIMI_CODING_HIGHSPEED_MODEL_ID = "kimi-for-coding-highspeed";
60
+ export const KIMI_K3_MODEL_ID = "k3";
61
+ export const KIMI_MODEL_CATALOG_VERSION = 1;
62
+
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(
76
+ 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
+ }
59
100
 
60
101
  function resolveModelCost(modelDisplay: string | undefined): {
61
102
  input: number;
@@ -72,9 +113,15 @@ export function buildKimiModelFromConfig(
72
113
  modelId = KIMI_CODING_MODEL_ID,
73
114
  ): Model<Api> {
74
115
  const isHighSpeed = modelId === KIMI_CODING_HIGHSPEED_MODEL_ID;
116
+ const name =
117
+ modelId === KIMI_K3_MODEL_ID
118
+ ? "Kimi K3"
119
+ : isHighSpeed
120
+ ? "Kimi for Coding High Speed"
121
+ : "Kimi for Coding";
75
122
  return {
76
123
  id: modelId,
77
- name: isHighSpeed ? "Kimi for Coding High Speed" : "Kimi for Coding",
124
+ name,
78
125
  reasoning: config.reasoning,
79
126
  input: [...config.input] as unknown as ("text" | "image" | "video")[],
80
127
  cost: { ...(isHighSpeed ? COST_HIGH_SPEED : COST_STANDARD) },
@@ -201,7 +248,11 @@ export async function discoverKimiModelMetadata(
201
248
  if (!response.ok) return {};
202
249
  const json = (await response.json()) as { data?: unknown };
203
250
  const list = Array.isArray(json.data) ? (json.data as KimiServerModel[]) : [];
204
- const supportedIds = new Set([KIMI_CODING_MODEL_ID, KIMI_CODING_HIGHSPEED_MODEL_ID]);
251
+ const supportedIds = new Set([
252
+ KIMI_CODING_MODEL_ID,
253
+ KIMI_CODING_HIGHSPEED_MODEL_ID,
254
+ KIMI_K3_MODEL_ID,
255
+ ]);
205
256
  const modelCatalog: Record<string, KimiModelMetadata> = {};
206
257
  for (const model of list) {
207
258
  if (typeof model.id !== "string" || !supportedIds.has(model.id)) continue;
@@ -210,9 +261,11 @@ export async function discoverKimiModelMetadata(
210
261
  }
211
262
  const preferred = modelCatalog[KIMI_CODING_MODEL_ID];
212
263
  if (!preferred) {
213
- return Object.keys(modelCatalog).length > 0 ? { modelCatalog } : {};
264
+ return Object.keys(modelCatalog).length > 0
265
+ ? { modelCatalog, modelCatalogVersion: KIMI_MODEL_CATALOG_VERSION }
266
+ : {};
214
267
  }
215
- return { ...preferred, modelCatalog };
268
+ return { ...preferred, modelCatalog, modelCatalogVersion: KIMI_MODEL_CATALOG_VERSION };
216
269
  } catch {
217
270
  return {};
218
271
  } finally {
@@ -238,7 +291,10 @@ export function applyKimiOAuthExtrasToModel(
238
291
  defaultEffort?: string;
239
292
  } = { ...model };
240
293
  if (typeof extras.modelDisplay === "string" && extras.modelDisplay) {
241
- next.name = extras.modelDisplay;
294
+ next.name =
295
+ model.id === KIMI_K3_MODEL_ID && /^k3$/i.test(extras.modelDisplay)
296
+ ? "Kimi K3"
297
+ : extras.modelDisplay;
242
298
  next.cost = resolveModelCost(extras.modelDisplay);
243
299
  }
244
300
  if (typeof extras.contextLength === "number" && extras.contextLength > 0) {
package/src/oauth.ts CHANGED
@@ -2,13 +2,94 @@
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 acquireAuthFileLock(authPath: string): Promise<() => Promise<void>> {
56
+ return acquireFileLock(authPath, {
57
+ realpath: false,
58
+ stale: 30_000,
59
+ retries: { retries: 10, factor: 2, minTimeout: 100, maxTimeout: 10_000, randomize: true },
60
+ });
61
+ }
62
+
63
+ async function writeStoredCredential(
64
+ providerId: string,
65
+ credential: StoredOAuthCredential,
66
+ ): Promise<void> {
67
+ const AuthStorage = piAgentRuntime.AuthStorage;
68
+ if (AuthStorage) {
69
+ AuthStorage.create().set(providerId, credential);
70
+ return;
71
+ }
72
+ // pi >=0.80.8: no exported write path, so do a locked read-modify-write of
73
+ // auth.json in pi's on-disk format (indent 2). The read happens inside the
74
+ // lock so concurrent updates from other processes are never clobbered, and
75
+ // the explicit chmod covers pre-existing files (writeFileSync's mode only
76
+ // applies on creation).
77
+ const authPath = join(piAgentRuntime.getAgentDir(), "auth.json");
78
+ const release = await acquireAuthFileLock(authPath);
79
+ try {
80
+ let data: Record<string, unknown> = {};
81
+ try {
82
+ data = JSON.parse(readFileSync(authPath, "utf-8"));
83
+ } catch {
84
+ // Missing or unreadable auth.json — start from an empty store.
85
+ }
86
+ data[providerId] = credential;
87
+ writeFileSync(authPath, JSON.stringify(data, null, 2), { encoding: "utf-8", mode: 0o600 });
88
+ chmodSync(authPath, 0o600);
89
+ } finally {
90
+ await release();
91
+ }
92
+ }
12
93
 
13
94
  import { CLIENT_ID, PROVIDER_ID, RETRYABLE_REFRESH_STATUSES, getOAuthHost } from "./constants.ts";
14
95
  import { getCommonHeaders } from "./device.ts";
@@ -120,6 +201,7 @@ export async function requestDeviceToken(
120
201
 
121
202
  interface RefreshAccessTokenOptions {
122
203
  maxRetries?: number;
204
+ signal?: AbortSignal;
123
205
  sleep?: (ms: number) => Promise<void>;
124
206
  }
125
207
 
@@ -133,8 +215,20 @@ export class KimiLoginRequiredError extends Error {
133
215
  }
134
216
  }
135
217
 
136
- function sleep(ms: number): Promise<void> {
137
- return new Promise((resolve) => setTimeout(resolve, ms));
218
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
219
+ if (!signal) return new Promise((resolve) => setTimeout(resolve, ms));
220
+ signal.throwIfAborted();
221
+ return new Promise((resolve, reject) => {
222
+ const onAbort = () => {
223
+ clearTimeout(timeout);
224
+ reject(signal.reason);
225
+ };
226
+ const timeout = setTimeout(() => {
227
+ signal.removeEventListener("abort", onAbort);
228
+ resolve();
229
+ }, ms);
230
+ signal.addEventListener("abort", onAbort, { once: true });
231
+ });
138
232
  }
139
233
 
140
234
  export async function refreshAccessToken(
@@ -142,11 +236,12 @@ export async function refreshAccessToken(
142
236
  options: RefreshAccessTokenOptions = {},
143
237
  ): Promise<TokenResponse> {
144
238
  const maxRetries = options.maxRetries ?? 3;
145
- const wait = options.sleep ?? sleep;
239
+ const wait = options.sleep ?? ((ms: number) => sleep(ms, options.signal));
146
240
  let lastError: unknown;
147
241
 
148
242
  for (let attempt = 0; attempt < maxRetries; attempt++) {
149
243
  try {
244
+ options.signal?.throwIfAborted();
150
245
  const response = await fetch(`${getOAuthHost()}/api/oauth/token`, {
151
246
  method: "POST",
152
247
  headers: {
@@ -158,6 +253,7 @@ export async function refreshAccessToken(
158
253
  grant_type: "refresh_token",
159
254
  refresh_token: refreshToken,
160
255
  }),
256
+ signal: options.signal,
161
257
  });
162
258
 
163
259
  if (!response.ok) {
@@ -185,6 +281,7 @@ export async function refreshAccessToken(
185
281
 
186
282
  return data;
187
283
  } catch (error) {
284
+ if (options.signal?.aborted) throw options.signal.reason;
188
285
  lastError = error;
189
286
  const message = error instanceof Error ? error.message : String(error);
190
287
  if (error instanceof KimiLoginRequiredError) throw error;
@@ -408,8 +505,8 @@ export async function refreshKimiCodeToken(
408
505
  // `expires` is in the past. If the server rotates/revokes the access token
409
506
  // before that (common with short-lived session tokens), every request keeps
410
507
  // returning 401. The streaming handler invokes refreshKimiAuthToken on the
411
- // first auth error to force a refresh through AuthStorage (which persists the
412
- // new credentials under a file lock), and retry once.
508
+ // first auth error to force a refresh and persist the new credentials, and
509
+ // retry once.
413
510
  // =============================================================================
414
511
 
415
512
  export function getKimiApiKey(credentials: OAuthCredentials): string {
@@ -418,6 +515,11 @@ export function getKimiApiKey(credentials: OAuthCredentials): string {
418
515
 
419
516
  export function isKimiAuthErrorMessage(message: unknown): boolean {
420
517
  const text = String(message ?? "").toLowerCase();
518
+ const isMembershipPermissionError =
519
+ text.includes("current subscription does not have access to k3") ||
520
+ text.includes("current plan supports only kimi-k3 up to 256k context") ||
521
+ text.includes("current subscription does not have access to kimi-for-coding-highspeed");
522
+ if (isMembershipPermissionError) return false;
421
523
  return (
422
524
  /\b401\b/.test(text) ||
423
525
  text.includes("unauthorized") ||
@@ -426,12 +528,13 @@ export function isKimiAuthErrorMessage(message: unknown): boolean {
426
528
  );
427
529
  }
428
530
 
429
- export async function refreshKimiAuthToken(currentKey: string): Promise<string | null> {
531
+ export async function refreshKimiAuthToken(
532
+ currentKey: string,
533
+ options: { signal?: AbortSignal } = {},
534
+ ): Promise<string | null> {
430
535
  try {
431
536
  const kimiCred = readKimiCliCredentials();
432
- const storage = AuthStorage.create();
433
- const piCred = storage.get(PROVIDER_ID);
434
- const piOAuth = piCred?.type === "oauth" ? piCred : null;
537
+ const piOAuth = readStoredOAuthCredential(PROVIDER_ID);
435
538
 
436
539
  if (piOAuth) {
437
540
  if (piOAuth.access !== currentKey && Date.now() < piOAuth.expires) {
@@ -442,35 +545,35 @@ export async function refreshKimiAuthToken(currentKey: string): Promise<string |
442
545
  console.error("[kimi-coding] auth refresh: requesting new access token");
443
546
  let refreshed: Awaited<ReturnType<typeof refreshAccessToken>>;
444
547
  try {
445
- refreshed = await refreshAccessToken(piOAuth.refresh);
548
+ refreshed = await refreshAccessToken(piOAuth.refresh, { signal: options.signal });
446
549
  } catch (error) {
447
550
  if (!kimiCred?.refresh_token || kimiCred.refresh_token === piOAuth.refresh) throw error;
448
551
  const kimiExpiresMs = (kimiCred.expires_at ?? 0) * 1000;
449
552
  if (kimiCred.access_token !== currentKey && Date.now() < kimiExpiresMs) {
450
- const recovered: OAuthCredential = {
553
+ const recovered: StoredOAuthCredential = {
451
554
  ...piOAuth,
452
555
  type: "oauth",
453
556
  access: kimiCred.access_token!,
454
557
  refresh: kimiCred.refresh_token,
455
558
  expires: kimiExpiresMs,
456
559
  };
457
- storage.set(PROVIDER_ID, recovered);
560
+ await writeStoredCredential(PROVIDER_ID, recovered);
458
561
  console.error("[kimi-coding] auth refresh: recovered newer kimi-code token");
459
562
  return recovered.access;
460
563
  }
461
564
  console.error("[kimi-coding] auth refresh: pi token rejected, trying kimi-code token");
462
- refreshed = await refreshAccessToken(kimiCred.refresh_token);
565
+ refreshed = await refreshAccessToken(kimiCred.refresh_token, { signal: options.signal });
463
566
  }
464
567
 
465
568
  const newExpiresMs = Date.now() + refreshed.expires_in * 1000;
466
- const newCred: OAuthCredential = {
569
+ const newCred: StoredOAuthCredential = {
467
570
  ...piOAuth,
468
571
  type: "oauth",
469
572
  access: refreshed.access_token,
470
573
  refresh: refreshed.refresh_token,
471
574
  expires: newExpiresMs,
472
575
  };
473
- storage.set(PROVIDER_ID, newCred);
576
+ await writeStoredCredential(PROVIDER_ID, newCred);
474
577
  writeKimiCodeCredentials(refreshed.access_token, refreshed.refresh_token, newExpiresMs);
475
578
  console.error("[kimi-coding] auth refresh: new token persisted");
476
579
  return newCred.access;
@@ -490,12 +593,15 @@ export async function refreshKimiAuthToken(currentKey: string): Promise<string |
490
593
  }
491
594
 
492
595
  console.error("[kimi-coding] auth refresh: requesting new access token via kimi-code");
493
- const refreshed = await refreshAccessToken(kimiCred.refresh_token!);
596
+ const refreshed = await refreshAccessToken(kimiCred.refresh_token!, {
597
+ signal: options.signal,
598
+ });
494
599
  const newExpiresMs = Date.now() + refreshed.expires_in * 1000;
495
600
  writeKimiCodeCredentials(refreshed.access_token, refreshed.refresh_token, newExpiresMs);
496
601
  console.error("[kimi-coding] auth refresh: new token persisted to kimi-code");
497
602
  return refreshed.access_token;
498
603
  } catch (err) {
604
+ if (options.signal?.aborted) return null;
499
605
  if (err instanceof KimiLoginRequiredError) {
500
606
  console.error(`[kimi-coding] ${KIMI_LOGIN_REQUIRED_MESSAGE}`);
501
607
  } else {
package/src/payload.ts CHANGED
@@ -488,6 +488,28 @@ export async function applyKimiPayloadMutations(
488
488
  }
489
489
  }
490
490
 
491
+ // Preserved-thinking Kimi endpoints require every replayed assistant turn to
492
+ // carry reasoning_content, including turns whose reasoning delta was empty.
493
+ // pi-ai drops empty thinking blocks while building Chat Completions history,
494
+ // so restore the explicit empty field after the final thinking mode is known.
495
+ if (
496
+ ctx.api === "openai-completions" &&
497
+ isRecord(payload.thinking) &&
498
+ payload.thinking.type !== "disabled" &&
499
+ payload.thinking.keep === "all" &&
500
+ Array.isArray(payload.messages)
501
+ ) {
502
+ for (const message of payload.messages) {
503
+ if (
504
+ isRecord(message) &&
505
+ message.role === "assistant" &&
506
+ message.reasoning_content === undefined
507
+ ) {
508
+ message.reasoning_content = "";
509
+ }
510
+ }
511
+ }
512
+
491
513
  // 8. K2.7 Code API constraints: the server rejects non-default values for
492
514
  // temperature (must be 1.0) and top_p (must be 0.95), and tool_choice
493
515
  // "required" / function-specific when thinking is enabled (always-on).
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,8 +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";
3
+ import { readStoredOAuthCredential, refreshKimiAuthToken } from "./oauth.ts";
6
4
 
7
5
  const MEMBERSHIP_LEVEL_NAMES: Record<string, string> = {
8
6
  LEVEL_FREE: "Free",
@@ -36,6 +34,11 @@ export interface UsageFormatOptions {
36
34
  timeZone?: string;
37
35
  }
38
36
 
37
+ export interface KimiUsageSnapshot {
38
+ summary: string;
39
+ membershipLevel: string | null;
40
+ }
41
+
39
42
  interface BoosterWalletInfo {
40
43
  balanceCents: bigint;
41
44
  monthlyChargeLimitEnabled: boolean;
@@ -47,8 +50,8 @@ interface BoosterWalletInfo {
47
50
  const FIXED_POINT_CENTS = 1_000_000n;
48
51
 
49
52
  export function getKimiUsageToken(): string | null {
50
- const credential = AuthStorage.create().get(PROVIDER_ID);
51
- if (credential?.type === "oauth" && credential.access) return credential.access;
53
+ const credential = readStoredOAuthCredential(PROVIDER_ID);
54
+ if (credential?.access) return credential.access;
52
55
  const apiKey = process.env.KIMI_API_KEY?.trim();
53
56
  return apiKey || null;
54
57
  }
@@ -232,7 +235,7 @@ function formatWindowLabel(value: unknown, fallbackLabel: string): string {
232
235
  return `Current ${minutes}m window`;
233
236
  }
234
237
 
235
- export function parseMembership(record: Record<string, unknown>): string | null {
238
+ export function parseMembershipLevel(record: Record<string, unknown>): string | null {
236
239
  const user = record.user;
237
240
  if (typeof user !== "object" || user === null || Array.isArray(user)) return null;
238
241
  const membership = (user as Record<string, unknown>).membership;
@@ -240,7 +243,12 @@ export function parseMembership(record: Record<string, unknown>): string | null
240
243
  return null;
241
244
  }
242
245
  const level = (membership as Record<string, unknown>).level;
243
- if (typeof level !== "string" || !level) return null;
246
+ return typeof level === "string" && level ? level : null;
247
+ }
248
+
249
+ export function parseMembership(record: Record<string, unknown>): string | null {
250
+ const level = parseMembershipLevel(record);
251
+ if (!level) return null;
244
252
  const name = MEMBERSHIP_LEVEL_NAMES[level];
245
253
  return name ? `Membership: ${name} (${level})` : `Membership: ${level}`;
246
254
  }
@@ -343,24 +351,42 @@ function fetchKimiUsage(token: string, signal: AbortSignal): Promise<Response> {
343
351
  });
344
352
  }
345
353
 
346
- export async function fetchKimiUsageSummary(): Promise<string> {
347
- const token = getKimiUsageToken();
348
- if (!token) return "Usage: missing credentials. Run /login kimi-coding.";
354
+ export async function fetchKimiUsageSnapshot(
355
+ options: { timeoutMs?: number; token?: string; refreshOnUnauthorized?: boolean } = {},
356
+ ): Promise<KimiUsageSnapshot> {
357
+ const token = options.token ?? getKimiUsageToken();
358
+ if (!token) {
359
+ return {
360
+ summary: "Usage: missing credentials. Run /login kimi-coding.",
361
+ membershipLevel: null,
362
+ };
363
+ }
349
364
 
350
365
  const controller = new AbortController();
351
- const timeout = setTimeout(() => controller.abort(), 15_000);
366
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 15_000);
352
367
  try {
353
368
  let response = await fetchKimiUsage(token, controller.signal);
354
- if (response.status === 401) {
355
- const refreshed = await refreshKimiAuthToken(token);
369
+ if (response.status === 401 && options.refreshOnUnauthorized !== false) {
370
+ const refreshed = await refreshKimiAuthToken(token, { signal: controller.signal });
356
371
  if (refreshed) response = await fetchKimiUsage(refreshed, controller.signal);
357
372
  }
358
- if (!response.ok) return `Usage: fetch failed (${response.status})`;
359
- return parseUsageSummary(await response.json());
373
+ if (!response.ok) {
374
+ return { summary: `Usage: fetch failed (${response.status})`, membershipLevel: null };
375
+ }
376
+ const payload = (await response.json()) as unknown;
377
+ const membershipLevel =
378
+ typeof payload === "object" && payload !== null && !Array.isArray(payload)
379
+ ? parseMembershipLevel(payload as Record<string, unknown>)
380
+ : null;
381
+ return { summary: parseUsageSummary(payload), membershipLevel };
360
382
  } catch (error) {
361
383
  const message = error instanceof Error ? error.message : String(error);
362
- return `Usage: fetch failed (${message})`;
384
+ return { summary: `Usage: fetch failed (${message})`, membershipLevel: null };
363
385
  } finally {
364
386
  clearTimeout(timeout);
365
387
  }
366
388
  }
389
+
390
+ export async function fetchKimiUsageSummary(): Promise<string> {
391
+ return (await fetchKimiUsageSnapshot()).summary;
392
+ }