pi-provider-kimi-code 0.6.3 → 0.6.5

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
@@ -102,16 +102,18 @@ KIMI_API_KEY=sk-... pi
102
102
 
103
103
  ## Model
104
104
 
105
- This provider publishes one Pi model ID:
105
+ This provider publishes two Pi model IDs:
106
106
 
107
107
  ```text
108
108
  kimi-coding/kimi-for-coding
109
+ kimi-coding/kimi-for-coding-highspeed
109
110
  ```
110
111
 
111
- Select it inside Pi:
112
+ Select standard or highspeed mode inside Pi:
112
113
 
113
114
  ```text
114
115
  /model kimi-coding/kimi-for-coding
116
+ /model kimi-coding/kimi-for-coding-highspeed
115
117
  ```
116
118
 
117
119
  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.
@@ -123,7 +125,7 @@ Fallback values:
123
125
  - Input: text and image
124
126
  - Reasoning: enabled
125
127
 
126
- The provider maps Pi's reasoning levels to Kimi's `reasoning_effort` and top-level `thinking` parameters. The mapping refreshes automatically on credential refresh. Opening `/kimi-settings` also re-discovers the latest model metadata.
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.
127
129
 
128
130
  ## Optional tools
129
131
 
@@ -135,7 +137,7 @@ Inside Pi, run:
135
137
  /kimi-settings
