@hung319/opencode-iflow-cli 1.1.1

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/dist/plugin.js ADDED
@@ -0,0 +1,676 @@
1
+ import { loadConfig } from './plugin/config';
2
+ import { exec } from 'node:child_process';
3
+ import { AccountManager, generateAccountId } from './plugin/accounts';
4
+ import { accessTokenExpired } from './plugin/token';
5
+ import { refreshAccessToken } from './plugin/token';
6
+ import { authorizeIFlowOAuth } from './iflow/oauth';
7
+ import { validateApiKey } from './iflow/apikey';
8
+ import { startOAuthServer } from './plugin/server';
9
+ import { promptAddAnotherAccount, promptLoginMode, promptApiKey, promptEmail, promptOAuthCallback } from './plugin/cli';
10
+ import { IFLOW_CONSTANTS, applyThinkingConfig } from './constants';
11
+ import * as logger from './plugin/logger';
12
+ const IFLOW_PROVIDER_ID = 'iflow';
13
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
14
+ const isNetworkError = (e) => e instanceof Error && /econnreset|etimedout|enotfound|network|fetch failed/i.test(e.message);
15
+ const openBrowser = (url) => {
16
+ const escapedUrl = url.replace(/"/g, '\\"');
17
+ const platform = process.platform;
18
+ const command = platform === 'win32'
19
+ ? `cmd /c start "" "${escapedUrl}"`
20
+ : platform === 'darwin'
21
+ ? `open "${escapedUrl}"`
22
+ : `xdg-open "${escapedUrl}"`;
23
+ exec(command, (error) => {
24
+ if (error)
25
+ logger.warn(`Failed to open browser automatically: ${error.message}`, error);
26
+ });
27
+ };
28
+ /**
29
+ * Detect if running in headless environment (SSH, container, CI, etc.)
30
+ */
31
+ function isHeadlessEnvironment() {
32
+ return !!(process.env.SSH_CONNECTION ||
33
+ process.env.SSH_CLIENT ||
34
+ process.env.SSH_TTY ||
35
+ process.env.OPENCODE_HEADLESS ||
36
+ process.env.CI ||
37
+ process.env.CONTAINER ||
38
+ (!process.env.DISPLAY && process.platform !== 'darwin' && process.platform !== 'win32'));
39
+ }
40
+ /**
41
+ * Parse OAuth callback input - can be full URL or just the code
42
+ */
43
+ function parseOAuthCallbackInput(input) {
44
+ const trimmed = input.trim();
45
+ if (!trimmed) {
46
+ return {};
47
+ }
48
+ // If it's a URL, extract code and state from query params
49
+ if (/^https?:\/\//i.test(trimmed)) {
50
+ try {
51
+ const url = new URL(trimmed);
52
+ return {
53
+ code: url.searchParams.get('code') || undefined,
54
+ state: url.searchParams.get('state') || undefined,
55
+ };
56
+ }
57
+ catch {
58
+ return {};
59
+ }
60
+ }
61
+ // If it looks like query params (code=...&state=...)
62
+ const candidate = trimmed.startsWith('?') ? trimmed.slice(1) : trimmed;
63
+ if (candidate.includes('=')) {
64
+ const params = new URLSearchParams(candidate);
65
+ const code = params.get('code') || undefined;
66
+ const state = params.get('state') || undefined;
67
+ if (code || state) {
68
+ return { code, state };
69
+ }
70
+ }
71
+ // Assume it's just the code
72
+ return { code: trimmed };
73
+ }
74
+ /**
75
+ * Default model configurations for iFlow
76
+ */
77
+ const DEFAULT_MODELS = {
78
+ 'iflow-rome-30ba3b': {
79
+ name: 'iFlow ROME 30B',
80
+ limit: { context: 256000, output: 64000 },
81
+ modalities: { input: ['text'], output: ['text'] }
82
+ },
83
+ 'qwen3-coder-plus': {
84
+ name: 'Qwen3 Coder Plus',
85
+ limit: { context: 1000000, output: 64000 },
86
+ modalities: { input: ['text'], output: ['text'] }
87
+ },
88
+ 'qwen3-max': {
89
+ name: 'Qwen3 Max',
90
+ limit: { context: 256000, output: 32000 },
91
+ modalities: { input: ['text'], output: ['text'] }
92
+ },
93
+ 'qwen3-vl-plus': {
94
+ name: 'Qwen3 VL Plus',
95
+ limit: { context: 256000, output: 32000 },
96
+ modalities: { input: ['text', 'image'], output: ['text'] }
97
+ },
98
+ 'qwen3-235b-a22b-thinking-2507': {
99
+ name: 'Qwen3 235B Thinking',
100
+ limit: { context: 256000, output: 64000 },
101
+ modalities: { input: ['text'], output: ['text'] }
102
+ },
103
+ 'kimi-k2': {
104
+ name: 'Kimi K2',
105
+ limit: { context: 128000, output: 64000 },
106
+ modalities: { input: ['text'], output: ['text'] }
107
+ },
108
+ 'kimi-k2-0905': {
109
+ name: 'Kimi K2 0905',
110
+ limit: { context: 256000, output: 64000 },
111
+ modalities: { input: ['text'], output: ['text'] }
112
+ },
113
+ 'glm-4.6': {
114
+ name: 'GLM-4.6 Thinking',
115
+ limit: { context: 200000, output: 128000 },
116
+ modalities: { input: ['text', 'image'], output: ['text'] },
117
+ variants: {
118
+ low: { thinkingConfig: { thinkingBudget: 1024 } },
119
+ medium: { thinkingConfig: { thinkingBudget: 8192 } },
120
+ max: { thinkingConfig: { thinkingBudget: 32768 } }
121
+ }
122
+ },
123
+ 'deepseek-v3': {
124
+ name: 'DeepSeek V3',
125
+ limit: { context: 128000, output: 32000 },
126
+ modalities: { input: ['text'], output: ['text'] }
127
+ },
128
+ 'deepseek-v3.2': {
129
+ name: 'DeepSeek V3.2',
130
+ limit: { context: 128000, output: 64000 },
131
+ modalities: { input: ['text'], output: ['text'] }
132
+ },
133
+ 'deepseek-r1': {
134
+ name: 'DeepSeek R1',
135
+ limit: { context: 128000, output: 32000 },
136
+ modalities: { input: ['text'], output: ['text'] },
137
+ variants: {
138
+ low: { thinkingConfig: { thinkingBudget: 1024 } },
139
+ medium: { thinkingConfig: { thinkingBudget: 8192 } },
140
+ max: { thinkingConfig: { thinkingBudget: 32768 } }
141
+ }
142
+ },
143
+ 'qwen3-32b': {
144
+ name: 'Qwen3 32B',
145
+ limit: { context: 128000, output: 32000 },
146
+ modalities: { input: ['text'], output: ['text'] }
147
+ }
148
+ };
149
+ export const createIFlowPlugin = (id) => async ({ client, directory }) => {
150
+ const config = loadConfig();
151
+ const showToast = (message, variant) => {
152
+ client.tui.showToast({ body: { message, variant } }).catch(() => { });
153
+ };
154
+ return {
155
+ auth: {
156
+ provider: id,
157
+ loader: async (getAuth, provider) => {
158
+ await getAuth();
159
+ const am = await AccountManager.loadFromDisk(config.account_selection_strategy);
160
+ // Auto-configure models if not already configured
161
+ const configuredModels = provider?.models || {};
162
+ const mergedModels = { ...DEFAULT_MODELS, ...configuredModels };
163
+ return {
164
+ apiKey: '',
165
+ baseURL: IFLOW_CONSTANTS.BASE_URL,
166
+ models: mergedModels,
167
+ async fetch(input, init) {
168
+ const url = typeof input === 'string' ? input : input.url;
169
+ let retry = 0;
170
+ let iterations = 0;
171
+ const startTime = Date.now();
172
+ const maxIterations = config.max_request_iterations;
173
+ const timeoutMs = config.request_timeout_ms;
174
+ while (true) {
175
+ iterations++;
176
+ const elapsed = Date.now() - startTime;
177
+ if (iterations > maxIterations) {
178
+ throw new Error(`Request exceeded max iterations (${maxIterations}). All accounts may be unhealthy or rate-limited.`);
179
+ }
180
+ if (elapsed > timeoutMs) {
181
+ throw new Error(`Request timeout after ${Math.ceil(elapsed / 1000)}s. Max timeout: ${Math.ceil(timeoutMs / 1000)}s.`);
182
+ }
183
+ const count = am.getAccountCount();
184
+ if (count === 0)
185
+ throw new Error('No accounts. Login first.');
186
+ const acc = am.getCurrentOrNext();
187
+ if (!acc) {
188
+ const minWait = am.getMinWaitTime();
189
+ if (minWait > 0) {
190
+ showToast(`All accounts rate-limited. Waiting ${Math.ceil(minWait / 1000)}s...`, 'warning');
191
+ await sleep(Math.min(minWait, 5000));
192
+ continue;
193
+ }
194
+ throw new Error('No healthy accounts available');
195
+ }
196
+ if (count > 1 && am.shouldShowToast()) {
197
+ showToast(`Using ${acc.email} (${am.getAccounts().indexOf(acc) + 1}/${count})`, 'info');
198
+ }
199
+ if (acc.authMethod === 'oauth' &&
200
+ acc.expiresAt &&
201
+ accessTokenExpired(acc.expiresAt)) {
202
+ try {
203
+ const authDetails = am.toAuthDetails(acc);
204
+ const refreshed = await refreshAccessToken(authDetails);
205
+ am.updateFromAuth(acc, refreshed);
206
+ await am.saveToDisk();
207
+ }
208
+ catch (error) {
209
+ logger.error(`Token refresh failed for account ${acc.id}`, error);
210
+ am.markUnhealthy(acc, 'Token refresh failed', Date.now() + 300000);
211
+ continue;
212
+ }
213
+ }
214
+ const body = init?.body ? JSON.parse(init.body) : {};
215
+ const model = body.model || 'qwen3-max';
216
+ let processedBody = applyThinkingConfig(body, model);
217
+ if (processedBody.stream === false && processedBody.stream_options) {
218
+ const { stream_options, ...rest } = processedBody;
219
+ processedBody = rest;
220
+ }
221
+ const apiTimestamp = config.enable_log_api_request ? logger.getTimestamp() : null;
222
+ const incomingHeaders = init?.headers || {};
223
+ const cleanedHeaders = {};
224
+ for (const [key, value] of Object.entries(incomingHeaders)) {
225
+ const lowerKey = key.toLowerCase();
226
+ if (lowerKey !== 'authorization' &&
227
+ lowerKey !== 'user-agent' &&
228
+ lowerKey !== 'content-type') {
229
+ cleanedHeaders[key] = value;
230
+ }
231
+ }
232
+ const headers = {
233
+ Authorization: `Bearer ${acc.apiKey}`,
234
+ 'User-Agent': IFLOW_CONSTANTS.USER_AGENT,
235
+ 'Content-Type': 'application/json',
236
+ ...cleanedHeaders
237
+ };
238
+ if (apiTimestamp) {
239
+ const sanitizedHeaders = {
240
+ ...headers,
241
+ Authorization: `Bearer ${acc.apiKey.substring(0, 10)}...`
242
+ };
243
+ const requestData = {
244
+ url: typeof input === 'string' ? input : input.url,
245
+ method: init?.method || 'POST',
246
+ headers: sanitizedHeaders,
247
+ body: processedBody,
248
+ account: acc.email
249
+ };
250
+ logger.logApiRequest(requestData, apiTimestamp);
251
+ }
252
+ try {
253
+ const response = await fetch(input, {
254
+ ...init,
255
+ headers,
256
+ body: JSON.stringify(processedBody),
257
+ method: init?.method || 'POST'
258
+ });
259
+ if (response.ok) {
260
+ if (apiTimestamp) {
261
+ const responseData = {
262
+ status: response.status,
263
+ statusText: response.statusText,
264
+ headers: {}
265
+ };
266
+ logger.logApiResponse(responseData, apiTimestamp);
267
+ }
268
+ return response;
269
+ }
270
+ const errorText = await response.text().catch(() => '');
271
+ const responseData = {
272
+ status: response.status,
273
+ statusText: response.statusText,
274
+ body: errorText,
275
+ account: acc.email
276
+ };
277
+ const sanitizedHeaders = {
278
+ ...headers,
279
+ Authorization: `Bearer ${acc.apiKey.substring(0, 10)}...`
280
+ };
281
+ const requestData = {
282
+ url: typeof input === 'string' ? input : input.url,
283
+ method: init?.method || 'POST',
284
+ headers: sanitizedHeaders,
285
+ body: processedBody,
286
+ account: acc.email
287
+ };
288
+ if (config.enable_log_api_request && apiTimestamp) {
289
+ logger.logApiResponse(responseData, apiTimestamp);
290
+ }
291
+ else {
292
+ const errorTimestamp = logger.getTimestamp();
293
+ logger.logApiError(requestData, responseData, errorTimestamp);
294
+ }
295
+ if (response.status === 429) {
296
+ const retryAfter = parseInt(response.headers.get('retry-after') || '60', 10);
297
+ logger.warn(`Rate limited on account ${acc.email}, retry after ${retryAfter}s`);
298
+ am.markRateLimited(acc, retryAfter * 1000);
299
+ await sleep(1000);
300
+ continue;
301
+ }
302
+ if (response.status === 401 || response.status === 403) {
303
+ logger.warn(`Authentication failed for ${acc.email}: ${response.status}`);
304
+ am.markUnhealthy(acc, 'Authentication failed', Date.now() + 300000);
305
+ continue;
306
+ }
307
+ if (response.status >= 500) {
308
+ if (retry < 3) {
309
+ retry++;
310
+ logger.warn(`Server error ${response.status}, retry ${retry}/3`);
311
+ await sleep(1000 * Math.pow(2, retry));
312
+ continue;
313
+ }
314
+ logger.error(`Server error ${response.status} after ${retry} retries`);
315
+ am.markUnhealthy(acc, 'Server error', Date.now() + 300000);
316
+ continue;
317
+ }
318
+ throw new Error(`iFlow Error: ${response.status} - ${errorText}`);
319
+ }
320
+ catch (error) {
321
+ if (isNetworkError(error) && retry < 3) {
322
+ retry++;
323
+ logger.warn(`Network error, retry ${retry}/3: ${error.message}`);
324
+ await sleep(1000 * Math.pow(2, retry));
325
+ continue;
326
+ }
327
+ const sanitizedHeaders = {
328
+ ...headers,
329
+ Authorization: `Bearer ${acc.apiKey.substring(0, 10)}...`
330
+ };
331
+ const requestData = {
332
+ url: typeof input === 'string' ? input : input.url,
333
+ method: init?.method || 'POST',
334
+ headers: sanitizedHeaders,
335
+ body: processedBody,
336
+ account: acc.email
337
+ };
338
+ const networkErrorData = {
339
+ status: 0,
340
+ statusText: 'Network Error',
341
+ body: error.message,
342
+ account: acc.email
343
+ };
344
+ if (config.enable_log_api_request && apiTimestamp) {
345
+ logger.logApiResponse(networkErrorData, apiTimestamp);
346
+ }
347
+ else {
348
+ const errorTimestamp = logger.getTimestamp();
349
+ logger.logApiError(requestData, networkErrorData, errorTimestamp);
350
+ }
351
+ logger.error(`Request failed after ${retry} retries: ${error.message}`, error);
352
+ throw error;
353
+ }
354
+ }
355
+ }
356
+ };
357
+ },
358
+ methods: [
359
+ {
360
+ id: 'oauth',
361
+ label: 'iFlow OAuth 2.0',
362
+ type: 'oauth',
363
+ authorize: async (inputs) => new Promise(async (resolve) => {
364
+ const isHeadless = isHeadlessEnvironment();
365
+ /**
366
+ * Perform OAuth with local server + manual code input fallback
367
+ * Always starts server, always shows URL, always allows manual input
368
+ */
369
+ const performOAuth = async () => {
370
+ try {
371
+ const authData = await authorizeIFlowOAuth(config.auth_server_port_start);
372
+ // Start local OAuth server (always)
373
+ const server = await startOAuthServer(authData.authUrl, authData.state, authData.redirectUri, config.auth_server_port_start, config.auth_server_port_range);
374
+ console.log('\n=== iFlow OAuth Authentication ===\n');
375
+ console.log('OAuth URL:');
376
+ console.log(authData.authUrl);
377
+ console.log('');
378
+ // Open browser automatically if not headless
379
+ if (!isHeadless) {
380
+ console.log('Opening browser automatically...');
381
+ openBrowser(authData.authUrl);
382
+ }
383
+ console.log(`\nLocal callback server running on port ${server.actualPort}`);
384
+ console.log('Waiting for authentication...');
385
+ console.log('\n(If the browser does not open automatically, open the URL above manually)');
386
+ console.log('(You can also paste the callback URL or authorization code below)\n');
387
+ // Race between server callback and manual code input
388
+ const manualInputPromise = (async () => {
389
+ const callbackInput = await promptOAuthCallback();
390
+ const { code, state } = parseOAuthCallbackInput(callbackInput);
391
+ if (!code) {
392
+ throw new Error('No authorization code provided');
393
+ }
394
+ if (state && state !== authData.state) {
395
+ throw new Error('State mismatch - possible CSRF attempt');
396
+ }
397
+ // Close server since we got manual input
398
+ server.close();
399
+ return await server.exchangeCode(code);
400
+ })();
401
+ const result = await Promise.race([
402
+ server.waitForAuth(),
403
+ manualInputPromise
404
+ ]);
405
+ return result;
406
+ }
407
+ catch (e) {
408
+ logger.error(`OAuth authorization failed: ${e.message}`, e);
409
+ return { type: 'failed', error: e.message };
410
+ }
411
+ };
412
+ if (inputs) {
413
+ const accounts = [];
414
+ let startFresh = true;
415
+ const existingAm = await AccountManager.loadFromDisk(config.account_selection_strategy);
416
+ if (existingAm.getAccountCount() > 0) {
417
+ const existingAccounts = existingAm.getAccounts().map((acc, idx) => ({
418
+ email: acc.email,
419
+ index: idx
420
+ }));
421
+ const loginMode = await promptLoginMode(existingAccounts);
422
+ startFresh = loginMode === 'fresh';
423
+ console.log(startFresh
424
+ ? '\nStarting fresh - existing accounts will be replaced.\n'
425
+ : '\nAdding to existing accounts.\n');
426
+ }
427
+ while (true) {
428
+ console.log(`\n=== iFlow OAuth (Account ${accounts.length + 1}) ===\n`);
429
+ const result = await performOAuth();
430
+ if ('type' in result && result.type === 'failed') {
431
+ if (accounts.length === 0) {
432
+ return resolve({
433
+ url: '',
434
+ instructions: `Authentication failed: ${result.error}`,
435
+ method: 'code',
436
+ callback: async () => ({ type: 'failed' })
437
+ });
438
+ }
439
+ console.warn(`[opencode-iflow-auth] Skipping failed account ${accounts.length + 1}: ${result.error}`);
440
+ break;
441
+ }
442
+ const successResult = result;
443
+ accounts.push(successResult);
444
+ const isFirstAccount = accounts.length === 1;
445
+ const am = await AccountManager.loadFromDisk(config.account_selection_strategy);
446
+ if (isFirstAccount && startFresh) {
447
+ am.getAccounts().forEach((acc) => am.removeAccount(acc));
448
+ }
449
+ const acc = {
450
+ id: generateAccountId(),
451
+ email: successResult.email,
452
+ authMethod: 'oauth',
453
+ refreshToken: successResult.refreshToken,
454
+ accessToken: successResult.accessToken,
455
+ expiresAt: successResult.expiresAt,
456
+ apiKey: successResult.apiKey,
457
+ rateLimitResetTime: 0,
458
+ isHealthy: true
459
+ };
460
+ am.addAccount(acc);
461
+ await am.saveToDisk();
462
+ showToast(`Account ${accounts.length} authenticated${successResult.email ? ` (${successResult.email})` : ''}`, 'success');
463
+ let currentAccountCount = accounts.length;
464
+ try {
465
+ const currentStorage = await AccountManager.loadFromDisk(config.account_selection_strategy);
466
+ currentAccountCount = currentStorage.getAccountCount();
467
+ }
468
+ catch (e) {
469
+ logger.warn(`Failed to load account count: ${e.message}`);
470
+ }
471
+ const addAnother = await promptAddAnotherAccount(currentAccountCount);
472
+ if (!addAnother) {
473
+ break;
474
+ }
475
+ }
476
+ const primary = accounts[0];
477
+ if (!primary) {
478
+ return resolve({
479
+ url: '',
480
+ instructions: 'Authentication cancelled',
481
+ method: 'code',
482
+ callback: async () => ({ type: 'failed' })
483
+ });
484
+ }
485
+ let actualAccountCount = accounts.length;
486
+ try {
487
+ const finalStorage = await AccountManager.loadFromDisk(config.account_selection_strategy);
488
+ actualAccountCount = finalStorage.getAccountCount();
489
+ }
490
+ catch (e) {
491
+ logger.warn(`Failed to load account count: ${e.message}`);
492
+ }
493
+ return resolve({
494
+ url: '',
495
+ instructions: `Multi-account setup complete (${actualAccountCount} account(s)).`,
496
+ method: 'code',
497
+ callback: async () => ({ type: 'success', key: primary.apiKey })
498
+ });
499
+ }
500
+ // TUI mode (no inputs) - return code-based auth that works for both headless and non-headless
501
+ try {
502
+ const authData = await authorizeIFlowOAuth(config.auth_server_port_start);
503
+ // Start server in background
504
+ const server = await startOAuthServer(authData.authUrl, authData.state, authData.redirectUri, config.auth_server_port_start, config.auth_server_port_range);
505
+ // Open browser if not headless
506
+ if (!isHeadless) {
507
+ openBrowser(authData.authUrl);
508
+ }
509
+ resolve({
510
+ url: authData.authUrl,
511
+ instructions: `Open this URL to authenticate:\n${authData.authUrl}\n\nA local callback server is running on port ${server.actualPort}.\nYou can either wait for automatic redirect (if browser opened) or paste the callback URL/code below.`,
512
+ method: 'code',
513
+ callback: async (callbackInput) => {
514
+ try {
515
+ const { code, state } = parseOAuthCallbackInput(callbackInput);
516
+ if (!code) {
517
+ return { type: 'failed', error: 'Missing authorization code' };
518
+ }
519
+ if (state && state !== authData.state) {
520
+ return { type: 'failed', error: 'State mismatch - possible CSRF attempt' };
521
+ }
522
+ const res = await server.exchangeCode(code);
523
+ const am = await AccountManager.loadFromDisk(config.account_selection_strategy);
524
+ const acc = {
525
+ id: generateAccountId(),
526
+ email: res.email,
527
+ authMethod: 'oauth',
528
+ refreshToken: res.refreshToken,
529
+ accessToken: res.accessToken,
530
+ expiresAt: res.expiresAt,
531
+ apiKey: res.apiKey,
532
+ rateLimitResetTime: 0,
533
+ isHealthy: true
534
+ };
535
+ am.addAccount(acc);
536
+ await am.saveToDisk();
537
+ showToast(`Successfully logged in as ${res.email}`, 'success');
538
+ return { type: 'success', key: res.apiKey };
539
+ }
540
+ catch (e) {
541
+ logger.error(`Login failed: ${e.message}`, e);
542
+ showToast(`Login failed: ${e.message}`, 'error');
543
+ return { type: 'failed', error: e.message };
544
+ }
545
+ }
546
+ });
547
+ }
548
+ catch (e) {
549
+ logger.error(`Authorization failed: ${e.message}`, e);
550
+ showToast(`Authorization failed: ${e.message}`, 'error');
551
+ resolve({
552
+ url: '',
553
+ instructions: 'Authorization failed',
554
+ method: 'code',
555
+ callback: async () => ({ type: 'failed' })
556
+ });
557
+ }
558
+ })
559
+ },
560
+ {
561
+ id: 'api',
562
+ label: 'iFlow API Key',
563
+ type: 'api',
564
+ authorize: async (inputs) => new Promise(async (resolve) => {
565
+ if (inputs) {
566
+ const accounts = [];
567
+ let startFresh = true;
568
+ const existingAm = await AccountManager.loadFromDisk(config.account_selection_strategy);
569
+ if (existingAm.getAccountCount() > 0) {
570
+ const existingAccounts = existingAm.getAccounts().map((acc, idx) => ({
571
+ email: acc.email,
572
+ index: idx
573
+ }));
574
+ const loginMode = await promptLoginMode(existingAccounts);
575
+ startFresh = loginMode === 'fresh';
576
+ console.log(startFresh
577
+ ? '\nStarting fresh - existing accounts will be replaced.\n'
578
+ : '\nAdding to existing accounts.\n');
579
+ }
580
+ while (true) {
581
+ console.log(`\n=== iFlow API Key (Account ${accounts.length + 1}) ===\n`);
582
+ const apiKey = await promptApiKey();
583
+ if (!apiKey) {
584
+ if (accounts.length === 0) {
585
+ return resolve({
586
+ url: '',
587
+ instructions: 'API key required',
588
+ method: 'auto',
589
+ callback: async () => ({ type: 'failed' })
590
+ });
591
+ }
592
+ break;
593
+ }
594
+ try {
595
+ await validateApiKey(apiKey);
596
+ const email = await promptEmail();
597
+ accounts.push({ apiKey, email });
598
+ const isFirstAccount = accounts.length === 1;
599
+ const am = await AccountManager.loadFromDisk(config.account_selection_strategy);
600
+ if (isFirstAccount && startFresh) {
601
+ am.getAccounts().forEach((acc) => am.removeAccount(acc));
602
+ }
603
+ const acc = {
604
+ id: generateAccountId(),
605
+ email,
606
+ authMethod: 'apikey',
607
+ apiKey,
608
+ rateLimitResetTime: 0,
609
+ isHealthy: true
610
+ };
611
+ am.addAccount(acc);
612
+ await am.saveToDisk();
613
+ showToast(`Account ${accounts.length} added (${email})`, 'success');
614
+ let currentAccountCount = accounts.length;
615
+ try {
616
+ const currentStorage = await AccountManager.loadFromDisk(config.account_selection_strategy);
617
+ currentAccountCount = currentStorage.getAccountCount();
618
+ }
619
+ catch (e) {
620
+ logger.warn(`Failed to load account count: ${e.message}`);
621
+ }
622
+ const addAnother = await promptAddAnotherAccount(currentAccountCount);
623
+ if (!addAnother) {
624
+ break;
625
+ }
626
+ }
627
+ catch (error) {
628
+ console.error(`API key validation failed: ${error.message}`);
629
+ if (accounts.length === 0) {
630
+ return resolve({
631
+ url: '',
632
+ instructions: `API key validation failed: ${error.message}`,
633
+ method: 'auto',
634
+ callback: async () => ({ type: 'failed' })
635
+ });
636
+ }
637
+ break;
638
+ }
639
+ }
640
+ const primary = accounts[0];
641
+ if (!primary) {
642
+ return resolve({
643
+ url: '',
644
+ instructions: 'Authentication cancelled',
645
+ method: 'auto',
646
+ callback: async () => ({ type: 'failed' })
647
+ });
648
+ }
649
+ let actualAccountCount = accounts.length;
650
+ try {
651
+ const finalStorage = await AccountManager.loadFromDisk(config.account_selection_strategy);
652
+ actualAccountCount = finalStorage.getAccountCount();
653
+ }
654
+ catch (e) {
655
+ logger.warn(`Failed to load account count: ${e.message}`);
656
+ }
657
+ return resolve({
658
+ url: '',
659
+ instructions: `Multi-account setup complete (${actualAccountCount} account(s)).`,
660
+ method: 'auto',
661
+ callback: async () => ({ type: 'success', key: primary.apiKey })
662
+ });
663
+ }
664
+ resolve({
665
+ url: '',
666
+ instructions: 'API Key authentication not supported in TUI mode. Use CLI: opencode auth login',
667
+ method: 'auto',
668
+ callback: async () => ({ type: 'failed' })
669
+ });
670
+ })
671
+ }
672
+ ]
673
+ }
674
+ };
675
+ };
676
+ export const IFlowOAuthPlugin = createIFlowPlugin(IFLOW_PROVIDER_ID);