pi-provider-kimi-code 0.6.11 → 0.6.13

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.
@@ -58,6 +58,13 @@ export function setProtocol(
58
58
  return { ...config, protocol };
59
59
  }
60
60
 
61
+ export function setRegion(
62
+ config: KimiCodeConfig,
63
+ region: KimiCodeConfig["region"],
64
+ ): KimiCodeConfig {
65
+ return { ...config, region };
66
+ }
67
+
61
68
  export function setUploadThreshold(config: KimiCodeConfig, thresholdBytes: number): KimiCodeConfig {
62
69
  return { ...config, uploads: { ...config.uploads, thresholdBytes } };
63
70
  }
@@ -73,6 +80,7 @@ export function buildKimiMainTitle(
73
80
  "",
74
81
  `Model: ${modelName}`,
75
82
  `Protocol: ${config.protocol} (${sources.protocol})`,
83
+ `Region: ${formatRegionValue(config.region)} (${sources.region})`,
76
84
  `Upload threshold: ${formatByteSize(config.uploads.thresholdBytes)} (${sources.uploads.thresholdBytes})`,
77
85
  "",
78
86
  "Effective tools:",
@@ -138,6 +146,18 @@ export function toolMenuItem(config: KimiCodeConfig, toolName: KimiToolName): st
138
146
  return `${toolName} -> ${formatToolStatus(config, toolName)}`;
139
147
  }
140
148
 
149
+ export function formatRegionValue(region: KimiCodeConfig["region"]): string {
150
+ return region ?? "auto";
151
+ }
152
+
153
+ export function formatRegionStatus(config: KimiCodeConfig): string {
154
+ return `region: ${formatRegionValue(config.region)}`;
155
+ }
156
+
157
+ export function regionMenuItem(config: KimiCodeConfig): string {
158
+ return `Region -> ${formatRegionValue(config.region)}`;
159
+ }
160
+
141
161
  export function protocolMenuItem(config: KimiCodeConfig): string {
142
162
  return `Protocol -> ${config.protocol}`;
143
163
  }
