pi-provider-kimi-code 0.6.3 → 0.6.6

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/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,67 @@ 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;
486
+ }
487
+ payload.thinking = thinking;
488
+ }
489
+ }
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 = "";
474
509
  }
475
510
  }
476
511
  }
477
512
 
478
- // 7. K2.7 Code API constraints: the server rejects non-default values for
513
+ // 8. K2.7 Code API constraints: the server rejects non-default values for
479
514
  // temperature (must be 1.0) and top_p (must be 0.95), and tool_choice
480
515
  // "required" / function-specific when thinking is enabled (always-on).
481
516
  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,21 @@ export interface UsageFormatOptions {
36
36
  timeZone?: string;
37
37
  }
38
38
 
39
+ export interface KimiUsageSnapshot {
40
+ summary: string;
41
+ membershipLevel: string | null;
42
+ }
43
+
44
+ interface BoosterWalletInfo {
45
+ balanceCents: bigint;
46
+ monthlyChargeLimitEnabled: boolean;
47
+ monthlyChargeLimitCents: bigint;
48
+ monthlyUsedCents: bigint;
49
+ currency: string;
50
+ }
51
+
52
+ const FIXED_POINT_CENTS = 1_000_000n;
53
+
39
54
  export function getKimiUsageToken(): string | null {
40
55
  const credential = AuthStorage.create().get(PROVIDER_ID);
41
56
  if (credential?.type === "oauth" && credential.access) return credential.access;
@@ -62,6 +77,82 @@ function getResetTime(record: Record<string, unknown>): string | undefined {
62
77
  return undefined;
63
78
  }
64
79
 
80
+ function toBigInt(value: unknown): bigint | null {
81
+ if (typeof value === "bigint") return value;
82
+ if (typeof value === "string" && /^-?\d+$/.test(value)) return BigInt(value);
83
+ if (typeof value === "number" && Number.isSafeInteger(value)) return BigInt(value);
84
+ return null;
85
+ }
86
+
87
+ function fixedPointToCents(value: bigint): bigint {
88
+ if (value > 0n && value < FIXED_POINT_CENTS) return 1n;
89
+ return (value + FIXED_POINT_CENTS / 2n) / FIXED_POINT_CENTS;
90
+ }
91
+
92
+ function parseMoney(value: unknown): { cents: bigint; currency: string } | null {
93
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
94
+ const record = value as Record<string, unknown>;
95
+ const cents = toBigInt(record.priceInCents);
96
+ if (cents === null) return null;
97
+ return {
98
+ cents,
99
+ currency: typeof record.currency === "string" ? record.currency : "",
100
+ };
101
+ }
102
+
103
+ function parseBoosterWallet(value: unknown): BoosterWalletInfo | null {
104
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
105
+ const record = value as Record<string, unknown>;
106
+ const balance = record.balance;
107
+ if (typeof balance !== "object" || balance === null || Array.isArray(balance)) return null;
108
+ const balanceRecord = balance as Record<string, unknown>;
109
+ if (balanceRecord.type !== "BOOSTER") return null;
110
+ const amount = toBigInt(balanceRecord.amount);
111
+ if (amount === null || amount < 0n) return null;
112
+
113
+ const amountLeft = toBigInt(balanceRecord.amountLeft);
114
+ const monthlyLimit = parseMoney(record.monthlyChargeLimit);
115
+ const monthlyUsed = parseMoney(record.monthlyUsed);
116
+ return {
117
+ balanceCents: amountLeft === null ? 0n : fixedPointToCents(amountLeft),
118
+ monthlyChargeLimitEnabled: record.monthlyChargeLimitEnabled === true,
119
+ monthlyChargeLimitCents: monthlyLimit?.cents ?? 0n,
120
+ monthlyUsedCents: monthlyUsed?.cents ?? 0n,
121
+ currency: monthlyLimit?.currency || monthlyUsed?.currency || "USD",
122
+ };
123
+ }
124
+
125
+ function formatCurrency(cents: bigint, currency: string): string {
126
+ const symbol =
127
+ currency.toUpperCase() === "USD" ? "$" : currency.toUpperCase() === "CNY" ? "¥" : "";
128
+ const sign = cents < 0n ? "-" : "";
129
+ const absolute = cents < 0n ? -cents : cents;
130
+ const amount = `${sign}${absolute / 100n}.${String(absolute % 100n).padStart(2, "0")}`;
131
+ return symbol ? `${symbol}${amount}` : `${amount} ${currency}`.trim();
132
+ }
133
+
134
+ function formatExtraUsage(info: BoosterWalletInfo): string[] {
135
+ const hasMonthlyLimit = info.monthlyChargeLimitEnabled && info.monthlyChargeLimitCents > 0n;
136
+ const lines = ["Extra Usage"];
137
+ if (hasMonthlyLimit) {
138
+ const used =
139
+ info.monthlyUsedCents < info.monthlyChargeLimitCents
140
+ ? info.monthlyUsedCents
141
+ : info.monthlyChargeLimitCents;
142
+ const percent = Number(
143
+ (used * 100n + info.monthlyChargeLimitCents / 2n) / info.monthlyChargeLimitCents,
144
+ );
145
+ const barUnits = Number((used * 400n) / info.monthlyChargeLimitCents);
146
+ lines.push(`${quotaBar(barUnits, 400)} ${percent}% used`);
147
+ }
148
+ lines.push(`Used this month: ${formatCurrency(info.monthlyUsedCents, info.currency)}`);
149
+ lines.push(
150
+ `Monthly limit: ${hasMonthlyLimit ? formatCurrency(info.monthlyChargeLimitCents, info.currency) : "Unlimited"}`,
151
+ );
152
+ lines.push(`Balance: ${formatCurrency(info.balanceCents, info.currency)}`);
153
+ return lines;
154
+ }
155
+
65
156
  export function parseUsageRow(value: unknown, fallbackLabel: string): UsageRow | null {
66
157
  if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
67
158
  const record = value as Record<string, unknown>;
@@ -115,6 +206,12 @@ export function parseUsageSummary(payload: unknown, options: UsageFormatOptions
115
206
  }
116
207
  }
117
208
 
209
+ const extraUsage = parseBoosterWallet(record.boosterWallet);
210
+ if (extraUsage) {
211
+ if (lines.length > 0) lines.push("");
212
+ lines.push(...formatExtraUsage(extraUsage));
213
+ }
214
+
118
215
  while (lines.at(-1) === "") lines.pop();
119
216
  return lines.length === 0 ? "Usage: no usage data" : lines.join("\n");
120
217
  }
