pi-provider-kimi-code 0.6.7 → 0.6.9

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/models.ts CHANGED
@@ -1,8 +1,14 @@
1
1
  // Model identity: discovery against the server's /v1/models endpoint, plus the
2
2
  // extras-merging helpers used by both registration and the OAuth modifyModels hook.
3
3
 
4
- import type { Api, Model, OAuthCredentials } from "@earendil-works/pi-ai";
5
- import type { KimiInputModality, KimiResolvedModelConfig, ModelConfig } from "./config.ts";
4
+ import type { Api, Model, OAuthCredentials, ThinkingLevelMap } from "@earendil-works/pi-ai";
5
+ import type {
6
+ KimiInputModality,
7
+ KimiResolvedModelConfig,
8
+ ModelConfig,
9
+ ModelReasoningMap,
10
+ } from "./config.ts";
11
+ import { DEFAULT_KIMI_CODE_CONFIG } from "./config.ts";
6
12
 
7
13
  import { type KimiWireProtocol, getBaseUrl } from "./constants.ts";
8
14
  import { getKimiProviderHeaders } from "./device.ts";
@@ -31,6 +37,10 @@ const DEFAULT_DISCOVERY_TIMEOUT_MS = 2500;
31
37
 
32
38
  export interface DiscoverKimiModelMetadataOptions {
33
39
  timeoutMs?: number;
40
+ // 401 recovery for runtime callers (extension startup, settings menu).
41
+ // oauth.ts's login/refresh flows must NOT pass this: they call discovery
42
+ // while holding the OAuth lock, and refreshKimiAuthToken would re-enter it.
43
+ refreshAccessToken?: (currentToken: string) => Promise<string | null>;
34
44
  }
35
45
 
36
46
  function mergeInputModalities(
@@ -50,62 +60,63 @@ function mergeInputModalities(
50
60
  return (["text", "image", "video"] as const).filter((modality) => next.has(modality));
51
61
  }
52
62
 
53
- // Pricing per million tokens in USD (CNY converted at ~7.25).
54
- // Source: https://platform.kimi.com/docs/pricing/chat-k27-code
55
- const COST_STANDARD = { input: 0.897, output: 3.724, cacheRead: 0.179, cacheWrite: 0.897 };
56
- const COST_HIGH_SPEED = { input: 1.793, output: 7.448, cacheRead: 0.359, cacheWrite: 1.793 };
63
+ // Pricing per million tokens in USD.
64
+ // Sources: https://www.kimi.com/resources/kimi-k2-7-code-pricing
65
+ // https://www.kimi.com/resources/kimi-k3-pricing
66
+ const COST_STANDARD = { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0.95 };
67
+ const COST_HIGH_SPEED = { input: 1.9, output: 8, cacheRead: 0.38, cacheWrite: 1.9 };
68
+ const COST_K3 = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3 };
57
69
 
58
70
  export const KIMI_CODING_MODEL_ID = "kimi-for-coding";
59
71
  export const KIMI_CODING_HIGHSPEED_MODEL_ID = "kimi-for-coding-highspeed";
60
72
  export const KIMI_K3_MODEL_ID = "k3";
61
73
  export const KIMI_MODEL_CATALOG_VERSION = 1;
62
74
 