136
138
  ```
137
139
 
138
- That command shows the current server-side model name (e.g. "K2.7 Code High Speed"), your Kimi usage summary, and lets you edit the home or project config. Changes apply to the active session tool set.
140
+ That command shows the current server-side model name (e.g. "K2.7 Code High Speed"), your Kimi quota and Extra Usage balance, and lets you edit the home or project config. Changes apply to the active session tool set.
139
141
 
140
142
  Configurable settings include protocol mode, upload threshold, and per-tool enable/collapse.
141
143
 
@@ -277,7 +279,7 @@ Kimi's cache is **content-based**: it fires automatically when your prompt prefi
277
279
  - A timestamp, request ID, or randomized header is being interpolated into the prompt.
278
280
  - The first ~256 tokens of the prompt differ between turns.
279
281
 
280
- `PI_CACHE_RETENTION=none` skips `prompt_cache_key` injection but **does not** disable Kimi's caching (the cache is unconditional). For deterministic measurement, run `scripts/test_e2e.sh` with `KIMI_E2E_ONLY_CACHE=1`. See [docs/caching.md](docs/caching.md) for full mechanics.
282
+ `PI_CACHE_RETENTION=none` skips `prompt_cache_key` injection but **does not** disable Kimi's caching (the cache is unconditional). For deterministic measurement, run a focused suite under `scripts/e2e/cache/`, starting with `scripts/e2e/cache/ttl.sh`. See [docs/caching.md](docs/caching.md) for full mechanics.
281
283
 
282
284
  ### OpenAI-compatible tools complain about a `developer` role
283
285
 
package/index.ts CHANGED
@@ -44,10 +44,13 @@ import {
44
44
  type KimiOAuthExtras,
45
45
  buildKimiModelFromConfig,
46
46
  applyKimiOAuthExtrasToModel,
47
+ KIMI_CODING_HIGHSPEED_MODEL_ID,
48
+ KIMI_CODING_MODEL_ID,
47
49
  discoverKimiModelMetadata,
50
+ getKimiModelMetadata,
48
51
  resolveKimiModelConfig,
49
52
  } from "./src/models.ts";
50
- import { loginKimiCode, refreshKimiCodeToken } from "./src/oauth.ts";
53
+ import { getKimiApiKey, loginKimiCode, refreshKimiCodeToken } from "./src/oauth.ts";
51
54
  import { isKimiProjectConfigApproved } from "./src/project-trust.ts";
52
55
  import {
53
56
  type KimiConfigScope,
@@ -114,7 +117,10 @@ function reloadEffectiveKimiRuntimeConfig(
114
117
  state.config = config;
115
118
  state.projectTrusted = projectTrusted;
116
119
  setStoreResolvedKimiConfig({
117
- model: resolveKimiModelConfig(config.model, state.modelExtras),
120
+ model: resolveKimiModelConfig(
121
+ config.model,
122
+ getKimiModelMetadata(state.modelExtras, KIMI_CODING_MODEL_ID),
123
+ ),
118
124
  protocol: config.protocol,
119
125
  uploads: config.uploads,
120
126
  });
@@ -132,13 +138,13 @@ function applyEffectiveKimiRuntimeConfig(
132
138
  return config;
133
139
  }
134
140
 
135
- async function refreshModelExtras(state: KimiRuntimeState): Promise<void> {
141
+ async function refreshModelExtras(state: KimiRuntimeState): Promise<boolean> {
136
142
  const token = getKimiUsageToken();
137
- if (!token) return;
143
+ if (!token) return false;
138
144
  const extras = await discoverKimiModelMetadata(token, state.config.protocol);
139
- if (Object.keys(extras).length > 0) {
140
- Object.assign(state.modelExtras, extras);
141
- }
145
+ if (Object.keys(extras).length === 0) return false;
146
+ Object.assign(state.modelExtras, extras);
147
+ return true;
142
148
  }
143
149
 
144
150
  async function openSettingsMenu(
@@ -146,7 +152,11 @@ async function openSettingsMenu(
146
152
  ctx: ExtensionCommandContext,
147
153
  state: KimiRuntimeState,
148
154
  ): Promise<void> {
149
- const [usage] = await Promise.all([fetchKimiUsageSummary(), refreshModelExtras(state)]);
155
+ const [usage, modelsRefreshed] = await Promise.all([
156
+ fetchKimiUsageSummary(),
157
+ refreshModelExtras(state),
158
+ ]);
159
+ if (modelsRefreshed) registerKimiProvider(pi, state);
150
160
 
151
161
  const projectTrusted = await isKimiProjectConfigApproved(ctx, ctx.cwd);
152
162
  const homeDraft = loadHomeKimiCodeConfig(os.homedir());
@@ -354,10 +364,22 @@ function saveScopeKimiCodeConfig(
354
364
  }
355
365
  }
356
366
 
367
+ function filterAvailableKimiModels<T extends { id: string }>(
368
+ models: T[],
369
+ extras: KimiOAuthExtras,
370
+ ): T[] {
371
+ const available = extras.modelCatalog ? new Set(Object.keys(extras.modelCatalog)) : null;
372
+ return available ? models.filter((model) => available.has(model.id)) : models;
373
+ }
374
+
357
375
  function registerKimiProvider(pi: ExtensionAPI, state: KimiRuntimeState): void {
358
- const model = applyKimiOAuthExtrasToModel(
376
+ const standardModel = applyKimiOAuthExtrasToModel(
359
377
  buildKimiModelFromConfig(state.config.model),
360
- state.modelExtras,
378
+ getKimiModelMetadata(state.modelExtras, KIMI_CODING_MODEL_ID),
379
+ );
380
+ const highSpeedModel = applyKimiOAuthExtrasToModel(
381
+ buildKimiModelFromConfig(state.config.model, KIMI_CODING_HIGHSPEED_MODEL_ID),
382
+ getKimiModelMetadata(state.modelExtras, KIMI_CODING_HIGHSPEED_MODEL_ID),
361
383
  );
362
384
 
363
385
  pi.registerProvider(PROVIDER_ID, {
@@ -366,13 +388,13 @@ function registerKimiProvider(pi: ExtensionAPI, state: KimiRuntimeState): void {
366
388
  api: getKimiApiType(state.config.protocol),
367
389
  streamSimple: streamSimpleKimi,
368
390
 
369
- models: [model],
391
+ models: filterAvailableKimiModels([standardModel, highSpeedModel], state.modelExtras),
370
392
 
371
393
  oauth: {
372
394
  name: "Kimi Code (OAuth)",
373
395
  login: loginKimiCode,
374
396
  refreshToken: refreshKimiCodeToken,
375
- getApiKey: (cred) => cred.access,
397
+ getApiKey: getKimiApiKey,
376
398
  // Reflect server-side model identity on the registered model after login
377
399
  // / refresh. We never rewrite the model id (pi-side `/model` selections
378
400
  // and persisted sessions reference it); only the human-facing name, the
@@ -382,10 +404,16 @@ function registerKimiProvider(pi: ExtensionAPI, state: KimiRuntimeState): void {
382
404
  const extras = cred as KimiOAuthCredentials;
383
405
  state.modelExtras = extras;
384
406
  reloadEffectiveKimiRuntimeConfig(state, state.cwd, state.projectTrusted);
385
- return models.map((model) => {
386
- if (model.id !== "kimi-for-coding") return model;
387
- return applyKimiOAuthExtrasToModel(model, extras);
388
- });
407
+ const available = extras.modelCatalog ? new Set(Object.keys(extras.modelCatalog)) : null;
408
+ return models
409
+ .filter(
410
+ (model) => model.provider !== PROVIDER_ID || !available || available.has(model.id),
411
+ )
412
+ .map((model) =>
413
+ model.provider === PROVIDER_ID
414
+ ? applyKimiOAuthExtrasToModel(model, getKimiModelMetadata(extras, model.id))
415
+ : model,
416
+ );
389
417
  },
390
418
  },
391
419
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-provider-kimi-code",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "Use Kimi Code in Pi without losing the Kimi-specific bits",
5
5
  "keywords": [
6
6
  "ai-provider",
package/src/config.ts CHANGED
@@ -38,6 +38,8 @@ export interface ModelConfig {
38
38
 
39
39
  export interface KimiResolvedModelConfig extends ModelConfig {
40
40
  supportsThinkingType?: "only" | "no" | "both";
41
+ supportEfforts?: string[];
42
+ defaultEffort?: string;
41
43
  }
42
44
 
43
45
  export interface KimiCodeConfig {
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.3";
44
+ export const PROVIDER_VERSION = "0.6.5";
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/anthropics/kimi-code.
49
- export const KIMI_UPSTREAM_VERSION = "0.18.0";
49
+ export const KIMI_UPSTREAM_VERSION = "0.23.4";
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/device.ts CHANGED
@@ -152,6 +152,22 @@ function getStableDeviceId(): string {
152
152
  return DEVICE_ID;
153
153
  }
154
154
 
155
+ export function parseKimiCodeCustomHeaders(
156
+ env: NodeJS.ProcessEnv = process.env,
157
+ ): Record<string, string> {
158
+ const raw = env.KIMI_CODE_CUSTOM_HEADERS?.trim();
159
+ if (!raw) return {};
160
+ const headers: Record<string, string> = {};
161
+ for (const line of raw.split("\n")) {
162
+ const colon = line.indexOf(":");
163
+ if (colon < 0) continue;
164
+ const name = line.slice(0, colon).trim();
165
+ if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name)) continue;
166
+ headers[name] = asciiHeaderValue(line.slice(colon + 1));
167
+ }
168
+ return headers;
169
+ }
170
+
155
171
  export function getCommonHeaders(): Record<string, string> {
156
172
  const headers = {
157
173
  "User-Agent": KIMI_CODE_USER_AGENT,
@@ -166,3 +182,28 @@ export function getCommonHeaders(): Record<string, string> {
166
182
  Object.entries(headers).map(([key, value]) => [key, asciiHeaderValue(value)]),
167
183
  ) as Record<string, string>;
168
184
  }
185
+
186
+ const RESERVED_PROVIDER_HEADERS = new Set([
187
+ "accept",
188
+ "anthropic-version",
189
+ "authorization",
190
+ "content-type",
191
+ "user-agent",
192
+ "x-api-key",
193
+ ]);
194
+
195
+ function isReservedProviderHeader(name: string): boolean {
196
+ const normalized = name.toLowerCase();
197
+ return RESERVED_PROVIDER_HEADERS.has(normalized) || normalized.startsWith("x-msh-");
198
+ }
199
+
200
+ export function getKimiProviderHeaders(
201
+ env: NodeJS.ProcessEnv = process.env,
202
+ ): Record<string, string> {
203
+ const customHeaders = Object.fromEntries(
204
+ Object.entries(parseKimiCodeCustomHeaders(env)).filter(
205
+ ([name]) => !isReservedProviderHeader(name),
206
+ ),
207
+ );
208
+ return { ...customHeaders, ...getCommonHeaders() };
209
+ }
package/src/models.ts CHANGED
@@ -5,9 +5,9 @@ import type { Api, Model, OAuthCredentials } from "@earendil-works/pi-ai";
5
5
  import type { KimiInputModality, KimiResolvedModelConfig, ModelConfig } from "./config.ts";
6
6
 
7
7
  import { type KimiWireProtocol, getBaseUrl } from "./constants.ts";
8
- import { getCommonHeaders } from "./device.ts";
8
+ import { getKimiProviderHeaders } from "./device.ts";
9
9
 
10
- export interface KimiOAuthExtras {
10
+ export interface KimiModelMetadata {
11
11
  wireModelId?: string;
12
12
  modelDisplay?: string;
13
13
  contextLength?: number;
@@ -15,6 +15,13 @@ export interface KimiOAuthExtras {
15
15
  supportsImageIn?: boolean;
16
16
  supportsVideoIn?: boolean;
17
17
  supportsThinkingType?: "only" | "no" | "both";
18
+ protocol?: KimiWireProtocol;
19
+ supportEfforts?: string[];
20
+ defaultEffort?: string;
21
+ }
22
+
23
+ export interface KimiOAuthExtras extends KimiModelMetadata {
24
+ modelCatalog?: Record<string, KimiModelMetadata>;
18
25
  }
19
26
 
20
27
  export type KimiOAuthCredentials = OAuthCredentials & KimiOAuthExtras;
@@ -47,6 +54,9 @@ function mergeInputModalities(
47
54
  const COST_STANDARD = { input: 0.897, output: 3.724, cacheRead: 0.179, cacheWrite: 0.897 };
48
55
  const COST_HIGH_SPEED = { input: 1.793, output: 7.448, cacheRead: 0.359, cacheWrite: 1.793 };
49
56
 
57
+ export const KIMI_CODING_MODEL_ID = "kimi-for-coding";
58
+ export const KIMI_CODING_HIGHSPEED_MODEL_ID = "kimi-for-coding-highspeed";
59
+
50
60
  function resolveModelCost(modelDisplay: string | undefined): {
51
61
  input: number;
52
62
  output: number;
@@ -57,13 +67,17 @@ function resolveModelCost(modelDisplay: string | undefined): {
57
67
  return COST_STANDARD;
58
68
  }
59
69
 
60
- export function buildKimiModelFromConfig(config: ModelConfig): Model<Api> {
70
+ export function buildKimiModelFromConfig(
71
+ config: ModelConfig,
72
+ modelId = KIMI_CODING_MODEL_ID,
73
+ ): Model<Api> {
74
+ const isHighSpeed = modelId === KIMI_CODING_HIGHSPEED_MODEL_ID;
61
75
  return {
62
- id: "kimi-for-coding",
63
- name: "Kimi for Coding",
76
+ id: modelId,
77
+ name: isHighSpeed ? "Kimi for Coding High Speed" : "Kimi for Coding",
64
78
  reasoning: config.reasoning,
65
79
  input: [...config.input] as unknown as ("text" | "image" | "video")[],
66
- cost: { ...COST_STANDARD },
80
+ cost: { ...(isHighSpeed ? COST_HIGH_SPEED : COST_STANDARD) },
67
81
  contextWindow: config.contextWindow,
68
82
  maxTokens: config.maxTokens,
69
83
  } as Model<Api>;
@@ -86,6 +100,8 @@ export function resolveKimiModelConfig(
86
100
  if (typeof extras.supportsImageIn === "boolean" || typeof extras.supportsVideoIn === "boolean") {
87
101
  resolved.input = mergeInputModalities(resolved.input, extras);
88
102
  }
103
+ if (extras.supportEfforts) resolved.supportEfforts = [...extras.supportEfforts];
104
+ if (extras.defaultEffort) resolved.defaultEffort = extras.defaultEffort;
89
105
  return resolved;
90
106
  }
91
107
 
@@ -97,6 +113,8 @@ interface KimiServerModel {
97
113
  supports_image_in?: unknown;
98
114
  supports_video_in?: unknown;
99
115
  supports_thinking_type?: unknown;
116
+ protocol?: unknown;
117
+ think_efforts?: unknown;
100
118
  }
101
119
 
102
120
  function parseSupportsThinkingType(value: unknown): "only" | "no" | "both" | undefined {
@@ -104,6 +122,54 @@ function parseSupportsThinkingType(value: unknown): "only" | "no" | "both" | und
104
122
  return undefined;
105
123
  }
106
124
 
125
+ function isRecord(value: unknown): value is Record<string, unknown> {
126
+ return typeof value === "object" && value !== null && !Array.isArray(value);
127
+ }
128
+
129
+ function parseThinkEfforts(value: unknown): {
130
+ supportEfforts?: string[];
131
+ defaultEffort?: string;
132
+ } {
133
+ if (!isRecord(value) || value.support !== true) return {};
134
+ const validEfforts = Array.isArray(value.valid_efforts)
135
+ ? value.valid_efforts.filter(
136
+ (effort): effort is string => typeof effort === "string" && !!effort,
137
+ )
138
+ : [];
139
+ return {
140
+ ...(validEfforts.length > 0 ? { supportEfforts: validEfforts } : {}),
141
+ ...(typeof value.default_effort === "string" && value.default_effort
142
+ ? { defaultEffort: value.default_effort }
143
+ : {}),
144
+ };
145
+ }
146
+
147
+ function parseKimiModelMetadata(model: KimiServerModel): KimiModelMetadata | undefined {
148
+ if (typeof model.id !== "string" || !model.id) return undefined;
149
+ const metadata: KimiModelMetadata = { wireModelId: model.id };
150
+ if (typeof model.display_name === "string") metadata.modelDisplay = model.display_name;
151
+ if (typeof model.context_length === "number" && model.context_length > 0) {
152
+ metadata.contextLength = model.context_length;
153
+ }
154
+ const thinkingType = parseSupportsThinkingType(model.supports_thinking_type);
155
+ if (thinkingType) {
156
+ metadata.supportsThinkingType = thinkingType;
157
+ } else if (typeof model.supports_reasoning === "boolean") {
158
+ metadata.supportsReasoning = model.supports_reasoning;
159
+ }
160
+ if (typeof model.supports_image_in === "boolean") {
161
+ metadata.supportsImageIn = model.supports_image_in;
162
+ }
163
+ if (typeof model.supports_video_in === "boolean") {
164
+ metadata.supportsVideoIn = model.supports_video_in;
165
+ }
166
+ if (model.protocol === "openai" || model.protocol === "anthropic") {
167
+ metadata.protocol = model.protocol;
168
+ }
169
+ Object.assign(metadata, parseThinkEfforts(model.think_efforts));
170
+ return metadata;
171
+ }
172
+
107
173
  export function buildModelsUrl(baseUrl: string): string {
108
174
  const base = baseUrl.replace(/\/+$/, "");
109
175
  return base.endsWith("/v1") ? `${base}/models` : `${base}/v1/models`;
@@ -127,7 +193,7 @@ export async function discoverKimiModelMetadata(
127
193
  const response = await fetch(getModelsUrl(protocol), {
128
194
  signal: controller.signal,
129
195
  headers: {
130
- ...getCommonHeaders(),
196
+ ...getKimiProviderHeaders(),
131
197
  Authorization: `Bearer ${accessToken}`,
132
198
  Accept: "application/json",
133
199
  },
@@ -135,26 +201,18 @@ export async function discoverKimiModelMetadata(
135
201
  if (!response.ok) return {};
136
202
  const json = (await response.json()) as { data?: unknown };
137
203
  const list = Array.isArray(json.data) ? (json.data as KimiServerModel[]) : [];
138
- const preferred = list.find((m) => typeof m.id === "string" && m.id === "kimi-for-coding");
139
- if (!preferred || typeof preferred.id !== "string") return {};
140
- const extras: KimiOAuthExtras = { wireModelId: preferred.id };
141
- if (typeof preferred.display_name === "string") extras.modelDisplay = preferred.display_name;
142
- if (typeof preferred.context_length === "number" && preferred.context_length > 0) {
143
- extras.contextLength = preferred.context_length;
144
- }
145
- const thinkingType = parseSupportsThinkingType(preferred.supports_thinking_type);
146
- if (thinkingType) {
147
- extras.supportsThinkingType = thinkingType;
148
- } else if (typeof preferred.supports_reasoning === "boolean") {
149
- extras.supportsReasoning = preferred.supports_reasoning;
150
- }
151
- if (typeof preferred.supports_image_in === "boolean") {
152
- extras.supportsImageIn = preferred.supports_image_in;
204
+ const supportedIds = new Set([KIMI_CODING_MODEL_ID, KIMI_CODING_HIGHSPEED_MODEL_ID]);
205
+ const modelCatalog: Record<string, KimiModelMetadata> = {};
206
+ for (const model of list) {
207
+ if (typeof model.id !== "string" || !supportedIds.has(model.id)) continue;
208
+ const metadata = parseKimiModelMetadata(model);
209
+ if (metadata) modelCatalog[model.id] = metadata;
153
210
  }
154
- if (typeof preferred.supports_video_in === "boolean") {
155
- extras.supportsVideoIn = preferred.supports_video_in;
211
+ const preferred = modelCatalog[KIMI_CODING_MODEL_ID];
212
+ if (!preferred) {
213
+ return Object.keys(modelCatalog).length > 0 ? { modelCatalog } : {};
156
214
  }
157
- return extras;
215
+ return { ...preferred, modelCatalog };
158
216
  } catch {
159
217
  return {};
160
218
  } finally {
@@ -162,12 +220,23 @@ export async function discoverKimiModelMetadata(
162
220
  }
163
221
  }
164
222
 
223
+ export function getKimiModelMetadata(extras: KimiOAuthExtras, modelId: string): KimiModelMetadata {
224
+ const discovered = extras.modelCatalog?.[modelId];
225
+ if (discovered) return discovered;
226
+ return modelId === KIMI_CODING_MODEL_ID ? extras : {};
227
+ }
228
+
165
229
  export function applyKimiOAuthExtrasToModel(
166
230
  model: Model<Api>,
167
- extras: KimiOAuthExtras,
231
+ extras: KimiModelMetadata,
168
232
  ): Model<Api> {
169
- const next: Model<Api> & { wireModelId?: string; supportsThinkingType?: "only" | "no" | "both" } =
170
- { ...model };
233
+ const next: Model<Api> & {
234
+ wireModelId?: string;
235
+ supportsThinkingType?: "only" | "no" | "both";
236
+ wireProtocol?: KimiWireProtocol;
237
+ supportEfforts?: string[];
238
+ defaultEffort?: string;
239
+ } = { ...model };
171
240
  if (typeof extras.modelDisplay === "string" && extras.modelDisplay) {
172
241
  next.name = extras.modelDisplay;
173
242
  next.cost = resolveModelCost(extras.modelDisplay);
@@ -189,5 +258,8 @@ export function applyKimiOAuthExtrasToModel(
189
258
  const input = mergeInputModalities(next.input as KimiInputModality[], extras);
190
259
  (next as unknown as { input: string[] }).input = input;
191
260
  }
261
+ if (extras.protocol) next.wireProtocol = extras.protocol;
262
+ if (extras.supportEfforts) next.supportEfforts = [...extras.supportEfforts];
263
+ if (extras.defaultEffort) next.defaultEffort = extras.defaultEffort;
192
264
  return next;
193
265
  }
package/src/oauth.ts CHANGED
@@ -2,9 +2,9 @@
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, readFileSync } from "node:fs";
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
6
  import os from "node:os";
7
- import { join } from "node:path";
7
+ import { dirname, join } from "node:path";
8
8
 
9
9
  import type { OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai";
10
10
  import type { OAuthCredential } from "@earendil-works/pi-coding-agent";
@@ -123,6 +123,16 @@ interface RefreshAccessTokenOptions {
123
123
  sleep?: (ms: number) => Promise<void>;
124
124
  }
125
125
 
126
+ export const KIMI_LOGIN_REQUIRED_MESSAGE =
127
+ "Kimi Code authorization is no longer valid. Sign in again with /login kimi-coding.";
128
+
129
+ export class KimiLoginRequiredError extends Error {
130
+ constructor() {
131
+ super(KIMI_LOGIN_REQUIRED_MESSAGE);
132
+ this.name = "KimiLoginRequiredError";
133
+ }
134
+ }
135
+
126
136
  function sleep(ms: number): Promise<void> {
127
137
  return new Promise((resolve) => setTimeout(resolve, ms));
128
138
  }
@@ -152,8 +162,15 @@ export async function refreshAccessToken(
152
162
 
153
163
  if (!response.ok) {
154
164
  const text = await response.text().catch(() => "");
155
- if (response.status === 401 || response.status === 403) {
156
- throw new Error(`Token refresh unauthorized: ${text}`);
165
+ let errorCode = "";
166
+ try {
167
+ const body = JSON.parse(text) as { error?: unknown };
168
+ if (typeof body.error === "string") errorCode = body.error;
169
+ } catch {
170
+ // Non-JSON OAuth errors are classified by status below.
171
+ }
172
+ if (response.status === 401 || response.status === 403 || errorCode === "invalid_grant") {
173
+ throw new KimiLoginRequiredError();
157
174
  }
158
175
  if (RETRYABLE_REFRESH_STATUSES.has(response.status)) {
159
176
  throw new Error(`Token refresh retryable: ${response.status} ${text}`);
@@ -170,7 +187,7 @@ export async function refreshAccessToken(
170
187
  } catch (error) {
171
188
  lastError = error;
172
189
  const message = error instanceof Error ? error.message : String(error);
173
- if (message.startsWith("Token refresh unauthorized:")) throw error;
190
+ if (error instanceof KimiLoginRequiredError) throw error;
174
191
  if (message.startsWith("Token refresh failed:")) throw error;
175
192
  if (attempt < maxRetries - 1) {
176
193
  await wait(2 ** attempt * 1000);
@@ -196,15 +213,19 @@ interface KimiCliCredentialsFile {
196
213
  access_token?: string;
197
214
  refresh_token?: string;
198
215
  expires_at?: number; // Unix seconds (upstream convention)
216
+ scope?: string;
217
+ token_type?: string;
218
+ expires_in?: number;
199
219
  }
200
220
 
201
- function getKimiCredentialPaths(): string[] {
221
+ function getKimiCodeCredentialPath(): string {
202
222
  const kimiCodeHome = process.env.KIMI_CODE_HOME || join(os.homedir(), ".kimi-code");
223
+ return join(kimiCodeHome, "credentials", "kimi-code.json");
224
+ }
225
+
226
+ function getKimiCredentialPaths(): string[] {
203
227
  const shareDir = process.env.KIMI_SHARE_DIR || join(os.homedir(), ".kimi");
204
- return [
205
- join(kimiCodeHome, "credentials", "kimi-code.json"),
206
- join(shareDir, "credentials", "kimi-code.json"),
207
- ];
228
+ return [getKimiCodeCredentialPath(), join(shareDir, "credentials", "kimi-code.json")];
208
229
  }
209
230
 
210
231
  function readKimiCliCredentials(): KimiCliCredentialsFile | null {
@@ -221,6 +242,25 @@ function readKimiCliCredentials(): KimiCliCredentialsFile | null {
221
242
  return null;
222
243
  }
223
244
 
245
+ function kimiCodeCredentialExists(): boolean {
246
+ return existsSync(getKimiCodeCredentialPath());
247
+ }
248
+
249
+ function writeKimiCodeCredentials(access: string, refresh: string, expiresMs: number): void {
250
+ const path = getKimiCodeCredentialPath();
251
+ const dir = dirname(path);
252
+ mkdirSync(dir, { recursive: true });
253
+ const data: KimiCliCredentialsFile = {
254
+ access_token: access,
255
+ refresh_token: refresh,
256
+ expires_at: Math.floor(expiresMs / 1000),
257
+ scope: "",
258
+ token_type: "Bearer",
259
+ expires_in: Math.max(0, Math.floor((expiresMs - Date.now()) / 1000)),
260
+ };
261
+ writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
262
+ }
263
+
224
264
  async function tryReuseKimiCliCredentials(
225
265
  callbacks: OAuthLoginCallbacks,
226
266
  ): Promise<KimiOAuthCredentials | null> {
@@ -247,11 +287,13 @@ async function tryReuseKimiCliCredentials(
247
287
  callbacks.onProgress?.("Kimi access token expired, refreshing.");
248
288
  try {
249
289
  const token = await refreshAccessToken(data.refresh_token!);
290
+ const expiresMs = Date.now() + token.expires_in * 1000;
291
+ writeKimiCodeCredentials(token.access_token, token.refresh_token, expiresMs);
250
292
  const extras = await discoverKimiModelMetadata(token.access_token);
251
293
  return {
252
294
  access: token.access_token,
253
295
  refresh: token.refresh_token,
254
- expires: Date.now() + token.expires_in * 1000,
296
+ expires: expiresMs,
255
297
  ...extras,
256
298
  };
257
299
  } catch {
@@ -319,11 +361,13 @@ export async function loginKimiCode(callbacks: OAuthLoginCallbacks): Promise<Kim
319
361
  }
320
362
 
321
363
  if (token) {
364
+ const expiresMs = Date.now() + token.expires_in * 1000;
365
+ writeKimiCodeCredentials(token.access_token, token.refresh_token, expiresMs);
322
366
  const extras = await discoverKimiModelMetadata(token.access_token);
323
367
  return {
324
368
  access: token.access_token,
325
369
  refresh: token.refresh_token,
326
- expires: Date.now() + token.expires_in * 1000,
370
+ expires: expiresMs,
327
371
  ...extras,
328
372
  };
329
373
  }
@@ -335,12 +379,24 @@ export async function loginKimiCode(callbacks: OAuthLoginCallbacks): Promise<Kim
335
379
  export async function refreshKimiCodeToken(
336
380
  credentials: OAuthCredentials,
337
381
  ): Promise<KimiOAuthCredentials> {
338
- const token = await refreshAccessToken(credentials.refresh);
382
+ const kimiCred = kimiCodeCredentialExists() ? readKimiCliCredentials() : null;
383
+ let token: Awaited<ReturnType<typeof refreshAccessToken>>;
384
+ try {
385
+ token = await refreshAccessToken(credentials.refresh);
386
+ } catch (error) {
387
+ if (kimiCred?.refresh_token && kimiCred.refresh_token !== credentials.refresh) {
388
+ token = await refreshAccessToken(kimiCred.refresh_token);
389
+ } else {
390
+ throw error;
391
+ }
392
+ }
393
+ const expiresMs = Date.now() + token.expires_in * 1000;
394
+ writeKimiCodeCredentials(token.access_token, token.refresh_token, expiresMs);
339
395
  const extras = await discoverKimiModelMetadata(token.access_token);
340
396
  return {
341
397
  access: token.access_token,
342
398
  refresh: token.refresh_token,
343
- expires: Date.now() + token.expires_in * 1000,
399
+ expires: expiresMs,
344
400
  ...extras,
345
401
  };
346
402
  }
@@ -356,6 +412,10 @@ export async function refreshKimiCodeToken(
356
412
  // new credentials under a file lock), and retry once.
357
413
  // =============================================================================
358
414
 
415
+ export function getKimiApiKey(credentials: OAuthCredentials): string {
416
+ return credentials.access;
417
+ }
418
+
359
419
  export function isKimiAuthErrorMessage(message: unknown): boolean {
360
420
  const text = String(message ?? "").toLowerCase();
361
421
  return (
@@ -368,37 +428,79 @@ export function isKimiAuthErrorMessage(message: unknown): boolean {
368
428
 
369
429
  export async function refreshKimiAuthToken(currentKey: string): Promise<string | null> {
370
430
  try {
431
+ const kimiCred = readKimiCliCredentials();
371
432
  const storage = AuthStorage.create();
372
- const cred = storage.get(PROVIDER_ID);
373
- if (!cred || cred.type !== "oauth") {
433
+ const piCred = storage.get(PROVIDER_ID);
434
+ const piOAuth = piCred?.type === "oauth" ? piCred : null;
435
+
436
+ if (piOAuth) {
437
+ if (piOAuth.access !== currentKey && Date.now() < piOAuth.expires) {
438
+ console.error("[kimi-coding] auth refresh: reusing newer on-disk token");
439
+ return piOAuth.access;
440
+ }
441
+
442
+ console.error("[kimi-coding] auth refresh: requesting new access token");
443
+ let refreshed: Awaited<ReturnType<typeof refreshAccessToken>>;
444
+ try {
445
+ refreshed = await refreshAccessToken(piOAuth.refresh);
446
+ } catch (error) {
447
+ if (!kimiCred?.refresh_token || kimiCred.refresh_token === piOAuth.refresh) throw error;
448
+ const kimiExpiresMs = (kimiCred.expires_at ?? 0) * 1000;
449
+ if (kimiCred.access_token !== currentKey && Date.now() < kimiExpiresMs) {
450
+ const recovered: OAuthCredential = {
451
+ ...piOAuth,
452
+ type: "oauth",
453
+ access: kimiCred.access_token!,
454
+ refresh: kimiCred.refresh_token,
455
+ expires: kimiExpiresMs,
456
+ };
457
+ storage.set(PROVIDER_ID, recovered);
458
+ console.error("[kimi-coding] auth refresh: recovered newer kimi-code token");
459
+ return recovered.access;
460
+ }
461
+ console.error("[kimi-coding] auth refresh: pi token rejected, trying kimi-code token");
462
+ refreshed = await refreshAccessToken(kimiCred.refresh_token);
463
+ }
464
+
465
+ const newExpiresMs = Date.now() + refreshed.expires_in * 1000;
466
+ const newCred: OAuthCredential = {
467
+ ...piOAuth,
468
+ type: "oauth",
469
+ access: refreshed.access_token,
470
+ refresh: refreshed.refresh_token,
471
+ expires: newExpiresMs,
472
+ };
473
+ storage.set(PROVIDER_ID, newCred);
474
+ writeKimiCodeCredentials(refreshed.access_token, refreshed.refresh_token, newExpiresMs);
475
+ console.error("[kimi-coding] auth refresh: new token persisted");
476
+ return newCred.access;
477
+ }
478
+
479
+ if (!kimiCred) {
374
480
  console.error(
375
481
  `[kimi-coding] auth refresh skipped: no OAuth credentials for ${PROVIDER_ID} on disk`,
376
482
  );
377
483
  return null;
378
484
  }
379
485
 
380
- // If disk already has a different valid token (e.g., another process or a
381
- // previous retry refreshed it while the caller's in-memory cache went
382
- // stale), reuse it without hitting the OAuth endpoint.
383
- if (cred.access !== currentKey && Date.now() < cred.expires) {
384
- console.error("[kimi-coding] auth refresh: reusing newer on-disk token");
385
- return cred.access;
486
+ const kimiExpiresMs = (kimiCred.expires_at ?? 0) * 1000;
487
+ if (kimiCred.access_token !== currentKey && Date.now() < kimiExpiresMs) {
488
+ console.error("[kimi-coding] auth refresh: reusing newer kimi-code token");
489
+ return kimiCred.access_token!;
386
490
  }
387
491
 
388
- console.error("[kimi-coding] auth refresh: requesting new access token");
389
- const refreshed = await refreshAccessToken(cred.refresh);
390
- const newCred: OAuthCredential = {
391
- ...cred,
392
- type: "oauth",
393
- access: refreshed.access_token,
394
- refresh: refreshed.refresh_token,
395
- expires: Date.now() + refreshed.expires_in * 1000,
396
- };
397
- storage.set(PROVIDER_ID, newCred);
398
- console.error("[kimi-coding] auth refresh: new token persisted");
399
- return newCred.access;
492
+ console.error("[kimi-coding] auth refresh: requesting new access token via kimi-code");
493
+ const refreshed = await refreshAccessToken(kimiCred.refresh_token!);
494
+ const newExpiresMs = Date.now() + refreshed.expires_in * 1000;
495
+ writeKimiCodeCredentials(refreshed.access_token, refreshed.refresh_token, newExpiresMs);
496
+ console.error("[kimi-coding] auth refresh: new token persisted to kimi-code");
497
+ return refreshed.access_token;
400
498
  } catch (err) {
401
- console.error("[kimi-coding] auth refresh failed:", err);
499
+ if (err instanceof KimiLoginRequiredError) {
500
+ console.error(`[kimi-coding] ${KIMI_LOGIN_REQUIRED_MESSAGE}`);
501
+ } else {
502
+ console.error("[kimi-coding] auth refresh failed:", err);
503
+ }
402
504
  return null;
403
505
  }
404
506
  }
package/src/payload.ts CHANGED
@@ -6,7 +6,7 @@ import type { CacheRetention, ThinkingLevel } from "@earendil-works/pi-ai";
6
6
  import type { KimiResolvedModelConfig, ModelReasoningEntry } from "./config.ts";
7
7
 
8
8
  import { getBaseUrl } from "./constants.ts";
9
- import { getCommonHeaders } from "./device.ts";
9
+ import { getKimiProviderHeaders } from "./device.ts";
10
10
  import { optimizeToolSchemas } from "./schema-dedup.ts";
11
11
 
12
12
  // =============================================================================
@@ -116,7 +116,7 @@ export async function uploadKimiFile(
116
116
  try {
117
117
  const response = await fetch(uploadUrl, {
118
118
  method: "POST",
119
- headers: { Authorization: `Bearer ${apiKey}`, ...getCommonHeaders() },
119
+ headers: { ...getKimiProviderHeaders(), Authorization: `Bearer ${apiKey}` },
120
120
  body: formData,
121
121
  });
122
122
  if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
@@ -425,7 +425,19 @@ export async function applyKimiPayloadMutations(
425
425
  : { include_usage: true };
426
426
  }
427
427
 
428
- // 5. Normalize deprecated max_tokens (OpenAI path only — Anthropic
428
+ // 5. Spread extra_body into the top-level payload before normalization and
429
+ // config caps. Top-level fields retain precedence over extra_body.
430
+ if (isRecord(payload.extra_body)) {
431
+ const extraBody = payload.extra_body as JsonRecord;
432
+ delete payload.extra_body;
433
+ for (const [key, value] of Object.entries(extraBody)) {
434
+ if (payload[key] === undefined) {
435
+ payload[key] = value;
436
+ }
437
+ }
438
+ }
439
+
440
+ // 6. Normalize deprecated max_tokens (OpenAI path only — Anthropic
429
441
  // /messages uses max_tokens natively).
430
442
  if (ctx.api === "openai-completions") {
431
443
  if (payload.max_completion_tokens === undefined && typeof payload.max_tokens === "number") {
@@ -438,44 +450,45 @@ export async function applyKimiPayloadMutations(
438
450
  if (generation.temperature !== undefined) payload.temperature = generation.temperature;
439
451
  if (generation.topP !== undefined) payload.top_p = generation.topP;
440
452
  if (generation.maxCompletionTokens !== undefined) {
441
- payload.max_completion_tokens = generation.maxCompletionTokens;
453
+ const maxTokensKey = ctx.api === "anthropic-messages" ? "max_tokens" : "max_completion_tokens";
454
+ const currentMaxTokens = payload[maxTokensKey];
455
+ payload[maxTokensKey] =
456
+ typeof currentMaxTokens === "number"
457
+ ? Math.min(currentMaxTokens, generation.maxCompletionTokens)
458
+ : generation.maxCompletionTokens;
442
459
  }
443
460
 
444
- // 5. Spread extra_body into top-level payload before reasoning mapping,
445
- // matching upstream (Kimi expects thinking etc. at the root).
446
- if (isRecord(payload.extra_body)) {
447
- const extraBody = payload.extra_body as JsonRecord;
448
- delete payload.extra_body;
449
- for (const [key, value] of Object.entries(extraBody)) {
450
- if (payload[key] === undefined) {
451
- payload[key] = value;
452
- }
453
- }
461
+ // 7. Reasoning effort mapping. Kimi now accepts effort only inside the
462
+ // thinking object, and only for values advertised by the model catalog.
463
+ delete payload.reasoning_effort;
464
+ if (ctx.modelConfig.supportsThinkingType === "no") {
465
+ delete payload.thinking;
454
466
  }
455
-
456
- // 6. Reasoning effort mapping. Merges into any existing top-level thinking
457
- // field (which may have come from extra_body above).
458
467
  const resolvedReasoning = resolveThinkingLevel(ctx);
459
468
  if (resolvedReasoning) {
460
469
  const mapped = resolveReasoningForLevel(resolvedReasoning, ctx.modelConfig);
461
470
  if (mapped) {
462
- if (mapped.effort !== null) {
463
- payload.reasoning_effort = mapped.effort;
464
- } else {
465
- delete payload.reasoning_effort;
466
- }
467
471
  const oldThinking = isRecord(payload.thinking) ? payload.thinking : {};
468
- payload.thinking = {
472
+ const thinking: JsonRecord = {
469
473
  ...oldThinking,
470
474
  type: mapped.enabled ? "enabled" : "disabled",
471
475
  };
476
+ delete thinking.effort;
477
+ if (!mapped.enabled) delete thinking.keep;
478
+ const effort = ctx.reasoning
479
+ ? mapped.effort
480
+ : (ctx.modelConfig.defaultEffort ?? mapped.effort);
481
+ if (mapped.enabled && effort !== null && ctx.modelConfig.supportEfforts?.includes(effort)) {
482
+ thinking.effort = effort;
483
+ }
472
484
  if (mapped.enabled && ctx.modelConfig.thinkingKeep) {
473
- (payload.thinking as JsonRecord).keep = ctx.modelConfig.thinkingKeep;
485
+ thinking.keep = ctx.modelConfig.thinkingKeep;
474
486
  }
487
+ payload.thinking = thinking;
475
488
  }
476
489
  }
477
490
 
478
- // 7. K2.7 Code API constraints: the server rejects non-default values for
491
+ // 8. K2.7 Code API constraints: the server rejects non-default values for
479
492
  // temperature (must be 1.0) and top_p (must be 0.95), and tool_choice
480
493
  // "required" / function-specific when thinking is enabled (always-on).
481
494
  if (payload.temperature !== undefined && payload.temperature !== 1) {
package/src/stream.ts CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  } from "./config.ts";
25
25
 
26
26
  import { ENV_KIMI_CODE_PROTOCOL, getApiProtocol, getBaseUrl } from "./constants.ts";
27
- import { getCommonHeaders } from "./device.ts";
27
+ import { getKimiProviderHeaders } from "./device.ts";
28
28
  import { isKimiAuthErrorMessage, refreshKimiAuthToken } from "./oauth.ts";
29
29
  import {
30
30
  type Uploader,
@@ -163,7 +163,7 @@ export function resolveKimiApiKey(apiKey: string | undefined): string {
163
163
  }
164
164
 
165
165
  export function mergeKimiRequestHeaders(headers?: Record<string, string>): Record<string, string> {
166
- return { ...getCommonHeaders(), ...headers };
166
+ return { ...getKimiProviderHeaders(), ...headers };
167
167
  }
168
168
 
169
169
  export function streamSimpleKimi(
@@ -192,8 +192,27 @@ export function streamSimpleKimi(
192
192
  protocol: ENV_KIMI_CODE_PROTOCOL,
193
193
  uploads: DEFAULT_KIMI_CODE_CONFIG.uploads,
194
194
  };
195
- const modelConfig = streamConfig.model;
196
- const apiProtocol = getApiProtocol(streamConfig.protocol);
195
+ const discoveredModel = model as Model<Api> & {
196
+ supportsThinkingType?: "only" | "no" | "both";
197
+ wireProtocol?: KimiCodeConfig["protocol"];
198
+ supportEfforts?: string[];
199
+ defaultEffort?: string;
200
+ };
201
+ const modelConfig: KimiResolvedModelConfig = {
202
+ ...streamConfig.model,
203
+ contextWindow: model.contextWindow,
204
+ maxTokens: model.maxTokens,
205
+ input: [...model.input],
206
+ reasoning: model.reasoning,
207
+ supportsThinkingType:
208
+ discoveredModel.supportsThinkingType ?? (model.reasoning ? undefined : "no"),
209
+ supportEfforts: discoveredModel.supportEfforts
210
+ ? [...discoveredModel.supportEfforts]
211
+ : undefined,
212
+ defaultEffort: discoveredModel.defaultEffort,
213
+ };
214
+ const wireProtocol = discoveredModel.wireProtocol ?? streamConfig.protocol;
215
+ const apiProtocol = getApiProtocol(wireProtocol);
197
216
  const originalOnPayload = options?.onPayload;
198
217
  // The pi-side model id ("kimi-for-coding") is what users select via /model
199
218
  // and what gets persisted into sessions. The wire model id discovered at
@@ -257,10 +276,10 @@ export function streamSimpleKimi(
257
276
  const runtimeModel = {
258
277
  ...model,
259
278
  api: apiProtocol,
260
- baseUrl: getBaseUrl(streamConfig.protocol),
279
+ baseUrl: getBaseUrl(wireProtocol),
261
280
  } as Model<Api>;
262
281
  const upstream =
263
- streamConfig.protocol === "openai"
282
+ wireProtocol === "openai"
264
283
  ? streamSimpleOpenAICompletions(
265
284
  runtimeModel as Model<"openai-completions">,
266
285
  context,
@@ -2,8 +2,8 @@ import { AuthStorage, type AgentToolResult } from "@earendil-works/pi-coding-age
2
2
  import { truncateToWidth } from "@earendil-works/pi-tui";
3
3
 
4
4
  import { PROVIDER_ID, getBaseUrl } from "../constants.ts";
5
- import { getCommonHeaders } from "../device.ts";
6
- import { refreshKimiAuthToken } from "../oauth.ts";
5
+ import { getKimiProviderHeaders } from "../device.ts";
6
+ import { KIMI_LOGIN_REQUIRED_MESSAGE, refreshKimiAuthToken } from "../oauth.ts";
7
7
 
8
8
  export const KIMI_TOOL_TIMEOUT_MS = 180_000;
9
9
 
@@ -67,7 +67,7 @@ export function getKimiDatasourceUrl(): string {
67
67
 
68
68
  export function buildHeaders(accessToken: string, toolCallId: string): Record<string, string> {
69
69
  return {
70
- ...getCommonHeaders(),
70
+ ...getKimiProviderHeaders(),
71
71
  Authorization: `Bearer ${accessToken}`,
72
72
  "Content-Type": "application/json",
73
73
  "X-Msh-Tool-Call-Id": toolCallId,
@@ -94,7 +94,9 @@ export async function fetchWithAuthRetry(
94
94
  if (response.status !== 401) return response;
95
95
 
96
96
  const refreshed = await deps.refreshAccessToken(accessToken);
97
- if (!refreshed || refreshed === accessToken) return response;
97
+ if (!refreshed || refreshed === accessToken) {
98
+ throw new Error(KIMI_LOGIN_REQUIRED_MESSAGE);
99
+ }
98
100
  return request(refreshed);
99
101
  }
100
102
 
@@ -17,20 +17,6 @@ import {
17
17
 
18
18
  export const moonshotSearchSchema = Type.Object({
19
19
  query: Type.String({ description: "The query text to search for." }),
20
- limit: Type.Optional(
21
- Type.Number({
22
- description: "The number of results to return.",
23
- default: 5,
24
- minimum: 1,
25
- maximum: 20,
26
- }),
27
- ),
28
- include_content: Type.Optional(
29
- Type.Boolean({
30
- description: "Whether to include fetched page content in the search results.",
31
- default: false,
32
- }),
33
- ),
34
20
  });
35
21
 
36
22
  export const moonshotFetchSchema = Type.Object({
@@ -44,7 +30,8 @@ export interface MoonshotSearchResult {
44
30
  url: string;
45
31
  title: string;
46
32
  snippet: string;
47
- content?: string;
33
+ date?: string;
34
+ siteName?: string;
48
35
  }
49
36
 
50
37
  export interface MoonshotFetchResult {
@@ -52,11 +39,6 @@ export interface MoonshotFetchResult {
52
39
  content: string;
53
40
  }
54
41
 
55
- function clampLimit(value: number | undefined): number {
56
- if (typeof value !== "number" || !Number.isFinite(value)) return 5;
57
- return Math.max(1, Math.min(20, Math.trunc(value)));
58
- }
59
-
60
42
  interface MoonshotSearchResponse {
61
43
  search_results?: unknown;
62
44
  }
@@ -66,7 +48,7 @@ function stringField(record: Record<string, unknown>, key: string): string {
66
48
  return typeof value === "string" ? value : "";
67
49
  }
68
50
 
69
- function mapSearchResults(value: unknown, includeContent: boolean): MoonshotSearchResult[] {
51
+ function mapSearchResults(value: unknown): MoonshotSearchResult[] {
70
52
  if (!Array.isArray(value)) return [];
71
53
  return value
72
54
  .filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
@@ -76,9 +58,10 @@ function mapSearchResults(value: unknown, includeContent: boolean): MoonshotSear
76
58
  title: stringField(item, "title"),
77
59
  snippet: stringField(item, "snippet"),
78
60
  };
79
- if (includeContent) {
80
- result.content = stringField(item, "content");
81
- }
61
+ const date = stringField(item, "date");
62
+ if (date) result.date = date;
63
+ const siteName = stringField(item, "site_name");
64
+ if (siteName) result.siteName = siteName;
82
65
  return result;
83
66
  });
84
67
  }
@@ -130,8 +113,10 @@ export function buildMoonshotSearchTool(options: BuildKimiToolOptions = {}) {
130
113
  return defineTool({
131
114
  name: "moonshot_search",
132
115
  label: "Moonshot Search",
133
- description: "Search the web through Kimi Coding's server-side Moonshot search service.",
134
- promptSnippet: "Search the web with Kimi Coding's Moonshot search service",
116
+ description:
117
+ "Search the web through Kimi Coding. Results are summaries; use moonshot_fetch to read relevant pages.",
118
+ promptSnippet:
119
+ "Search the web with Kimi Coding, then fetch the few relevant result URLs before answering",
135
120
  parameters: moonshotSearchSchema,
136
121
 
137
122
  async execute(toolCallId, params, signal) {
@@ -142,20 +127,13 @@ export function buildMoonshotSearchTool(options: BuildKimiToolOptions = {}) {
142
127
  );
143
128
  }
144
129
 
145
- const limit = clampLimit(params.limit);
146
- const includeContent = params.include_content === true;
147
130
  const timeout = buildTimeoutSignal(signal);
148
131
  try {
149
132
  const response = await fetchWithAuthRetry(deps, accessToken, (token) =>
150
133
  deps.fetch(`${getKimiBaseV1()}/search`, {
151
134
  method: "POST",
152
135
  headers: buildHeaders(token, toolCallId),
153
- body: JSON.stringify({
154
- text_query: params.query,
155
- limit,
156
- enable_page_crawling: includeContent,
157
- timeout_seconds: 30,
158
- }),
136
+ body: JSON.stringify({ text_query: params.query }),
159
137
  signal: timeout.signal,
160
138
  }),
161
139
  );
@@ -168,7 +146,7 @@ export function buildMoonshotSearchTool(options: BuildKimiToolOptions = {}) {
168
146
  }
169
147
 
170
148
  const data = (await response.json()) as MoonshotSearchResponse;
171
- const results = mapSearchResults(data.search_results, includeContent);
149
+ const results = mapSearchResults(data.search_results);
172
150
  return {
173
151
  content: [{ type: "text", text: searchResultsText(results) }],
174
152
  details: results,
package/src/usage.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { AuthStorage } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  import { PROVIDER_ID, getBaseUrl } from "./constants.ts";
4
- import { getCommonHeaders } from "./device.ts";
4
+ import { getKimiProviderHeaders } from "./device.ts";
5
5
  import { refreshKimiAuthToken } from "./oauth.ts";
6
6
 
7
7
  const MEMBERSHIP_LEVEL_NAMES: Record<string, string> = {
@@ -36,6 +36,16 @@ export interface UsageFormatOptions {
36
36
  timeZone?: string;
37
37
  }
38
38
 
39
+ interface BoosterWalletInfo {
40
+ balanceCents: bigint;
41
+ monthlyChargeLimitEnabled: boolean;
42
+ monthlyChargeLimitCents: bigint;
43
+ monthlyUsedCents: bigint;
44
+ currency: string;
45
+ }
46
+
47
+ const FIXED_POINT_CENTS = 1_000_000n;
48
+
39
49
  export function getKimiUsageToken(): string | null {
40
50
  const credential = AuthStorage.create().get(PROVIDER_ID);
41
51
  if (credential?.type === "oauth" && credential.access) return credential.access;
@@ -62,6 +72,82 @@ function getResetTime(record: Record<string, unknown>): string | undefined {
62
72
  return undefined;
63
73
  }
64
74
 
75
+ function toBigInt(value: unknown): bigint | null {
76
+ if (typeof value === "bigint") return value;
77
+ if (typeof value === "string" && /^-?\d+$/.test(value)) return BigInt(value);
78
+ if (typeof value === "number" && Number.isSafeInteger(value)) return BigInt(value);
79
+ return null;
80
+ }
81
+
82
+ function fixedPointToCents(value: bigint): bigint {
83
+ if (value > 0n && value < FIXED_POINT_CENTS) return 1n;
84
+ return (value + FIXED_POINT_CENTS / 2n) / FIXED_POINT_CENTS;
85
+ }
86
+
87
+ function parseMoney(value: unknown): { cents: bigint; currency: string } | null {
88
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
89
+ const record = value as Record<string, unknown>;
90
+ const cents = toBigInt(record.priceInCents);
91
+ if (cents === null) return null;
92
+ return {
93
+ cents,
94
+ currency: typeof record.currency === "string" ? record.currency : "",
95
+ };
96
+ }
97
+
98
+ function parseBoosterWallet(value: unknown): BoosterWalletInfo | null {
99
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
100
+ const record = value as Record<string, unknown>;
101
+ const balance = record.balance;
102
+ if (typeof balance !== "object" || balance === null || Array.isArray(balance)) return null;
103
+ const balanceRecord = balance as Record<string, unknown>;
104
+ if (balanceRecord.type !== "BOOSTER") return null;
105
+ const amount = toBigInt(balanceRecord.amount);
106
+ if (amount === null || amount < 0n) return null;
107
+
108
+ const amountLeft = toBigInt(balanceRecord.amountLeft);
109
+ const monthlyLimit = parseMoney(record.monthlyChargeLimit);
110
+ const monthlyUsed = parseMoney(record.monthlyUsed);
111
+ return {
112
+ balanceCents: amountLeft === null ? 0n : fixedPointToCents(amountLeft),
113
+ monthlyChargeLimitEnabled: record.monthlyChargeLimitEnabled === true,
114
+ monthlyChargeLimitCents: monthlyLimit?.cents ?? 0n,
115
+ monthlyUsedCents: monthlyUsed?.cents ?? 0n,
116
+ currency: monthlyLimit?.currency || monthlyUsed?.currency || "USD",
117
+ };
118
+ }
119
+
120
+ function formatCurrency(cents: bigint, currency: string): string {
121
+ const symbol =
122
+ currency.toUpperCase() === "USD" ? "$" : currency.toUpperCase() === "CNY" ? "¥" : "";
123
+ const sign = cents < 0n ? "-" : "";
124
+ const absolute = cents < 0n ? -cents : cents;
125
+ const amount = `${sign}${absolute / 100n}.${String(absolute % 100n).padStart(2, "0")}`;
126
+ return symbol ? `${symbol}${amount}` : `${amount} ${currency}`.trim();
127
+ }
128
+
129
+ function formatExtraUsage(info: BoosterWalletInfo): string[] {
130
+ const hasMonthlyLimit = info.monthlyChargeLimitEnabled && info.monthlyChargeLimitCents > 0n;
131
+ const lines = ["Extra Usage"];
132
+ if (hasMonthlyLimit) {
133
+ const used =
134
+ info.monthlyUsedCents < info.monthlyChargeLimitCents
135
+ ? info.monthlyUsedCents
136
+ : info.monthlyChargeLimitCents;
137
+ const percent = Number(
138
+ (used * 100n + info.monthlyChargeLimitCents / 2n) / info.monthlyChargeLimitCents,
139
+ );
140
+ const barUnits = Number((used * 400n) / info.monthlyChargeLimitCents);
141
+ lines.push(`${quotaBar(barUnits, 400)} ${percent}% used`);
142
+ }
143
+ lines.push(`Used this month: ${formatCurrency(info.monthlyUsedCents, info.currency)}`);
144
+ lines.push(
145
+ `Monthly limit: ${hasMonthlyLimit ? formatCurrency(info.monthlyChargeLimitCents, info.currency) : "Unlimited"}`,
146
+ );
147
+ lines.push(`Balance: ${formatCurrency(info.balanceCents, info.currency)}`);
148
+ return lines;
149
+ }
150
+
65
151
  export function parseUsageRow(value: unknown, fallbackLabel: string): UsageRow | null {
66
152
  if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
67
153
  const record = value as Record<string, unknown>;
@@ -115,6 +201,12 @@ export function parseUsageSummary(payload: unknown, options: UsageFormatOptions
115
201
  }
116
202
  }
117
203
 
204
+ const extraUsage = parseBoosterWallet(record.boosterWallet);
205
+ if (extraUsage) {
206
+ if (lines.length > 0) lines.push("");
207
+ lines.push(...formatExtraUsage(extraUsage));
208
+ }
209
+
118
210
  while (lines.at(-1) === "") lines.pop();
119
211
  return lines.length === 0 ? "Usage: no usage data" : lines.join("\n");
120
212
  }
@@ -244,7 +336,7 @@ function fetchKimiUsage(token: string, signal: AbortSignal): Promise<Response> {
244
336
  return fetch(buildKimiUsageUrl(), {
245
337
  method: "GET",
246
338
  headers: {
247
- ...getCommonHeaders(),
339
+ ...getKimiProviderHeaders(),
248
340
  Authorization: `Bearer ${token}`,
249
341
  },
250
342
  signal,