pi-provider-kimi-code 0.6.12 → 0.6.14

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/config.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
 
4
- import { PROVIDER_ID, type KimiWireProtocol } from "./constants.ts";
4
+ import { PROVIDER_ID, type KimiRegion, type KimiWireProtocol } from "./constants.ts";
5
5
 
6
6
  export const KIMI_TOOL_NAMES = ["moonshot_search", "moonshot_fetch", "kimi_datasource"] as const;
7
7
 
@@ -47,6 +47,13 @@ export interface KimiCodeConfig {
47
47
  tools: Record<KimiToolName, { enabled: boolean; default_collapsed: boolean }>;
48
48
  uploads: { thresholdBytes: number };
49
49
  protocol: KimiWireProtocol;
50
+ /**
51
+ * Managed region for the default endpoints. `null` means "not specified
52
+ * here" — the module-load `KIMI_CODE_REGION` env capture (or the
53
+ * mainland-cn default) then decides. An explicit value from project/home
54
+ * config wins over the env capture once pushed via setKimiRegionOverride.
55
+ */
56
+ region: KimiRegion | null;
50
57
  }
51
58
 
52
59
  export type KimiCodeConfigPatch = Partial<{
@@ -62,6 +69,7 @@ export type KimiCodeConfigPatch = Partial<{
62
69
  tools: Partial<Record<KimiToolName, Partial<{ enabled: unknown; default_collapsed: unknown }>>>;
63
70
  uploads: Partial<{ thresholdBytes: unknown }>;
64
71
  protocol: unknown;
72
+ region: unknown;
65
73
  }>;
66
74
 
67
75
  export interface KimiCodeConfigSources {
@@ -81,6 +89,7 @@ export interface KimiCodeConfigSources {
81
89
  tools: Record<KimiToolName, { enabled: KimiConfigSource; default_collapsed: KimiConfigSource }>;
82
90
  uploads: { thresholdBytes: KimiConfigSource };
83
91
  protocol: KimiConfigSource;
92
+ region: KimiConfigSource;
84
93
  }
85
94
 
86
95
  export interface LoadKimiCodeConfigOptions {
@@ -132,6 +141,7 @@ export const DEFAULT_KIMI_CODE_CONFIG: KimiCodeConfig = {
132
141
  tools: makeDefaultTools(),
133
142
  uploads: { thresholdBytes: 1048576 },
134
143
  protocol: "openai",
144
+ region: null,
135
145
  };
136
146
 
137
147
  let runtimeOverride: KimiCodeConfigPatch = {};
@@ -257,6 +267,12 @@ function envConfigPatch(env: NodeJS.ProcessEnv): KimiCodeConfigPatch {
257
267
  const protocol = env.KIMI_CODE_PROTOCOL?.trim();
258
268
  if (protocol) patch.protocol = protocol;
259
269
 
270
+ // KIMI_CODE_REGION intentionally does NOT enter the merged config: the env
271
+ // acts as a fallback below the config.json layers (documented precedence:
272
+ // explicit config > env > built-in default), and an invalid value must not
273
+ // crash startup via strict validation — currentKimiRegion() falls back to
274
+ // the module-load capture instead.
275
+
260
276
  const capabilities = env.KIMI_MODEL_CAPABILITIES;
261
277
  if (capabilities) {
262
278
  const caps = new Set(
@@ -323,6 +339,20 @@ function requireProtocol(
323
339
  );
324
340
  }
325
341
 
342
+ function requireRegionOrNull(
343
+ raw: unknown,
344
+ configPath: string,
345
+ pointer: string,
346
+ ): KimiCodeConfig["region"] {
347
+ if (raw === null) return null;
348
+ if (raw === "mainland-cn" || raw === "global") return raw;
349
+ return fail(
350
+ configPath,
351
+ pointer,
352
+ `expected "mainland-cn" | "global" | null, got ${JSON.stringify(raw)}`,
353
+ );
354
+ }
355
+
326
356
  function requireInputArray(raw: unknown, configPath: string, pointer: string): KimiInputModality[] {
327
357
  const valid = new Set(["text", "image", "video"]);
328
358
  if (!Array.isArray(raw)) return fail(configPath, pointer, `expected an array, got ${typeof raw}`);
@@ -438,6 +468,7 @@ export function validateKimiCodeConfig(
438
468
  ),
439
469
  },
440
470
  protocol: requireProtocol(config.protocol, configPath, "/protocol"),
471
+ region: requireRegionOrNull(config.region, configPath, "/region"),
441
472
  };
442
473
  }
443
474
 
@@ -487,6 +518,7 @@ function buildSources(
487
518
  tools,
488
519
  uploads: { thresholdBytes: sourceForPath(layers, ["uploads", "thresholdBytes"]) },
489
520
  protocol: sourceForPath(layers, ["protocol"]),
521
+ region: sourceForPath(layers, ["region"]),
490
522
  };
491
523
  }
492
524
 
package/src/constants.ts CHANGED
@@ -7,7 +7,82 @@ import os from "node:os";
7
7
  import { join } from "node:path";
8
8
 
9
9
  export const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
10
- export const DEFAULT_OAUTH_HOST = "https://auth.kimi.com";
10
+
11
+ // Region profiles mirror upstream kimi-code's packages/oauth region.ts: a
12
+ // region is a bundle of the managed API base origin and the OAuth host. The
13
+ // region only picks the DEFAULT endpoints — explicit env overrides
14
+ // (KIMI_CODE_BASE_URL / KIMI_CODE_OAUTH_HOST) always win. Global users with
15
+ // kimi.ai credentials otherwise hit 401s against the hardcoded .com defaults
16
+ // (issue #77).
17
+ export type KimiRegion = "mainland-cn" | "global";
18
+
19
+ export const KIMI_REGION_PROFILES: Record<KimiRegion, { apiBase: string; oauthHost: string }> = {
20
+ "mainland-cn": { apiBase: "https://api.kimi.com", oauthHost: "https://auth.kimi.com" },
21
+ global: { apiBase: "https://api.kimi.ai", oauthHost: "https://auth.kimi.ai" },
22
+ };
23
+
24
+ export function parseKimiRegion(value: string | undefined): KimiRegion {
25
+ if (value === "global" || value === "mainland-cn") return value;
26
+ return "mainland-cn";
27
+ }
28
+
29
+ export const ENV_KIMI_CODE_REGION: KimiRegion = parseKimiRegion(process.env.KIMI_CODE_REGION);
30
+
31
+ // Effective region pushed from the plugin config (project/home JSON via
32
+ // /kimi-settings), taking precedence over the module-load env capture so a
33
+ // settings change applies without a restart. `null` means "config did not
34
+ // specify" and falls back to ENV_KIMI_CODE_REGION.
35
+ let resolvedRegionOverride: KimiRegion | null = null;
36
+
37
+ export function setKimiRegionOverride(region: KimiRegion | null): void {
38
+ resolvedRegionOverride = region;
39
+ }
40
+
41
+ export function currentKimiRegion(): KimiRegion {
42
+ return resolvedRegionOverride ?? ENV_KIMI_CODE_REGION;
43
+ }
44
+
45
+ // Tracks a KIMI_CODE_OAUTH_HOST value injected by the bridge so a re-apply
46
+ // (e.g. region switched back to mainland in /kimi-settings) can retract it
47
+ // without clobbering a value the user configured in the meantime.
48
+ let regionBridgeOwnedValue: string | undefined;
49
+
50
+ // Bridge the selected region into pi core's built-in Kimi OAuth: pi-ai reads
51
+ // KIMI_CODE_OAUTH_HOST at call time, so filling it makes /login follow the
52
+ // configured region even when the login runs through pi's own kimi-coding
53
+ // auth path instead of this extension's. Only fills the variable when the
54
+ // region is non-default and the user has not configured an explicit override.
55
+ export function applyKimiRegionEnvBridge(
56
+ env: NodeJS.ProcessEnv = process.env,
57
+ region: KimiRegion = currentKimiRegion(),
58
+ ): boolean {
59
+ if (regionBridgeOwnedValue !== undefined && env.KIMI_CODE_OAUTH_HOST === regionBridgeOwnedValue) {
60
+ delete env.KIMI_CODE_OAUTH_HOST;
61
+ }
62
+ regionBridgeOwnedValue = undefined;
63
+ // pi core's OAuth reads only KIMI_CODE_OAUTH_HOST; mirror the documented
64
+ // KIMI_OAUTH_HOST alias into it so the alias keeps working on the built-in
65
+ // login path. The alias stays the source of truth.
66
+ if (!env.KIMI_CODE_OAUTH_HOST && env.KIMI_OAUTH_HOST?.trim()) {
67
+ env.KIMI_CODE_OAUTH_HOST = env.KIMI_OAUTH_HOST.trim();
68
+ regionBridgeOwnedValue = env.KIMI_CODE_OAUTH_HOST;
69
+ }
70
+ if (region === "mainland-cn") return false;
71
+ if (env.KIMI_CODE_OAUTH_HOST || env.KIMI_OAUTH_HOST) return false;
72
+ env.KIMI_CODE_OAUTH_HOST = KIMI_REGION_PROFILES[region].oauthHost;
73
+ regionBridgeOwnedValue = env.KIMI_CODE_OAUTH_HOST;
74
+ return true;
75
+ }
76
+
77
+ /** Normalize any managed base URL to its `/v1` form. Single implementation
78
+ * shared by the tool endpoints (src/tools/common.ts), the usage/account URLs
79
+ * (src/usage.ts), and the credential probe (src/oauth.ts). */
80
+ export function normalizeKimiBaseV1(baseUrl: string): string {
81
+ const base = baseUrl.replace(/\/+$/, "");
82
+ return base.endsWith("/v1") ? base : `${base}/v1`;
83
+ }
84
+
85
+ export const DEFAULT_OAUTH_HOST = KIMI_REGION_PROFILES[ENV_KIMI_CODE_REGION].oauthHost;
11
86
 
12
87
  export const KIMI_WIRE_PROTOCOLS = ["openai", "anthropic", "responses"] as const;
13
88
  export type KimiWireProtocol = (typeof KIMI_WIRE_PROTOCOLS)[number];
@@ -48,15 +123,17 @@ export function getKimiApiType(protocol: KimiWireProtocol): KimiApiType {
48
123
  // built-in anthropic-messages / openai-completions / openai-responses handlers.
49
124
  export const KIMI_API_TYPE = getKimiApiType(ENV_KIMI_CODE_PROTOCOL);
50
125
 
51
- export function getDefaultBaseUrl(protocol: KimiWireProtocol): string {
52
- return protocol === "anthropic"
53
- ? "https://api.kimi.com/coding"
54
- : "https://api.kimi.com/coding/v1";
126
+ export function getDefaultBaseUrl(
127
+ protocol: KimiWireProtocol,
128
+ region: KimiRegion = currentKimiRegion(),
129
+ ): string {
130
+ const apiBase = KIMI_REGION_PROFILES[region].apiBase;
131
+ return protocol === "anthropic" ? `${apiBase}/coding` : `${apiBase}/coding/v1`;
55
132
  }
56
133
 
57
134
  export const DEFAULT_BASE_URL = getDefaultBaseUrl(ENV_KIMI_CODE_PROTOCOL);
58
135
 
59
- export const PROVIDER_VERSION = "0.6.12";
136
+ export const PROVIDER_VERSION = "0.6.14";
60
137
 
61
138
  export const KIMI_CODE_USER_AGENT = `pi-provider-kimi-code/${PROVIDER_VERSION}`;
62
139
  export const KIMI_PLATFORM = "pi";
@@ -79,9 +156,9 @@ export const PROVIDER_ID = "kimi-coding";
79
156
  export const KIMI_GLOBAL_FALLBACK_SOURCE_ID = "pi-provider-kimi-code-global-fallback";
80
157
 
81
158
  export function getOAuthHost(): string {
82
- const value =
83
- process.env.KIMI_CODE_OAUTH_HOST || process.env.KIMI_OAUTH_HOST || DEFAULT_OAUTH_HOST;
84
- return value.trim() || DEFAULT_OAUTH_HOST;
159
+ const value = process.env.KIMI_CODE_OAUTH_HOST || process.env.KIMI_OAUTH_HOST;
160
+ if (value?.trim()) return value.trim();
161
+ return KIMI_REGION_PROFILES[currentKimiRegion()].oauthHost;
85
162
  }
86
163
 
87
164
  export function getBaseUrl(protocol: KimiWireProtocol = ENV_KIMI_CODE_PROTOCOL): string {
package/src/errors.ts ADDED
@@ -0,0 +1,31 @@
1
+ // Kimi error-message classification, mirroring upstream kimi-code #4477 /
2
+ // #4703: quota exhaustion is a hard limit with explicit non-retryability, not
3
+ // a transient rate limit — say so instead of letting the raw 429 body read
4
+ // like something a retry would fix.
5
+
6
+ const QUOTA_ERROR_CODE = "exceeded_current_quota_error";
7
+
8
+ /**
9
+ * Detect Kimi's structured quota error in a flattened error message
10
+ * (status line + JSON body). Both markers are required to avoid matching
11
+ * unrelated 429 rate limits, which ARE transient.
12
+ */
13
+ export function isKimiQuotaExhaustedMessage(message: unknown): boolean {
14
+ const text = String(message ?? "").toLowerCase();
15
+ return text.includes(QUOTA_ERROR_CODE) && /\b429\b/.test(text);
16
+ }
17
+
18
+ /**
19
+ * Rewrite a raw upstream error into actionable text. Returns the input
20
+ * unchanged when nothing applies.
21
+ */
22
+ export function improveKimiErrorMessage(message: string): string {
23
+ if (!isKimiQuotaExhaustedMessage(message)) return message;
24
+ return (
25
+ "Kimi Code quota exhausted (HTTP 429, exceeded_current_quota_error). " +
26
+ "This is a hard quota limit, not a transient rate limit — retrying will " +
27
+ "keep failing until the quota window resets. Run /kimi-settings to check " +
28
+ "your current usage. " +
29
+ `Original error: ${message}`
30
+ );
31
+ }
package/src/models.ts CHANGED
@@ -99,6 +99,15 @@ function resolveModelCost(
99
99
  // per-request cap still wins (options.maxTokens ?? model.maxTokens). Reconcile
100
100
  // wherever contextWindow is authored so the two stay in lockstep.
101
101
  //
102
+ // Wire note (upstream kimi-code #4091): the OpenAI wires now omit the
103
+ // completion cap entirely unless one is explicitly configured —
104
+ // applyKimiPayloadMutations drops the pi-ai-seeded cap when the model cap is
105
+ // window-tracked, keeping it only for Anthropic /messages (which requires
106
+ // max_tokens, where the window-clamped value matches upstream's
107
+ // kimiUnsetCompletionTokens). The window-tracked maxTokens therefore feeds
108
+ // pi-ai internals (thinking-budget math) and the Anthropic wire, not the
109
+ // OpenAI payloads.
110
+ //
102
111
  // "Explicitly configured" means any value other than the built-in default:
103
112
  // ensureKimiCodeConfig materializes the full default config (including
104
113
  // maxTokens) into the home config file, so config-source tracking cannot
@@ -175,6 +184,13 @@ interface KimiServerModel {
175
184
  supports_thinking_type?: unknown;
176
185
  protocol?: unknown;
177
186
  think_efforts?: unknown;
187
+ // Unified schema (upstream kimi-code #4209). The server currently sends
188
+ // both shapes; the official client already reads ONLY these fields, so
189
+ // prefer them and treat the flat legacy fields above as fallback.
190
+ limit?: unknown;
191
+ reasoning?: unknown;
192
+ modalities?: unknown;
193
+ tools?: unknown;
178
194
  }
179
195
 
180
196
  function parseSupportsThinkingType(value: unknown): "only" | "no" | "both" | undefined {
@@ -212,28 +228,89 @@ function parseThinkEfforts(value: unknown): {
212
228
  };
213
229
  }
214
230
 
231
+ // Unified `reasoning` object: { type: "only" | "both" | "none", effort: {
232
+ // valid: [...], default } }. A missing object means the legacy flat fields
233
+ // decide; a present object always wins (type "none" clears a stale thinking
234
+ // default, matching upstream's three-state mapping to internal "no").
235
+ function parseUnifiedReasoning(value: unknown): {
236
+ readonly matched: boolean;
237
+ readonly supportsThinkingType?: "only" | "no" | "both";
238
+ readonly supportsReasoning?: boolean;
239
+ readonly supportEfforts?: string[];
240
+ readonly defaultEffort?: string;
241
+ } {
242
+ if (!isRecord(value)) return { matched: false };
243
+ const rawType = value.type;
244
+ const supportsThinkingType =
245
+ rawType === "only" || rawType === "both" ? rawType : rawType === "none" ? "no" : undefined;
246
+ const effort = isRecord(value.effort) ? value.effort : undefined;
247
+ const supportEfforts = Array.isArray(effort?.valid)
248
+ ? effort.valid.filter((e): e is string => typeof e === "string" && !!e)
249
+ : undefined;
250
+ const rawDefault =
251
+ typeof effort?.default === "string" && effort.default ? effort.default : undefined;
252
+ // A default not listed in valid is dropped, same as upstream.
253
+ const defaultEffort =
254
+ rawDefault && supportEfforts && !supportEfforts.includes(rawDefault) ? undefined : rawDefault;
255
+ return {
256
+ matched: true,
257
+ ...(supportsThinkingType ? { supportsThinkingType } : {}),
258
+ supportsReasoning: supportsThinkingType !== undefined && supportsThinkingType !== "no",
259
+ ...(supportEfforts && supportEfforts.length > 0 ? { supportEfforts } : {}),
260
+ ...(defaultEffort ? { defaultEffort } : {}),
261
+ };
262
+ }
263
+
264
+ function parseInputModalities(value: unknown): {
265
+ supportsImageIn?: boolean;
266
+ supportsVideoIn?: boolean;
267
+ } {
268
+ if (!isRecord(value) || !Array.isArray(value.input)) return {};
269
+ const input = value.input;
270
+ return {
271
+ supportsImageIn: input.includes("image"),
272
+ supportsVideoIn: input.includes("video"),
273
+ };
274
+ }
275
+
215
276
  function parseKimiModelMetadata(model: KimiServerModel): KimiModelMetadata | undefined {
216
277
  if (typeof model.id !== "string" || !model.id) return undefined;
217
278
  const metadata: KimiModelMetadata = { wireModelId: model.id };
218
279
  if (typeof model.display_name === "string") metadata.modelDisplay = model.display_name;
219
- if (typeof model.context_length === "number" && model.context_length > 0) {
280
+ // Unified limit.context wins over legacy context_length.
281
+ const limit = isRecord(model.limit) ? model.limit : undefined;
282
+ if (typeof limit?.context === "number" && limit.context > 0) {
283
+ metadata.contextLength = limit.context;
284
+ } else if (typeof model.context_length === "number" && model.context_length > 0) {
220
285
  metadata.contextLength = model.context_length;
221
286
  }
222
- const thinkingType = parseSupportsThinkingType(model.supports_thinking_type);
223
- if (thinkingType) {
224
- metadata.supportsThinkingType = thinkingType;
225
- } else if (typeof model.supports_reasoning === "boolean") {
226
- metadata.supportsReasoning = model.supports_reasoning;
227
- }
228
- if (typeof model.supports_image_in === "boolean") {
229
- metadata.supportsImageIn = model.supports_image_in;
287
+ const unifiedReasoning = parseUnifiedReasoning(model.reasoning);
288
+ if (unifiedReasoning.matched) {
289
+ const { matched: _, ...fields } = unifiedReasoning;
290
+ Object.assign(metadata, fields);
291
+ } else {
292
+ const thinkingType = parseSupportsThinkingType(model.supports_thinking_type);
293
+ if (thinkingType) {
294
+ metadata.supportsThinkingType = thinkingType;
295
+ } else if (typeof model.supports_reasoning === "boolean") {
296
+ metadata.supportsReasoning = model.supports_reasoning;
297
+ }
298
+ Object.assign(metadata, parseThinkEfforts(model.think_efforts));
230
299
  }
231
- if (typeof model.supports_video_in === "boolean") {
232
- metadata.supportsVideoIn = model.supports_video_in;
300
+ const modalities = parseInputModalities(model.modalities);
301
+ if (modalities.supportsImageIn !== undefined) {
302
+ metadata.supportsImageIn = modalities.supportsImageIn;
303
+ metadata.supportsVideoIn = modalities.supportsVideoIn;
304
+ } else {
305
+ if (typeof model.supports_image_in === "boolean") {
306
+ metadata.supportsImageIn = model.supports_image_in;
307
+ }
308
+ if (typeof model.supports_video_in === "boolean") {
309
+ metadata.supportsVideoIn = model.supports_video_in;
310
+ }
233
311
  }
234
312
  const protocol = parseKimiCatalogProtocol(model.protocol);
235
313
  if (protocol) metadata.protocol = protocol;
236
- Object.assign(metadata, parseThinkEfforts(model.think_efforts));
237
314
  return metadata;
238
315
  }
239
316
 
@@ -246,10 +323,15 @@ function getModelsUrl(protocol?: KimiWireProtocol): string {
246
323
  return buildModelsUrl(getBaseUrl(protocol));
247
324
  }
248
325
 
326
+ // Both official managed deployments serve /coding/v1/models; global (kimi.ai)
327
+ // users get model discovery too, not just mainland (.com).
249
328
  export function isOfficialKimiModelsUrl(url: string): boolean {
250
329
  try {
251
330
  const parsed = new URL(url);
252
- return parsed.origin === "https://api.kimi.com" && parsed.pathname === "/coding/v1/models";
331
+ return (
332
+ (parsed.origin === "https://api.kimi.com" || parsed.origin === "https://api.kimi.ai") &&
333
+ parsed.pathname === "/coding/v1/models"
334
+ );
253
335
  } catch {
254
336
  return false;
255
337
  }
package/src/oauth.ts CHANGED
@@ -155,7 +155,14 @@ async function lockStoredOAuthCredential(
155
155
  };
156
156
  }
157
157
 
158
- import { CLIENT_ID, PROVIDER_ID, RETRYABLE_REFRESH_STATUSES, getOAuthHost } from "./constants.ts";
158
+ import {
159
+ CLIENT_ID,
160
+ PROVIDER_ID,
161
+ RETRYABLE_REFRESH_STATUSES,
162
+ getBaseUrl,
163
+ getOAuthHost,
164
+ normalizeKimiBaseV1,
165
+ } from "./constants.ts";
159
166
  import { getCommonHeaders } from "./device.ts";
160
167
  import { type KimiOAuthCredentials, discoverKimiModelMetadata } from "./models.ts";
161
168
 
@@ -211,6 +218,24 @@ export async function requestDeviceAuthorization(): Promise<DeviceAuthorization>
211
218
  throw new Error("Invalid device authorization response");
212
219
  }
213
220
 
221
+ // Guard against a malicious or buggy server handing us a non-web URL
222
+ // (file:, custom scheme, ...) that the OS URL handler would happily open.
223
+ // Mirrors pi 1.5.0's check for the built-in OAuth providers.
224
+ for (const url of [data.verification_uri, verificationUrl]) {
225
+ if (!url) continue;
226
+ let parsed: URL;
227
+ try {
228
+ parsed = new URL(url);
229
+ } catch {
230
+ throw new Error("Device authorization returned an invalid verification URL");
231
+ }
232
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
233
+ throw new Error(
234
+ `Device authorization returned a non-web verification URL (${parsed.protocol})`,
235
+ );
236
+ }
237
+ }
238
+
214
239
  return {
215
240
  user_code: data.user_code,
216
241
  device_code: data.device_code,
@@ -435,6 +460,25 @@ async function lockKimiCodeCredentials(signal?: AbortSignal): Promise<LockedKimi
435
460
  };
436
461
  }
437
462
 
463
+ // Tokens are region-bound, but the kimi-code CLI credential file does not
464
+ // record which OAuth host minted them — so a fresh credential reused across
465
+ // regions (a mainland CLI login while KIMI_CODE_REGION=global, or vice versa)
466
+ // would 401 every request. Probe the current region's managed API before
467
+ // reuse. Only an explicit 401 disproves usability: network failures must not
468
+ // force an unnecessary re-login. Exported for tests.
469
+ export async function isKimiCredentialRegionValid(accessToken: string): Promise<boolean> {
470
+ const meUrl = `${normalizeKimiBaseV1(getBaseUrl())}/me`;
471
+ try {
472
+ const response = await fetch(meUrl, {
473
+ headers: { ...getCommonHeaders(), Authorization: `Bearer ${accessToken}` },
474
+ signal: AbortSignal.timeout(5_000),
475
+ });
476
+ return response.status !== 401;
477
+ } catch {
478
+ return true;
479
+ }
480
+ }
481
+
438
482
  async function tryReuseKimiCliCredentials(
439
483
  callbacks: OAuthLoginCallbacks,
440
484
  ): Promise<KimiOAuthCredentials | null> {
@@ -449,6 +493,12 @@ async function tryReuseKimiCliCredentials(
449
493
  callbacks.onProgress?.("Found existing Kimi credentials, reusing them.");
450
494
 
451
495
  if (stillFresh) {
496
+ if (!(await isKimiCredentialRegionValid(data.access_token!))) {
497
+ callbacks.onProgress?.(
498
+ "Existing Kimi credentials are not valid for the configured region, starting a fresh login.",
499
+ );
500
+ return null;
501
+ }
452
502
  const extras = await discoverKimiModelMetadata(data.access_token!);
453
503
  return {
454
504
  access: data.access_token!,
@@ -461,6 +511,15 @@ async function tryReuseKimiCliCredentials(
461
511
  callbacks.onProgress?.("Kimi access token expired, refreshing.");
462
512
  try {
463
513
  const token = await refreshAccessToken(data.refresh_token!);
514
+ // Probe before persisting: a token that fails the region check must not
515
+ // be written into the shared CLI credential file, or a later CLI-side
516
+ // reuse (or the next /login) would pick up a token that 401s.
517
+ if (!(await isKimiCredentialRegionValid(token.access_token))) {
518
+ callbacks.onProgress?.(
519
+ "Refreshed Kimi credentials are not valid for the configured region, starting a fresh login.",
520
+ );
521
+ return null;
522
+ }
464
523
  const expiresMs = Date.now() + token.expires_in * 1000;
465
524
  writeKimiCodeCredentials(token.access_token, token.refresh_token, expiresMs);
466
525
  const extras = await discoverKimiModelMetadata(token.access_token);