63
- const KIMI_K3_MODERATO_CONTEXT_WINDOW = 262144;
64
- const KIMI_MEMBERSHIP_RANK: Readonly<Record<string, number>> = {
65
- LEVEL_FREE: 0,
66
- LEVEL_BASIC: 1,
67
- LEVEL_STANDARD: 2,
68
- LEVEL_INTERMEDIATE: 3,
69
- LEVEL_ADVANCED: 4,
70
- LEVEL_PREMIUM: 5,
71
- };
72
- const KIMI_MODERATO_RANK = KIMI_MEMBERSHIP_RANK.LEVEL_STANDARD;
73
- const KIMI_ALLEGRETTO_RANK = KIMI_MEMBERSHIP_RANK.LEVEL_INTERMEDIATE;
74
-
75
- export function isKimiModelAvailableForMembership(
75
+ function resolveModelCost(
76
76
  modelId: string,
77
- membershipLevel: string | null | undefined,
78
- ): boolean | undefined {
79
- if (!membershipLevel) return undefined;
80
- const rank = KIMI_MEMBERSHIP_RANK[membershipLevel];
81
- if (rank === undefined) return undefined;
82
- if (modelId === KIMI_K3_MODEL_ID) return rank >= KIMI_MODERATO_RANK;
83
- if (modelId === KIMI_CODING_HIGHSPEED_MODEL_ID) return rank >= KIMI_ALLEGRETTO_RANK;
84
- return true;
85
- }
86
-
87
- export function applyKimiMembershipLimitsToModel(
88
- model: Model<Api>,
89
- membershipLevel: string | null | undefined,
90
- ): Model<Api> {
91
- const rank = membershipLevel ? KIMI_MEMBERSHIP_RANK[membershipLevel] : undefined;
92
- if (model.id !== KIMI_K3_MODEL_ID || rank === undefined || rank >= KIMI_ALLEGRETTO_RANK) {
93
- return model;
94
- }
95
- return {
96
- ...model,
97
- contextWindow: Math.min(model.contextWindow, KIMI_K3_MODERATO_CONTEXT_WINDOW),
98
- };
99
- }
100
-
101
- function resolveModelCost(modelDisplay: string | undefined): {
77
+ modelDisplay?: string,
78
+ ): {
102
79
  input: number;
103
80
  output: number;
104
81
  cacheRead: number;
105
82
  cacheWrite: number;
106
83
  } {
107
- if (modelDisplay && /high\s*speed/i.test(modelDisplay)) return COST_HIGH_SPEED;
108
- return COST_STANDARD;
84
+ if (modelId === KIMI_K3_MODEL_ID) return COST_K3;
85
+ if (modelId === KIMI_CODING_MODEL_ID) return COST_STANDARD;
86
+ if (modelId === KIMI_CODING_HIGHSPEED_MODEL_ID || /high\s*speed/i.test(modelDisplay ?? "")) {
87
+ return COST_HIGH_SPEED;
88
+ }
89
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
90
+ }
91
+
92
+ // Policy: a model's output cap tracks its context window unless the user
93
+ // explicitly configured model.maxTokens. pi-ai clamps the request cap to
94
+ // contextWindow - used - 4096 (clampMaxTokensToContext in
95
+ // @earendil-works/pi-ai), so setting maxTokens to the window yields the official
96
+ // kimi-code behavior (computeCompletionBudgetCap: maxCtx, clamped to
97
+ // maxCtx - usedContext) instead of a fixed small cap that truncates long
98
+ // max-effort reasoning (reasoning_content counts toward the cap). A deliberate
99
+ // per-request cap still wins (options.maxTokens ?? model.maxTokens). Reconcile
100
+ // wherever contextWindow is authored so the two stay in lockstep.
101
+ //
102
+ // "Explicitly configured" means any value other than the built-in default:
103
+ // ensureKimiCodeConfig materializes the full default config (including
104
+ // maxTokens) into the home config file, so config-source tracking cannot
105
+ // distinguish a deliberate 32000 from a materialized default. A user who
106
+ // explicitly sets the default value gets the window cap — indistinguishable
107
+ // from the materialized-default case, and the intended new behavior anyway.
108
+ const DEFAULT_MODEL_MAX_TOKENS = DEFAULT_KIMI_CODE_CONFIG.model.maxTokens;
109
+
110
+ // True while the cap is "unset": still the built-in default, or already
111
+ // tracking the context window (set by an earlier withWindowCappedMaxTokens
112
+ // call, before discovery grew the window).
113
+ function isWindowTrackedMaxTokens(model: Model<Api>): boolean {
114
+ return model.maxTokens === DEFAULT_MODEL_MAX_TOKENS || model.maxTokens === model.contextWindow;
115
+ }
116
+
117
+ function withWindowCappedMaxTokens<M extends Model<Api>>(model: M): M {
118
+ if (!isWindowTrackedMaxTokens(model)) return model;
119
+ return { ...model, maxTokens: model.contextWindow };
109
120
  }
110
121
 
111
122
  export function buildKimiModelFromConfig(
@@ -118,16 +129,18 @@ export function buildKimiModelFromConfig(
118
129
  ? "Kimi K3"
119
130
  : isHighSpeed
120
131
  ? "Kimi for Coding High Speed"
121
- : "Kimi for Coding";
122
- return {
132
+ : modelId === KIMI_CODING_MODEL_ID
133
+ ? "Kimi for Coding"
134
+ : modelId;
135
+ return withWindowCappedMaxTokens({
123
136
  id: modelId,
124
137
  name,
125
138
  reasoning: config.reasoning,
126
139
  input: [...config.input] as unknown as ("text" | "image" | "video")[],
127
- cost: { ...(isHighSpeed ? COST_HIGH_SPEED : COST_STANDARD) },
140
+ cost: { ...resolveModelCost(modelId) },
128
141
  contextWindow: config.contextWindow,
129
142
  maxTokens: config.maxTokens,
130
- } as Model<Api>;
143
+ } as Model<Api>);
131
144
  }
132
145
 
133
146
  export function resolveKimiModelConfig(
@@ -226,38 +239,58 @@ function getModelsUrl(protocol?: KimiWireProtocol): string {
226
239
  return buildModelsUrl(getBaseUrl(protocol));
227
240
  }
228
241
 
242
+ export function isOfficialKimiModelsUrl(url: string): boolean {
243
+ try {
244
+ const parsed = new URL(url);
245
+ return parsed.origin === "https://api.kimi.com" && parsed.pathname === "/coding/v1/models";
246
+ } catch {
247
+ return false;
248
+ }
249
+ }
250
+
229
251
  export async function discoverKimiModelMetadata(
230
252
  accessToken: string,
231
253
  protocol?: KimiWireProtocol,
232
254
  options: DiscoverKimiModelMetadataOptions = {},
233
255
  ): Promise<KimiOAuthExtras> {
234
256
  if (!accessToken) return {};
257
+ const modelsUrl = getModelsUrl(protocol);
258
+ if (!isOfficialKimiModelsUrl(modelsUrl)) return {};
235
259
  const timeoutMs = options.timeoutMs ?? DEFAULT_DISCOVERY_TIMEOUT_MS;
236
260
  const controller = new AbortController();
237
261
  const timeout =
238
262
  timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs).unref() : undefined;
239
- try {
240
- const response = await fetch(getModelsUrl(protocol), {
241
- signal: controller.signal,
263
+ const requestModels = (token: string, signal: AbortSignal) =>
264
+ fetch(modelsUrl, {
265
+ signal,
242
266
  headers: {
243
267
  ...getKimiProviderHeaders(),
244
- Authorization: `Bearer ${accessToken}`,
268
+ Authorization: `Bearer ${token}`,
245
269
  Accept: "application/json",
246
270
  },
247
271
  });
272
+ try {
273
+ let response = await requestModels(accessToken, controller.signal);
274
+ if (response.status === 401 && options.refreshAccessToken) {
275
+ const refreshed = await options.refreshAccessToken(accessToken);
276
+ if (refreshed && refreshed !== accessToken) {
277
+ const retryController = new AbortController();
278
+ const retryTimeout =
279
+ timeoutMs > 0 ? setTimeout(() => retryController.abort(), timeoutMs).unref() : undefined;
280
+ try {
281
+ response = await requestModels(refreshed, retryController.signal);
282
+ } finally {
283
+ if (retryTimeout) clearTimeout(retryTimeout);
284
+ }
285
+ }
286
+ }
248
287
  if (!response.ok) return {};
249
288
  const json = (await response.json()) as { data?: unknown };
250
289
  const list = Array.isArray(json.data) ? (json.data as KimiServerModel[]) : [];
251
- const supportedIds = new Set([
252
- KIMI_CODING_MODEL_ID,
253
- KIMI_CODING_HIGHSPEED_MODEL_ID,
254
- KIMI_K3_MODEL_ID,
255
- ]);
256
290
  const modelCatalog: Record<string, KimiModelMetadata> = {};
257
291
  for (const model of list) {
258
- if (typeof model.id !== "string" || !supportedIds.has(model.id)) continue;
259
292
  const metadata = parseKimiModelMetadata(model);
260
- if (metadata) modelCatalog[model.id] = metadata;
293
+ if (metadata) modelCatalog[metadata.wireModelId!] = metadata;
261
294
  }
262
295
  const preferred = modelCatalog[KIMI_CODING_MODEL_ID];
263
296
  if (!preferred) {
@@ -279,10 +312,53 @@ export function getKimiModelMetadata(extras: KimiOAuthExtras, modelId: string):
279
312
  return modelId === KIMI_CODING_MODEL_ID ? extras : {};
280
313
  }
281
314
 
315
+ const KIMI_DISCOVERY_KEYS = [
316
+ "wireModelId",
317
+ "modelDisplay",
318
+ "contextLength",
319
+ "supportsReasoning",
320
+ "supportsImageIn",
321
+ "supportsVideoIn",
322
+ "supportsThinkingType",
323
+ "protocol",
324
+ "supportEfforts",
325
+ "defaultEffort",
326
+ "modelCatalog",
327
+ ] as const;
328
+
329
+ // Credentials always carry access/refresh/expires, so discovery presence must
330
+ // be checked on the metadata fields themselves. Empty means either a legacy
331
+ // pre-discovery credential or a failed discovery during login/refresh.
332
+ export function hasKimiModelMetadata(extras: KimiOAuthExtras): boolean {
333
+ return KIMI_DISCOVERY_KEYS.some((key) => extras[key] !== undefined);
334
+ }
335
+
336
+ export function buildKimiThinkingLevelMap(
337
+ reasoningMap: ModelReasoningMap,
338
+ extras: Pick<KimiModelMetadata, "supportEfforts" | "supportsThinkingType">,
339
+ ): ThinkingLevelMap | undefined {
340
+ if (!extras.supportEfforts?.length) return undefined;
341
+ const map = {
342
+ off: extras.supportsThinkingType === "only" ? null : "off",
343
+ } as ThinkingLevelMap & Record<string, string | null>;
344
+ for (const level of ["minimal", "low", "medium", "high", "xhigh", "max"] as const) {
345
+ const entry = reasoningMap[level];
346
+ map[level] =
347
+ entry?.enabled && entry.effort && extras.supportEfforts.includes(entry.effort)
348
+ ? entry.effort
349
+ : null;
350
+ }
351
+ return map;
352
+ }
353
+
282
354
  export function applyKimiOAuthExtrasToModel(
283
355
  model: Model<Api>,
284
356
  extras: KimiModelMetadata,
357
+ reasoningMap?: ModelReasoningMap,
285
358
  ): Model<Api> {
359
+ // Capture before extras grow the window: a window-tracked cap must follow
360
+ // the new window, an explicit cap must survive unchanged.
361
+ const windowTracked = isWindowTrackedMaxTokens(model);
286
362
  const next: Model<Api> & {
287
363
  wireModelId?: string;
288
364
  supportsThinkingType?: "only" | "no" | "both";
@@ -295,7 +371,7 @@ export function applyKimiOAuthExtrasToModel(
295
371
  model.id === KIMI_K3_MODEL_ID && /^k3$/i.test(extras.modelDisplay)
296
372
  ? "Kimi K3"
297
373
  : extras.modelDisplay;
298
- next.cost = resolveModelCost(extras.modelDisplay);
374
+ next.cost = resolveModelCost(model.id, extras.modelDisplay);
299
375
  }
300
376
  if (typeof extras.contextLength === "number" && extras.contextLength > 0) {
301
377
  next.contextWindow = extras.contextLength;
@@ -316,6 +392,15 @@ export function applyKimiOAuthExtrasToModel(
316
392
  }
317
393
  if (extras.protocol) next.wireProtocol = extras.protocol;
318
394
  if (extras.supportEfforts) next.supportEfforts = [...extras.supportEfforts];
395
+ else delete next.supportEfforts;
319
396
  if (extras.defaultEffort) next.defaultEffort = extras.defaultEffort;
397
+ else delete next.defaultEffort;
398
+ if (reasoningMap) {
399
+ const thinkingLevelMap = buildKimiThinkingLevelMap(reasoningMap, extras);
400
+ if (thinkingLevelMap) next.thinkingLevelMap = thinkingLevelMap;
401
+ else delete next.thinkingLevelMap;
402
+ }
403
+ // Reconcile the output cap with the (possibly discovery-updated) window.
404
+ if (windowTracked) next.maxTokens = next.contextWindow;
320
405
  return next;
321
406
  }