@itssimplereally/opencode-kimicode-auth 0.1.0

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.
Files changed (115) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +87 -0
  3. package/dist/index.d.ts +4 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +3 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/src/constants.d.ts +69 -0
  8. package/dist/src/constants.d.ts.map +1 -0
  9. package/dist/src/constants.js +207 -0
  10. package/dist/src/constants.js.map +1 -0
  11. package/dist/src/kimi/oauth.d.ts +64 -0
  12. package/dist/src/kimi/oauth.d.ts.map +1 -0
  13. package/dist/src/kimi/oauth.js +130 -0
  14. package/dist/src/kimi/oauth.js.map +1 -0
  15. package/dist/src/plugin/accounts.d.ts +167 -0
  16. package/dist/src/plugin/accounts.d.ts.map +1 -0
  17. package/dist/src/plugin/accounts.js +843 -0
  18. package/dist/src/plugin/accounts.js.map +1 -0
  19. package/dist/src/plugin/auth.d.ts +13 -0
  20. package/dist/src/plugin/auth.d.ts.map +1 -0
  21. package/dist/src/plugin/auth.js +26 -0
  22. package/dist/src/plugin/auth.js.map +1 -0
  23. package/dist/src/plugin/cache.d.ts +14 -0
  24. package/dist/src/plugin/cache.d.ts.map +1 -0
  25. package/dist/src/plugin/cache.js +56 -0
  26. package/dist/src/plugin/cache.js.map +1 -0
  27. package/dist/src/plugin/cli.d.ts +21 -0
  28. package/dist/src/plugin/cli.d.ts.map +1 -0
  29. package/dist/src/plugin/cli.js +98 -0
  30. package/dist/src/plugin/cli.js.map +1 -0
  31. package/dist/src/plugin/config/index.d.ts +16 -0
  32. package/dist/src/plugin/config/index.d.ts.map +1 -0
  33. package/dist/src/plugin/config/index.js +16 -0
  34. package/dist/src/plugin/config/index.js.map +1 -0
  35. package/dist/src/plugin/config/loader.d.ts +36 -0
  36. package/dist/src/plugin/config/loader.d.ts.map +1 -0
  37. package/dist/src/plugin/config/loader.js +182 -0
  38. package/dist/src/plugin/config/loader.js.map +1 -0
  39. package/dist/src/plugin/config/models.d.ts +18 -0
  40. package/dist/src/plugin/config/models.d.ts.map +1 -0
  41. package/dist/src/plugin/config/models.js +26 -0
  42. package/dist/src/plugin/config/models.js.map +1 -0
  43. package/dist/src/plugin/config/schema.d.ts +107 -0
  44. package/dist/src/plugin/config/schema.d.ts.map +1 -0
  45. package/dist/src/plugin/config/schema.js +282 -0
  46. package/dist/src/plugin/config/schema.js.map +1 -0
  47. package/dist/src/plugin/config/updater.d.ts +55 -0
  48. package/dist/src/plugin/config/updater.d.ts.map +1 -0
  49. package/dist/src/plugin/config/updater.js +154 -0
  50. package/dist/src/plugin/config/updater.js.map +1 -0
  51. package/dist/src/plugin/debug.d.ts +92 -0
  52. package/dist/src/plugin/debug.d.ts.map +1 -0
  53. package/dist/src/plugin/debug.js +406 -0
  54. package/dist/src/plugin/debug.js.map +1 -0
  55. package/dist/src/plugin/errors.d.ts +28 -0
  56. package/dist/src/plugin/errors.d.ts.map +1 -0
  57. package/dist/src/plugin/errors.js +42 -0
  58. package/dist/src/plugin/errors.js.map +1 -0
  59. package/dist/src/plugin/fingerprint.d.ts +41 -0
  60. package/dist/src/plugin/fingerprint.d.ts.map +1 -0
  61. package/dist/src/plugin/fingerprint.js +94 -0
  62. package/dist/src/plugin/fingerprint.js.map +1 -0
  63. package/dist/src/plugin/logger.d.ts +54 -0
  64. package/dist/src/plugin/logger.d.ts.map +1 -0
  65. package/dist/src/plugin/logger.js +120 -0
  66. package/dist/src/plugin/logger.js.map +1 -0
  67. package/dist/src/plugin/recovery/constants.d.ts +26 -0
  68. package/dist/src/plugin/recovery/constants.d.ts.map +1 -0
  69. package/dist/src/plugin/recovery/constants.js +47 -0
  70. package/dist/src/plugin/recovery/constants.js.map +1 -0
  71. package/dist/src/plugin/recovery/index.d.ts +16 -0
  72. package/dist/src/plugin/recovery/index.d.ts.map +1 -0
  73. package/dist/src/plugin/recovery/index.js +16 -0
  74. package/dist/src/plugin/recovery/index.js.map +1 -0
  75. package/dist/src/plugin/recovery/storage.d.ts +24 -0
  76. package/dist/src/plugin/recovery/storage.d.ts.map +1 -0
  77. package/dist/src/plugin/recovery/storage.js +354 -0
  78. package/dist/src/plugin/recovery/storage.js.map +1 -0
  79. package/dist/src/plugin/recovery/types.d.ts +116 -0
  80. package/dist/src/plugin/recovery/types.d.ts.map +1 -0
  81. package/dist/src/plugin/recovery/types.js +6 -0
  82. package/dist/src/plugin/recovery/types.js.map +1 -0
  83. package/dist/src/plugin/recovery.d.ts +63 -0
  84. package/dist/src/plugin/recovery.d.ts.map +1 -0
  85. package/dist/src/plugin/recovery.js +331 -0
  86. package/dist/src/plugin/recovery.js.map +1 -0
  87. package/dist/src/plugin/refresh-queue.d.ts +101 -0
  88. package/dist/src/plugin/refresh-queue.d.ts.map +1 -0
  89. package/dist/src/plugin/refresh-queue.js +248 -0
  90. package/dist/src/plugin/refresh-queue.js.map +1 -0
  91. package/dist/src/plugin/rotation.d.ts +169 -0
  92. package/dist/src/plugin/rotation.d.ts.map +1 -0
  93. package/dist/src/plugin/rotation.js +328 -0
  94. package/dist/src/plugin/rotation.js.map +1 -0
  95. package/dist/src/plugin/storage.d.ts +90 -0
  96. package/dist/src/plugin/storage.d.ts.map +1 -0
  97. package/dist/src/plugin/storage.js +450 -0
  98. package/dist/src/plugin/storage.js.map +1 -0
  99. package/dist/src/plugin/token.d.ts +19 -0
  100. package/dist/src/plugin/token.d.ts.map +1 -0
  101. package/dist/src/plugin/token.js +112 -0
  102. package/dist/src/plugin/token.js.map +1 -0
  103. package/dist/src/plugin/types.d.ts +97 -0
  104. package/dist/src/plugin/types.d.ts.map +1 -0
  105. package/dist/src/plugin/types.js +1 -0
  106. package/dist/src/plugin/types.js.map +1 -0
  107. package/dist/src/plugin/version.d.ts +14 -0
  108. package/dist/src/plugin/version.d.ts.map +1 -0
  109. package/dist/src/plugin/version.js +20 -0
  110. package/dist/src/plugin/version.js.map +1 -0
  111. package/dist/src/plugin.d.ts +5 -0
  112. package/dist/src/plugin.d.ts.map +1 -0
  113. package/dist/src/plugin.js +1077 -0
  114. package/dist/src/plugin.js.map +1 -0
  115. package/package.json +55 -0
