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/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,14 @@ 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>;
25
+ modelCatalogVersion?: number;
18
26
  }
19
27
 
20
28
  export type KimiOAuthCredentials = OAuthCredentials & KimiOAuthExtras;
@@ -47,6 +55,49 @@ function mergeInputModalities(
47
55
  const COST_STANDARD = { input: 0.897, output: 3.724, cacheRead: 0.179, cacheWrite: 0.897 };
48
56
  const COST_HIGH_SPEED = { input: 1.793, output: 7.448, cacheRead: 0.359, cacheWrite: 1.793 };
49
57
 
58
+ export const KIMI_CODING_MODEL_ID = "kimi-for-coding";
59
+ export const KIMI_CODING_HIGHSPEED_MODEL_ID = "kimi-for-coding-highspeed";
60
+ export const KIMI_K3_MODEL_ID = "k3";
61
+ export const KIMI_MODEL_CATALOG_VERSION = 1;
62
+
63
+ const KIMI_K3_MODERATO_CONTEXT_WINDOW = 262144;
64
+ const KIMI_MEMBERSHIP_RANK: Readonly<Record<string, number>> = {
65
+ LEVEL_FREE: 0,
66
+ LEVEL_BASIC: 1,
67
+ LEVEL_STANDARD: 2,
68
+ LEVEL_INTERMEDIATE: 3,
69
+ LEVEL_ADVANCED: 4,
70
+ LEVEL_PREMIUM: 5,
71
+ };
72
+ const KIMI_MODERATO_RANK = KIMI_MEMBERSHIP_RANK.LEVEL_STANDARD;
73
+ const KIMI_ALLEGRETTO_RANK = KIMI_MEMBERSHIP_RANK.LEVEL_INTERMEDIATE;
74
+
75
+ export function isKimiModelAvailableForMembership(
76
+ modelId: string,
77
+ membershipLevel: string | null | undefined,
78
+ ): boolean | undefined {
79
+ if (!membershipLevel) return undefined;
80
+ const rank = KIMI_MEMBERSHIP_RANK[membershipLevel];
81
+ if (rank === undefined) return undefined;
82
+ if (modelId === KIMI_K3_MODEL_ID) return rank >= KIMI_MODERATO_RANK;
83
+ if (modelId === KIMI_CODING_HIGHSPEED_MODEL_ID) return rank >= KIMI_ALLEGRETTO_RANK;
84
+ return true;
85
+ }
86
+
87
+ export function applyKimiMembershipLimitsToModel(
88
+ model: Model<Api>,
89
+ membershipLevel: string | null | undefined,
90
+ ): Model<Api> {
91
+ const rank = membershipLevel ? KIMI_MEMBERSHIP_RANK[membershipLevel] : undefined;
92
+ if (model.id !== KIMI_K3_MODEL_ID || rank === undefined || rank >= KIMI_ALLEGRETTO_RANK) {
93
+ return model;
94
+ }
95
+ return {
96
+ ...model,
97
+ contextWindow: Math.min(model.contextWindow, KIMI_K3_MODERATO_CONTEXT_WINDOW),
98
+ };
99
+ }
100
+
50
101
  function resolveModelCost(modelDisplay: string | undefined): {
51
102
  input: number;
52
103
  output: number;
@@ -57,13 +108,23 @@ function resolveModelCost(modelDisplay: string | undefined): {
57
108
  return COST_STANDARD;
58
109
  }
59
110
 
60
- export function buildKimiModelFromConfig(config: ModelConfig): Model<Api> {
111
+ export function buildKimiModelFromConfig(
112
+ config: ModelConfig,
113
+ modelId = KIMI_CODING_MODEL_ID,
114
+ ): Model<Api> {
115
+ const isHighSpeed = modelId === KIMI_CODING_HIGHSPEED_MODEL_ID;
116
+ const name =
117
+ modelId === KIMI_K3_MODEL_ID
118
+ ? "Kimi K3"
119
+ : isHighSpeed
120
+ ? "Kimi for Coding High Speed"
121
+ : "Kimi for Coding";
61
122
  return {
62
- id: "kimi-for-coding",
63
- name: "Kimi for Coding",
123
+ id: modelId,
124
+ name,
64
125
  reasoning: config.reasoning,
65
126
  input: [...config.input] as unknown as ("text" | "image" | "video")[],
66
- cost: { ...COST_STANDARD },
127
+ cost: { ...(isHighSpeed ? COST_HIGH_SPEED : COST_STANDARD) },
67
128
  contextWindow: config.contextWindow,
68
129
  maxTokens: config.maxTokens,
69
130
  } as Model<Api>;
@@ -86,6 +147,8 @@ export function resolveKimiModelConfig(
86
147
  if (typeof extras.supportsImageIn === "boolean" || typeof extras.supportsVideoIn === "boolean") {
87
148
  resolved.input = mergeInputModalities(resolved.input, extras);
88
149
  }
150
+ if (extras.supportEfforts) resolved.supportEfforts = [...extras.supportEfforts];
151
+ if (extras.defaultEffort) resolved.defaultEffort = extras.defaultEffort;
89
152
  return resolved;
90
153
  }
91
154
 
@@ -97,6 +160,8 @@ interface KimiServerModel {
97
160
  supports_image_in?: unknown;
98
161
  supports_video_in?: unknown;
99
162
  supports_thinking_type?: unknown;
163
+ protocol?: unknown;
164
+ think_efforts?: unknown;
100
165
  }
101
166
 
102
167
  function parseSupportsThinkingType(value: unknown): "only" | "no" | "both" | undefined {
@@ -104,6 +169,54 @@ function parseSupportsThinkingType(value: unknown): "only" | "no" | "both" | und
104
169
  return undefined;
105
170
  }
106
171
 
172
+ function isRecord(value: unknown): value is Record<string, unknown> {
173
+ return typeof value === "object" && value !== null && !Array.isArray(value);
174
+ }
175
+
176
+ function parseThinkEfforts(value: unknown): {
177
+ supportEfforts?: string[];
178
+ defaultEffort?: string;
179
+ } {
180
+ if (!isRecord(value) || value.support !== true) return {};
181
+ const validEfforts = Array.isArray(value.valid_efforts)
182
+ ? value.valid_efforts.filter(
183
+ (effort): effort is string => typeof effort === "string" && !!effort,
184
+ )
185
+ : [];
186
+ return {
187
+ ...(validEfforts.length > 0 ? { supportEfforts: validEfforts } : {}),
188
+ ...(typeof value.default_effort === "string" && value.default_effort
189
+ ? { defaultEffort: value.default_effort }
190
+ : {}),
191
+ };
192
+ }
193
+
194
+ function parseKimiModelMetadata(model: KimiServerModel): KimiModelMetadata | undefined {
195
+ if (typeof model.id !== "string" || !model.id) return undefined;
196
+ const metadata: KimiModelMetadata = { wireModelId: model.id };
197
+ if (typeof model.display_name === "string") metadata.modelDisplay = model.display_name;
198
+ if (typeof model.context_length === "number" && model.context_length > 0) {
199
+ metadata.contextLength = model.context_length;
200
+ }
201
+ const thinkingType = parseSupportsThinkingType(model.supports_thinking_type);
202
+ if (thinkingType) {
203
+ metadata.supportsThinkingType = thinkingType;
204
+ } else if (typeof model.supports_reasoning === "boolean") {
205
+ metadata.supportsReasoning = model.supports_reasoning;
206
+ }
207
+ if (typeof model.supports_image_in === "boolean") {
208
+ metadata.supportsImageIn = model.supports_image_in;
209
+ }
210
+ if (typeof model.supports_video_in === "boolean") {
211
+ metadata.supportsVideoIn = model.supports_video_in;
212
+ }
213
+ if (model.protocol === "openai" || model.protocol === "anthropic") {
214
+ metadata.protocol = model.protocol;
215
+ }
216
+ Object.assign(metadata, parseThinkEfforts(model.think_efforts));
217
+ return metadata;
218
+ }
219
+
107
220
  export function buildModelsUrl(baseUrl: string): string {
108
221
  const base = baseUrl.replace(/\/+$/, "");
109
222
  return base.endsWith("/v1") ? `${base}/models` : `${base}/v1/models`;
@@ -127,7 +240,7 @@ export async function discoverKimiModelMetadata(
127
240
  const response = await fetch(getModelsUrl(protocol), {
128
241
  signal: controller.signal,
129
242
  headers: {
130
- ...getCommonHeaders(),
243
+ ...getKimiProviderHeaders(),
131
244
  Authorization: `Bearer ${accessToken}`,
132
245
  Accept: "application/json",
133
246
  },
@@ -135,26 +248,24 @@ export async function discoverKimiModelMetadata(
135
248
  if (!response.ok) return {};
136
249
  const json = (await response.json()) as { data?: unknown };
137
250
  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;
251
+ const supportedIds = new Set([
252
+ KIMI_CODING_MODEL_ID,
253
+ KIMI_CODING_HIGHSPEED_MODEL_ID,
254
+ KIMI_K3_MODEL_ID,
255
+ ]);
256
+ const modelCatalog: Record<string, KimiModelMetadata> = {};
257
+ for (const model of list) {
258
+ if (typeof model.id !== "string" || !supportedIds.has(model.id)) continue;
259
+ const metadata = parseKimiModelMetadata(model);
260
+ if (metadata) modelCatalog[model.id] = metadata;
150
261
  }
151
- if (typeof preferred.supports_image_in === "boolean") {
152
- extras.supportsImageIn = preferred.supports_image_in;
262
+ const preferred = modelCatalog[KIMI_CODING_MODEL_ID];
263
+ if (!preferred) {
264
+ return Object.keys(modelCatalog).length > 0
265
+ ? { modelCatalog, modelCatalogVersion: KIMI_MODEL_CATALOG_VERSION }
266
+ : {};
153
267
  }
154
- if (typeof preferred.supports_video_in === "boolean") {
155
- extras.supportsVideoIn = preferred.supports_video_in;
156
- }
157
- return extras;
268
+ return { ...preferred, modelCatalog, modelCatalogVersion: KIMI_MODEL_CATALOG_VERSION };
158
269
  } catch {
159
270
  return {};
160
271
  } finally {
@@ -162,14 +273,28 @@ export async function discoverKimiModelMetadata(
162
273
  }
163
274
  }
164
275
 
276
+ export function getKimiModelMetadata(extras: KimiOAuthExtras, modelId: string): KimiModelMetadata {
277
+ const discovered = extras.modelCatalog?.[modelId];
278
+ if (discovered) return discovered;
279
+ return modelId === KIMI_CODING_MODEL_ID ? extras : {};
280
+ }
281
+
165
282
  export function applyKimiOAuthExtrasToModel(
166
283
  model: Model<Api>,
167
- extras: KimiOAuthExtras,
284
+ extras: KimiModelMetadata,
168
285
  ): Model<Api> {
169
- const next: Model<Api> & { wireModelId?: string; supportsThinkingType?: "only" | "no" | "both" } =
170
- { ...model };
286
+ const next: Model<Api> & {
287
+ wireModelId?: string;
288
+ supportsThinkingType?: "only" | "no" | "both";
289
+ wireProtocol?: KimiWireProtocol;
290
+ supportEfforts?: string[];
291
+ defaultEffort?: string;
292
+ } = { ...model };
171
293
  if (typeof extras.modelDisplay === "string" && extras.modelDisplay) {
172
- next.name = extras.modelDisplay;
294
+ next.name =
295
+ model.id === KIMI_K3_MODEL_ID && /^k3$/i.test(extras.modelDisplay)
296
+ ? "Kimi K3"
297
+ : extras.modelDisplay;
173
298
  next.cost = resolveModelCost(extras.modelDisplay);
174
299
  }
175
300
  if (typeof extras.contextLength === "number" && extras.contextLength > 0) {
@@ -189,5 +314,8 @@ export function applyKimiOAuthExtrasToModel(
189
314
  const input = mergeInputModalities(next.input as KimiInputModality[], extras);
190
315
  (next as unknown as { input: string[] }).input = input;
191
316
  }
317
+ if (extras.protocol) next.wireProtocol = extras.protocol;
318
+ if (extras.supportEfforts) next.supportEfforts = [...extras.supportEfforts];
319
+ if (extras.defaultEffort) next.defaultEffort = extras.defaultEffort;
192
320
  return next;
193
321
  }
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";
@@ -120,11 +120,34 @@ export async function requestDeviceToken(
120
120
 
121
121
  interface RefreshAccessTokenOptions {
122
122
  maxRetries?: number;
123
+ signal?: AbortSignal;
123
124
  sleep?: (ms: number) => Promise<void>;
124
125
  }
125
126
 
126
- function sleep(ms: number): Promise<void> {
127
- return new Promise((resolve) => setTimeout(resolve, ms));
127
+ export const KIMI_LOGIN_REQUIRED_MESSAGE =
128
+ "Kimi Code authorization is no longer valid. Sign in again with /login kimi-coding.";
129
+
130
+ export class KimiLoginRequiredError extends Error {
131
+ constructor() {
132
+ super(KIMI_LOGIN_REQUIRED_MESSAGE);
133
+ this.name = "KimiLoginRequiredError";
134
+ }
135
+ }
136
+
137
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
138
+ if (!signal) return new Promise((resolve) => setTimeout(resolve, ms));
139
+ signal.throwIfAborted();
140
+ return new Promise((resolve, reject) => {
141
+ const onAbort = () => {
142
+ clearTimeout(timeout);
143
+ reject(signal.reason);
144
+ };
145
+ const timeout = setTimeout(() => {
146
+ signal.removeEventListener("abort", onAbort);
147
+ resolve();
148
+ }, ms);
149
+ signal.addEventListener("abort", onAbort, { once: true });
150
+ });
128
151
  }
129
152
 
130
153
  export async function refreshAccessToken(
@@ -132,11 +155,12 @@ export async function refreshAccessToken(
132
155
  options: RefreshAccessTokenOptions = {},
133
156
  ): Promise<TokenResponse> {
134
157
  const maxRetries = options.maxRetries ?? 3;
135
- const wait = options.sleep ?? sleep;
158
+ const wait = options.sleep ?? ((ms: number) => sleep(ms, options.signal));
136
159
  let lastError: unknown;
137
160
 
138
161
  for (let attempt = 0; attempt < maxRetries; attempt++) {
139
162
  try {
163
+ options.signal?.throwIfAborted();
140
164
  const response = await fetch(`${getOAuthHost()}/api/oauth/token`, {
141
165
  method: "POST",
142
166
  headers: {
@@ -148,12 +172,20 @@ export async function refreshAccessToken(
148
172
  grant_type: "refresh_token",
149
173
  refresh_token: refreshToken,
150
174
  }),
175
+ signal: options.signal,
151
176
  });
152
177
 
153
178
  if (!response.ok) {
154
179
  const text = await response.text().catch(() => "");
155
- if (response.status === 401 || response.status === 403) {
156
- throw new Error(`Token refresh unauthorized: ${text}`);
180
+ let errorCode = "";
181
+ try {
182
+ const body = JSON.parse(text) as { error?: unknown };
183
+ if (typeof body.error === "string") errorCode = body.error;
184
+ } catch {
185
+ // Non-JSON OAuth errors are classified by status below.
186
+ }
187
+ if (response.status === 401 || response.status === 403 || errorCode === "invalid_grant") {
188
+ throw new KimiLoginRequiredError();
157
189
  }
158
190
  if (RETRYABLE_REFRESH_STATUSES.has(response.status)) {
159
191
  throw new Error(`Token refresh retryable: ${response.status} ${text}`);
@@ -168,9 +200,10 @@ export async function refreshAccessToken(
168
200
 
169
201
  return data;
170
202
  } catch (error) {
203
+ if (options.signal?.aborted) throw options.signal.reason;
171
204
  lastError = error;
172
205
  const message = error instanceof Error ? error.message : String(error);
173
- if (message.startsWith("Token refresh unauthorized:")) throw error;
206
+ if (error instanceof KimiLoginRequiredError) throw error;
174
207
  if (message.startsWith("Token refresh failed:")) throw error;
175
208
  if (attempt < maxRetries - 1) {
176
209
  await wait(2 ** attempt * 1000);
@@ -196,15 +229,19 @@ interface KimiCliCredentialsFile {
196
229
  access_token?: string;
197
230
  refresh_token?: string;
198
231
  expires_at?: number; // Unix seconds (upstream convention)
232
+ scope?: string;
233
+ token_type?: string;
234
+ expires_in?: number;
199
235
  }
200
236
 
201
- function getKimiCredentialPaths(): string[] {
237
+ function getKimiCodeCredentialPath(): string {
202
238
  const kimiCodeHome = process.env.KIMI_CODE_HOME || join(os.homedir(), ".kimi-code");
239
+ return join(kimiCodeHome, "credentials", "kimi-code.json");
240
+ }
241
+
242
+ function getKimiCredentialPaths(): string[] {
203
243
  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
- ];
244
+ return [getKimiCodeCredentialPath(), join(shareDir, "credentials", "kimi-code.json")];
208
245
  }
209
246
 
210
247
  function readKimiCliCredentials(): KimiCliCredentialsFile | null {
@@ -221,6 +258,25 @@ function readKimiCliCredentials(): KimiCliCredentialsFile | null {
221
258
  return null;
222
259
  }
223
260
 
261
+ function kimiCodeCredentialExists(): boolean {
262
+ return existsSync(getKimiCodeCredentialPath());
263
+ }
264
+
265
+ function writeKimiCodeCredentials(access: string, refresh: string, expiresMs: number): void {
266
+ const path = getKimiCodeCredentialPath();
267
+ const dir = dirname(path);
268
+ mkdirSync(dir, { recursive: true });
269
+ const data: KimiCliCredentialsFile = {
270
+ access_token: access,
271
+ refresh_token: refresh,
272
+ expires_at: Math.floor(expiresMs / 1000),
273
+ scope: "",
274
+ token_type: "Bearer",
275
+ expires_in: Math.max(0, Math.floor((expiresMs - Date.now()) / 1000)),
276
+ };
277
+ writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
278
+ }
279
+
224
280
  async function tryReuseKimiCliCredentials(
225
281
  callbacks: OAuthLoginCallbacks,
226
282
  ): Promise<KimiOAuthCredentials | null> {
@@ -247,11 +303,13 @@ async function tryReuseKimiCliCredentials(
247
303
  callbacks.onProgress?.("Kimi access token expired, refreshing.");
248
304
  try {
249
305
  const token = await refreshAccessToken(data.refresh_token!);
306
+ const expiresMs = Date.now() + token.expires_in * 1000;
307
+ writeKimiCodeCredentials(token.access_token, token.refresh_token, expiresMs);
250
308
  const extras = await discoverKimiModelMetadata(token.access_token);
251
309
  return {
252
310
  access: token.access_token,
253
311
  refresh: token.refresh_token,
254
- expires: Date.now() + token.expires_in * 1000,
312
+ expires: expiresMs,
255
313
  ...extras,
256
314
  };
257
315
  } catch {
@@ -319,11 +377,13 @@ export async function loginKimiCode(callbacks: OAuthLoginCallbacks): Promise<Kim
319
377
  }
320
378
 
321
379
  if (token) {
380
+ const expiresMs = Date.now() + token.expires_in * 1000;
381
+ writeKimiCodeCredentials(token.access_token, token.refresh_token, expiresMs);
322
382
  const extras = await discoverKimiModelMetadata(token.access_token);
323
383
  return {
324
384
  access: token.access_token,
325
385
  refresh: token.refresh_token,
326
- expires: Date.now() + token.expires_in * 1000,
386
+ expires: expiresMs,
327
387
  ...extras,
328
388
  };
329
389
  }
@@ -335,12 +395,24 @@ export async function loginKimiCode(callbacks: OAuthLoginCallbacks): Promise<Kim
335
395
  export async function refreshKimiCodeToken(
336
396
  credentials: OAuthCredentials,
337
397
  ): Promise<KimiOAuthCredentials> {
338
- const token = await refreshAccessToken(credentials.refresh);
398
+ const kimiCred = kimiCodeCredentialExists() ? readKimiCliCredentials() : null;
399
+ let token: Awaited<ReturnType<typeof refreshAccessToken>>;
400
+ try {
401
+ token = await refreshAccessToken(credentials.refresh);
402
+ } catch (error) {
403
+ if (kimiCred?.refresh_token && kimiCred.refresh_token !== credentials.refresh) {
404
+ token = await refreshAccessToken(kimiCred.refresh_token);
405
+ } else {
406
+ throw error;
407
+ }
408
+ }
409
+ const expiresMs = Date.now() + token.expires_in * 1000;
410
+ writeKimiCodeCredentials(token.access_token, token.refresh_token, expiresMs);
339
411
  const extras = await discoverKimiModelMetadata(token.access_token);
340
412
  return {
341
413
  access: token.access_token,
342
414
  refresh: token.refresh_token,
343
- expires: Date.now() + token.expires_in * 1000,
415
+ expires: expiresMs,
344
416
  ...extras,
345
417
  };
346
418
  }
@@ -356,8 +428,17 @@ export async function refreshKimiCodeToken(
356
428
  // new credentials under a file lock), and retry once.
357
429
  // =============================================================================
358
430
 
431
+ export function getKimiApiKey(credentials: OAuthCredentials): string {
432
+ return credentials.access;
433
+ }
434
+
359
435
  export function isKimiAuthErrorMessage(message: unknown): boolean {
360
436
  const text = String(message ?? "").toLowerCase();
437
+ const isMembershipPermissionError =
438
+ text.includes("current subscription does not have access to k3") ||
439
+ text.includes("current plan supports only kimi-k3 up to 256k context") ||
440
+ text.includes("current subscription does not have access to kimi-for-coding-highspeed");
441
+ if (isMembershipPermissionError) return false;
361
442
  return (
362
443
  /\b401\b/.test(text) ||
363
444
  text.includes("unauthorized") ||
@@ -366,39 +447,87 @@ export function isKimiAuthErrorMessage(message: unknown): boolean {
366
447
  );
367
448
  }
368
449
 
369
- export async function refreshKimiAuthToken(currentKey: string): Promise<string | null> {
450
+ export async function refreshKimiAuthToken(
451
+ currentKey: string,
452
+ options: { signal?: AbortSignal } = {},
453
+ ): Promise<string | null> {
370
454
  try {
455
+ const kimiCred = readKimiCliCredentials();
371
456
  const storage = AuthStorage.create();
372
- const cred = storage.get(PROVIDER_ID);
373
- if (!cred || cred.type !== "oauth") {
457
+ const piCred = storage.get(PROVIDER_ID);
458
+ const piOAuth = piCred?.type === "oauth" ? piCred : null;
459
+
460
+ if (piOAuth) {
461
+ if (piOAuth.access !== currentKey && Date.now() < piOAuth.expires) {
462
+ console.error("[kimi-coding] auth refresh: reusing newer on-disk token");
463
+ return piOAuth.access;
464
+ }
465
+
466
+ console.error("[kimi-coding] auth refresh: requesting new access token");
467
+ let refreshed: Awaited<ReturnType<typeof refreshAccessToken>>;
468
+ try {
469
+ refreshed = await refreshAccessToken(piOAuth.refresh, { signal: options.signal });
470
+ } catch (error) {
471
+ if (!kimiCred?.refresh_token || kimiCred.refresh_token === piOAuth.refresh) throw error;
472
+ const kimiExpiresMs = (kimiCred.expires_at ?? 0) * 1000;
473
+ if (kimiCred.access_token !== currentKey && Date.now() < kimiExpiresMs) {
474
+ const recovered: OAuthCredential = {
475
+ ...piOAuth,
476
+ type: "oauth",
477
+ access: kimiCred.access_token!,
478
+ refresh: kimiCred.refresh_token,
479
+ expires: kimiExpiresMs,
480
+ };
481
+ storage.set(PROVIDER_ID, recovered);
482
+ console.error("[kimi-coding] auth refresh: recovered newer kimi-code token");
483
+ return recovered.access;
484
+ }
485
+ console.error("[kimi-coding] auth refresh: pi token rejected, trying kimi-code token");
486
+ refreshed = await refreshAccessToken(kimiCred.refresh_token, { signal: options.signal });
487
+ }
488
+
489
+ const newExpiresMs = Date.now() + refreshed.expires_in * 1000;
490
+ const newCred: OAuthCredential = {
491
+ ...piOAuth,
492
+ type: "oauth",
493
+ access: refreshed.access_token,
494
+ refresh: refreshed.refresh_token,
495
+ expires: newExpiresMs,
496
+ };
497
+ storage.set(PROVIDER_ID, newCred);
498
+ writeKimiCodeCredentials(refreshed.access_token, refreshed.refresh_token, newExpiresMs);
499
+ console.error("[kimi-coding] auth refresh: new token persisted");
500
+ return newCred.access;
501
+ }
502
+
503
+ if (!kimiCred) {
374
504
  console.error(
375
505
  `[kimi-coding] auth refresh skipped: no OAuth credentials for ${PROVIDER_ID} on disk`,
376
506
  );
377
507
  return null;
378
508
  }
379
509
 
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;
510
+ const kimiExpiresMs = (kimiCred.expires_at ?? 0) * 1000;
511
+ if (kimiCred.access_token !== currentKey && Date.now() < kimiExpiresMs) {
512
+ console.error("[kimi-coding] auth refresh: reusing newer kimi-code token");
513
+ return kimiCred.access_token!;
386
514
  }
387
515
 
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;
516
+ console.error("[kimi-coding] auth refresh: requesting new access token via kimi-code");
517
+ const refreshed = await refreshAccessToken(kimiCred.refresh_token!, {
518
+ signal: options.signal,
519
+ });
520
+ const newExpiresMs = Date.now() + refreshed.expires_in * 1000;
521
+ writeKimiCodeCredentials(refreshed.access_token, refreshed.refresh_token, newExpiresMs);
522
+ console.error("[kimi-coding] auth refresh: new token persisted to kimi-code");
523
+ return refreshed.access_token;
400
524
  } catch (err) {
401
- console.error("[kimi-coding] auth refresh failed:", err);
525
+ if (options.signal?.aborted) return null;
526
+ if (err instanceof KimiLoginRequiredError) {
527
+ console.error(`[kimi-coding] ${KIMI_LOGIN_REQUIRED_MESSAGE}`);
528
+ } else {
529
+ console.error("[kimi-coding] auth refresh failed:", err);
530
+ }
402
531
  return null;
403
532
  }
404
533
  }