package/src/stream.ts CHANGED
@@ -32,8 +32,10 @@ type KimiStreamSimple = (
32
32
  const piAiRuntime = piAi as unknown as {
33
33
  anthropicMessagesApi?: () => { streamSimple: KimiStreamSimple };
34
34
  openAICompletionsApi?: () => { streamSimple: KimiStreamSimple };
35
+ openAIResponsesApi?: () => { streamSimple: KimiStreamSimple };
35
36
  streamSimpleAnthropic?: KimiStreamSimple;
36
37
  streamSimpleOpenAICompletions?: KimiStreamSimple;
38
+ streamSimpleOpenAIResponses?: KimiStreamSimple;
37
39
  lazyApi?: (load: () => Promise<object>) => { streamSimple: KimiStreamSimple };
38
40
  };
39
41
 
@@ -43,22 +45,43 @@ const piAiRuntime = piAi as unknown as {
43
45
  // evaluated in plain-Node environments where the subpaths do resolve.
44
46
  const anthropicMessagesModule = "@earendil-works/pi-ai/api/anthropic-messages";
45
47
  const openAICompletionsModule = "@earendil-works/pi-ai/api/openai-completions";
48
+ const openAIResponsesModule = "@earendil-works/pi-ai/api/openai-responses";
46
49
 
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!;
50
+ // lazyApi is the last resort; it only exists on pi-ai versions that ship the
51
+ // ./api/* subpath modules. Undefined when none of the three lookup paths
52
+ // resolve (e.g. pi <=0.79 has no responses entry point at all).
53
+ function lazyStreamSimple(load: () => Promise<object>): KimiStreamSimple | undefined {
54
+ return piAiRuntime.lazyApi?.(load)?.streamSimple;
55
+ }
56
+
57
+ // Not readonly: tests swap entries to simulate runtimes missing an API.
58
+ const streamSimpleImpls: Record<KimiWireProtocol, KimiStreamSimple | undefined> = {
59
+ openai:
60
+ piAiRuntime.openAICompletionsApi?.().streamSimple ??
61
+ piAiRuntime.streamSimpleOpenAICompletions ??
62
+ lazyStreamSimple(() => import(openAICompletionsModule)),
63
+ anthropic:
64
+ piAiRuntime.anthropicMessagesApi?.().streamSimple ??
65
+ piAiRuntime.streamSimpleAnthropic ??
66
+ lazyStreamSimple(() => import(anthropicMessagesModule)),
67
+ responses:
68
+ piAiRuntime.openAIResponsesApi?.().streamSimple ??
69
+ piAiRuntime.streamSimpleOpenAIResponses ??
70
+ lazyStreamSimple(() => import(openAIResponsesModule)),
71
+ };
55
72
  import {
56
73
  DEFAULT_KIMI_CODE_CONFIG,
57
74
  type KimiCodeConfig,
58
75
  type KimiResolvedModelConfig,
59
76
  } from "./config.ts";
60
77
 
61
- import { ENV_KIMI_CODE_PROTOCOL, PROVIDER_ID, getApiProtocol, getBaseUrl } from "./constants.ts";
78
+ import {
79
+ ENV_KIMI_CODE_PROTOCOL,
80
+ PROVIDER_ID,
81
+ type KimiWireProtocol,
82
+ getApiProtocol,
83
+ getBaseUrl,
84
+ } from "./constants.ts";
62
85
  import { getKimiProviderHeaders } from "./device.ts";
63
86
  import {
64
87
  isKimiAuthErrorMessage,
@@ -224,6 +247,40 @@ export function mergeKimiRequestHeaders(headers?: HeaderMap): HeaderMap {
224
247
  return { ...getKimiProviderHeaders(), ...headers };
225
248
  }
226
249
 
250
+ function streamSimpleForProtocol(
251
+ protocol: KimiWireProtocol,
252
+ model: Model<Api>,
253
+ context: Context,
254
+ options: SimpleStreamOptions,
255
+ ): AssistantMessageEventStream {
256
+ const impl = streamSimpleImpls[protocol];
257
+ // Resolution runs once at module load against whichever pi-ai shape is
258
+ // installed, so an old pi-ai can leave the entry point undefined. Throw a
259
+ // descriptive error here — the caller invokes this inside the retry loop's
260
+ // try, so it surfaces as a stream error event instead of an unhandled
261
+ // rejection from a bare "not a function" TypeError.
262
+ if (!impl) {
263
+ throw new Error(
264
+ `installed pi-ai has no streamSimple entry point for ${getApiProtocol(protocol)}; ` +
265
+ "upgrade pi-ai or choose another KIMI_CODE_PROTOCOL",
266
+ );
267
+ }
268
+ return impl(model, context, options);
269
+ }
270
+
271
+ // Test hook: swap a resolved stream factory (pass undefined to simulate a
272
+ // pi-ai runtime that lacks the entry point). Returns a restore function.
273
+ export function overrideStreamSimpleForTests(
274
+ protocol: KimiWireProtocol,
275
+ impl: KimiStreamSimple | undefined,
276
+ ): () => void {
277
+ const original = streamSimpleImpls[protocol];
278
+ streamSimpleImpls[protocol] = impl;
279
+ return () => {
280
+ streamSimpleImpls[protocol] = original;
281
+ };
282
+ }
283
+
227
284
  export function streamSimpleKimi(
228
285
  model: Model<Api>,
229
286
  context: Context,
@@ -303,6 +360,7 @@ export function streamSimpleKimi(
303
360
  cacheKey,
304
361
  cacheRetention,
305
362
  reasoning: options?.reasoning,
363
+ requestMaxTokens: options?.maxTokens,
306
364
  modelConfig,
307
365
  });
308
366
  if (
@@ -332,8 +390,9 @@ export function streamSimpleKimi(
332
390
  const patchedOptions = buildPatchedOptions(currentKey);
333
391
  // Route by the module-level protocol flag, not model.api, since we
334
392
  // register with a custom api type (kimi-openai-completions /
335
- // kimi-anthropic-messages) to avoid overriding the built-in
336
- // Anthropic/OpenAI stream handlers.
393
+ // kimi-anthropic-messages / kimi-openai-responses) to avoid overriding
394
+ // the built-in Anthropic/OpenAI stream handlers.
395
+ const existingCompat = (model as { compat?: Record<string, unknown> }).compat;
337
396
  const runtimeModel = {
338
397
  ...model,
339
398
  api: apiProtocol,
@@ -342,33 +401,38 @@ export function streamSimpleKimi(
342
401
  // rejects/ignores the interleaved-thinking beta header; pi-ai >=0.82
343
402
  // keys both behaviors off these compat flags (its own built-in
344
403
  // kimi-coding models carry them in generated metadata).
404
+ // Responses rejects prompt_cache_retention (400) unless this is off.
345
405
  ...(wireProtocol === "anthropic"
346
406
  ? {
347
407
  compat: {
348
- ...(model as { compat?: Record<string, unknown> }).compat,
408
+ ...existingCompat,
349
409
  forceAdaptiveThinking: true,
350
410
  allowEmptySignature: true,
351
411
  },
352
412
  }
353
- : {}),
413
+ : wireProtocol === "responses"
414
+ ? {
415
+ compat: {
416
+ ...existingCompat,
417
+ supportsLongCacheRetention: false,
418
+ },
419
+ }
420
+ : {}),
354
421
  } as Model<Api>;
355
- const upstream =
356
- wireProtocol === "openai"
357
- ? streamSimpleOpenAICompletions(
358
- runtimeModel as Model<"openai-completions">,
359
- context,
360
- patchedOptions,
361
- )
362
- : streamSimpleAnthropic(
363
- runtimeModel as Model<"anthropic-messages">,
364
- context,
365
- patchedOptions,
366
- );
367
422
 
368
423
  let shouldRetry = false;
369
424
  let prefixBuffer: AssistantMessageEvent[] = [];
370
425
 
371
426
  try {
427
+ // Inside the try so a missing pi-ai stream entry point (old pi-ai +
428
+ // a protocol it does not ship) surfaces through the catch's error
429
+ // event instead of escaping the async IIFE as an unhandled rejection.
430
+ const upstream = streamSimpleForProtocol(
431
+ wireProtocol,
432
+ runtimeModel,
433
+ context,
434
+ patchedOptions,
435
+ );
372
436
  for await (const event of filterEmptyResponseStream(upstream)) {
373
437
  // streamAnthropic emits a synthetic "start" event synchronously,
374
438
  // before the for-await loop begins iterating and therefore before
@@ -1,7 +1,7 @@
1
1
  import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
2
2
  import { truncateToWidth } from "@earendil-works/pi-tui";
3
3
 
4
- import { PROVIDER_ID, getBaseUrl } from "../constants.ts";
4
+ import { PROVIDER_ID, getBaseUrl, normalizeKimiBaseV1 } from "../constants.ts";
5
5
  import { getKimiProviderHeaders } from "../device.ts";
6
6
  import {
7
7
  KIMI_LOGIN_REQUIRED_MESSAGE,
@@ -57,8 +57,7 @@ export function buildTimeoutSignal(signal: AbortSignal | undefined): {
57
57
  }
58
58
 
59
59
  export function getKimiBaseV1(): string {
60
- const base = getBaseUrl().replace(/\/+$/, "");
61
- return base.endsWith("/v1") ? base : `${base}/v1`;
60
+ return normalizeKimiBaseV1(getBaseUrl());
62
61
  }
63
62
 
64
63
  export function getKimiDatasourceUrl(): string {
package/src/usage.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { PROVIDER_ID, getBaseUrl } from "./constants.ts";
1
+ import { PROVIDER_ID, getBaseUrl, normalizeKimiBaseV1 } from "./constants.ts";
2
2
  import { getKimiProviderHeaders } from "./device.ts";
3
3
  import { readStoredOAuthCredential, refreshKimiAuthToken } from "./oauth.ts";
4
4
 
@@ -51,6 +51,13 @@ function toNumber(value: unknown): number | null {
51
51
  return Number.isFinite(number) ? number : null;
52
52
  }
53
53
 
54
+ function toOptionalNumber(value: unknown): number | null {
55
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
56
+ if (typeof value !== "string" || !value.trim()) return null;
57
+ const number = Number(value);
58
+ return Number.isFinite(number) ? number : null;
59
+ }
60
+
54
61
  function toStringValue(value: unknown): string | undefined {
55
62
  if (typeof value === "string" && value.trim()) return value;
56
63
  if (typeof value === "number" && Number.isFinite(value)) return String(value);
@@ -141,6 +148,85 @@ function formatExtraUsage(info: BoosterWalletInfo): string[] {
141
148
  return lines;
142
149
  }
143
150
 
151
+ // Quota model (upstream kimi-code #3787): the platform /usages payload now
152
+ // serves per-window used ratios under `usages` instead of absolute used/limit
153
+ // rows. Keys are snake_case (`limit_5h`, `limit_7d`, `limit_month_total`,
154
+ // `limit_month_code`); each entry carries `used_ratio` (0..1, sometimes a
155
+ // decimal string) and an optional `reset_time`. Rows are presence-driven —
156
+ // the backend omits windows the plan does not have. The legacy `usage` /
157
+ // `limits` rows are still served alongside during the transition, but the
158
+ // legacy wire is gone from kap-server, so quota rows win when present.
159
+ interface QuotaEntry {
160
+ usedRatio: number;
161
+ resetTime?: string;
162
+ }
163
+
164
+ const QUOTA_WINDOWS = [
165
+ ["limit_5h", "5h limit"],
166
+ ["limit_7d", "Weekly limit"],
167
+ ["limit_month_total", "Monthly limit"],
168
+ ] as const;
169
+
170
+ const QUOTA_MONTH_CODE_KEY = "limit_month_code";
171
+
172
+ function isRecordValue(value: unknown): value is Record<string, unknown> {
173
+ return typeof value === "object" && value !== null && !Array.isArray(value);
174
+ }
175
+
176
+ function toRatio(value: unknown): number | undefined {
177
+ if (typeof value === "number") {
178
+ return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : undefined;
179
+ }
180
+ if (typeof value !== "string" || !value.trim()) return undefined;
181
+ const parsed = Number(value);
182
+ return Number.isFinite(parsed) ? Math.max(0, Math.min(1, parsed)) : undefined;
183
+ }
184
+
185
+ function parseQuotaEntry(value: unknown): QuotaEntry | undefined {
186
+ if (!isRecordValue(value)) return undefined;
187
+ const usedRatio = toRatio(value.used_ratio);
188
+ if (usedRatio === undefined) return undefined;
189
+ const resetTime = toStringValue(value.reset_time);
190
+ return {
191
+ usedRatio,
192
+ ...(resetTime ? { resetTime } : {}),
193
+ };
194
+ }
195
+
196
+ function parseQuotaWindows(record: Record<string, unknown>): Map<string, QuotaEntry> {
197
+ const windows = new Map<string, QuotaEntry>();
198
+ const usages = record.usages;
199
+ if (!isRecordValue(usages)) return windows;
200
+ for (const key of [...QUOTA_WINDOWS.map(([key]) => key), QUOTA_MONTH_CODE_KEY]) {
201
+ const entry = parseQuotaEntry(usages[key]);
202
+ if (entry) windows.set(key, entry);
203
+ }
204
+ return windows;
205
+ }
206
+
207
+ function formatQuotaRow(label: string, entry: QuotaEntry, options: UsageFormatOptions): string {
208
+ const percent = Math.min(100, Math.max(0, Math.ceil(entry.usedRatio * 100)));
209
+ const lines = [label, `${quotaBar(entry.usedRatio * 100, 100)} ${percent}% used`];
210
+ const reset = formatResetTime(entry.resetTime, options);
211
+ if (reset) lines.push(`Resets ${reset}`);
212
+ return lines.join("\n");
213
+ }
214
+
215
+ // The kimi/code split of the monthly quota on the new plan: `code` is the
216
+ // code-typed share of the monthly total as served, `kimi` the remainder
217
+ // (mirrors upstream monthlyBreakdown in apps/kimi-code usage-format).
218
+ function formatMonthlyBreakdown(
219
+ total: QuotaEntry,
220
+ windows: Map<string, QuotaEntry>,
221
+ ): string | null {
222
+ const code = windows.get(QUOTA_MONTH_CODE_KEY);
223
+ if (!code) return null;
224
+ const codePercent = Math.min(100, Math.max(0, Math.ceil(code.usedRatio * 100)));
225
+ const totalPercent = Math.min(100, Math.max(0, Math.ceil(total.usedRatio * 100)));
226
+ const kimiPercent = Math.max(0, totalPercent - codePercent);
227
+ return `kimi ${kimiPercent}% · code ${codePercent}%`;
228
+ }
229
+
144
230
  export function parseUsageRow(value: unknown, fallbackLabel: string): UsageRow | null {
145
231
  if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
146
232
  const record = value as Record<string, unknown>;
@@ -164,11 +250,28 @@ export function parseUsageSummary(payload: unknown, options: UsageFormatOptions
164
250
 
165
251
  const record = payload as Record<string, unknown>;
166
252
  const lines: string[] = [];
167
- const summary = parseUsageRow(record.usage, "Current week");
253
+
254
+ const quotaWindows = parseQuotaWindows(record);
255
+ // A code-split entry without a monthly total renders no row of its own; it
256
+ // only feeds the breakdown line, so it must not suppress the legacy rows.
257
+ const hasQuotaRows = QUOTA_WINDOWS.some(([key]) => quotaWindows.has(key));
258
+ if (hasQuotaRows) {
259
+ for (const [key, label] of QUOTA_WINDOWS) {
260
+ const entry = quotaWindows.get(key);
261
+ if (!entry) continue;
262
+ if (lines.length > 0) lines.push("");
263
+ lines.push(...formatQuotaRow(label, entry, options).split("\n"));
264
+ const breakdown =
265
+ key === "limit_month_total" ? formatMonthlyBreakdown(entry, quotaWindows) : null;
266
+ if (breakdown) lines.push(breakdown);
267
+ }
268
+ }
269
+
270
+ const summary = hasQuotaRows ? null : parseUsageRow(record.usage, "Current week");
168
271
  if (summary)
169
272
  lines.push(formatUsageRow({ ...summary, label: normalizeUsageLabel(summary.label) }, options));
170
273
 
171
- if (Array.isArray(record.limits)) {
274
+ if (!hasQuotaRows && Array.isArray(record.limits)) {
172
275
  for (const [index, item] of record.limits.entries()) {
173
276
  const itemRecord =
174
277
  typeof item === "object" && item !== null && !Array.isArray(item)
@@ -307,8 +410,7 @@ function partialBlock(value: number): string {
307
410
  }
308
411
 
309
412
  export function buildKimiUsageUrl(baseUrl = getBaseUrl("openai")): string {
310
- const normalized = baseUrl.replace(/\/+$/, "");
311
- return normalized.endsWith("/v1") ? `${normalized}/usages` : `${normalized}/v1/usages`;
413
+ return `${normalizeKimiBaseV1(baseUrl)}/usages`;
312
414
  }
313
415
 
314
416
  function fetchKimiUsage(token: string, signal: AbortSignal): Promise<Response> {
@@ -383,6 +485,12 @@ export interface KimiUserInfo {
383
485
  userLevel: number;
384
486
  userLevelName: string;
385
487
  email?: string;
488
+ /**
489
+ * Plan generation from the managed /me profile (upstream #3921). upstream
490
+ * gates feedback surveys and plan-dependent surfaces by it; the monthly
491
+ * kimi/code quota split only exists on newer goods versions.
492
+ */
493
+ goodsVersion?: number;
386
494
  }
387
495
 
388
496
  export function parseKimiUserInfo(payload: unknown): KimiUserInfo | null {
@@ -391,12 +499,14 @@ export function parseKimiUserInfo(payload: unknown): KimiUserInfo | null {
391
499
  const userId = toStringValue(record.user_id);
392
500
  if (!userId) return null;
393
501
  const email = toStringValue(record.email);
502
+ const goodsVersion = toOptionalNumber(record.goods_version);
394
503
  return {
395
504
  userId,
396
505
  nickname: toStringValue(record.nickname) ?? "",
397
506
  userLevel: toNumber(record.user_level) ?? 0,
398
507
  userLevelName: toStringValue(record.user_level_name) ?? "",
399
508
  ...(email ? { email } : {}),
509
+ ...(goodsVersion !== null ? { goodsVersion } : {}),
400
510
  };
401
511
  }
402
512
 
@@ -412,8 +522,7 @@ export function formatKimiUserInfo(info: KimiUserInfo): string {
412
522
  }
413
523
 
414
524
  export function buildKimiUserInfoUrl(baseUrl = getBaseUrl("openai")): string {
415
- const normalized = baseUrl.replace(/\/+$/, "");
416
- return normalized.endsWith("/v1") ? `${normalized}/me` : `${normalized}/v1/me`;
525
+ return `${normalizeKimiBaseV1(baseUrl)}/me`;
417
526
  }
418
527
 
419
528
  function fetchKimiUserInfo(token: string, signal: AbortSignal): Promise<Response> {