@@ -0,0 +1,1077 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { exec } from "node:child_process";
3
+ import { KIMI_API_BASE_URL, getKimiDeviceHeaders, getKimiUserAgent, setOAuthDeviceId, } from "./constants";
4
+ import { authorizeKimi } from "./kimi/oauth";
5
+ import { accessTokenExpired, calculateTokenExpiry, isOAuthAuth } from "./plugin/auth";
6
+ import { promptAddAnotherAccount, promptLoginMode, promptRemoveAccount } from "./plugin/cli";
7
+ import { getLogFilePath, initializeDebug, isDebugEnabled, logToast, startKimicodeDebugRequest, logKimicodeDebugResponse, logResponseBody, } from "./plugin/debug";
8
+ import { createLogger, initLogger } from "./plugin/logger";
9
+ import { createSessionRecoveryHook, getRecoverySuccessToast } from "./plugin/recovery";
10
+ import { createProactiveRefreshQueue } from "./plugin/refresh-queue";
11
+ import { initHealthTracker, initTokenTracker, getHealthTracker, getTokenTracker } from "./plugin/rotation";
12
+ import { resolveCachedAuth } from "./plugin/cache";
13
+ import { KimiTokenRefreshError, refreshAccessToken } from "./plugin/token";
14
+ import { AccountManager, parseRateLimitReason } from "./plugin/accounts";
15
+ import { clearAccounts, loadAccounts, saveAccounts, saveAccountsReplace } from "./plugin/storage";
16
+ import { loadConfig, initRuntimeConfig } from "./plugin/config";
17
+ import { updateOpencodeConfig } from "./plugin/config/updater";
18
+ import { initKimicodeVersion } from "./plugin/version";
19
+ import { generateFingerprint } from "./plugin/fingerprint";
20
+ const log = createLogger("plugin");
21
+ const MAX_OAUTH_ACCOUNTS = 10;
22
+ // Track if this plugin instance is running in a child session (subagent, background task)
23
+ // Used to filter toasts based on toast_scope config
24
+ let isChildSession = false;
25
+ let childSessionParentID = undefined;
26
+ // Debounce repeated rate-limit toasts to avoid spam during retry loops.
27
+ const rateLimitToastCooldowns = new Map();
28
+ const RATE_LIMIT_TOAST_COOLDOWN_MS = 5000;
29
+ const MAX_TOAST_COOLDOWN_ENTRIES = 100;
30
+ const DUMMY_URL_BASE = "http://opencode.local";
31
+ // Stable per-plugin-instance session ID for Kimi server-side prompt caching.
32
+ // Mirrors kimi-cli's session.id passed as prompt_cache_key.
33
+ const PLUGIN_SESSION_ID = randomUUID();
34
+ const KIMICODE_MODEL_PREFIX = "kimicode-";
35
+ function resolveKimiModelAlias(requestedModel) {
36
+ // Both the base and thinking models map to the same Kimi API model.
37
+ if (requestedModel === "kimicode-kimi-k2.5" ||
38
+ requestedModel === "kimicode-kimi-k2.5-thinking") {
39
+ return "kimi-for-coding";
40
+ }
41
+ if (requestedModel.startsWith(KIMICODE_MODEL_PREFIX)) {
42
+ return requestedModel.slice(KIMICODE_MODEL_PREFIX.length);
43
+ }
44
+ return requestedModel;
45
+ }
46
+ /**
47
+ * Whether the requested OpenCode model id asks for extended thinking.
48
+ * Mirrors kimi-cli's `with_thinking("high")` vs `with_thinking("off")`.
49
+ */
50
+ function isThinkingModel(requestedModel) {
51
+ return requestedModel === "kimicode-kimi-k2.5-thinking";
52
+ }
53
+ function extractKimiUserIdFromJwt(token) {
54
+ const parts = token.split(".");
55
+ if (parts.length < 2)
56
+ return undefined;
57
+ try {
58
+ const payloadJson = Buffer.from(parts[1], "base64url").toString("utf8");
59
+ const payload = JSON.parse(payloadJson);
60
+ if (typeof payload.user_id === "string" && payload.user_id.length > 0) {
61
+ return payload.user_id;
62
+ }
63
+ }
64
+ catch {
65
+ // ignore
66
+ }
67
+ return undefined;
68
+ }
69
+ function cleanupToastCooldowns() {
70
+ if (rateLimitToastCooldowns.size <= MAX_TOAST_COOLDOWN_ENTRIES) {
71
+ return;
72
+ }
73
+ const now = Date.now();
74
+ for (const [key, time] of rateLimitToastCooldowns) {
75
+ if (now - time > RATE_LIMIT_TOAST_COOLDOWN_MS * 2) {
76
+ rateLimitToastCooldowns.delete(key);
77
+ }
78
+ }
79
+ }
80
+ function shouldShowRateLimitToast(message) {
81
+ cleanupToastCooldowns();
82
+ const toastKey = message.replace(/\d+/g, "X");
83
+ const lastShown = rateLimitToastCooldowns.get(toastKey) ?? 0;
84
+ const now = Date.now();
85
+ if (now - lastShown < RATE_LIMIT_TOAST_COOLDOWN_MS) {
86
+ return false;
87
+ }
88
+ rateLimitToastCooldowns.set(toastKey, now);
89
+ return true;
90
+ }
91
+ function toUrlString(input) {
92
+ if (typeof input === "string")
93
+ return input;
94
+ if (input instanceof URL)
95
+ return input.toString();
96
+ if (input instanceof Request)
97
+ return input.url;
98
+ return String(input);
99
+ }
100
+ function toAbsoluteUrlString(input) {
101
+ const raw = toUrlString(input);
102
+ try {
103
+ return new URL(raw).toString();
104
+ }
105
+ catch {
106
+ try {
107
+ return new URL(raw, DUMMY_URL_BASE).toString();
108
+ }
109
+ catch {
110
+ return null;
111
+ }
112
+ }
113
+ }
114
+ function makeRequest(input, init) {
115
+ try {
116
+ return new Request(input, init);
117
+ }
118
+ catch {
119
+ const abs = toAbsoluteUrlString(input);
120
+ if (!abs)
121
+ return null;
122
+ try {
123
+ return new Request(abs, init);
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ }
129
+ }
130
+ function rewriteToKimi(url) {
131
+ const base = KIMI_API_BASE_URL.replace(/\/+$/, "");
132
+ const path = url.pathname;
133
+ let nextPath = path;
134
+ if (path === "/v1") {
135
+ nextPath = "/";
136
+ }
137
+ else if (path.startsWith("/v1/")) {
138
+ nextPath = path.slice("/v1".length);
139
+ }
140
+ if (!nextPath.startsWith("/")) {
141
+ nextPath = `/${nextPath}`;
142
+ }
143
+ return new URL(`${base}${nextPath}${url.search}`);
144
+ }
145
+ function isHeadless() {
146
+ return !!(process.env.SSH_CONNECTION ||
147
+ process.env.SSH_CLIENT ||
148
+ process.env.SSH_TTY ||
149
+ process.env.OPENCODE_HEADLESS);
150
+ }
151
+ function isWSL() {
152
+ if (process.platform !== "linux")
153
+ return false;
154
+ try {
155
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
156
+ const { readFileSync } = require("node:fs");
157
+ const release = readFileSync("/proc/version", "utf8").toLowerCase();
158
+ return release.includes("microsoft") || release.includes("wsl");
159
+ }
160
+ catch {
161
+ return false;
162
+ }
163
+ }
164
+ async function openBrowser(url) {
165
+ try {
166
+ if (process.platform === "darwin") {
167
+ exec(`open "${url}"`);
168
+ return true;
169
+ }
170
+ if (process.platform === "win32") {
171
+ exec(`start "" "${url}"`);
172
+ return true;
173
+ }
174
+ if (isWSL()) {
175
+ try {
176
+ exec(`wslview "${url}"`);
177
+ return true;
178
+ }
179
+ catch { }
180
+ }
181
+ exec(`xdg-open "${url}"`);
182
+ return true;
183
+ }
184
+ catch {
185
+ return false;
186
+ }
187
+ }
188
+ function retryAfterMsFromResponse(response, defaultRetryMs) {
189
+ const retryAfterMsHeader = response.headers.get("retry-after-ms");
190
+ if (retryAfterMsHeader) {
191
+ const parsed = Number.parseInt(retryAfterMsHeader, 10);
192
+ if (!Number.isNaN(parsed) && parsed > 0) {
193
+ return parsed;
194
+ }
195
+ }
196
+ const retryAfterHeader = response.headers.get("retry-after");
197
+ if (retryAfterHeader) {
198
+ const parsed = Number.parseInt(retryAfterHeader, 10);
199
+ if (!Number.isNaN(parsed) && parsed > 0) {
200
+ return parsed * 1000;
201
+ }
202
+ }
203
+ return defaultRetryMs;
204
+ }
205
+ async function sleep(ms, signal) {
206
+ return new Promise((resolve, reject) => {
207
+ if (signal?.aborted) {
208
+ reject(signal.reason instanceof Error ? signal.reason : new Error("Aborted"));
209
+ return;
210
+ }
211
+ const timeout = setTimeout(() => {
212
+ cleanup();
213
+ resolve();
214
+ }, ms);
215
+ const onAbort = () => {
216
+ cleanup();
217
+ reject(signal?.reason instanceof Error ? signal.reason : new Error("Aborted"));
218
+ };
219
+ const cleanup = () => {
220
+ clearTimeout(timeout);
221
+ signal?.removeEventListener("abort", onAbort);
222
+ };
223
+ signal?.addEventListener("abort", onAbort, { once: true });
224
+ });
225
+ }
226
+ async function parseErrorInfo(response) {
227
+ try {
228
+ const text = await response.clone().text();
229
+ if (!text)
230
+ return {};
231
+ try {
232
+ const parsed = JSON.parse(text);
233
+ const err = parsed?.error;
234
+ if (typeof err === "string") {
235
+ return { reason: err, rawText: text };
236
+ }
237
+ if (err && typeof err === "object") {
238
+ const message = typeof err.message === "string"
239
+ ? err.message
240
+ : typeof parsed.message === "string"
241
+ ? parsed.message
242
+ : undefined;
243
+ const reason = typeof err.type === "string"
244
+ ? err.type
245
+ : typeof err.code === "string"
246
+ ? err.code
247
+ : typeof parsed.code === "string"
248
+ ? parsed.code
249
+ : undefined;
250
+ const retryDelayMs = typeof err.retry_delay_ms === "number"
251
+ ? err.retry_delay_ms
252
+ : typeof parsed.retry_delay_ms === "number"
253
+ ? parsed.retry_delay_ms
254
+ : null;
255
+ return { message, reason, retryDelayMs, rawText: text };
256
+ }
257
+ return { rawText: text };
258
+ }
259
+ catch {
260
+ return { rawText: text };
261
+ }
262
+ }
263
+ catch {
264
+ return {};
265
+ }
266
+ }
267
+ function authSuccessFromTokens(tokens) {
268
+ const now = Date.now();
269
+ return {
270
+ type: "success",
271
+ refresh: tokens.refresh_token,
272
+ access: tokens.access_token,
273
+ expires: calculateTokenExpiry(now, tokens.expires_in),
274
+ };
275
+ }
276
+ function toExistingAccountsForMenu(stored) {
277
+ if (!stored?.accounts?.length)
278
+ return [];
279
+ const now = Date.now();
280
+ return stored.accounts.map((acc, idx) => {
281
+ let status = "active";
282
+ if (acc.enabled === false) {
283
+ status = "disabled";
284
+ }
285
+ else if (acc.coolingDownUntil && acc.coolingDownUntil > now) {
286
+ status = "cooling-down";
287
+ }
288
+ else if (acc.rateLimitResetTimes) {
289
+ const isRateLimited = Object.values(acc.rateLimitResetTimes).some((resetTime) => typeof resetTime === "number" && resetTime > now);
290
+ status = isRateLimited ? "rate-limited" : "active";
291
+ }
292
+ return {
293
+ email: acc.email,
294
+ index: idx,
295
+ addedAt: acc.addedAt,
296
+ lastUsed: acc.lastUsed,
297
+ status,
298
+ isCurrentAccount: idx === (stored.activeIndex ?? 0),
299
+ enabled: acc.enabled !== false,
300
+ };
301
+ });
302
+ }
303
+ async function persistAccountPool(results, replaceAll) {
304
+ if (results.length === 0)
305
+ return;
306
+ const now = Date.now();
307
+ const stored = replaceAll ? null : await loadAccounts();
308
+ const accounts = stored?.accounts ? [...stored.accounts] : [];
309
+ const indexByRefreshToken = new Map();
310
+ const indexByUserId = new Map();
311
+ for (let i = 0; i < accounts.length; i++) {
312
+ const acc = accounts[i];
313
+ const token = acc?.refreshToken;
314
+ if (typeof token === "string" && token.length > 0) {
315
+ indexByRefreshToken.set(token, i);
316
+ }
317
+ const userId = acc?.email;
318
+ if (typeof userId === "string" && userId.length > 0) {
319
+ indexByUserId.set(userId, i);
320
+ }
321
+ }
322
+ for (const result of results) {
323
+ const refreshToken = result.refresh;
324
+ if (!refreshToken)
325
+ continue;
326
+ const userId = extractKimiUserIdFromJwt(refreshToken);
327
+ const existingIndex = indexByRefreshToken.get(refreshToken);
328
+ if (existingIndex !== undefined) {
329
+ const existing = accounts[existingIndex];
330
+ if (!existing)
331
+ continue;
332
+ accounts[existingIndex] = {
333
+ ...existing,
334
+ email: existing.email ?? userId,
335
+ refreshToken,
336
+ lastUsed: now,
337
+ enabled: existing.enabled !== false,
338
+ fingerprint: existing.fingerprint ?? generateFingerprint(),
339
+ };
340
+ if (userId) {
341
+ indexByUserId.set(userId, existingIndex);
342
+ }
343
+ continue;
344
+ }
345
+ // If the refresh token rotated, merge by user id to keep stable account pools.
346
+ if (userId) {
347
+ const byUserIndex = indexByUserId.get(userId);
348
+ if (byUserIndex !== undefined) {
349
+ const existing = accounts[byUserIndex];
350
+ if (existing) {
351
+ if (existing.refreshToken) {
352
+ indexByRefreshToken.delete(existing.refreshToken);
353
+ }
354
+ const updated = {
355
+ ...existing,
356
+ email: existing.email ?? userId,
357
+ refreshToken,
358
+ lastUsed: now,
359
+ enabled: true,
360
+ fingerprint: existing.fingerprint ?? generateFingerprint(),
361
+ };
362
+ accounts[byUserIndex] = updated;
363
+ indexByRefreshToken.set(refreshToken, byUserIndex);
364
+ indexByUserId.set(userId, byUserIndex);
365
+ continue;
366
+ }
367
+ }
368
+ }
369
+ const newIndex = accounts.length;
370
+ indexByRefreshToken.set(refreshToken, newIndex);
371
+ if (userId) {
372
+ indexByUserId.set(userId, newIndex);
373
+ }
374
+ accounts.push({
375
+ email: userId,
376
+ refreshToken,
377
+ addedAt: now,
378
+ lastUsed: now,
379
+ enabled: true,
380
+ fingerprint: generateFingerprint(),
381
+ });
382
+ }
383
+ if (accounts.length === 0)
384
+ return;
385
+ const activeIndex = replaceAll
386
+ ? 0
387
+ : typeof stored?.activeIndex === "number" && Number.isFinite(stored.activeIndex)
388
+ ? stored.activeIndex
389
+ : 0;
390
+ const payload = {
391
+ version: 1,
392
+ accounts,
393
+ activeIndex: Math.max(0, Math.min(activeIndex, accounts.length - 1)),
394
+ activeIndexByFamily: {
395
+ kimi: Math.max(0, Math.min(activeIndex, accounts.length - 1)),
396
+ },
397
+ };
398
+ // persistAccountPool already merges/deduplicates. Avoid a second merge layer.
399
+ await saveAccountsReplace(payload);
400
+ }
401
+ async function removeAccountFromPool(removeIndex) {
402
+ const stored = await loadAccounts();
403
+ if (!stored || stored.accounts.length === 0)
404
+ return false;
405
+ if (!Number.isFinite(removeIndex) || removeIndex < 0 || removeIndex >= stored.accounts.length) {
406
+ return false;
407
+ }
408
+ const nextAccounts = stored.accounts.filter((_, idx) => idx !== removeIndex);
409
+ if (nextAccounts.length === 0) {
410
+ await clearAccounts().catch(() => { });
411
+ return true;
412
+ }
413
+ const previousActiveIndex = typeof stored.activeIndex === "number" && Number.isFinite(stored.activeIndex)
414
+ ? stored.activeIndex
415
+ : 0;
416
+ let nextActiveIndex = previousActiveIndex;
417
+ if (removeIndex < previousActiveIndex) {
418
+ nextActiveIndex = Math.max(0, previousActiveIndex - 1);
419
+ }
420
+ nextActiveIndex = Math.max(0, Math.min(nextActiveIndex, nextAccounts.length - 1));
421
+ const payload = {
422
+ version: 1,
423
+ accounts: nextAccounts,
424
+ activeIndex: nextActiveIndex,
425
+ activeIndexByFamily: {
426
+ kimi: nextActiveIndex,
427
+ },
428
+ };
429
+ await saveAccountsReplace(payload);
430
+ return true;
431
+ }
432
+ export const createKimicodePlugin = (providerId) => async ({ client, directory }) => {
433
+ const config = loadConfig(directory);
434
+ initRuntimeConfig(config);
435
+ // Initialize debug + structured logger for TUI integration.
436
+ initializeDebug(config);
437
+ initLogger(client);
438
+ // Resolve plugin version for X-Msh-Version headers (best-effort).
439
+ await initKimicodeVersion();
440
+ // Sync model definitions to opencode.json on every load (best-effort).
441
+ await updateOpencodeConfig().catch(() => { });
442
+ // Initialize trackers (hybrid strategy).
443
+ if (config.health_score) {
444
+ initHealthTracker({
445
+ initial: config.health_score.initial,
446
+ successReward: config.health_score.success_reward,
447
+ rateLimitPenalty: config.health_score.rate_limit_penalty,
448
+ failurePenalty: config.health_score.failure_penalty,
449
+ recoveryRatePerHour: config.health_score.recovery_rate_per_hour,
450
+ minUsable: config.health_score.min_usable,
451
+ maxScore: config.health_score.max_score,
452
+ });
453
+ }
454
+ if (config.token_bucket) {
455
+ initTokenTracker({
456
+ maxTokens: config.token_bucket.max_tokens,
457
+ regenerationRatePerMinute: config.token_bucket.regeneration_rate_per_minute,
458
+ initialTokens: config.token_bucket.initial_tokens,
459
+ });
460
+ }
461
+ const sessionRecovery = createSessionRecoveryHook({ client, directory }, config);
462
+ const eventHandler = async (payload) => {
463
+ if (payload.event.type === "session.created") {
464
+ const props = payload.event.properties;
465
+ if (props?.info?.parentID) {
466
+ isChildSession = true;
467
+ childSessionParentID = props.info.parentID;
468
+ log.debug("child-session-detected", { parentID: props.info.parentID });
469
+ }
470
+ else {
471
+ isChildSession = false;
472
+ childSessionParentID = undefined;
473
+ log.debug("root-session-detected", {});
474
+ }
475
+ }
476
+ if (sessionRecovery && payload.event.type === "session.error") {
477
+ const props = payload.event.properties;
478
+ const sessionID = props?.sessionID;
479
+ const messageID = props?.messageID;
480
+ const error = props?.error;
481
+ if (sessionRecovery.isRecoverableError(error)) {
482
+ const recovered = await sessionRecovery.handleSessionRecovery({
483
+ id: messageID,
484
+ role: "assistant",
485
+ sessionID,
486
+ error,
487
+ });
488
+ if (recovered && sessionID && config.auto_resume) {
489
+ await client.session.prompt({
490
+ path: { id: sessionID },
491
+ body: { parts: [{ type: "text", text: config.resume_text }] },
492
+ query: { directory },
493
+ }).catch(() => { });
494
+ const toast = getRecoverySuccessToast();
495
+ if (!(config.toast_scope === "root_only" && isChildSession)) {
496
+ await client.tui.showToast({
497
+ body: {
498
+ title: toast.title,
499
+ message: toast.message,
500
+ variant: "success",
501
+ },
502
+ }).catch(() => { });
503
+ }
504
+ }
505
+ }
506
+ }
507
+ };
508
+ // Cached getAuth function for potential tool access.
509
+ let cachedGetAuth = null;
510
+ return {
511
+ event: eventHandler,
512
+ auth: {
513
+ provider: providerId,
514
+ loader: async (getAuth, provider) => {
515
+ cachedGetAuth = getAuth;
516
+ const auth = await getAuth();
517
+ if (!isOAuthAuth(auth)) {
518
+ return {};
519
+ }
520
+ const accountManager = await AccountManager.loadFromDisk(auth);
521
+ // Seed the OAuth device ID from the first account's fingerprint so that
522
+ // OAuth requests (refresh, etc.) use a consistent device identity,
523
+ // mirroring kimi-cli's persistent ~/.kimi/device_id.
524
+ try {
525
+ const firstAccount = accountManager.getAccounts()[0];
526
+ if (firstAccount?.fingerprint?.deviceId) {
527
+ setOAuthDeviceId(firstAccount.fingerprint.deviceId);
528
+ }
529
+ }
530
+ catch {
531
+ // best-effort
532
+ }
533
+ // Self-heal: older versions could disable valid accounts due to 403 gating.
534
+ // Re-enable accounts disabled for "auth-failure" so they can be retried/refreshed.
535
+ try {
536
+ for (const acc of accountManager.getAccounts()) {
537
+ if (acc.enabled === false && acc.cooldownReason === "auth-failure") {
538
+ accountManager.setAccountEnabled(acc.index, true);
539
+ accountManager.clearAccountCooldown(acc);
540
+ }
541
+ }
542
+ }
543
+ catch {
544
+ // best-effort
545
+ }
546
+ // Start proactive refresh queue (best-effort).
547
+ let refreshQueue = null;
548
+ if (config.proactive_token_refresh && accountManager.getAccountCount() > 0) {
549
+ refreshQueue = createProactiveRefreshQueue(client, providerId, {
550
+ enabled: config.proactive_token_refresh,
551
+ bufferSeconds: config.proactive_refresh_buffer_seconds,
552
+ checkIntervalSeconds: config.proactive_refresh_check_interval_seconds,
553
+ });
554
+ refreshQueue.setAccountManager(accountManager);
555
+ refreshQueue.start();
556
+ }
557
+ if (isDebugEnabled()) {
558
+ const logPath = getLogFilePath();
559
+ if (logPath) {
560
+ await client.tui.showToast({
561
+ body: { message: `Debug log: ${logPath}`, variant: "info" },
562
+ }).catch(() => { });
563
+ }
564
+ }
565
+ // Optional: ensure costs are zeroed (avoid misleading pricing).
566
+ if (provider.models) {
567
+ for (const model of Object.values(provider.models)) {
568
+ if (model) {
569
+ model.cost = { input: 0, output: 0 };
570
+ }
571
+ }
572
+ }
573
+ const quietMode = config.quiet_mode;
574
+ const toastScope = config.toast_scope;
575
+ const showToast = async (message, variant) => {
576
+ logToast(message, variant);
577
+ if (quietMode)
578
+ return;
579
+ if (toastScope === "root_only" && isChildSession)
580
+ return;
581
+ await client.tui.showToast({
582
+ body: { message, variant },
583
+ }).catch(() => { });
584
+ };
585
+ return {
586
+ apiKey: "",
587
+ async fetch(input, init) {
588
+ const originalRequest = makeRequest(input, init);
589
+ if (!originalRequest) {
590
+ return fetch(input, init);
591
+ }
592
+ // Normalize the request once and buffer the body so we can retry safely.
593
+ const originalUrl = new URL(originalRequest.url);
594
+ const urlString = toUrlString(input);
595
+ const method = originalRequest.method || "GET";
596
+ const abortSignal = init?.signal ?? null;
597
+ let bodyBuffer = null;
598
+ let canRetry = true;
599
+ if (method !== "GET" && method !== "HEAD") {
600
+ try {
601
+ bodyBuffer = await originalRequest.clone().arrayBuffer();
602
+ }
603
+ catch {
604
+ // If we cannot buffer the body, we still do one attempt but disable retries.
605
+ bodyBuffer = null;
606
+ canRetry = false;
607
+ }
608
+ }
609
+ const baseHeaders = new Headers(originalRequest.headers);
610
+ const rewrittenUrl = rewriteToKimi(originalUrl);
611
+ const isChatCompletions = rewrittenUrl.pathname.endsWith("/chat/completions");
612
+ // Kimi Code uses OpenAI-compatible bodies. For our kimicode-* models,
613
+ // rewrite the model name to the actual Kimi API model id.
614
+ let bodyForAttempts = bodyBuffer;
615
+ if (isChatCompletions && bodyBuffer) {
616
+ const contentType = baseHeaders.get("content-type") ?? "";
617
+ const maybeJson = contentType.includes("application/json") ||
618
+ contentType.includes("+json") ||
619
+ contentType.trim() === "";
620
+ if (maybeJson) {
621
+ try {
622
+ const rawBody = new TextDecoder().decode(bodyBuffer);
623
+ const parsed = JSON.parse(rawBody);
624
+ const requestedModel = parsed?.model;
625
+ if (typeof requestedModel === "string" && requestedModel.length > 0) {
626
+ const isKimicodeModel = requestedModel.startsWith(KIMICODE_MODEL_PREFIX);
627
+ if (!isKimicodeModel) {
628
+ throw new Error(`Moonshot AI OAuth (Kimi Code) only supports models with the '${KIMICODE_MODEL_PREFIX}' prefix. ` +
629
+ `Use moonshotai/kimicode-kimi-k2.5 or moonshotai/kimicode-kimi-k2.5-thinking, ` +
630
+ `or re-run 'opencode auth login' and choose API Key to use moonshotai/${requestedModel}.`);
631
+ }
632
+ const effectiveModel = resolveKimiModelAlias(requestedModel);
633
+ const wantsThinking = isThinkingModel(requestedModel);
634
+ // Always rewrite: model alias + thinking parameters.
635
+ parsed.model = effectiveModel;
636
+ // Inject thinking parameters matching kimi-cli wire format.
637
+ // Thinking ON: reasoning_effort="high", thinking={type:"enabled"}
638
+ // Thinking OFF: remove reasoning_effort, thinking={type:"disabled"}
639
+ if (wantsThinking) {
640
+ parsed.reasoning_effort = "high";
641
+ parsed.thinking = { type: "enabled" };
642
+ }
643
+ else {
644
+ delete parsed.reasoning_effort;
645
+ parsed.thinking = { type: "disabled" };
646
+ }
647
+ // Enable Kimi server-side prompt caching (mirrors kimi-cli).
648
+ parsed.prompt_cache_key = PLUGIN_SESSION_ID;
649
+ bodyForAttempts = Buffer.from(JSON.stringify(parsed), "utf8");
650
+ // Body length may have changed; let fetch() compute it.
651
+ baseHeaders.delete("content-length");
652
+ }
653
+ }
654
+ catch (e) {
655
+ // If we can't parse JSON, fall back to sending the buffered body unmodified.
656
+ if (!(e instanceof SyntaxError)) {
657
+ throw e;
658
+ }
659
+ }
660
+ }
661
+ }
662
+ // Retry loop with account rotation/backoff.
663
+ const maxWaitMs = (config.max_rate_limit_wait_seconds ?? 0) > 0
664
+ ? (config.max_rate_limit_wait_seconds * 1000)
665
+ : 0;
666
+ const FAMILY = "kimi";
667
+ const HEADER_STYLE = "kimi-cli";
668
+ let capacityRetryCount = 0;
669
+ let attemptedRefreshForAccount = false;
670
+ let lastAccountIndex = null;
671
+ while (true) {
672
+ if (abortSignal?.aborted) {
673
+ throw abortSignal.reason instanceof Error ? abortSignal.reason : new Error("Aborted");
674
+ }
675
+ const account = accountManager.getCurrentOrNextForFamily(FAMILY, undefined, config.account_selection_strategy, HEADER_STYLE, config.pid_offset_enabled);
676
+ if (!account) {
677
+ const minWait = accountManager.getMinWaitTimeForFamily(FAMILY, undefined, HEADER_STYLE, true);
678
+ if (maxWaitMs > 0 && minWait > maxWaitMs) {
679
+ throw new Error(`All Kimi accounts are rate-limited. Minimum wait is ${Math.ceil(minWait / 1000)}s ` +
680
+ `which exceeds max_rate_limit_wait_seconds=${config.max_rate_limit_wait_seconds}s.`);
681
+ }
682
+ if (!quietMode && shouldShowRateLimitToast("All accounts rate-limited")) {
683
+ await showToast(`All accounts rate-limited. Waiting ${Math.ceil(minWait / 1000)}s...`, "warning");
684
+ }
685
+ await sleep(Math.max(1000, minWait), abortSignal);
686
+ continue;
687
+ }
688
+ if (lastAccountIndex !== account.index) {
689
+ attemptedRefreshForAccount = false;
690
+ capacityRetryCount = 0;
691
+ lastAccountIndex = account.index;
692
+ }
693
+ // Resolve/refresh the account's access token.
694
+ let accountAuth = resolveCachedAuth(accountManager.toAuthDetails(account));
695
+ if (accessTokenExpired(accountAuth)) {
696
+ try {
697
+ const refreshed = await refreshAccessToken(accountAuth, client, providerId);
698
+ if (refreshed) {
699
+ accountAuth = refreshed;
700
+ accountManager.updateFromAuth(account, refreshed);
701
+ accountManager.requestSaveToDisk();
702
+ }
703
+ }
704
+ catch (e) {
705
+ // Token refresh failed; disable or cool down this account.
706
+ const err = e instanceof Error ? e.message : String(e);
707
+ if (e instanceof KimiTokenRefreshError &&
708
+ (e.code === "invalid_grant" || e.status === 401 || e.status === 403)) {
709
+ // Refresh token revoked/invalid. Disable so we don't keep retrying it.
710
+ accountManager.setAccountEnabled(account.index, false);
711
+ accountManager.markAccountCoolingDown(account, 5 * 60_000, "auth-failure");
712
+ accountManager.requestSaveToDisk();
713
+ await showToast(`Account ${account.index + 1} refresh token invalid. Disabled.`, "warning");
714
+ }
715
+ else {
716
+ accountManager.markAccountCoolingDown(account, 60_000, "auth-failure");
717
+ accountManager.requestSaveToDisk();
718
+ await showToast(`Account ${account.index + 1} auth failed. Switching...`, "warning");
719
+ }
720
+ log.warn("token-refresh-failed", { accountIndex: account.index, error: err });
721
+ continue;
722
+ }
723
+ }
724
+ const accessToken = accountAuth.access;
725
+ if (!accessToken) {
726
+ // If we still have no access token, the refresh flow failed silently.
727
+ accountManager.markAccountCoolingDown(account, 60_000, "auth-failure");
728
+ accountManager.requestSaveToDisk();
729
+ await showToast(`Account ${account.index + 1} missing access token. Switching...`, "warning");
730
+ continue;
731
+ }
732
+ // Apply request jitter (optional).
733
+ if (config.request_jitter_max_ms && config.request_jitter_max_ms > 0) {
734
+ const jitterMs = Math.floor(Math.random() * config.request_jitter_max_ms);
735
+ if (jitterMs > 0) {
736
+ await sleep(jitterMs, abortSignal);
737
+ }
738
+ }
739
+ const headers = new Headers(baseHeaders);
740
+ headers.set("authorization", `Bearer ${accessToken}`);
741
+ headers.set("user-agent", getKimiUserAgent());
742
+ const deviceId = account.fingerprint?.deviceId ?? generateFingerprint().deviceId;
743
+ const deviceHeaders = getKimiDeviceHeaders(deviceId);
744
+ for (const [k, v] of Object.entries(deviceHeaders)) {
745
+ headers.set(k, v);
746
+ }
747
+ const attemptBody = bodyForAttempts
748
+ ? bodyForAttempts instanceof ArrayBuffer
749
+ ? bodyForAttempts.slice(0)
750
+ : bodyForAttempts.slice()
751
+ : null;
752
+ const requestInit = {
753
+ method,
754
+ headers,
755
+ body: attemptBody,
756
+ signal: abortSignal ?? undefined,
757
+ };
758
+ const attemptRequest = new Request(rewrittenUrl.toString(), requestInit);
759
+ const debugContext = startKimicodeDebugRequest({
760
+ originalUrl: urlString,
761
+ resolvedUrl: rewrittenUrl.toString(),
762
+ method,
763
+ headers,
764
+ body: attemptBody ? "[buffered]" : undefined,
765
+ streaming: false,
766
+ });
767
+ // Consume token for hybrid strategy (refund on failure).
768
+ let tokenConsumed = false;
769
+ if (config.account_selection_strategy === "hybrid") {
770
+ tokenConsumed = getTokenTracker().consume(account.index);
771
+ }
772
+ try {
773
+ const response = await fetch(attemptRequest);
774
+ logKimicodeDebugResponse(debugContext, response, { note: `account=${account.index}` });
775
+ if (response.ok) {
776
+ if (config.account_selection_strategy === "hybrid" && tokenConsumed) {
777
+ // Token consumed successfully; keep it consumed.
778
+ }
779
+ getHealthTracker().recordSuccess(account.index);
780
+ accountManager.markRequestSuccess(account);
781
+ accountManager.markAccountUsed(account.index);
782
+ accountManager.requestSaveToDisk();
783
+ return response;
784
+ }
785
+ // Read response body for debug + error parsing (clone to preserve original response if we return it).
786
+ const responseBodyText = await logResponseBody(debugContext, response, response.status);
787
+ // Handle auth errors: try refreshing once per selected account.
788
+ if ((response.status === 401 || response.status === 403) && canRetry) {
789
+ const info = await parseErrorInfo(response);
790
+ // If Kimi says access is terminated for non-CLI agents, rotating accounts won't help.
791
+ if (response.status === 403 &&
792
+ (info.reason === "access_terminated_error" ||
793
+ (info.message && /kimi\s+cli|coding\s+agents/i.test(info.message)))) {
794
+ const details = [info.reason, info.message].filter(Boolean).join(": ");
795
+ throw new Error(`Kimi Code denied access (${response.status}). ` +
796
+ `This usually means the request is missing a Kimi CLI user-agent. ` +
797
+ `If needed, set OPENCODE_KIMICODE_USER_AGENT=KimiCLI/<version>. ` +
798
+ `${details ? `Details: ${details}` : ""}`.trim());
799
+ }
800
+ if (!attemptedRefreshForAccount) {
801
+ attemptedRefreshForAccount = true;
802
+ if (tokenConsumed) {
803
+ getTokenTracker().refund(account.index);
804
+ tokenConsumed = false;
805
+ }
806
+ try {
807
+ const refreshed = await refreshAccessToken(accountAuth, client, providerId);
808
+ if (refreshed) {
809
+ accountManager.updateFromAuth(account, refreshed);
810
+ accountManager.requestSaveToDisk();
811
+ continue; // retry same request with updated access token
812
+ }
813
+ }
814
+ catch (e) {
815
+ if (e instanceof KimiTokenRefreshError &&
816
+ (e.code === "invalid_grant" || e.status === 401 || e.status === 403)) {
817
+ accountManager.setAccountEnabled(account.index, false);
818
+ accountManager.markAccountCoolingDown(account, 5 * 60_000, "auth-failure");
819
+ accountManager.requestSaveToDisk();
820
+ getHealthTracker().recordFailure(account.index);
821
+ await showToast(`Account ${account.index + 1} refresh token invalid. Disabled and switching...`, "warning");
822
+ continue;
823
+ }
824
+ }
825
+ }
826
+ // Refresh didn't help -> cool down and switch (don't permanently disable).
827
+ accountManager.markAccountCoolingDown(account, 60_000, "auth-failure");
828
+ accountManager.requestSaveToDisk();
829
+ getHealthTracker().recordFailure(account.index);
830
+ if (tokenConsumed) {
831
+ getTokenTracker().refund(account.index);
832
+ tokenConsumed = false;
833
+ }
834
+ await showToast(`Account ${account.index + 1} unauthorized. Switching...`, "warning");
835
+ continue;
836
+ }
837
+ // Rate limit / overload handling.
838
+ if (response.status === 429 || response.status === 503 || response.status === 529) {
839
+ if (tokenConsumed) {
840
+ getTokenTracker().refund(account.index);
841
+ tokenConsumed = false;
842
+ }
843
+ const defaultRetryMs = (config.default_retry_after_seconds ?? 60) * 1000;
844
+ const headerRetryMs = retryAfterMsFromResponse(response, defaultRetryMs);
845
+ const info = await parseErrorInfo(response);
846
+ const retryAfterMs = info.retryDelayMs ?? headerRetryMs;
847
+ const reason = parseRateLimitReason(info.reason, info.message, response.status);
848
+ // Capacity/server errors: exponential backoff, retry same account.
849
+ if (reason === "MODEL_CAPACITY_EXHAUSTED" || reason === "SERVER_ERROR") {
850
+ if (!canRetry) {
851
+ return response;
852
+ }
853
+ const baseDelayMs = 1000;
854
+ const maxDelayMs = 8000;
855
+ const exponentialDelay = Math.min(baseDelayMs * Math.pow(2, capacityRetryCount), maxDelayMs);
856
+ const jitter = exponentialDelay * (0.9 + Math.random() * 0.2);
857
+ const waitMs = Math.round(jitter);
858
+ capacityRetryCount = Math.min(capacityRetryCount + 1, 10);
859
+ if (shouldShowRateLimitToast(`capacity-${response.status}`)) {
860
+ await showToast(`Server busy (${response.status}). Retrying in ${Math.ceil(waitMs / 1000)}s...`, "warning");
861
+ }
862
+ await sleep(waitMs, abortSignal);
863
+ continue;
864
+ }
865
+ // Normal rate limit: mark account limited and switch.
866
+ getHealthTracker().recordRateLimit(account.index);
867
+ const failureTtlMs = (config.failure_ttl_seconds ?? 3600) * 1000;
868
+ const backoffMs = accountManager.markRateLimitedWithReason(account, FAMILY, HEADER_STYLE, undefined, reason, retryAfterMs, failureTtlMs);
869
+ accountManager.requestSaveToDisk();
870
+ if (shouldShowRateLimitToast(`${reason}-${response.status}`)) {
871
+ await showToast(`Rate limited (${response.status}, ${reason}). Switching accounts (wait ${Math.ceil(backoffMs / 1000)}s).`, "warning");
872
+ }
873
+ continue;
874
+ }
875
+ // For other errors: if we can't retry (streaming/unbuffered), just return the response.
876
+ if (!canRetry) {
877
+ return response;
878
+ }
879
+ // Non-rate-limit failures: penalize health and briefly cool down.
880
+ getHealthTracker().recordFailure(account.index);
881
+ accountManager.markAccountCoolingDown(account, 15_000, "network-error");
882
+ accountManager.requestSaveToDisk();
883
+ if (!quietMode && response.status >= 500 && shouldShowRateLimitToast(`server-${response.status}`)) {
884
+ await showToast(`Server error (${response.status}). Retrying with another account...`, "warning");
885
+ }
886
+ // Avoid infinite tight loops on persistent errors.
887
+ await sleep(1000, abortSignal);
888
+ continue;
889
+ }
890
+ catch (error) {
891
+ logKimicodeDebugResponse(debugContext, new Response(null, { status: 0, statusText: "network-error" }), { error });
892
+ if (config.account_selection_strategy === "hybrid" && tokenConsumed) {
893
+ getTokenTracker().refund(account.index);
894
+ tokenConsumed = false;
895
+ }
896
+ getHealthTracker().recordFailure(account.index);
897
+ accountManager.markAccountCoolingDown(account, 15_000, "network-error");
898
+ accountManager.requestSaveToDisk();
899
+ // Backoff slightly to prevent hammering on transient network errors.
900
+ if (!quietMode && shouldShowRateLimitToast("network-error")) {
901
+ await showToast("Network error. Retrying...", "warning");
902
+ }
903
+ await sleep(1000, abortSignal);
904
+ continue;
905
+ }
906
+ }
907
+ },
908
+ };
909
+ },
910
+ methods: [
911
+ {
912
+ label: "OAuth (Kimi Code / kimi-cli)",
913
+ type: "oauth",
914
+ authorize: async (inputs) => {
915
+ const noBrowser = inputs?.noBrowser === "true" || inputs?.["no-browser"] === "true";
916
+ // CLI flow (`opencode auth login`) passes an inputs object.
917
+ if (inputs) {
918
+ const results = [];
919
+ let startFresh = false;
920
+ let existingStorage = await loadAccounts();
921
+ if (existingStorage && existingStorage.accounts.length > 0) {
922
+ while (true) {
923
+ const existingAccounts = toExistingAccountsForMenu(existingStorage);
924
+ const menu = await promptLoginMode(existingAccounts);
925
+ if (menu.mode === "configure-models") {
926
+ // promptLoginModeFallback performs the update and then loops.
927
+ continue;
928
+ }
929
+ if (menu.mode === "remove") {
930
+ const removeIndex = await promptRemoveAccount(existingAccounts);
931
+ if (removeIndex === null) {
932
+ existingStorage = await loadAccounts();
933
+ continue;
934
+ }
935
+ const removed = await removeAccountFromPool(removeIndex).catch(() => false);
936
+ if (!removed) {
937
+ console.log("\n✗ Failed to remove account\n");
938
+ }
939
+ else {
940
+ console.log("\n✓ Account removed\n");
941
+ }
942
+ existingStorage = await loadAccounts();
943
+ if (!existingStorage || existingStorage.accounts.length === 0) {
944
+ // No accounts left, proceed to auth flow.
945
+ startFresh = true;
946
+ break;
947
+ }
948
+ continue;
949
+ }
950
+ if (menu.mode === "cancel") {
951
+ return {
952
+ url: "",
953
+ instructions: "Authentication cancelled",
954
+ method: "auto",
955
+ callback: async () => ({ type: "failed", error: "Authentication cancelled" }),
956
+ };
957
+ }
958
+ if (menu.mode === "fresh") {
959
+ startFresh = true;
960
+ await clearAccounts().catch(() => { });
961
+ existingStorage = null;
962
+ }
963
+ break;
964
+ }
965
+ }
966
+ while (results.length < MAX_OAUTH_ACCOUNTS) {
967
+ const authorization = await authorizeKimi();
968
+ const url = authorization.verificationUri;
969
+ const code = authorization.userCode;
970
+ console.log("\nKimi device authorization");
971
+ console.log(` URL: ${url}`);
972
+ console.log(` Code: ${code}`);
973
+ console.log(" Expires: 30 minutes");
974
+ console.log("");
975
+ if (!noBrowser && !isHeadless()) {
976
+ await openBrowser(url).catch(() => { });
977
+ }
978
+ const tokens = await authorization.poll();
979
+ const result = authSuccessFromTokens(tokens);
980
+ if (result.type === "failed") {
981
+ if (results.length === 0) {
982
+ return {
983
+ url: "",
984
+ instructions: `Authentication failed: ${result.error}`,
985
+ method: "auto",
986
+ callback: async () => result,
987
+ };
988
+ }
989
+ break;
990
+ }
991
+ results.push(result);
992
+ try {
993
+ await client.tui.showToast({
994
+ body: {
995
+ message: `Account ${results.length} authenticated`,
996
+ variant: "success",
997
+ },
998
+ });
999
+ }
1000
+ catch { }
1001
+ // Persist the refresh token in the pool file.
1002
+ try {
1003
+ const isFirstAccount = results.length === 1;
1004
+ await persistAccountPool([{ refresh: result.refresh }], isFirstAccount && startFresh);
1005
+ }
1006
+ catch { }
1007
+ // Ask user if they want to add another account.
1008
+ let currentAccountCount = results.length;
1009
+ try {
1010
+ const currentStorage = await loadAccounts();
1011
+ if (currentStorage) {
1012
+ currentAccountCount = currentStorage.accounts.length;
1013
+ }
1014
+ }
1015
+ catch { }
1016
+ const addAnother = await promptAddAnotherAccount(currentAccountCount);
1017
+ if (!addAnother)
1018
+ break;
1019
+ }
1020
+ const primary = results[0];
1021
+ if (!primary) {
1022
+ return {
1023
+ url: "",
1024
+ instructions: "Authentication cancelled",
1025
+ method: "auto",
1026
+ callback: async () => ({ type: "failed", error: "Authentication cancelled" }),
1027
+ };
1028
+ }
1029
+ let actualAccountCount = results.length;
1030
+ try {
1031
+ const finalStorage = await loadAccounts();
1032
+ if (finalStorage) {
1033
+ actualAccountCount = finalStorage.accounts.length;
1034
+ }
1035
+ }
1036
+ catch { }
1037
+ return {
1038
+ url: "",
1039
+ instructions: `Multi-account setup complete (${actualAccountCount} account(s)).`,
1040
+ method: "auto",
1041
+ callback: async () => primary,
1042
+ };
1043
+ }
1044
+ // TUI flow: single-account only (no prompts).
1045
+ const authorization = await authorizeKimi();
1046
+ const url = authorization.verificationUri;
1047
+ const code = authorization.userCode;
1048
+ if (!noBrowser && !isHeadless()) {
1049
+ await openBrowser(url).catch(() => { });
1050
+ }
1051
+ return {
1052
+ url,
1053
+ instructions: `Open the URL and enter code: ${code}`,
1054
+ method: "auto",
1055
+ callback: async () => {
1056
+ const tokens = await authorization.poll();
1057
+ const result = authSuccessFromTokens(tokens);
1058
+ if (result.type === "success") {
1059
+ await persistAccountPool([{ refresh: result.refresh }], false).catch(() => { });
1060
+ }
1061
+ return result;
1062
+ },
1063
+ };
1064
+ },
1065
+ },
1066
+ {
1067
+ label: "API Key",
1068
+ type: "api",
1069
+ },
1070
+ ],
1071
+ },
1072
+ };
1073
+ };
1074
+ export const KimicodeCLIOAuthPlugin = createKimicodePlugin("moonshotai");
1075
+ // Convenience alias.
1076
+ export const MoonshotAIOAuthPlugin = KimicodeCLIOAuthPlugin;
1077
+ //# sourceMappingURL=plugin.js.map