@zhafron/opencode-iflow-auth 1.0.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.
package/dist/plugin.js ADDED
@@ -0,0 +1,504 @@
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 } 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
+ export const createIFlowPlugin = (id) => async ({ client, directory }) => {
29
+ const config = loadConfig();
30
+ const showToast = (message, variant) => {
31
+ client.tui.showToast({ body: { message, variant } }).catch(() => { });
32
+ };
33
+ return {
34
+ auth: {
35
+ provider: id,
36
+ loader: async (getAuth) => {
37
+ await getAuth();
38
+ const am = await AccountManager.loadFromDisk(config.account_selection_strategy);
39
+ return {
40
+ apiKey: '',
41
+ baseURL: IFLOW_CONSTANTS.BASE_URL,
42
+ async fetch(input, init) {
43
+ const url = typeof input === 'string' ? input : input.url;
44
+ let retry = 0;
45
+ let iterations = 0;
46
+ const startTime = Date.now();
47
+ const maxIterations = config.max_request_iterations;
48
+ const timeoutMs = config.request_timeout_ms;
49
+ while (true) {
50
+ iterations++;
51
+ const elapsed = Date.now() - startTime;
52
+ if (iterations > maxIterations) {
53
+ throw new Error(`Request exceeded max iterations (${maxIterations}). All accounts may be unhealthy or rate-limited.`);
54
+ }
55
+ if (elapsed > timeoutMs) {
56
+ throw new Error(`Request timeout after ${Math.ceil(elapsed / 1000)}s. Max timeout: ${Math.ceil(timeoutMs / 1000)}s.`);
57
+ }
58
+ const count = am.getAccountCount();
59
+ if (count === 0)
60
+ throw new Error('No accounts. Login first.');
61
+ const acc = am.getCurrentOrNext();
62
+ if (!acc) {
63
+ const minWait = am.getMinWaitTime();
64
+ if (minWait > 0) {
65
+ showToast(`All accounts rate-limited. Waiting ${Math.ceil(minWait / 1000)}s...`, 'warning');
66
+ await sleep(Math.min(minWait, 5000));
67
+ continue;
68
+ }
69
+ throw new Error('No healthy accounts available');
70
+ }
71
+ if (count > 1 && am.shouldShowToast()) {
72
+ showToast(`Using ${acc.email} (${am.getAccounts().indexOf(acc) + 1}/${count})`, 'info');
73
+ }
74
+ if (acc.authMethod === 'oauth' &&
75
+ acc.expiresAt &&
76
+ accessTokenExpired(acc.expiresAt)) {
77
+ try {
78
+ const authDetails = am.toAuthDetails(acc);
79
+ const refreshed = await refreshAccessToken(authDetails);
80
+ am.updateFromAuth(acc, refreshed);
81
+ await am.saveToDisk();
82
+ }
83
+ catch (error) {
84
+ logger.error(`Token refresh failed for account ${acc.id}`, error);
85
+ am.markUnhealthy(acc, 'Token refresh failed', Date.now() + 300000);
86
+ continue;
87
+ }
88
+ }
89
+ const body = init?.body ? JSON.parse(init.body) : {};
90
+ const model = body.model || 'qwen3-max';
91
+ let processedBody = applyThinkingConfig(body, model);
92
+ if (processedBody.stream === false && processedBody.stream_options) {
93
+ const { stream_options, ...rest } = processedBody;
94
+ processedBody = rest;
95
+ }
96
+ const apiTimestamp = config.enable_log_api_request ? logger.getTimestamp() : null;
97
+ const incomingHeaders = init?.headers || {};
98
+ const cleanedHeaders = {};
99
+ for (const [key, value] of Object.entries(incomingHeaders)) {
100
+ const lowerKey = key.toLowerCase();
101
+ if (lowerKey !== 'authorization' &&
102
+ lowerKey !== 'user-agent' &&
103
+ lowerKey !== 'content-type') {
104
+ cleanedHeaders[key] = value;
105
+ }
106
+ }
107
+ const headers = {
108
+ Authorization: `Bearer ${acc.apiKey}`,
109
+ 'User-Agent': IFLOW_CONSTANTS.USER_AGENT,
110
+ 'Content-Type': 'application/json',
111
+ ...cleanedHeaders
112
+ };
113
+ if (apiTimestamp) {
114
+ const sanitizedHeaders = {
115
+ ...headers,
116
+ Authorization: `Bearer ${acc.apiKey.substring(0, 10)}...`
117
+ };
118
+ const requestData = {
119
+ url: typeof input === 'string' ? input : input.url,
120
+ method: init?.method || 'POST',
121
+ headers: sanitizedHeaders,
122
+ body: processedBody,
123
+ account: acc.email
124
+ };
125
+ logger.logApiRequest(requestData, apiTimestamp);
126
+ }
127
+ try {
128
+ const response = await fetch(input, {
129
+ ...init,
130
+ headers,
131
+ body: JSON.stringify(processedBody),
132
+ method: init?.method || 'POST'
133
+ });
134
+ if (response.ok) {
135
+ if (apiTimestamp) {
136
+ const responseData = {
137
+ status: response.status,
138
+ statusText: response.statusText,
139
+ headers: {}
140
+ };
141
+ logger.logApiResponse(responseData, apiTimestamp);
142
+ }
143
+ return response;
144
+ }
145
+ const errorText = await response.text().catch(() => '');
146
+ const responseData = {
147
+ status: response.status,
148
+ statusText: response.statusText,
149
+ body: errorText,
150
+ account: acc.email
151
+ };
152
+ const sanitizedHeaders = {
153
+ ...headers,
154
+ Authorization: `Bearer ${acc.apiKey.substring(0, 10)}...`
155
+ };
156
+ const requestData = {
157
+ url: typeof input === 'string' ? input : input.url,
158
+ method: init?.method || 'POST',
159
+ headers: sanitizedHeaders,
160
+ body: processedBody,
161
+ account: acc.email
162
+ };
163
+ if (config.enable_log_api_request && apiTimestamp) {
164
+ logger.logApiResponse(responseData, apiTimestamp);
165
+ }
166
+ else {
167
+ const errorTimestamp = logger.getTimestamp();
168
+ logger.logApiError(requestData, responseData, errorTimestamp);
169
+ }
170
+ if (response.status === 429) {
171
+ const retryAfter = parseInt(response.headers.get('retry-after') || '60', 10);
172
+ logger.warn(`Rate limited on account ${acc.email}, retry after ${retryAfter}s`);
173
+ am.markRateLimited(acc, retryAfter * 1000);
174
+ await sleep(1000);
175
+ continue;
176
+ }
177
+ if (response.status === 401 || response.status === 403) {
178
+ logger.warn(`Authentication failed for ${acc.email}: ${response.status}`);
179
+ am.markUnhealthy(acc, 'Authentication failed', Date.now() + 300000);
180
+ continue;
181
+ }
182
+ if (response.status >= 500) {
183
+ if (retry < 3) {
184
+ retry++;
185
+ logger.warn(`Server error ${response.status}, retry ${retry}/3`);
186
+ await sleep(1000 * Math.pow(2, retry));
187
+ continue;
188
+ }
189
+ logger.error(`Server error ${response.status} after ${retry} retries`);
190
+ am.markUnhealthy(acc, 'Server error', Date.now() + 300000);
191
+ continue;
192
+ }
193
+ throw new Error(`iFlow Error: ${response.status} - ${errorText}`);
194
+ }
195
+ catch (error) {
196
+ if (isNetworkError(error) && retry < 3) {
197
+ retry++;
198
+ logger.warn(`Network error, retry ${retry}/3: ${error.message}`);
199
+ await sleep(1000 * Math.pow(2, retry));
200
+ continue;
201
+ }
202
+ const sanitizedHeaders = {
203
+ ...headers,
204
+ Authorization: `Bearer ${acc.apiKey.substring(0, 10)}...`
205
+ };
206
+ const requestData = {
207
+ url: typeof input === 'string' ? input : input.url,
208
+ method: init?.method || 'POST',
209
+ headers: sanitizedHeaders,
210
+ body: processedBody,
211
+ account: acc.email
212
+ };
213
+ const networkErrorData = {
214
+ status: 0,
215
+ statusText: 'Network Error',
216
+ body: error.message,
217
+ account: acc.email
218
+ };
219
+ if (config.enable_log_api_request && apiTimestamp) {
220
+ logger.logApiResponse(networkErrorData, apiTimestamp);
221
+ }
222
+ else {
223
+ const errorTimestamp = logger.getTimestamp();
224
+ logger.logApiError(requestData, networkErrorData, errorTimestamp);
225
+ }
226
+ logger.error(`Request failed after ${retry} retries: ${error.message}`, error);
227
+ throw error;
228
+ }
229
+ }
230
+ }
231
+ };
232
+ },
233
+ methods: [
234
+ {
235
+ id: 'oauth',
236
+ label: 'iFlow OAuth 2.0',
237
+ type: 'oauth',
238
+ authorize: async (inputs) => new Promise(async (resolve) => {
239
+ if (inputs) {
240
+ const accounts = [];
241
+ let startFresh = true;
242
+ const existingAm = await AccountManager.loadFromDisk(config.account_selection_strategy);
243
+ if (existingAm.getAccountCount() > 0) {
244
+ const existingAccounts = existingAm.getAccounts().map((acc, idx) => ({
245
+ email: acc.email,
246
+ index: idx
247
+ }));
248
+ const loginMode = await promptLoginMode(existingAccounts);
249
+ startFresh = loginMode === 'fresh';
250
+ console.log(startFresh
251
+ ? '\nStarting fresh - existing accounts will be replaced.\n'
252
+ : '\nAdding to existing accounts.\n');
253
+ }
254
+ while (true) {
255
+ console.log(`\n=== iFlow OAuth (Account ${accounts.length + 1}) ===\n`);
256
+ const result = await (async () => {
257
+ try {
258
+ const authData = await authorizeIFlowOAuth(config.auth_server_port_start);
259
+ const { url, redirectUri, waitForAuth } = await startOAuthServer(authData.authUrl, authData.state, authData.redirectUri, config.auth_server_port_start, config.auth_server_port_range);
260
+ console.log('OAuth URL:\n' + url + '\n');
261
+ openBrowser(url);
262
+ const res = await waitForAuth();
263
+ return res;
264
+ }
265
+ catch (e) {
266
+ logger.error(`OAuth authorization failed: ${e.message}`, e);
267
+ return { type: 'failed', error: e.message };
268
+ }
269
+ })();
270
+ if ('type' in result && result.type === 'failed') {
271
+ if (accounts.length === 0) {
272
+ return resolve({
273
+ url: '',
274
+ instructions: `Authentication failed: ${result.error}`,
275
+ method: 'auto',
276
+ callback: async () => ({ type: 'failed' })
277
+ });
278
+ }
279
+ console.warn(`[opencode-iflow-auth] Skipping failed account ${accounts.length + 1}: ${result.error}`);
280
+ break;
281
+ }
282
+ const successResult = result;
283
+ accounts.push(successResult);
284
+ const isFirstAccount = accounts.length === 1;
285
+ const am = await AccountManager.loadFromDisk(config.account_selection_strategy);
286
+ if (isFirstAccount && startFresh) {
287
+ am.getAccounts().forEach((acc) => am.removeAccount(acc));
288
+ }
289
+ const acc = {
290
+ id: generateAccountId(),
291
+ email: successResult.email,
292
+ authMethod: 'oauth',
293
+ refreshToken: successResult.refreshToken,
294
+ accessToken: successResult.accessToken,
295
+ expiresAt: successResult.expiresAt,
296
+ apiKey: successResult.apiKey,
297
+ rateLimitResetTime: 0,
298
+ isHealthy: true
299
+ };
300
+ am.addAccount(acc);
301
+ await am.saveToDisk();
302
+ showToast(`Account ${accounts.length} authenticated${successResult.email ? ` (${successResult.email})` : ''}`, 'success');
303
+ let currentAccountCount = accounts.length;
304
+ try {
305
+ const currentStorage = await AccountManager.loadFromDisk(config.account_selection_strategy);
306
+ currentAccountCount = currentStorage.getAccountCount();
307
+ }
308
+ catch (e) {
309
+ logger.warn(`Failed to load account count: ${e.message}`);
310
+ }
311
+ const addAnother = await promptAddAnotherAccount(currentAccountCount);
312
+ if (!addAnother) {
313
+ break;
314
+ }
315
+ }
316
+ const primary = accounts[0];
317
+ if (!primary) {
318
+ return resolve({
319
+ url: '',
320
+ instructions: 'Authentication cancelled',
321
+ method: 'auto',
322
+ callback: async () => ({ type: 'failed' })
323
+ });
324
+ }
325
+ let actualAccountCount = accounts.length;
326
+ try {
327
+ const finalStorage = await AccountManager.loadFromDisk(config.account_selection_strategy);
328
+ actualAccountCount = finalStorage.getAccountCount();
329
+ }
330
+ catch (e) {
331
+ logger.warn(`Failed to load account count: ${e.message}`);
332
+ }
333
+ return resolve({
334
+ url: '',
335
+ instructions: `Multi-account setup complete (${actualAccountCount} account(s)).`,
336
+ method: 'auto',
337
+ callback: async () => ({ type: 'success', key: primary.apiKey })
338
+ });
339
+ }
340
+ try {
341
+ const authData = await authorizeIFlowOAuth(config.auth_server_port_start);
342
+ const { url, redirectUri, waitForAuth } = await startOAuthServer(authData.authUrl, authData.state, authData.redirectUri, config.auth_server_port_start, config.auth_server_port_range);
343
+ openBrowser(url);
344
+ resolve({
345
+ url,
346
+ instructions: `Open this URL to continue: ${url}`,
347
+ method: 'auto',
348
+ callback: async () => {
349
+ try {
350
+ const res = await waitForAuth();
351
+ const am = await AccountManager.loadFromDisk(config.account_selection_strategy);
352
+ const acc = {
353
+ id: generateAccountId(),
354
+ email: res.email,
355
+ authMethod: 'oauth',
356
+ refreshToken: res.refreshToken,
357
+ accessToken: res.accessToken,
358
+ expiresAt: res.expiresAt,
359
+ apiKey: res.apiKey,
360
+ rateLimitResetTime: 0,
361
+ isHealthy: true
362
+ };
363
+ am.addAccount(acc);
364
+ await am.saveToDisk();
365
+ showToast(`Successfully logged in as ${res.email}`, 'success');
366
+ return { type: 'success', key: res.apiKey };
367
+ }
368
+ catch (e) {
369
+ logger.error(`Login failed: ${e.message}`, e);
370
+ showToast(`Login failed: ${e.message}`, 'error');
371
+ return { type: 'failed' };
372
+ }
373
+ }
374
+ });
375
+ }
376
+ catch (e) {
377
+ logger.error(`Authorization failed: ${e.message}`, e);
378
+ showToast(`Authorization failed: ${e.message}`, 'error');
379
+ resolve({
380
+ url: '',
381
+ instructions: 'Authorization failed',
382
+ method: 'auto',
383
+ callback: async () => ({ type: 'failed' })
384
+ });
385
+ }
386
+ })
387
+ },
388
+ {
389
+ id: 'apikey',
390
+ label: 'iFlow API Key',
391
+ type: 'apikey',
392
+ authorize: async (inputs) => new Promise(async (resolve) => {
393
+ if (inputs) {
394
+ const accounts = [];
395
+ let startFresh = true;
396
+ const existingAm = await AccountManager.loadFromDisk(config.account_selection_strategy);
397
+ if (existingAm.getAccountCount() > 0) {
398
+ const existingAccounts = existingAm.getAccounts().map((acc, idx) => ({
399
+ email: acc.email,
400
+ index: idx
401
+ }));
402
+ const loginMode = await promptLoginMode(existingAccounts);
403
+ startFresh = loginMode === 'fresh';
404
+ console.log(startFresh
405
+ ? '\nStarting fresh - existing accounts will be replaced.\n'
406
+ : '\nAdding to existing accounts.\n');
407
+ }
408
+ while (true) {
409
+ console.log(`\n=== iFlow API Key (Account ${accounts.length + 1}) ===\n`);
410
+ const apiKey = await promptApiKey();
411
+ if (!apiKey) {
412
+ if (accounts.length === 0) {
413
+ return resolve({
414
+ url: '',
415
+ instructions: 'API key required',
416
+ method: 'auto',
417
+ callback: async () => ({ type: 'failed' })
418
+ });
419
+ }
420
+ break;
421
+ }
422
+ try {
423
+ await validateApiKey(apiKey);
424
+ const email = await promptEmail();
425
+ accounts.push({ apiKey, email });
426
+ const isFirstAccount = accounts.length === 1;
427
+ const am = await AccountManager.loadFromDisk(config.account_selection_strategy);
428
+ if (isFirstAccount && startFresh) {
429
+ am.getAccounts().forEach((acc) => am.removeAccount(acc));
430
+ }
431
+ const acc = {
432
+ id: generateAccountId(),
433
+ email,
434
+ authMethod: 'apikey',
435
+ apiKey,
436
+ rateLimitResetTime: 0,
437
+ isHealthy: true
438
+ };
439
+ am.addAccount(acc);
440
+ await am.saveToDisk();
441
+ showToast(`Account ${accounts.length} added (${email})`, 'success');
442
+ let currentAccountCount = accounts.length;
443
+ try {
444
+ const currentStorage = await AccountManager.loadFromDisk(config.account_selection_strategy);
445
+ currentAccountCount = currentStorage.getAccountCount();
446
+ }
447
+ catch (e) {
448
+ logger.warn(`Failed to load account count: ${e.message}`);
449
+ }
450
+ const addAnother = await promptAddAnotherAccount(currentAccountCount);
451
+ if (!addAnother) {
452
+ break;
453
+ }
454
+ }
455
+ catch (error) {
456
+ console.error(`API key validation failed: ${error.message}`);
457
+ if (accounts.length === 0) {
458
+ return resolve({
459
+ url: '',
460
+ instructions: `API key validation failed: ${error.message}`,
461
+ method: 'auto',
462
+ callback: async () => ({ type: 'failed' })
463
+ });
464
+ }
465
+ break;
466
+ }
467
+ }
468
+ const primary = accounts[0];
469
+ if (!primary) {
470
+ return resolve({
471
+ url: '',
472
+ instructions: 'Authentication cancelled',
473
+ method: 'auto',
474
+ callback: async () => ({ type: 'failed' })
475
+ });
476
+ }
477
+ let actualAccountCount = accounts.length;
478
+ try {
479
+ const finalStorage = await AccountManager.loadFromDisk(config.account_selection_strategy);
480
+ actualAccountCount = finalStorage.getAccountCount();
481
+ }
482
+ catch (e) {
483
+ logger.warn(`Failed to load account count: ${e.message}`);
484
+ }
485
+ return resolve({
486
+ url: '',
487
+ instructions: `Multi-account setup complete (${actualAccountCount} account(s)).`,
488
+ method: 'auto',
489
+ callback: async () => ({ type: 'success', key: primary.apiKey })
490
+ });
491
+ }
492
+ resolve({
493
+ url: '',
494
+ instructions: 'API Key authentication not supported in TUI mode. Use CLI: opencode auth login',
495
+ method: 'auto',
496
+ callback: async () => ({ type: 'failed' })
497
+ });
498
+ })
499
+ }
500
+ ]
501
+ }
502
+ };
503
+ };
504
+ export const IFlowOAuthPlugin = createIFlowPlugin(IFLOW_PROVIDER_ID);
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@zhafron/opencode-iflow-auth",
3
+ "version": "1.0.0",
4
+ "description": "OpenCode plugin for iFlow providing access to Claude, GPT, Gemini, DeepSeek, and Qwen models",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "scripts": {
9
+ "build": "tsc -p tsconfig.build.json",
10
+ "format": "prettier --write --no-config --no-semi --single-quote --trailing-comma none --print-width 100 'src/**/*.ts'",
11
+ "typecheck": "tsc --noEmit"
12
+ },
13
+ "keywords": [
14
+ "opencode",
15
+ "plugin",
16
+ "iflow",
17
+ "claude",
18
+ "gpt",
19
+ "gemini",
20
+ "deepseek",
21
+ "qwen",
22
+ "ai",
23
+ "auth",
24
+ "oauth",
25
+ "apikey"
26
+ ],
27
+ "author": "tickernelz",
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/tickernelz/opencode-iflow-auth.git"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "dependencies": {
37
+ "@opencode-ai/plugin": "^0.15.30",
38
+ "proper-lockfile": "^4.1.2",
39
+ "tiktoken": "^1.0.17",
40
+ "zod": "^3.24.0"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^20.0.0",
44
+ "@types/proper-lockfile": "^4.1.4",
45
+ "prettier": "^3.4.2",
46
+ "prettier-plugin-organize-imports": "^4.1.0",
47
+ "typescript": "^5.7.3"
48
+ },
49
+ "opencode": {
50
+ "type": "plugin",
51
+ "hooks": [
52
+ "auth",
53
+ "event"
54
+ ]
55
+ },
56
+ "files": [
57
+ "dist",
58
+ "package.json",
59
+ "README.md"
60
+ ]
61
+ }