@@ -140,7 +237,7 @@ function formatWindowLabel(value: unknown, fallbackLabel: string): string {
140
237
  return `Current ${minutes}m window`;
141
238
  }
142
239
 
143
- export function parseMembership(record: Record<string, unknown>): string | null {
240
+ export function parseMembershipLevel(record: Record<string, unknown>): string | null {
144
241
  const user = record.user;
145
242
  if (typeof user !== "object" || user === null || Array.isArray(user)) return null;
146
243
  const membership = (user as Record<string, unknown>).membership;
@@ -148,7 +245,12 @@ export function parseMembership(record: Record<string, unknown>): string | null
148
245
  return null;
149
246
  }
150
247
  const level = (membership as Record<string, unknown>).level;
151
- if (typeof level !== "string" || !level) return null;
248
+ return typeof level === "string" && level ? level : null;
249
+ }
250
+
251
+ export function parseMembership(record: Record<string, unknown>): string | null {
252
+ const level = parseMembershipLevel(record);
253
+ if (!level) return null;
152
254
  const name = MEMBERSHIP_LEVEL_NAMES[level];
153
255
  return name ? `Membership: ${name} (${level})` : `Membership: ${level}`;
154
256
  }
@@ -244,31 +346,49 @@ function fetchKimiUsage(token: string, signal: AbortSignal): Promise<Response> {
244
346
  return fetch(buildKimiUsageUrl(), {
245
347
  method: "GET",
246
348
  headers: {
247
- ...getCommonHeaders(),
349
+ ...getKimiProviderHeaders(),
248
350
  Authorization: `Bearer ${token}`,
249
351
  },
250
352
  signal,
251
353
  });
252
354
  }
253
355
 
254
- export async function fetchKimiUsageSummary(): Promise<string> {
255
- const token = getKimiUsageToken();
256
- if (!token) return "Usage: missing credentials. Run /login kimi-coding.";
356
+ export async function fetchKimiUsageSnapshot(
357
+ options: { timeoutMs?: number; token?: string; refreshOnUnauthorized?: boolean } = {},
358
+ ): Promise<KimiUsageSnapshot> {
359
+ const token = options.token ?? getKimiUsageToken();
360
+ if (!token) {
361
+ return {
362
+ summary: "Usage: missing credentials. Run /login kimi-coding.",
363
+ membershipLevel: null,
364
+ };
365
+ }
257
366
 
258
367
  const controller = new AbortController();
259
- const timeout = setTimeout(() => controller.abort(), 15_000);
368
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 15_000);
260
369
  try {
261
370
  let response = await fetchKimiUsage(token, controller.signal);
262
- if (response.status === 401) {
263
- const refreshed = await refreshKimiAuthToken(token);
371
+ if (response.status === 401 && options.refreshOnUnauthorized !== false) {
372
+ const refreshed = await refreshKimiAuthToken(token, { signal: controller.signal });
264
373
  if (refreshed) response = await fetchKimiUsage(refreshed, controller.signal);
265
374
  }
266
- if (!response.ok) return `Usage: fetch failed (${response.status})`;
267
- return parseUsageSummary(await response.json());
375
+ if (!response.ok) {
376
+ return { summary: `Usage: fetch failed (${response.status})`, membershipLevel: null };
377
+ }
378
+ const payload = (await response.json()) as unknown;
379
+ const membershipLevel =
380
+ typeof payload === "object" && payload !== null && !Array.isArray(payload)
381
+ ? parseMembershipLevel(payload as Record<string, unknown>)
382
+ : null;
383
+ return { summary: parseUsageSummary(payload), membershipLevel };
268
384
  } catch (error) {
269
385
  const message = error instanceof Error ? error.message : String(error);
270
- return `Usage: fetch failed (${message})`;
386
+ return { summary: `Usage: fetch failed (${message})`, membershipLevel: null };
271
387
  } finally {
272
388
  clearTimeout(timeout);
273
389
  }
274
390
  }
391
+
392
+ export async function fetchKimiUsageSummary(): Promise<string> {
393
+ return (await fetchKimiUsageSnapshot()).summary;
394
+ }