@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/README.md +187 -0
- package/dist/constants.d.ts +19 -0
- package/dist/constants.js +61 -0
- package/dist/iflow/apikey.d.ts +6 -0
- package/dist/iflow/apikey.js +17 -0
- package/dist/iflow/oauth.d.ts +20 -0
- package/dist/iflow/oauth.js +113 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -0
- package/dist/plugin/accounts.d.ts +24 -0
- package/dist/plugin/accounts.js +147 -0
- package/dist/plugin/auth-page.d.ts +3 -0
- package/dist/plugin/auth-page.js +573 -0
- package/dist/plugin/cli.d.ts +12 -0
- package/dist/plugin/cli.js +88 -0
- package/dist/plugin/config/index.d.ts +2 -0
- package/dist/plugin/config/index.js +2 -0
- package/dist/plugin/config/loader.d.ts +3 -0
- package/dist/plugin/config/loader.js +110 -0
- package/dist/plugin/config/schema.d.ts +35 -0
- package/dist/plugin/config/schema.js +22 -0
- package/dist/plugin/errors.d.ts +14 -0
- package/dist/plugin/errors.js +25 -0
- package/dist/plugin/logger.d.ts +8 -0
- package/dist/plugin/logger.js +63 -0
- package/dist/plugin/server.d.ts +10 -0
- package/dist/plugin/server.js +121 -0
- package/dist/plugin/storage.d.ts +4 -0
- package/dist/plugin/storage.js +91 -0
- package/dist/plugin/token.d.ts +3 -0
- package/dist/plugin/token.js +26 -0
- package/dist/plugin/types.d.ts +55 -0
- package/dist/plugin/types.js +0 -0
- package/dist/plugin.d.ts +34 -0
- package/dist/plugin.js +676 -0
- package/package.json +65 -0
package/README.md
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# OpenCode iFlow CLI Plugin
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@hung319/opencode-iflow-cli)
|
|
4
|
+
[](https://www.npmjs.com/package/@hung319/opencode-iflow-cli)
|
|
5
|
+
[](https://www.npmjs.com/package/@hung319/opencode-iflow-cli)
|
|
6
|
+
|
|
7
|
+
OpenCode plugin for iFlow.cn providing access to Qwen, DeepSeek, Kimi, GLM, and iFlow ROME models with auto-configuration and headless OAuth support.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **Auto-configuration**: Models are automatically configured, no manual setup needed.
|
|
12
|
+
- **Dual authentication**: OAuth 2.0 (PKCE) and API Key support.
|
|
13
|
+
- **Headless support**: Works in SSH, containers, and CI environments with manual code input.
|
|
14
|
+
- **Multi-account rotation**: Sticky and round-robin strategies for account selection.
|
|
15
|
+
- **Automated token refresh** and rate limit handling with exponential backoff.
|
|
16
|
+
- **Native thinking mode support** for GLM-4.x and DeepSeek R1 models.
|
|
17
|
+
- **Flexible OAuth**: Automatic browser redirect OR manual code input - both work!
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install -g @hung319/opencode-iflow-cli
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or add to your `opencode.json`:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"plugin": ["@hung319/opencode-iflow-cli"]
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
That's it! Models are automatically configured. No manual provider configuration needed.
|
|
34
|
+
|
|
35
|
+
## Quick Start
|
|
36
|
+
|
|
37
|
+
### Interactive Mode (with browser)
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
opencode auth login
|
|
41
|
+
# Select: Other → type "iflow" → Enter
|
|
42
|
+
# Choose: OAuth 2.0 or API Key
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Browser will open automatically. Complete authentication and you're done!
|
|
46
|
+
|
|
47
|
+
### Headless Mode (SSH, CI, Containers)
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
opencode auth login
|
|
51
|
+
# Select: Other → type "iflow" → Enter
|
|
52
|
+
# Choose: OAuth 2.0
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
1. Open the displayed URL in your local browser.
|
|
56
|
+
2. Complete authentication on iFlow.cn.
|
|
57
|
+
3. Copy the callback URL or authorization code.
|
|
58
|
+
4. Paste it back into the terminal.
|
|
59
|
+
|
|
60
|
+
The plugin automatically detects headless environments and adapts accordingly.
|
|
61
|
+
|
|
62
|
+
## Configuration
|
|
63
|
+
|
|
64
|
+
Optional configuration file at `~/.config/opencode/iflow.json`:
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"default_auth_method": "oauth",
|
|
69
|
+
"account_selection_strategy": "round-robin",
|
|
70
|
+
"auth_server_port_start": 8087,
|
|
71
|
+
"auth_server_port_range": 10,
|
|
72
|
+
"max_request_iterations": 50,
|
|
73
|
+
"request_timeout_ms": 300000,
|
|
74
|
+
"enable_log_api_request": false
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Configuration Options
|
|
79
|
+
|
|
80
|
+
| Option | Description | Default |
|
|
81
|
+
|--------|-------------|---------|
|
|
82
|
+
| `default_auth_method` | Auth method (`oauth`, `apikey`) | `oauth` |
|
|
83
|
+
| `account_selection_strategy` | Rotation strategy (`sticky`, `round-robin`) | `round-robin` |
|
|
84
|
+
| `auth_server_port_start` | OAuth callback server starting port | `8087` |
|
|
85
|
+
| `auth_server_port_range` | Number of ports to try | `10` |
|
|
86
|
+
| `max_request_iterations` | Max iterations to prevent hangs | `50` |
|
|
87
|
+
| `request_timeout_ms` | Request timeout in milliseconds | `300000` |
|
|
88
|
+
| `enable_log_api_request` | Enable request/response logging | `false` |
|
|
89
|
+
|
|
90
|
+
### Environment Variables
|
|
91
|
+
|
|
92
|
+
All config options can be overridden via environment variables:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
export IFLOW_DEFAULT_AUTH_METHOD=oauth
|
|
96
|
+
export IFLOW_ACCOUNT_SELECTION_STRATEGY=round-robin
|
|
97
|
+
export IFLOW_AUTH_SERVER_PORT_START=8087
|
|
98
|
+
export IFLOW_MAX_REQUEST_ITERATIONS=50
|
|
99
|
+
export IFLOW_REQUEST_TIMEOUT_MS=300000
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Supported Models
|
|
103
|
+
|
|
104
|
+
Models are automatically configured when you install the plugin:
|
|
105
|
+
|
|
106
|
+
| Model | Context | Output | Features |
|
|
107
|
+
|-------|---------|--------|----------|
|
|
108
|
+
| `iflow-rome-30ba3b` | 256K | 64K | iFlow ROME 30B |
|
|
109
|
+
| `qwen3-coder-plus` | 1M | 64K | Qwen3 Coder Plus |
|
|
110
|
+
| `qwen3-max` | 256K | 32K | Qwen3 Max |
|
|
111
|
+
| `qwen3-vl-plus` | 256K | 32K | Vision support |
|
|
112
|
+
| `qwen3-235b-a22b-thinking-2507` | 256K | 64K | Thinking mode |
|
|
113
|
+
| `kimi-k2` | 128K | 64K | Kimi K2 |
|
|
114
|
+
| `kimi-k2-0905` | 256K | 64K | Kimi K2 0905 |
|
|
115
|
+
| `glm-4.6` | 200K | 128K | Thinking + Vision |
|
|
116
|
+
| `deepseek-v3` | 128K | 32K | DeepSeek V3 |
|
|
117
|
+
| `deepseek-v3.2` | 128K | 64K | DeepSeek V3.2 |
|
|
118
|
+
| `deepseek-r1` | 128K | 32K | Reasoning model |
|
|
119
|
+
| `qwen3-32b` | 128K | 32K | Qwen3 32B |
|
|
120
|
+
|
|
121
|
+
## Data Storage
|
|
122
|
+
|
|
123
|
+
**Linux/macOS:**
|
|
124
|
+
- Credentials: `~/.config/opencode/iflow-accounts.json`
|
|
125
|
+
- Config: `~/.config/opencode/iflow.json`
|
|
126
|
+
|
|
127
|
+
**Windows:**
|
|
128
|
+
- Credentials: `%APPDATA%\opencode\iflow-accounts.json`
|
|
129
|
+
- Config: `%APPDATA%\opencode\iflow.json`
|
|
130
|
+
|
|
131
|
+
## Thinking Models
|
|
132
|
+
|
|
133
|
+
### GLM-4.6
|
|
134
|
+
|
|
135
|
+
Variants with thinking budgets:
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
{
|
|
139
|
+
"model": "glm-4.6",
|
|
140
|
+
"variant": "medium"
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Available variants:
|
|
145
|
+
- `low`: 1024 thinking tokens
|
|
146
|
+
- `medium`: 8192 thinking tokens
|
|
147
|
+
- `max`: 32768 thinking tokens
|
|
148
|
+
|
|
149
|
+
### DeepSeek R1
|
|
150
|
+
|
|
151
|
+
```json
|
|
152
|
+
{
|
|
153
|
+
"model": "deepseek-r1",
|
|
154
|
+
"variant": "medium"
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Same variant options as GLM-4.6.
|
|
159
|
+
|
|
160
|
+
## Headless Environment Detection
|
|
161
|
+
|
|
162
|
+
The plugin automatically detects headless environments via:
|
|
163
|
+
- `SSH_CONNECTION`, `SSH_CLIENT`, `SSH_TTY`
|
|
164
|
+
- `OPENCODE_HEADLESS`
|
|
165
|
+
- `CI`, `CONTAINER`
|
|
166
|
+
- Missing `DISPLAY` on Linux
|
|
167
|
+
|
|
168
|
+
In headless mode:
|
|
169
|
+
- OAuth URL is displayed for manual opening
|
|
170
|
+
- Browser auto-open is disabled
|
|
171
|
+
- Manual code input is prompted
|
|
172
|
+
|
|
173
|
+
## Links
|
|
174
|
+
|
|
175
|
+
- **NPM Package**: https://www.npmjs.com/package/@hung319/opencode-iflow-cli
|
|
176
|
+
- **GitHub Repository**: https://github.com/hung319/opencode-iflow-cli
|
|
177
|
+
- **Issues**: https://github.com/hung319/opencode-iflow-cli/issues
|
|
178
|
+
|
|
179
|
+
## License
|
|
180
|
+
|
|
181
|
+
MIT
|
|
182
|
+
|
|
183
|
+
## Disclaimer
|
|
184
|
+
|
|
185
|
+
This plugin is provided strictly for learning and educational purposes. It is an independent implementation and is not affiliated with, endorsed by, or supported by iFlow.cn. Use of this plugin is at your own risk.
|
|
186
|
+
|
|
187
|
+
Feel free to open a PR to optimize this plugin further.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type IFlowAuthMethod = 'oauth' | 'apikey';
|
|
2
|
+
export declare function isValidAuthMethod(method: string): method is IFlowAuthMethod;
|
|
3
|
+
export declare const IFLOW_CONSTANTS: {
|
|
4
|
+
BASE_URL: string;
|
|
5
|
+
OAUTH_TOKEN_URL: string;
|
|
6
|
+
OAUTH_AUTHORIZE_URL: string;
|
|
7
|
+
USER_INFO_URL: string;
|
|
8
|
+
SUCCESS_REDIRECT: string;
|
|
9
|
+
CLIENT_ID: string;
|
|
10
|
+
CLIENT_SECRET: string;
|
|
11
|
+
AXIOS_TIMEOUT: number;
|
|
12
|
+
USER_AGENT: string;
|
|
13
|
+
CALLBACK_PORT_START: number;
|
|
14
|
+
CALLBACK_PORT_RANGE: number;
|
|
15
|
+
};
|
|
16
|
+
export declare const SUPPORTED_MODELS: string[];
|
|
17
|
+
export declare const THINKING_MODELS: string[];
|
|
18
|
+
export declare function isThinkingModel(model: string): boolean;
|
|
19
|
+
export declare function applyThinkingConfig(body: any, model: string): any;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export function isValidAuthMethod(method) {
|
|
2
|
+
return method === 'oauth' || method === 'apikey';
|
|
3
|
+
}
|
|
4
|
+
export const IFLOW_CONSTANTS = {
|
|
5
|
+
BASE_URL: 'https://apis.iflow.cn/v1',
|
|
6
|
+
OAUTH_TOKEN_URL: 'https://iflow.cn/oauth/token',
|
|
7
|
+
OAUTH_AUTHORIZE_URL: 'https://iflow.cn/oauth',
|
|
8
|
+
USER_INFO_URL: 'https://iflow.cn/api/oauth/getUserInfo',
|
|
9
|
+
SUCCESS_REDIRECT: 'https://iflow.cn/oauth/success',
|
|
10
|
+
CLIENT_ID: '10009311001',
|
|
11
|
+
CLIENT_SECRET: '4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW',
|
|
12
|
+
AXIOS_TIMEOUT: 120000,
|
|
13
|
+
USER_AGENT: 'OpenCode-iFlow',
|
|
14
|
+
CALLBACK_PORT_START: 8087,
|
|
15
|
+
CALLBACK_PORT_RANGE: 10
|
|
16
|
+
};
|
|
17
|
+
export const SUPPORTED_MODELS = [
|
|
18
|
+
'iflow-rome-30ba3b',
|
|
19
|
+
'qwen3-coder-plus',
|
|
20
|
+
'qwen3-max',
|
|
21
|
+
'qwen3-vl-plus',
|
|
22
|
+
'qwen3-max-preview',
|
|
23
|
+
'qwen3-32b',
|
|
24
|
+
'qwen3-235b-a22b-thinking-2507',
|
|
25
|
+
'qwen3-235b-a22b-instruct',
|
|
26
|
+
'qwen3-235b',
|
|
27
|
+
'kimi-k2-0905',
|
|
28
|
+
'kimi-k2',
|
|
29
|
+
'glm-4.6',
|
|
30
|
+
'deepseek-v3.2',
|
|
31
|
+
'deepseek-r1',
|
|
32
|
+
'deepseek-v3'
|
|
33
|
+
];
|
|
34
|
+
export const THINKING_MODELS = ['glm-4.6', 'qwen3-235b-a22b-thinking-2507', 'deepseek-r1'];
|
|
35
|
+
export function isThinkingModel(model) {
|
|
36
|
+
return THINKING_MODELS.some((m) => model.startsWith(m));
|
|
37
|
+
}
|
|
38
|
+
export function applyThinkingConfig(body, model) {
|
|
39
|
+
const thinkingBudget = body.providerOptions?.thinkingConfig?.thinkingBudget;
|
|
40
|
+
if (model.startsWith('glm-4')) {
|
|
41
|
+
const result = {
|
|
42
|
+
...body,
|
|
43
|
+
chat_template_kwargs: {
|
|
44
|
+
enable_thinking: true,
|
|
45
|
+
clear_thinking: false
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
if (thinkingBudget) {
|
|
49
|
+
result.thinking_budget = thinkingBudget;
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
if (model.startsWith('deepseek-r1')) {
|
|
54
|
+
const result = { ...body };
|
|
55
|
+
if (thinkingBudget) {
|
|
56
|
+
result.thinking_budget = thinkingBudget;
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
return body;
|
|
61
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { IFLOW_CONSTANTS } from '../constants';
|
|
2
|
+
export async function validateApiKey(apiKey) {
|
|
3
|
+
const response = await fetch(`${IFLOW_CONSTANTS.BASE_URL}/models`, {
|
|
4
|
+
headers: {
|
|
5
|
+
Authorization: `Bearer ${apiKey}`,
|
|
6
|
+
'User-Agent': IFLOW_CONSTANTS.USER_AGENT
|
|
7
|
+
}
|
|
8
|
+
});
|
|
9
|
+
if (!response.ok) {
|
|
10
|
+
throw new Error(`API key validation failed: ${response.status}`);
|
|
11
|
+
}
|
|
12
|
+
return {
|
|
13
|
+
apiKey,
|
|
14
|
+
email: 'api-key-user',
|
|
15
|
+
authMethod: 'apikey'
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface IFlowOAuthAuthorization {
|
|
2
|
+
authUrl: string;
|
|
3
|
+
state: string;
|
|
4
|
+
redirectUri: string;
|
|
5
|
+
}
|
|
6
|
+
export interface IFlowOAuthTokenResult {
|
|
7
|
+
accessToken: string;
|
|
8
|
+
refreshToken: string;
|
|
9
|
+
expiresAt: number;
|
|
10
|
+
apiKey: string;
|
|
11
|
+
email: string;
|
|
12
|
+
authMethod: 'oauth';
|
|
13
|
+
}
|
|
14
|
+
export declare function authorizeIFlowOAuth(port: number): Promise<IFlowOAuthAuthorization>;
|
|
15
|
+
export declare function exchangeOAuthCode(code: string, redirectUri: string): Promise<IFlowOAuthTokenResult>;
|
|
16
|
+
export declare function refreshOAuthToken(refreshToken: string): Promise<IFlowOAuthTokenResult>;
|
|
17
|
+
export declare function fetchUserInfo(accessToken: string): Promise<{
|
|
18
|
+
apiKey: string;
|
|
19
|
+
email: string;
|
|
20
|
+
}>;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { IFLOW_CONSTANTS } from '../constants';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
function base64URLEncode(buffer) {
|
|
4
|
+
return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
|
5
|
+
}
|
|
6
|
+
function generateState() {
|
|
7
|
+
return base64URLEncode(randomBytes(16));
|
|
8
|
+
}
|
|
9
|
+
export async function authorizeIFlowOAuth(port) {
|
|
10
|
+
const state = generateState();
|
|
11
|
+
const redirectUri = `http://localhost:${port}/oauth2callback`;
|
|
12
|
+
const params = new URLSearchParams({
|
|
13
|
+
loginMethod: 'phone',
|
|
14
|
+
type: 'phone',
|
|
15
|
+
redirect: redirectUri,
|
|
16
|
+
state,
|
|
17
|
+
client_id: IFLOW_CONSTANTS.CLIENT_ID
|
|
18
|
+
});
|
|
19
|
+
const authUrl = `${IFLOW_CONSTANTS.OAUTH_AUTHORIZE_URL}?${params.toString()}`;
|
|
20
|
+
return { authUrl, state, redirectUri };
|
|
21
|
+
}
|
|
22
|
+
export async function exchangeOAuthCode(code, redirectUri) {
|
|
23
|
+
const params = new URLSearchParams({
|
|
24
|
+
grant_type: 'authorization_code',
|
|
25
|
+
code,
|
|
26
|
+
redirect_uri: redirectUri,
|
|
27
|
+
client_id: IFLOW_CONSTANTS.CLIENT_ID,
|
|
28
|
+
client_secret: IFLOW_CONSTANTS.CLIENT_SECRET
|
|
29
|
+
});
|
|
30
|
+
const basicAuth = Buffer.from(`${IFLOW_CONSTANTS.CLIENT_ID}:${IFLOW_CONSTANTS.CLIENT_SECRET}`).toString('base64');
|
|
31
|
+
const response = await fetch(IFLOW_CONSTANTS.OAUTH_TOKEN_URL, {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: {
|
|
34
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
35
|
+
Accept: 'application/json',
|
|
36
|
+
Authorization: `Basic ${basicAuth}`
|
|
37
|
+
},
|
|
38
|
+
body: params.toString()
|
|
39
|
+
});
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
const errorText = await response.text().catch(() => '');
|
|
42
|
+
throw new Error(`Token exchange failed: ${response.status} ${errorText}`);
|
|
43
|
+
}
|
|
44
|
+
const data = await response.json();
|
|
45
|
+
const userInfo = await fetchUserInfo(data.access_token);
|
|
46
|
+
const expiresIn = data.expires_in || 3600;
|
|
47
|
+
const expiresAt = Date.now() + expiresIn * 1000;
|
|
48
|
+
return {
|
|
49
|
+
accessToken: data.access_token,
|
|
50
|
+
refreshToken: data.refresh_token,
|
|
51
|
+
expiresAt,
|
|
52
|
+
apiKey: userInfo.apiKey,
|
|
53
|
+
email: userInfo.email,
|
|
54
|
+
authMethod: 'oauth'
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export async function refreshOAuthToken(refreshToken) {
|
|
58
|
+
const params = new URLSearchParams({
|
|
59
|
+
grant_type: 'refresh_token',
|
|
60
|
+
refresh_token: refreshToken,
|
|
61
|
+
client_id: IFLOW_CONSTANTS.CLIENT_ID,
|
|
62
|
+
client_secret: IFLOW_CONSTANTS.CLIENT_SECRET
|
|
63
|
+
});
|
|
64
|
+
const basicAuth = Buffer.from(`${IFLOW_CONSTANTS.CLIENT_ID}:${IFLOW_CONSTANTS.CLIENT_SECRET}`).toString('base64');
|
|
65
|
+
const response = await fetch(IFLOW_CONSTANTS.OAUTH_TOKEN_URL, {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: {
|
|
68
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
69
|
+
Accept: 'application/json',
|
|
70
|
+
Authorization: `Basic ${basicAuth}`
|
|
71
|
+
},
|
|
72
|
+
body: params.toString()
|
|
73
|
+
});
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
const errorText = await response.text().catch(() => '');
|
|
76
|
+
throw new Error(`Token refresh failed: ${response.status} ${errorText}`);
|
|
77
|
+
}
|
|
78
|
+
const data = await response.json();
|
|
79
|
+
const userInfo = await fetchUserInfo(data.access_token);
|
|
80
|
+
const expiresIn = data.expires_in || 3600;
|
|
81
|
+
const expiresAt = Date.now() + expiresIn * 1000;
|
|
82
|
+
return {
|
|
83
|
+
accessToken: data.access_token,
|
|
84
|
+
refreshToken: data.refresh_token || refreshToken,
|
|
85
|
+
expiresAt,
|
|
86
|
+
apiKey: userInfo.apiKey,
|
|
87
|
+
email: userInfo.email,
|
|
88
|
+
authMethod: 'oauth'
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
export async function fetchUserInfo(accessToken) {
|
|
92
|
+
const response = await fetch(`${IFLOW_CONSTANTS.USER_INFO_URL}?accessToken=${encodeURIComponent(accessToken)}`, {
|
|
93
|
+
headers: {
|
|
94
|
+
Accept: 'application/json'
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
const errorText = await response.text().catch(() => '');
|
|
99
|
+
throw new Error(`User info fetch failed: ${response.status} ${errorText}`);
|
|
100
|
+
}
|
|
101
|
+
const data = await response.json();
|
|
102
|
+
if (!data.success || !data.data) {
|
|
103
|
+
throw new Error('User info request not successful');
|
|
104
|
+
}
|
|
105
|
+
if (!data.data.apiKey) {
|
|
106
|
+
throw new Error('Missing apiKey in user info response');
|
|
107
|
+
}
|
|
108
|
+
const email = data.data.email || data.data.phone || 'oauth-user';
|
|
109
|
+
return {
|
|
110
|
+
apiKey: data.data.apiKey,
|
|
111
|
+
email
|
|
112
|
+
};
|
|
113
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { IFlowOAuthPlugin } from './plugin.js';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ManagedAccount, AccountSelectionStrategy, IFlowAuthDetails, RefreshParts } from './types';
|
|
2
|
+
export declare function generateAccountId(): string;
|
|
3
|
+
export declare function encodeRefreshToken(parts: RefreshParts): string;
|
|
4
|
+
export declare function decodeRefreshToken(encoded: string): RefreshParts;
|
|
5
|
+
export declare class AccountManager {
|
|
6
|
+
private accounts;
|
|
7
|
+
private cursor;
|
|
8
|
+
private strategy;
|
|
9
|
+
private lastToastTime;
|
|
10
|
+
constructor(accounts: ManagedAccount[], strategy?: AccountSelectionStrategy);
|
|
11
|
+
static loadFromDisk(strategy?: AccountSelectionStrategy): Promise<AccountManager>;
|
|
12
|
+
getAccountCount(): number;
|
|
13
|
+
getAccounts(): ManagedAccount[];
|
|
14
|
+
shouldShowToast(debounce?: number): boolean;
|
|
15
|
+
getMinWaitTime(): number;
|
|
16
|
+
getCurrentOrNext(): ManagedAccount | null;
|
|
17
|
+
addAccount(a: ManagedAccount): void;
|
|
18
|
+
removeAccount(a: ManagedAccount): void;
|
|
19
|
+
updateFromAuth(a: ManagedAccount, auth: IFlowAuthDetails): void;
|
|
20
|
+
markRateLimited(a: ManagedAccount, ms: number): void;
|
|
21
|
+
markUnhealthy(a: ManagedAccount, reason: string, recovery?: number): void;
|
|
22
|
+
saveToDisk(): Promise<void>;
|
|
23
|
+
toAuthDetails(a: ManagedAccount): IFlowAuthDetails;
|
|
24
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { loadAccounts, saveAccounts } from './storage';
|
|
3
|
+
export function generateAccountId() {
|
|
4
|
+
return randomBytes(16).toString('hex');
|
|
5
|
+
}
|
|
6
|
+
export function encodeRefreshToken(parts) {
|
|
7
|
+
return Buffer.from(JSON.stringify(parts)).toString('base64');
|
|
8
|
+
}
|
|
9
|
+
export function decodeRefreshToken(encoded) {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(Buffer.from(encoded, 'base64').toString('utf-8'));
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return { authMethod: 'apikey' };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export class AccountManager {
|
|
18
|
+
accounts;
|
|
19
|
+
cursor;
|
|
20
|
+
strategy;
|
|
21
|
+
lastToastTime = 0;
|
|
22
|
+
constructor(accounts, strategy = 'sticky') {
|
|
23
|
+
this.accounts = accounts;
|
|
24
|
+
this.cursor = 0;
|
|
25
|
+
this.strategy = strategy;
|
|
26
|
+
}
|
|
27
|
+
static async loadFromDisk(strategy) {
|
|
28
|
+
const s = await loadAccounts();
|
|
29
|
+
return new AccountManager(s.accounts, strategy || 'sticky');
|
|
30
|
+
}
|
|
31
|
+
getAccountCount() {
|
|
32
|
+
return this.accounts.length;
|
|
33
|
+
}
|
|
34
|
+
getAccounts() {
|
|
35
|
+
return [...this.accounts];
|
|
36
|
+
}
|
|
37
|
+
shouldShowToast(debounce = 30000) {
|
|
38
|
+
if (Date.now() - this.lastToastTime < debounce)
|
|
39
|
+
return false;
|
|
40
|
+
this.lastToastTime = Date.now();
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
getMinWaitTime() {
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
const waits = this.accounts.map((a) => (a.rateLimitResetTime || 0) - now).filter((t) => t > 0);
|
|
46
|
+
return waits.length > 0 ? Math.min(...waits) : 0;
|
|
47
|
+
}
|
|
48
|
+
getCurrentOrNext() {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
const available = this.accounts.filter((a) => {
|
|
51
|
+
if (!a.isHealthy) {
|
|
52
|
+
if (a.recoveryTime && now >= a.recoveryTime) {
|
|
53
|
+
a.isHealthy = true;
|
|
54
|
+
delete a.unhealthyReason;
|
|
55
|
+
delete a.recoveryTime;
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return !(a.rateLimitResetTime && now < a.rateLimitResetTime);
|
|
61
|
+
});
|
|
62
|
+
if (available.length === 0)
|
|
63
|
+
return null;
|
|
64
|
+
let selected;
|
|
65
|
+
if (this.strategy === 'sticky') {
|
|
66
|
+
selected = available.find((_, i) => i === this.cursor) || available[0];
|
|
67
|
+
}
|
|
68
|
+
else if (this.strategy === 'round-robin') {
|
|
69
|
+
selected = available[this.cursor % available.length];
|
|
70
|
+
this.cursor = (this.cursor + 1) % available.length;
|
|
71
|
+
}
|
|
72
|
+
if (selected) {
|
|
73
|
+
selected.lastUsed = now;
|
|
74
|
+
this.cursor = this.accounts.indexOf(selected);
|
|
75
|
+
return selected;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
addAccount(a) {
|
|
80
|
+
const i = this.accounts.findIndex((x) => x.id === a.id);
|
|
81
|
+
if (i === -1)
|
|
82
|
+
this.accounts.push(a);
|
|
83
|
+
else
|
|
84
|
+
this.accounts[i] = a;
|
|
85
|
+
}
|
|
86
|
+
removeAccount(a) {
|
|
87
|
+
const removedIndex = this.accounts.findIndex((x) => x.id === a.id);
|
|
88
|
+
if (removedIndex === -1)
|
|
89
|
+
return;
|
|
90
|
+
this.accounts = this.accounts.filter((x) => x.id !== a.id);
|
|
91
|
+
if (this.accounts.length === 0) {
|
|
92
|
+
this.cursor = 0;
|
|
93
|
+
}
|
|
94
|
+
else if (this.cursor >= this.accounts.length) {
|
|
95
|
+
this.cursor = this.accounts.length - 1;
|
|
96
|
+
}
|
|
97
|
+
else if (removedIndex <= this.cursor && this.cursor > 0) {
|
|
98
|
+
this.cursor--;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
updateFromAuth(a, auth) {
|
|
102
|
+
const acc = this.accounts.find((x) => x.id === a.id);
|
|
103
|
+
if (acc) {
|
|
104
|
+
acc.apiKey = auth.apiKey;
|
|
105
|
+
if (auth.authMethod === 'oauth') {
|
|
106
|
+
acc.accessToken = auth.access;
|
|
107
|
+
acc.expiresAt = auth.expires;
|
|
108
|
+
const p = decodeRefreshToken(auth.refresh);
|
|
109
|
+
acc.refreshToken = p.refreshToken;
|
|
110
|
+
}
|
|
111
|
+
acc.lastUsed = Date.now();
|
|
112
|
+
if (auth.email)
|
|
113
|
+
acc.email = auth.email;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
markRateLimited(a, ms) {
|
|
117
|
+
const acc = this.accounts.find((x) => x.id === a.id);
|
|
118
|
+
if (acc)
|
|
119
|
+
acc.rateLimitResetTime = Date.now() + ms;
|
|
120
|
+
}
|
|
121
|
+
markUnhealthy(a, reason, recovery) {
|
|
122
|
+
const acc = this.accounts.find((x) => x.id === a.id);
|
|
123
|
+
if (acc) {
|
|
124
|
+
acc.isHealthy = false;
|
|
125
|
+
acc.unhealthyReason = reason;
|
|
126
|
+
acc.recoveryTime = recovery;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async saveToDisk() {
|
|
130
|
+
const metadata = this.accounts.map(({ lastUsed, ...rest }) => rest);
|
|
131
|
+
await saveAccounts({ version: 1, accounts: metadata, activeIndex: this.cursor });
|
|
132
|
+
}
|
|
133
|
+
toAuthDetails(a) {
|
|
134
|
+
const p = {
|
|
135
|
+
refreshToken: a.refreshToken,
|
|
136
|
+
authMethod: a.authMethod
|
|
137
|
+
};
|
|
138
|
+
return {
|
|
139
|
+
refresh: encodeRefreshToken(p),
|
|
140
|
+
access: a.accessToken || '',
|
|
141
|
+
expires: a.expiresAt || 0,
|
|
142
|
+
authMethod: a.authMethod,
|
|
143
|
+
apiKey: a.apiKey,
|
|
144
|
+
email: a.email
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|