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/oauth.ts CHANGED
@@ -52,43 +52,107 @@ export function readStoredOAuthCredential(providerId: string): StoredOAuthCreden
52
52
  // while held, recovers stale locks from crashed processes, and retries for
53
53
  // ~40s so a live holder running a slow network refresh inside the lock is
54
54
  // waited out.
55
- async function acquireAuthFileLock(authPath: string): Promise<() => Promise<void>> {
56
- return acquireFileLock(authPath, {
57
- realpath: false,
55
+ async function acquireSharedFileLock(
56
+ path: string,
57
+ signal: AbortSignal | undefined,
58
+ options: { maxRetries: number; stale: number; minTimeout: number; maxTimeout: number },
59
+ ): Promise<() => Promise<void>> {
60
+ for (let attempt = 0; ; attempt++) {
61
+ signal?.throwIfAborted();
62
+ try {
63
+ return await acquireFileLock(path, {
64
+ realpath: false,
65
+ stale: options.stale,
66
+ // Retry via the outer abortable sleep below. proper-lockfile's own
67
+ // retry loop is not signal-aware and would otherwise ignore an abort
68
+ // for the full configured retry window.
69
+ retries: 0,
70
+ });
71
+ } catch (error) {
72
+ const code =
73
+ typeof error === "object" && error !== null && "code" in error
74
+ ? String((error as { code?: unknown }).code)
75
+ : undefined;
76
+ if (code !== "ELOCKED" || attempt >= options.maxRetries) throw error;
77
+ const baseDelay = Math.min(options.minTimeout * 2 ** attempt, options.maxTimeout);
78
+ await sleep(baseDelay * (1 + Math.random()), signal);
79
+ }
80
+ }
81
+ }
82
+
83
+ async function acquireAuthFileLock(
84
+ authPath: string,
85
+ signal?: AbortSignal,
86
+ ): Promise<() => Promise<void>> {
87
+ return acquireSharedFileLock(authPath, signal, {
88
+ maxRetries: 10,
58
89
  stale: 30_000,
59
- retries: { retries: 10, factor: 2, minTimeout: 100, maxTimeout: 10_000, randomize: true },
90
+ minTimeout: 100,
91
+ maxTimeout: 10_000,
60
92
  });
61
93
  }
62
94
 
63
- async function writeStoredCredential(
64
- providerId: string,
65
- credential: StoredOAuthCredential,
66
- ): Promise<void> {
67
- const AuthStorage = piAgentRuntime.AuthStorage;
68
- if (AuthStorage) {
69
- AuthStorage.create().set(providerId, credential);
70
- return;
95
+ async function acquireKimiCodeRefreshLock(signal?: AbortSignal): Promise<() => Promise<void>> {
96
+ if (process.platform === "win32" || process.env.KIMI_DISABLE_OAUTH_LOCK === "1") {
97
+ return async () => {};
71
98
  }
72
- // pi >=0.80.8: no exported write path, so do a locked read-modify-write of
73
- // auth.json in pi's on-disk format (indent 2). The read happens inside the
74
- // lock so concurrent updates from other processes are never clobbered, and
75
- // the explicit chmod covers pre-existing files (writeFileSync's mode only
76
- // applies on creation).
99
+ const target = join(
100
+ process.env.KIMI_CODE_HOME || join(os.homedir(), ".kimi-code"),
101
+ "oauth",
102
+ "kimi-code",
103
+ );
104
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
105
+ writeFileSync(target, "", { encoding: "utf-8", flag: "a" });
106
+ return acquireSharedFileLock(target, signal, {
107
+ maxRetries: 120,
108
+ stale: 5_000,
109
+ minTimeout: 500,
110
+ maxTimeout: 1_000,
111
+ });
112
+ }
113
+
114
+ interface LockedOAuthCredential {
115
+ credential: StoredOAuthCredential | null;
116
+ store(credential: StoredOAuthCredential): void;
117
+ release(): Promise<void>;
118
+ }
119
+
120
+ async function lockStoredOAuthCredential(
121
+ providerId: string,
122
+ signal?: AbortSignal,
123
+ ): Promise<LockedOAuthCredential> {
124
+ // Hold the same lock Pi uses across the complete read-refresh-write
125
+ // transaction. Kimi rotates refresh tokens, so locking only the write lets
126
+ // concurrent Pi processes exchange the same token twice.
77
127
  const authPath = join(piAgentRuntime.getAgentDir(), "auth.json");
78
- const release = await acquireAuthFileLock(authPath);
128
+ mkdirSync(dirname(authPath), { recursive: true, mode: 0o700 });
129
+ if (!existsSync(authPath)) {
130
+ writeFileSync(authPath, "{}", { encoding: "utf-8", mode: 0o600 });
131
+ chmodSync(authPath, 0o600);
132
+ }
133
+ const release = await acquireAuthFileLock(authPath, signal);
134
+ let data: Record<string, unknown> = {};
79
135
  try {
80
- let data: Record<string, unknown> = {};
81
- try {
82
- data = JSON.parse(readFileSync(authPath, "utf-8"));
83
- } catch {
84
- // Missing or unreadable auth.json — start from an empty store.
136
+ const parsed = JSON.parse(readFileSync(authPath, "utf-8")) as unknown;
137
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
138
+ data = parsed as Record<string, unknown>;
85
139
  }
86
- data[providerId] = credential;
87
- writeFileSync(authPath, JSON.stringify(data, null, 2), { encoding: "utf-8", mode: 0o600 });
88
- chmodSync(authPath, 0o600);
89
- } finally {
90
- await release();
140
+ } catch {
141
+ // Missing or unreadable auth.json — treat it as an empty store.
91
142
  }
143
+ const stored = data[providerId] as StoredCredential | undefined;
144
+ return {
145
+ credential: stored?.type === "oauth" ? (stored as unknown as StoredOAuthCredential) : null,
146
+ store(credential) {
147
+ data[providerId] = credential;
148
+ writeFileSync(authPath, JSON.stringify(data, null, 2), {
149
+ encoding: "utf-8",
150
+ mode: 0o600,
151
+ });
152
+ chmodSync(authPath, 0o600);
153
+ },
154
+ release,
155
+ };
92
156
  }
93
157
 
94
158
  import { CLIENT_ID, PROVIDER_ID, RETRYABLE_REFRESH_STATUSES, getOAuthHost } from "./constants.ts";
@@ -339,14 +403,10 @@ function readKimiCliCredentials(): KimiCliCredentialsFile | null {
339
403
  return null;
340
404
  }
341
405
 
342
- function kimiCodeCredentialExists(): boolean {
343
- return existsSync(getKimiCodeCredentialPath());
344
- }
345
-
346
406
  function writeKimiCodeCredentials(access: string, refresh: string, expiresMs: number): void {
347
407
  const path = getKimiCodeCredentialPath();
348
408
  const dir = dirname(path);
349
- mkdirSync(dir, { recursive: true });
409
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
350
410
  const data: KimiCliCredentialsFile = {
351
411
  access_token: access,
352
412
  refresh_token: refresh,
@@ -355,7 +415,24 @@ function writeKimiCodeCredentials(access: string, refresh: string, expiresMs: nu
355
415
  token_type: "Bearer",
356
416
  expires_in: Math.max(0, Math.floor((expiresMs - Date.now()) / 1000)),
357
417
  };
358
- writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
418
+ writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
419
+ chmodSync(path, 0o600);
420
+ }
421
+
422
+ interface LockedKimiCodeCredentials {
423
+ credential: KimiCliCredentialsFile | null;
424
+ store(access: string, refresh: string, expiresMs: number): void;
425
+ release(): Promise<void>;
426
+ }
427
+
428
+ async function lockKimiCodeCredentials(signal?: AbortSignal): Promise<LockedKimiCodeCredentials> {
429
+ const release = await acquireKimiCodeRefreshLock(signal);
430
+ const credential = readKimiCliCredentials();
431
+ return {
432
+ credential,
433
+ store: writeKimiCodeCredentials,
434
+ release,
435
+ };
359
436
  }
360
437
 
361
438
  async function tryReuseKimiCliCredentials(
@@ -476,26 +553,83 @@ export async function loginKimiCode(callbacks: OAuthLoginCallbacks): Promise<Kim
476
553
  export async function refreshKimiCodeToken(
477
554
  credentials: OAuthCredentials,
478
555
  ): Promise<KimiOAuthCredentials> {
479
- const kimiCred = kimiCodeCredentialExists() ? readKimiCliCredentials() : null;
480
- let token: Awaited<ReturnType<typeof refreshAccessToken>>;
556
+ const kimiLocked = await lockKimiCodeCredentials();
481
557
  try {
482
- token = await refreshAccessToken(credentials.refresh);
483
- } catch (error) {
484
- if (kimiCred?.refresh_token && kimiCred.refresh_token !== credentials.refresh) {
485
- token = await refreshAccessToken(kimiCred.refresh_token);
486
- } else {
558
+ const initialKimiCred = kimiLocked.credential;
559
+ const initialKimiExpiresMs = (initialKimiCred?.expires_at ?? 0) * 1000;
560
+ const peerHasNewerValidAccess =
561
+ initialKimiCred?.access_token !== undefined &&
562
+ initialKimiCred.access_token !== credentials.access &&
563
+ Date.now() < initialKimiExpiresMs;
564
+ const activeRefreshToken = peerHasNewerValidAccess
565
+ ? initialKimiCred.refresh_token!
566
+ : credentials.refresh;
567
+ const activeAccessToken = peerHasNewerValidAccess
568
+ ? initialKimiCred.access_token!
569
+ : credentials.access;
570
+ if (peerHasNewerValidAccess && initialKimiCred.refresh_token !== credentials.refresh) {
571
+ const extras = await discoverKimiModelMetadata(activeAccessToken);
572
+ return {
573
+ access: activeAccessToken,
574
+ refresh: activeRefreshToken,
575
+ expires: initialKimiExpiresMs,
576
+ ...extras,
577
+ };
578
+ }
579
+ let token: Awaited<ReturnType<typeof refreshAccessToken>>;
580
+ try {
581
+ token = await refreshAccessToken(activeRefreshToken);
582
+ } catch (error) {
583
+ // Lock-disabled platforms (Windows / KIMI_DISABLE_OAUTH_LOCK=1) use
584
+ // storage re-read as the only coordinator. A peer may have rotated the
585
+ // shared sidecar while this process was exchanging the old refresh
586
+ // token; recover from that newer valid credential instead of surfacing
587
+ // the stale-token invalid_grant as a login failure.
588
+ await sleep(100);
589
+ const peerCredential = readKimiCliCredentials();
590
+ const peerAccess = peerCredential?.access_token;
591
+ const peerRefresh = peerCredential?.refresh_token;
592
+ const peerExpiresMs = (peerCredential?.expires_at ?? 0) * 1000;
593
+ if (
594
+ peerAccess !== undefined &&
595
+ peerRefresh !== undefined &&
596
+ peerAccess !== credentials.access &&
597
+ Date.now() < peerExpiresMs
598
+ ) {
599
+ const extras = await discoverKimiModelMetadata(peerAccess);
600
+ return {
601
+ access: peerAccess,
602
+ refresh: peerRefresh,
603
+ expires: peerExpiresMs,
604
+ ...extras,
605
+ };
606
+ }
487
607
  throw error;
488
608
  }
609
+ const expiresMs = Date.now() + token.expires_in * 1000;
610
+ const kimiCredentialAfterRefresh = readKimiCliCredentials();
611
+ const peerAccess = kimiCredentialAfterRefresh?.access_token;
612
+ const peerRefresh = kimiCredentialAfterRefresh?.refresh_token;
613
+ const peerExpiresMs = (kimiCredentialAfterRefresh?.expires_at ?? 0) * 1000;
614
+ const accessToken =
615
+ peerAccess !== undefined && peerAccess !== activeAccessToken && Date.now() < peerExpiresMs
616
+ ? peerAccess
617
+ : token.access_token;
618
+ const refreshToken = accessToken === peerAccess ? peerRefresh! : token.refresh_token;
619
+ const effectiveExpiresMs = accessToken === peerAccess ? peerExpiresMs : expiresMs;
620
+ if (accessToken !== peerAccess) {
621
+ kimiLocked.store(accessToken, refreshToken, effectiveExpiresMs);
622
+ }
623
+ const extras = await discoverKimiModelMetadata(accessToken);
624
+ return {
625
+ access: accessToken,
626
+ refresh: refreshToken,
627
+ expires: effectiveExpiresMs,
628
+ ...extras,
629
+ };
630
+ } finally {
631
+ await kimiLocked.release().catch(() => {});
489
632
  }
490
- const expiresMs = Date.now() + token.expires_in * 1000;
491
- writeKimiCodeCredentials(token.access_token, token.refresh_token, expiresMs);
492
- const extras = await discoverKimiModelMetadata(token.access_token);
493
- return {
494
- access: token.access_token,
495
- refresh: token.refresh_token,
496
- expires: expiresMs,
497
- ...extras,
498
- };
499
633
  }
500
634
 
501
635
  // =============================================================================
@@ -524,7 +658,13 @@ export function isKimiAuthErrorMessage(message: unknown): boolean {
524
658
  /\b401\b/.test(text) ||
525
659
  text.includes("unauthorized") ||
526
660
  text.includes("incorrect api key") ||
527
- text.includes("invalid api key")
661
+ text.includes("invalid api key") ||
662
+ // Kimi's production 401 body: "The API Key appears to be invalid or may
663
+ // have expired..." with type "invalid_authentication_error". Neither
664
+ // contains the substrings above, so without these the digit fallback is
665
+ // the only match and vanishes when a transport strips the status prefix.
666
+ text.includes("api key appears to be invalid") ||
667
+ text.includes("invalid_authentication_error")
528
668
  );
529
669
  }
530
670
 
@@ -532,21 +672,23 @@ export async function refreshKimiAuthToken(
532
672
  currentKey: string,
533
673
  options: { signal?: AbortSignal } = {},
534
674
  ): Promise<string | null> {
675
+ let locked: LockedOAuthCredential | undefined;
676
+ let kimiLocked: LockedKimiCodeCredentials | undefined;
535
677
  try {
536
- const kimiCred = readKimiCliCredentials();
537
- const piOAuth = readStoredOAuthCredential(PROVIDER_ID);
678
+ locked = await lockStoredOAuthCredential(PROVIDER_ID, options.signal);
679
+ const piOAuth = locked.credential;
538
680
 
539
681
  if (piOAuth) {
540
682
  if (piOAuth.access !== currentKey && Date.now() < piOAuth.expires) {
541
- console.error("[kimi-coding] auth refresh: reusing newer on-disk token");
542
683
  return piOAuth.access;
543
684
  }
544
685
 
545
- console.error("[kimi-coding] auth refresh: requesting new access token");
546
686
  let refreshed: Awaited<ReturnType<typeof refreshAccessToken>>;
687
+ kimiLocked = await lockKimiCodeCredentials(options.signal);
547
688
  try {
548
689
  refreshed = await refreshAccessToken(piOAuth.refresh, { signal: options.signal });
549
690
  } catch (error) {
691
+ const kimiCred = readKimiCliCredentials();
550
692
  if (!kimiCred?.refresh_token || kimiCred.refresh_token === piOAuth.refresh) throw error;
551
693
  const kimiExpiresMs = (kimiCred.expires_at ?? 0) * 1000;
552
694
  if (kimiCred.access_token !== currentKey && Date.now() < kimiExpiresMs) {
@@ -557,15 +699,30 @@ export async function refreshKimiAuthToken(
557
699
  refresh: kimiCred.refresh_token,
558
700
  expires: kimiExpiresMs,
559
701
  };
560
- await writeStoredCredential(PROVIDER_ID, recovered);
561
- console.error("[kimi-coding] auth refresh: recovered newer kimi-code token");
702
+ locked.store(recovered);
562
703
  return recovered.access;
563
704
  }
564
- console.error("[kimi-coding] auth refresh: pi token rejected, trying kimi-code token");
565
705
  refreshed = await refreshAccessToken(kimiCred.refresh_token, { signal: options.signal });
566
706
  }
567
707
 
568
708
  const newExpiresMs = Date.now() + refreshed.expires_in * 1000;
709
+ const kimiCredentialAfterRefresh = readKimiCliCredentials();
710
+ const peerAccess = kimiCredentialAfterRefresh?.access_token;
711
+ const peerExpiresMs = (kimiCredentialAfterRefresh?.expires_at ?? 0) * 1000;
712
+ const peerIsNewerValid =
713
+ peerAccess !== undefined && peerAccess !== currentKey && Date.now() < peerExpiresMs;
714
+ if (peerIsNewerValid) {
715
+ const peerCred: StoredOAuthCredential = {
716
+ ...piOAuth,
717
+ type: "oauth",
718
+ access: peerAccess,
719
+ refresh: kimiCredentialAfterRefresh!.refresh_token!,
720
+ expires: peerExpiresMs,
721
+ };
722
+ locked.store(peerCred);
723
+ return peerCred.access;
724
+ }
725
+ kimiLocked.store(refreshed.access_token, refreshed.refresh_token, newExpiresMs);
569
726
  const newCred: StoredOAuthCredential = {
570
727
  ...piOAuth,
571
728
  type: "oauth",
@@ -573,32 +730,32 @@ export async function refreshKimiAuthToken(
573
730
  refresh: refreshed.refresh_token,
574
731
  expires: newExpiresMs,
575
732
  };
576
- await writeStoredCredential(PROVIDER_ID, newCred);
577
- writeKimiCodeCredentials(refreshed.access_token, refreshed.refresh_token, newExpiresMs);
578
- console.error("[kimi-coding] auth refresh: new token persisted");
733
+ locked.store(newCred);
579
734
  return newCred.access;
580
735
  }
581
736
 
737
+ kimiLocked = await lockKimiCodeCredentials(options.signal);
738
+ const kimiCred = kimiLocked.credential;
582
739
  if (!kimiCred) {
583
- console.error(
584
- `[kimi-coding] auth refresh skipped: no OAuth credentials for ${PROVIDER_ID} on disk`,
585
- );
586
740
  return null;
587
741
  }
588
742
 
589
743
  const kimiExpiresMs = (kimiCred.expires_at ?? 0) * 1000;
590
744
  if (kimiCred.access_token !== currentKey && Date.now() < kimiExpiresMs) {
591
- console.error("[kimi-coding] auth refresh: reusing newer kimi-code token");
592
745
  return kimiCred.access_token!;
593
746
  }
594
747
 
595
- console.error("[kimi-coding] auth refresh: requesting new access token via kimi-code");
596
748
  const refreshed = await refreshAccessToken(kimiCred.refresh_token!, {
597
749
  signal: options.signal,
598
750
  });
599
751
  const newExpiresMs = Date.now() + refreshed.expires_in * 1000;
600
- writeKimiCodeCredentials(refreshed.access_token, refreshed.refresh_token, newExpiresMs);
601
- console.error("[kimi-coding] auth refresh: new token persisted to kimi-code");
752
+ const kimiCredentialAfterRefresh = readKimiCliCredentials();
753
+ const peerAccess = kimiCredentialAfterRefresh?.access_token;
754
+ const peerExpiresMs = (kimiCredentialAfterRefresh?.expires_at ?? 0) * 1000;
755
+ if (peerAccess !== undefined && peerAccess !== currentKey && Date.now() < peerExpiresMs) {
756
+ return peerAccess;
757
+ }
758
+ kimiLocked.store(refreshed.access_token, refreshed.refresh_token, newExpiresMs);
602
759
  return refreshed.access_token;
603
760
  } catch (err) {
604
761
  if (options.signal?.aborted) return null;
@@ -608,5 +765,12 @@ export async function refreshKimiAuthToken(
608
765
  console.error("[kimi-coding] auth refresh failed:", err);
609
766
  }
610
767
  return null;
768
+ } finally {
769
+ if (kimiLocked) {
770
+ await kimiLocked.release().catch(() => {});
771
+ }
772
+ if (locked) {
773
+ await locked.release().catch(() => {});
774
+ }
611
775
  }
612
776
  }