codex-account-orchestrator 1.0.2 → 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/CHANGELOG.md +26 -0
- package/README.md +91 -38
- package/dist/account_inspector.d.ts +14 -0
- package/dist/account_inspector.js +73 -0
- package/dist/account_manager.js +3 -0
- package/dist/account_status_store.d.ts +24 -0
- package/dist/account_status_store.js +121 -0
- package/dist/cli_main.js +191 -12
- package/dist/constants.d.ts +1 -0
- package/dist/constants.js +2 -1
- package/dist/gateway/account_pool.d.ts +3 -1
- package/dist/gateway/account_pool.js +48 -4
- package/dist/gateway/openai_gateway.js +2 -0
- package/dist/paths.d.ts +1 -0
- package/dist/paths.js +4 -0
- package/package.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.1.1] - 2026-01-27
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Made `npm run test` bash-compatible by using `node --test test/*.test.js`
|
|
13
|
+
- Restored CI stability across the GitHub Actions Node.js matrix
|
|
14
|
+
|
|
15
|
+
## [1.1.0] - 2026-01-27
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- `cao status` command for detailed per-account inspection
|
|
20
|
+
- `cao list --details` for quick detailed status views
|
|
21
|
+
- Persisted account signals in `account_status.json` (attempt/success/quota/cooldown)
|
|
22
|
+
- Node.js test suite for the account status store
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- Gateway and fallback flows now update persisted account status signals
|
|
27
|
+
- CI now runs tests (build + Node test runner)
|
|
28
|
+
- README refreshed with badges, observability guidance, and clearer quick-start flows
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- Eliminated shared empty-registry state in account status loading
|
|
33
|
+
|
|
8
34
|
## [1.0.2] - 2026-01-27
|
|
9
35
|
|
|
10
36
|
### Added
|
package/README.md
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
|
-
# codex-account-orchestrator
|
|
1
|
+
# codex-account-orchestrator (CAO)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/DAWNCR0W/codex-account-orchestrator/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/codex-account-orchestrator)
|
|
5
|
+
[](https://www.npmjs.com/package/codex-account-orchestrator)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://nodejs.org/)
|
|
8
|
+
[](https://www.typescriptlang.org/)
|
|
9
|
+
|
|
10
|
+
Codex OAuth account fallback orchestrator. CAO keeps **separate `CODEX_HOME` directories per account** and automatically falls back to the next account when quota is exhausted.
|
|
11
|
+
|
|
12
|
+
> If you juggle multiple Codex accounts, CAO removes the friction and keeps you moving.
|
|
13
|
+
|
|
14
|
+
## Highlights
|
|
15
|
+
|
|
16
|
+
- Per-account isolation via separate `CODEX_HOME` directories
|
|
17
|
+
- Automatic fallback on quota exhaustion (keyword-based detector)
|
|
18
|
+
- Gateway mode for seamless account switching without session drops
|
|
19
|
+
- Lightweight observability via `cao status` and `cao list --details`
|
|
20
|
+
- Strict TypeScript build with a small, dependency-light CLI
|
|
4
21
|
|
|
5
22
|
## Requirements
|
|
6
23
|
|
|
@@ -23,9 +40,9 @@ npm run build
|
|
|
23
40
|
npm link # Makes 'cao' command available globally
|
|
24
41
|
```
|
|
25
42
|
|
|
26
|
-
##
|
|
43
|
+
## Quick Start
|
|
27
44
|
|
|
28
|
-
### Add accounts
|
|
45
|
+
### 1. Add accounts
|
|
29
46
|
|
|
30
47
|
```bash
|
|
31
48
|
cao add accountA
|
|
@@ -44,70 +61,64 @@ If you prefer device auth:
|
|
|
44
61
|
cao add accountA --device-auth
|
|
45
62
|
```
|
|
46
63
|
|
|
47
|
-
### Set default account
|
|
64
|
+
### 2. Set the default account
|
|
48
65
|
|
|
49
66
|
```bash
|
|
50
67
|
cao use accountA
|
|
51
68
|
```
|
|
52
69
|
|
|
53
|
-
###
|
|
70
|
+
### 3. Run with fallback
|
|
54
71
|
|
|
55
72
|
```bash
|
|
56
|
-
cao
|
|
73
|
+
cao run
|
|
57
74
|
```
|
|
58
75
|
|
|
59
|
-
|
|
76
|
+
To pass arguments to Codex, put them after `--`:
|
|
60
77
|
|
|
61
78
|
```bash
|
|
62
|
-
cao
|
|
79
|
+
cao run -- exec "summarize README"
|
|
63
80
|
```
|
|
64
81
|
|
|
65
|
-
To
|
|
82
|
+
To recheck all accounts when everyone is quota-limited, use multiple passes:
|
|
66
83
|
|
|
67
84
|
```bash
|
|
68
|
-
cao
|
|
85
|
+
cao run --max-passes 2 --retry-delay 5
|
|
69
86
|
```
|
|
70
87
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
```bash
|
|
74
|
-
cao run
|
|
75
|
-
```
|
|
88
|
+
## Account Status & Observability
|
|
76
89
|
|
|
77
|
-
|
|
90
|
+
### List accounts (quick)
|
|
78
91
|
|
|
79
92
|
```bash
|
|
80
|
-
cao
|
|
93
|
+
cao list
|
|
81
94
|
```
|
|
82
95
|
|
|
83
|
-
|
|
96
|
+
### Detailed status (recommended)
|
|
84
97
|
|
|
85
98
|
```bash
|
|
86
|
-
cao
|
|
99
|
+
cao status
|
|
87
100
|
```
|
|
88
101
|
|
|
89
|
-
|
|
102
|
+
You can also use:
|
|
90
103
|
|
|
91
104
|
```bash
|
|
92
|
-
cao
|
|
105
|
+
cao list --details
|
|
93
106
|
```
|
|
94
107
|
|
|
95
|
-
###
|
|
108
|
+
### JSON output for scripting
|
|
96
109
|
|
|
97
110
|
```bash
|
|
98
|
-
cao
|
|
111
|
+
cao status --json
|
|
99
112
|
```
|
|
100
113
|
|
|
101
|
-
|
|
114
|
+
This includes useful signals such as:
|
|
102
115
|
|
|
103
|
-
-
|
|
104
|
-
|
|
105
|
-
-
|
|
106
|
-
|
|
107
|
-
- `forced_login_method = "chatgpt"`
|
|
108
|
-
- On quota errors (detected via output keywords), the CLI re-runs Codex with the next account and can recheck all accounts for a configurable number of passes.
|
|
116
|
+
- Token expiry time
|
|
117
|
+
- Last refresh time
|
|
118
|
+
- Last attempt / success / quota-hit timestamps
|
|
119
|
+
- Cooldown window and consecutive failures
|
|
109
120
|
|
|
110
|
-
## Gateway
|
|
121
|
+
## Gateway Mode (No Session Drop)
|
|
111
122
|
|
|
112
123
|
Gateway mode keeps the Codex session open while switching accounts on quota errors. It requires routing Codex traffic through the local gateway.
|
|
113
124
|
|
|
@@ -153,17 +164,59 @@ If `~/.local/bin` is not in your PATH, add it so the shim is used.
|
|
|
153
164
|
cao gateway status
|
|
154
165
|
```
|
|
155
166
|
|
|
156
|
-
|
|
167
|
+
## How It Works
|
|
168
|
+
|
|
169
|
+
- Each account is stored in its own `CODEX_HOME` directory: `~/.codex-account-orchestrator/<account>/`
|
|
170
|
+
- A `config.toml` is created with the following values:
|
|
171
|
+
|
|
172
|
+
```toml
|
|
173
|
+
cli_auth_credentials_store = "file"
|
|
174
|
+
forced_login_method = "chatgpt"
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
- On quota errors (detected via output keywords), the CLI re-runs Codex with the next account and can recheck all accounts for a configurable number of passes.
|
|
178
|
+
- CAO now persists lightweight status signals to `account_status.json` for visibility.
|
|
179
|
+
|
|
180
|
+
## Data Layout
|
|
181
|
+
|
|
182
|
+
Default base directory:
|
|
183
|
+
|
|
184
|
+
```text
|
|
185
|
+
~/.codex-account-orchestrator/
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Key files:
|
|
189
|
+
|
|
190
|
+
- `registry.json`: registered accounts and default account
|
|
191
|
+
- `account_status.json`: persisted last-attempt/success/quota/cooldown signals
|
|
192
|
+
- `<account>/auth.json`: account-scoped tokens managed by Codex
|
|
193
|
+
- `<account>/config.toml`: account-scoped Codex configuration
|
|
194
|
+
|
|
195
|
+
## Development
|
|
196
|
+
|
|
197
|
+
Build:
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
npm run build
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Test (build + Node test runner):
|
|
157
204
|
|
|
158
|
-
|
|
205
|
+
```bash
|
|
206
|
+
npm run test
|
|
207
|
+
```
|
|
159
208
|
|
|
160
|
-
|
|
209
|
+
Run locally after build:
|
|
161
210
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
211
|
+
```bash
|
|
212
|
+
node dist/cli_main.js --help
|
|
213
|
+
```
|
|
165
214
|
|
|
166
215
|
## Notes
|
|
167
216
|
|
|
168
217
|
- Fallback requires capturing output; this may make Codex detect a non-TTY stdout. If you want a pure TTY session, use `--no-fallback`.
|
|
169
218
|
- The quota detector is keyword-based and can be extended in `src/constants.ts`.
|
|
219
|
+
|
|
220
|
+
## Changelog
|
|
221
|
+
|
|
222
|
+
See `CHANGELOG.md` for release notes and version history.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type AccountStatus } from "./account_status_store";
|
|
2
|
+
import { type TokenDetails } from "./gateway/token_utils";
|
|
3
|
+
export interface AccountInspection {
|
|
4
|
+
name: string;
|
|
5
|
+
isDefault: boolean;
|
|
6
|
+
accountDir: string;
|
|
7
|
+
authFilePath: string;
|
|
8
|
+
loggedIn: boolean;
|
|
9
|
+
lastRefreshAtMs?: number;
|
|
10
|
+
accountId?: string;
|
|
11
|
+
tokenDetails?: TokenDetails;
|
|
12
|
+
status?: AccountStatus;
|
|
13
|
+
}
|
|
14
|
+
export declare function inspectAccounts(baseDir: string): AccountInspection[];
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.inspectAccounts = inspectAccounts;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const account_status_store_1 = require("./account_status_store");
|
|
10
|
+
const paths_1 = require("./paths");
|
|
11
|
+
const constants_1 = require("./constants");
|
|
12
|
+
const registry_store_1 = require("./registry_store");
|
|
13
|
+
const token_utils_1 = require("./gateway/token_utils");
|
|
14
|
+
function parseLastRefresh(lastRefresh) {
|
|
15
|
+
if (typeof lastRefresh !== "string" || lastRefresh.trim().length === 0) {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
const ms = Date.parse(lastRefresh);
|
|
19
|
+
return Number.isFinite(ms) && ms > 0 ? ms : undefined;
|
|
20
|
+
}
|
|
21
|
+
function loadAuthInspection(accountDir) {
|
|
22
|
+
const authFilePath = path_1.default.join(accountDir, constants_1.AUTH_FILE_NAME);
|
|
23
|
+
if (!fs_1.default.existsSync(authFilePath)) {
|
|
24
|
+
return { loggedIn: false };
|
|
25
|
+
}
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
const raw = fs_1.default.readFileSync(authFilePath, "utf8");
|
|
29
|
+
parsed = JSON.parse(raw);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return { loggedIn: false };
|
|
33
|
+
}
|
|
34
|
+
const accessToken = parsed.tokens?.access_token;
|
|
35
|
+
const idToken = parsed.tokens?.id_token;
|
|
36
|
+
if (!accessToken || typeof accessToken !== "string") {
|
|
37
|
+
return { loggedIn: false };
|
|
38
|
+
}
|
|
39
|
+
const tokenDetails = (0, token_utils_1.deriveTokenDetails)(accessToken, idToken);
|
|
40
|
+
const lastRefreshAtMs = parseLastRefresh(parsed.last_refresh);
|
|
41
|
+
return {
|
|
42
|
+
loggedIn: true,
|
|
43
|
+
lastRefreshAtMs,
|
|
44
|
+
accountId: parsed.tokens?.account_id ?? tokenDetails.chatgptAccountId,
|
|
45
|
+
tokenDetails
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function inspectAccounts(baseDir) {
|
|
49
|
+
const registry = (0, registry_store_1.loadRegistry)(baseDir);
|
|
50
|
+
if (registry.accounts.length === 0) {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
const statuses = (0, account_status_store_1.loadAccountStatuses)(baseDir);
|
|
54
|
+
const inspections = [];
|
|
55
|
+
for (const name of registry.accounts) {
|
|
56
|
+
const accountDir = (0, paths_1.getAccountDir)(baseDir, name);
|
|
57
|
+
const authFilePath = path_1.default.join(accountDir, constants_1.AUTH_FILE_NAME);
|
|
58
|
+
const authInspection = loadAuthInspection(accountDir);
|
|
59
|
+
const status = statuses[name];
|
|
60
|
+
inspections.push({
|
|
61
|
+
name,
|
|
62
|
+
isDefault: registry.default_account === name,
|
|
63
|
+
accountDir,
|
|
64
|
+
authFilePath,
|
|
65
|
+
loggedIn: authInspection.loggedIn,
|
|
66
|
+
lastRefreshAtMs: authInspection.lastRefreshAtMs,
|
|
67
|
+
accountId: authInspection.accountId,
|
|
68
|
+
tokenDetails: authInspection.tokenDetails,
|
|
69
|
+
status
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return inspections;
|
|
73
|
+
}
|
package/dist/account_manager.js
CHANGED
|
@@ -13,6 +13,7 @@ exports.removeAccount = removeAccount;
|
|
|
13
13
|
exports.getAccountOrder = getAccountOrder;
|
|
14
14
|
const fs_1 = __importDefault(require("fs"));
|
|
15
15
|
const path_1 = __importDefault(require("path"));
|
|
16
|
+
const account_status_store_1 = require("./account_status_store");
|
|
16
17
|
const constants_1 = require("./constants");
|
|
17
18
|
const paths_1 = require("./paths");
|
|
18
19
|
const registry_store_1 = require("./registry_store");
|
|
@@ -55,6 +56,7 @@ function addAccount(baseDir, accountName) {
|
|
|
55
56
|
if (!registry.default_account) {
|
|
56
57
|
registry.default_account = normalizedName;
|
|
57
58
|
}
|
|
59
|
+
(0, account_status_store_1.updateAccountStatus)(baseDir, normalizedName, () => ({}));
|
|
58
60
|
(0, registry_store_1.saveRegistry)(baseDir, registry);
|
|
59
61
|
return registry;
|
|
60
62
|
}
|
|
@@ -87,6 +89,7 @@ function removeAccount(baseDir, accountName, removeFiles) {
|
|
|
87
89
|
fs_1.default.rmSync(accountDir, { recursive: true, force: true });
|
|
88
90
|
}
|
|
89
91
|
}
|
|
92
|
+
(0, account_status_store_1.deleteAccountStatus)(baseDir, normalizedName);
|
|
90
93
|
(0, registry_store_1.saveRegistry)(baseDir, registry);
|
|
91
94
|
return registry;
|
|
92
95
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface AccountStatus {
|
|
2
|
+
lastAttemptAtMs?: number;
|
|
3
|
+
lastSuccessAtMs?: number;
|
|
4
|
+
lastQuotaAtMs?: number;
|
|
5
|
+
cooldownUntilMs?: number;
|
|
6
|
+
consecutiveFailures?: number;
|
|
7
|
+
lastError?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface AccountStatusRegistry {
|
|
10
|
+
statuses: Record<string, AccountStatus>;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Loads the persisted per-account status registry. If the file is missing or
|
|
14
|
+
* invalid, a safe empty registry is returned.
|
|
15
|
+
*/
|
|
16
|
+
export declare function loadAccountStatusRegistry(baseDir: string): AccountStatusRegistry;
|
|
17
|
+
export declare function loadAccountStatuses(baseDir: string): Record<string, AccountStatus>;
|
|
18
|
+
export declare function getAccountStatus(baseDir: string, accountName: string): AccountStatus | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* Updates the stored status for a single account using a functional updater
|
|
21
|
+
* to avoid accidental partial writes.
|
|
22
|
+
*/
|
|
23
|
+
export declare function updateAccountStatus(baseDir: string, accountName: string, updater: (previous: AccountStatus) => AccountStatus): AccountStatus;
|
|
24
|
+
export declare function deleteAccountStatus(baseDir: string, accountName: string): void;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.loadAccountStatusRegistry = loadAccountStatusRegistry;
|
|
7
|
+
exports.loadAccountStatuses = loadAccountStatuses;
|
|
8
|
+
exports.getAccountStatus = getAccountStatus;
|
|
9
|
+
exports.updateAccountStatus = updateAccountStatus;
|
|
10
|
+
exports.deleteAccountStatus = deleteAccountStatus;
|
|
11
|
+
const fs_1 = __importDefault(require("fs"));
|
|
12
|
+
const path_1 = __importDefault(require("path"));
|
|
13
|
+
const paths_1 = require("./paths");
|
|
14
|
+
function createEmptyRegistry() {
|
|
15
|
+
return { statuses: {} };
|
|
16
|
+
}
|
|
17
|
+
function sanitizeTimestamp(value) {
|
|
18
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
return Math.floor(value);
|
|
22
|
+
}
|
|
23
|
+
function sanitizeCount(value) {
|
|
24
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
return Math.floor(value);
|
|
28
|
+
}
|
|
29
|
+
function sanitizeStatus(status) {
|
|
30
|
+
if (!status) {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
const lastAttemptAtMs = sanitizeTimestamp(status.lastAttemptAtMs);
|
|
34
|
+
const lastSuccessAtMs = sanitizeTimestamp(status.lastSuccessAtMs);
|
|
35
|
+
const lastQuotaAtMs = sanitizeTimestamp(status.lastQuotaAtMs);
|
|
36
|
+
const cooldownUntilMs = sanitizeTimestamp(status.cooldownUntilMs);
|
|
37
|
+
const consecutiveFailures = sanitizeCount(status.consecutiveFailures);
|
|
38
|
+
const lastError = typeof status.lastError === "string" && status.lastError.trim().length > 0
|
|
39
|
+
? status.lastError.trim()
|
|
40
|
+
: undefined;
|
|
41
|
+
return {
|
|
42
|
+
lastAttemptAtMs,
|
|
43
|
+
lastSuccessAtMs,
|
|
44
|
+
lastQuotaAtMs,
|
|
45
|
+
cooldownUntilMs,
|
|
46
|
+
consecutiveFailures,
|
|
47
|
+
lastError
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function normalizeStatuses(statuses) {
|
|
51
|
+
if (!statuses || typeof statuses !== "object") {
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
54
|
+
const normalized = {};
|
|
55
|
+
for (const [name, status] of Object.entries(statuses)) {
|
|
56
|
+
const trimmedName = name.trim();
|
|
57
|
+
if (!trimmedName) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
normalized[trimmedName] = sanitizeStatus(status);
|
|
61
|
+
}
|
|
62
|
+
return normalized;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Loads the persisted per-account status registry. If the file is missing or
|
|
66
|
+
* invalid, a safe empty registry is returned.
|
|
67
|
+
*/
|
|
68
|
+
function loadAccountStatusRegistry(baseDir) {
|
|
69
|
+
const statusPath = (0, paths_1.getAccountStatusPath)(baseDir);
|
|
70
|
+
if (!fs_1.default.existsSync(statusPath)) {
|
|
71
|
+
return createEmptyRegistry();
|
|
72
|
+
}
|
|
73
|
+
const raw = fs_1.default.readFileSync(statusPath, "utf8");
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(raw);
|
|
76
|
+
return { statuses: normalizeStatuses(parsed.statuses) };
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
const backupPath = `${statusPath}.corrupt-${Date.now()}`;
|
|
80
|
+
fs_1.default.writeFileSync(backupPath, raw, "utf8");
|
|
81
|
+
process.stderr.write(`Warning: account status file was invalid and has been backed up to ${backupPath}.\n`);
|
|
82
|
+
return createEmptyRegistry();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function loadAccountStatuses(baseDir) {
|
|
86
|
+
const registry = loadAccountStatusRegistry(baseDir);
|
|
87
|
+
return { ...registry.statuses };
|
|
88
|
+
}
|
|
89
|
+
function saveAccountStatusRegistry(baseDir, registry) {
|
|
90
|
+
const statusPath = (0, paths_1.getAccountStatusPath)(baseDir);
|
|
91
|
+
const dir = path_1.default.dirname(statusPath);
|
|
92
|
+
if (!fs_1.default.existsSync(dir)) {
|
|
93
|
+
fs_1.default.mkdirSync(dir, { recursive: true });
|
|
94
|
+
}
|
|
95
|
+
const payload = JSON.stringify(registry, null, 2) + "\n";
|
|
96
|
+
fs_1.default.writeFileSync(statusPath, payload, "utf8");
|
|
97
|
+
}
|
|
98
|
+
function getAccountStatus(baseDir, accountName) {
|
|
99
|
+
const registry = loadAccountStatusRegistry(baseDir);
|
|
100
|
+
return registry.statuses[accountName];
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Updates the stored status for a single account using a functional updater
|
|
104
|
+
* to avoid accidental partial writes.
|
|
105
|
+
*/
|
|
106
|
+
function updateAccountStatus(baseDir, accountName, updater) {
|
|
107
|
+
const registry = loadAccountStatusRegistry(baseDir);
|
|
108
|
+
const previous = sanitizeStatus(registry.statuses[accountName]);
|
|
109
|
+
const next = sanitizeStatus(updater(previous));
|
|
110
|
+
registry.statuses[accountName] = next;
|
|
111
|
+
saveAccountStatusRegistry(baseDir, registry);
|
|
112
|
+
return next;
|
|
113
|
+
}
|
|
114
|
+
function deleteAccountStatus(baseDir, accountName) {
|
|
115
|
+
const registry = loadAccountStatusRegistry(baseDir);
|
|
116
|
+
if (!(accountName in registry.statuses)) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
delete registry.statuses[accountName];
|
|
120
|
+
saveAccountStatusRegistry(baseDir, registry);
|
|
121
|
+
}
|
package/dist/cli_main.js
CHANGED
|
@@ -8,6 +8,8 @@ const commander_1 = require("commander");
|
|
|
8
8
|
const fs_1 = __importDefault(require("fs"));
|
|
9
9
|
const path_1 = __importDefault(require("path"));
|
|
10
10
|
const account_manager_1 = require("./account_manager");
|
|
11
|
+
const account_inspector_1 = require("./account_inspector");
|
|
12
|
+
const account_status_store_1 = require("./account_status_store");
|
|
11
13
|
const codex_auth_1 = require("./codex_auth");
|
|
12
14
|
const constants_1 = require("./constants");
|
|
13
15
|
const codex_config_1 = require("./gateway/codex_config");
|
|
@@ -61,21 +63,38 @@ program
|
|
|
61
63
|
program
|
|
62
64
|
.command("list")
|
|
63
65
|
.description("List registered accounts")
|
|
64
|
-
.
|
|
66
|
+
.option("--details", "Show detailed account and usage status")
|
|
67
|
+
.action((options) => {
|
|
65
68
|
const baseDir = (0, paths_1.getBaseDir)(program.opts().dataDir);
|
|
66
69
|
(0, account_manager_1.ensureBaseDir)(baseDir);
|
|
67
|
-
const
|
|
68
|
-
if (
|
|
70
|
+
const inspections = (0, account_inspector_1.inspectAccounts)(baseDir);
|
|
71
|
+
if (inspections.length === 0) {
|
|
72
|
+
process.stdout.write("No accounts registered. Use `cao add <name>` first.\n");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (!options.details) {
|
|
76
|
+
renderAccountSummary(inspections);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
renderAccountDetails(inspections);
|
|
80
|
+
});
|
|
81
|
+
program
|
|
82
|
+
.command("status")
|
|
83
|
+
.description("Show detailed account status and cooldown/usage signals")
|
|
84
|
+
.option("--json", "Output account status as JSON")
|
|
85
|
+
.action((options) => {
|
|
86
|
+
const baseDir = (0, paths_1.getBaseDir)(program.opts().dataDir);
|
|
87
|
+
(0, account_manager_1.ensureBaseDir)(baseDir);
|
|
88
|
+
const inspections = (0, account_inspector_1.inspectAccounts)(baseDir);
|
|
89
|
+
if (inspections.length === 0) {
|
|
69
90
|
process.stdout.write("No accounts registered. Use `cao add <name>` first.\n");
|
|
70
91
|
return;
|
|
71
92
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const loggedIn = fs_1.default.existsSync(getAuthFilePath(accountDir));
|
|
76
|
-
const status = loggedIn ? "logged-in" : "not-logged-in";
|
|
77
|
-
process.stdout.write(`${marker} ${name} (${status})\n`);
|
|
93
|
+
if (options.json) {
|
|
94
|
+
renderAccountDetailsJson(inspections);
|
|
95
|
+
return;
|
|
78
96
|
}
|
|
97
|
+
renderAccountDetails(inspections);
|
|
79
98
|
});
|
|
80
99
|
program
|
|
81
100
|
.command("use")
|
|
@@ -156,17 +175,24 @@ gateway
|
|
|
156
175
|
.description("Show gateway config and account readiness")
|
|
157
176
|
.action(() => {
|
|
158
177
|
const baseDir = (0, paths_1.getBaseDir)(program.opts().dataDir);
|
|
178
|
+
(0, account_manager_1.ensureBaseDir)(baseDir);
|
|
159
179
|
const config = (0, gateway_config_1.resolveGatewayConfig)((0, gateway_config_1.loadGatewayConfig)());
|
|
160
180
|
const pool = account_pool_1.AccountPool.loadFromRegistry(baseDir);
|
|
181
|
+
const inspections = (0, account_inspector_1.inspectAccounts)(baseDir);
|
|
182
|
+
const inspectionsByName = new Map(inspections.map((inspection) => [inspection.name, inspection]));
|
|
183
|
+
const nowMs = Date.now();
|
|
161
184
|
process.stdout.write(`Bind: ${config.bindAddress}:${config.port}\n`);
|
|
162
185
|
process.stdout.write(`Upstream: ${config.baseUrl}\n`);
|
|
163
186
|
process.stdout.write(`Override auth: ${config.overrideAuth ? "yes" : "no"}\n`);
|
|
164
187
|
process.stdout.write(`Accounts: ${pool.getAccounts().length}\n`);
|
|
165
188
|
for (const account of pool.getAccounts()) {
|
|
166
|
-
const
|
|
167
|
-
|
|
189
|
+
const inspection = inspectionsByName.get(account.name);
|
|
190
|
+
const cooldown = account.cooldownUntilMs > nowMs
|
|
191
|
+
? `cooldown ${Math.ceil((account.cooldownUntilMs - nowMs) / 1000)}s`
|
|
168
192
|
: "ready";
|
|
169
|
-
|
|
193
|
+
const tokenExpiry = formatTimestampWithRelative(inspection?.tokenDetails?.expiresAtMs, nowMs);
|
|
194
|
+
const lastRefresh = formatTimestampWithRelative(inspection?.lastRefreshAtMs, nowMs);
|
|
195
|
+
process.stdout.write(`- ${account.name} (${cooldown}) | token_expires_at: ${tokenExpiry} | last_refresh_at: ${lastRefresh}\n`);
|
|
170
196
|
}
|
|
171
197
|
});
|
|
172
198
|
gateway
|
|
@@ -250,6 +276,122 @@ function normalizeCodexArgs(args, codexBin) {
|
|
|
250
276
|
function getAuthFilePath(accountDir) {
|
|
251
277
|
return path_1.default.join(accountDir, constants_1.AUTH_FILE_NAME);
|
|
252
278
|
}
|
|
279
|
+
function renderAccountSummary(inspections) {
|
|
280
|
+
for (const inspection of inspections) {
|
|
281
|
+
const marker = inspection.isDefault ? "*" : " ";
|
|
282
|
+
const status = inspection.loggedIn ? "logged-in" : "not-logged-in";
|
|
283
|
+
process.stdout.write(`${marker} ${inspection.name} (${status})\n`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
function toAccountDetailRecord(inspection, referenceMs) {
|
|
287
|
+
const status = inspection.status ?? {};
|
|
288
|
+
const tokenExpiresAtMs = inspection.tokenDetails?.expiresAtMs;
|
|
289
|
+
const cooldownUntilMs = status.cooldownUntilMs;
|
|
290
|
+
const cooldownRemainingMs = cooldownUntilMs && cooldownUntilMs > referenceMs ? cooldownUntilMs - referenceMs : 0;
|
|
291
|
+
return {
|
|
292
|
+
name: inspection.name,
|
|
293
|
+
isDefault: inspection.isDefault,
|
|
294
|
+
loggedIn: inspection.loggedIn,
|
|
295
|
+
accountId: inspection.accountId ?? null,
|
|
296
|
+
organizationId: inspection.tokenDetails?.organizationId ?? null,
|
|
297
|
+
tokenExpiresAtMs: tokenExpiresAtMs ?? null,
|
|
298
|
+
tokenExpiresAtIso: tokenExpiresAtMs ? new Date(tokenExpiresAtMs).toISOString() : null,
|
|
299
|
+
tokenExpiresInMs: tokenExpiresAtMs ? Math.max(0, tokenExpiresAtMs - referenceMs) : null,
|
|
300
|
+
lastRefreshAtMs: inspection.lastRefreshAtMs ?? null,
|
|
301
|
+
lastRefreshAtIso: inspection.lastRefreshAtMs
|
|
302
|
+
? new Date(inspection.lastRefreshAtMs).toISOString()
|
|
303
|
+
: null,
|
|
304
|
+
lastAttemptAtMs: status.lastAttemptAtMs ?? null,
|
|
305
|
+
lastAttemptAtIso: status.lastAttemptAtMs
|
|
306
|
+
? new Date(status.lastAttemptAtMs).toISOString()
|
|
307
|
+
: null,
|
|
308
|
+
lastSuccessAtMs: status.lastSuccessAtMs ?? null,
|
|
309
|
+
lastSuccessAtIso: status.lastSuccessAtMs
|
|
310
|
+
? new Date(status.lastSuccessAtMs).toISOString()
|
|
311
|
+
: null,
|
|
312
|
+
lastQuotaAtMs: status.lastQuotaAtMs ?? null,
|
|
313
|
+
lastQuotaAtIso: status.lastQuotaAtMs
|
|
314
|
+
? new Date(status.lastQuotaAtMs).toISOString()
|
|
315
|
+
: null,
|
|
316
|
+
cooldownUntilMs: cooldownUntilMs ?? null,
|
|
317
|
+
cooldownUntilIso: cooldownUntilMs
|
|
318
|
+
? new Date(cooldownUntilMs).toISOString()
|
|
319
|
+
: null,
|
|
320
|
+
cooldownRemainingMs,
|
|
321
|
+
consecutiveFailures: status.consecutiveFailures ?? 0,
|
|
322
|
+
lastError: status.lastError ?? null,
|
|
323
|
+
accountDir: inspection.accountDir,
|
|
324
|
+
authFilePath: inspection.authFilePath
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
function renderAccountDetails(inspections, referenceMs = Date.now()) {
|
|
328
|
+
const indent = " ";
|
|
329
|
+
for (const inspection of inspections) {
|
|
330
|
+
const marker = inspection.isDefault ? "*" : " ";
|
|
331
|
+
const status = inspection.status ?? {};
|
|
332
|
+
const loginStatus = inspection.loggedIn ? "logged-in" : "not-logged-in";
|
|
333
|
+
process.stdout.write(`${marker} ${inspection.name}\n`);
|
|
334
|
+
process.stdout.write(`${indent}status: ${loginStatus}\n`);
|
|
335
|
+
process.stdout.write(`${indent}account_id: ${inspection.accountId ?? "(unknown)"}\n`);
|
|
336
|
+
process.stdout.write(`${indent}organization_id: ${inspection.tokenDetails?.organizationId ?? "(unknown)"}\n`);
|
|
337
|
+
process.stdout.write(`${indent}token_expires_at: ${formatTimestampWithRelative(inspection.tokenDetails?.expiresAtMs, referenceMs)}\n`);
|
|
338
|
+
process.stdout.write(`${indent}last_refresh_at: ${formatTimestampWithRelative(inspection.lastRefreshAtMs, referenceMs)}\n`);
|
|
339
|
+
process.stdout.write(`${indent}last_attempt_at: ${formatTimestampWithRelative(status.lastAttemptAtMs, referenceMs)}\n`);
|
|
340
|
+
process.stdout.write(`${indent}last_success_at: ${formatTimestampWithRelative(status.lastSuccessAtMs, referenceMs)}\n`);
|
|
341
|
+
process.stdout.write(`${indent}last_quota_at: ${formatTimestampWithRelative(status.lastQuotaAtMs, referenceMs)}\n`);
|
|
342
|
+
process.stdout.write(`${indent}cooldown_until: ${formatCooldown(status.cooldownUntilMs, referenceMs)}\n`);
|
|
343
|
+
process.stdout.write(`${indent}consecutive_failures: ${status.consecutiveFailures ?? 0}\n`);
|
|
344
|
+
process.stdout.write(`${indent}last_error: ${status.lastError ?? "none"}\n`);
|
|
345
|
+
process.stdout.write(`${indent}account_dir: ${inspection.accountDir}\n`);
|
|
346
|
+
process.stdout.write("\n");
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
function renderAccountDetailsJson(inspections) {
|
|
350
|
+
const referenceMs = Date.now();
|
|
351
|
+
const payload = inspections.map((inspection) => toAccountDetailRecord(inspection, referenceMs));
|
|
352
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
|
|
353
|
+
}
|
|
354
|
+
function formatDuration(durationMs) {
|
|
355
|
+
const totalSeconds = Math.max(0, Math.floor(durationMs / 1000));
|
|
356
|
+
if (totalSeconds < 60) {
|
|
357
|
+
return `${totalSeconds}s`;
|
|
358
|
+
}
|
|
359
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
360
|
+
if (minutes < 60) {
|
|
361
|
+
const seconds = totalSeconds % 60;
|
|
362
|
+
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
|
363
|
+
}
|
|
364
|
+
const hours = Math.floor(minutes / 60);
|
|
365
|
+
if (hours < 24) {
|
|
366
|
+
const remainingMinutes = minutes % 60;
|
|
367
|
+
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
|
|
368
|
+
}
|
|
369
|
+
const days = Math.floor(hours / 24);
|
|
370
|
+
const remainingHours = hours % 24;
|
|
371
|
+
return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`;
|
|
372
|
+
}
|
|
373
|
+
function formatTimestampWithRelative(timestampMs, referenceMs) {
|
|
374
|
+
if (!timestampMs) {
|
|
375
|
+
return "(unknown)";
|
|
376
|
+
}
|
|
377
|
+
const iso = new Date(timestampMs).toISOString();
|
|
378
|
+
const diffMs = timestampMs - referenceMs;
|
|
379
|
+
if (Math.abs(diffMs) < 5_000) {
|
|
380
|
+
return `${iso} (now)`;
|
|
381
|
+
}
|
|
382
|
+
const relative = diffMs > 0 ? `in ${formatDuration(diffMs)}` : `${formatDuration(-diffMs)} ago`;
|
|
383
|
+
return `${iso} (${relative})`;
|
|
384
|
+
}
|
|
385
|
+
function formatCooldown(cooldownUntilMs, referenceMs) {
|
|
386
|
+
if (!cooldownUntilMs) {
|
|
387
|
+
return "none";
|
|
388
|
+
}
|
|
389
|
+
if (cooldownUntilMs <= referenceMs) {
|
|
390
|
+
const iso = new Date(cooldownUntilMs).toISOString();
|
|
391
|
+
return `${iso} (elapsed)`;
|
|
392
|
+
}
|
|
393
|
+
return formatTimestampWithRelative(cooldownUntilMs, referenceMs);
|
|
394
|
+
}
|
|
253
395
|
function buildGatewayOverrides(options) {
|
|
254
396
|
const overrides = {};
|
|
255
397
|
overrides.bindAddress = options.bind;
|
|
@@ -300,22 +442,59 @@ async function runWithFallback(options, baseDir, accounts, codexArgs) {
|
|
|
300
442
|
const name = accounts[index];
|
|
301
443
|
const accountDir = (0, account_manager_1.ensureAccountDir)(baseDir, name);
|
|
302
444
|
(0, account_manager_1.ensureAccountConfig)(accountDir);
|
|
445
|
+
const attemptAtMs = Date.now();
|
|
446
|
+
(0, account_status_store_1.updateAccountStatus)(baseDir, name, (previous) => ({
|
|
447
|
+
...previous,
|
|
448
|
+
lastAttemptAtMs: attemptAtMs
|
|
449
|
+
}));
|
|
303
450
|
process.stderr.write(`Using account: ${name}\n`);
|
|
304
451
|
const result = await (0, process_runner_1.runCodexOnce)(codexBin, codexArgs, accountDir, options.fallback);
|
|
305
452
|
lastExitCode = result.exitCode;
|
|
306
453
|
if (result.exitCode === 0) {
|
|
454
|
+
(0, account_status_store_1.updateAccountStatus)(baseDir, name, (previous) => ({
|
|
455
|
+
...previous,
|
|
456
|
+
lastAttemptAtMs: attemptAtMs,
|
|
457
|
+
lastSuccessAtMs: Date.now(),
|
|
458
|
+
consecutiveFailures: 0,
|
|
459
|
+
cooldownUntilMs: undefined,
|
|
460
|
+
lastError: undefined
|
|
461
|
+
}));
|
|
307
462
|
process.exit(0);
|
|
308
463
|
return;
|
|
309
464
|
}
|
|
310
465
|
if (!options.fallback) {
|
|
466
|
+
(0, account_status_store_1.updateAccountStatus)(baseDir, name, (previous) => ({
|
|
467
|
+
...previous,
|
|
468
|
+
lastAttemptAtMs: attemptAtMs,
|
|
469
|
+
consecutiveFailures: (previous.consecutiveFailures ?? 0) + 1,
|
|
470
|
+
cooldownUntilMs: undefined,
|
|
471
|
+
lastError: `exit_code_${result.exitCode}`
|
|
472
|
+
}));
|
|
311
473
|
process.exit(result.exitCode);
|
|
312
474
|
return;
|
|
313
475
|
}
|
|
314
476
|
if (!result.quotaError) {
|
|
477
|
+
(0, account_status_store_1.updateAccountStatus)(baseDir, name, (previous) => ({
|
|
478
|
+
...previous,
|
|
479
|
+
lastAttemptAtMs: attemptAtMs,
|
|
480
|
+
consecutiveFailures: (previous.consecutiveFailures ?? 0) + 1,
|
|
481
|
+
cooldownUntilMs: undefined,
|
|
482
|
+
lastError: `exit_code_${result.exitCode}`
|
|
483
|
+
}));
|
|
315
484
|
process.exit(result.exitCode);
|
|
316
485
|
return;
|
|
317
486
|
}
|
|
318
487
|
quotaFailures += 1;
|
|
488
|
+
const quotaAtMs = Date.now();
|
|
489
|
+
const cooldownUntilMs = retryDelayMs > 0 ? quotaAtMs + retryDelayMs : undefined;
|
|
490
|
+
(0, account_status_store_1.updateAccountStatus)(baseDir, name, (previous) => ({
|
|
491
|
+
...previous,
|
|
492
|
+
lastAttemptAtMs: attemptAtMs,
|
|
493
|
+
lastQuotaAtMs: quotaAtMs,
|
|
494
|
+
cooldownUntilMs,
|
|
495
|
+
consecutiveFailures: (previous.consecutiveFailures ?? 0) + 1,
|
|
496
|
+
lastError: "usage_limit_reached"
|
|
497
|
+
}));
|
|
319
498
|
const nextName = accounts[index + 1];
|
|
320
499
|
if (nextName) {
|
|
321
500
|
process.stderr.write(`Quota exhausted. Falling back to: ${nextName}\n`);
|
package/dist/constants.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export declare const REGISTRY_FILE_NAME = "registry.json";
|
|
2
2
|
export declare const AUTH_FILE_NAME = "auth.json";
|
|
3
|
+
export declare const ACCOUNT_STATUS_FILE_NAME = "account_status.json";
|
|
3
4
|
export declare const DEFAULT_CONFIG_TOML: string;
|
|
4
5
|
export declare const QUOTA_ERROR_PATTERNS: RegExp[];
|
|
5
6
|
export declare const MAX_CAPTURED_LINES = 200;
|
package/dist/constants.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.MAX_CAPTURED_LINES = exports.QUOTA_ERROR_PATTERNS = exports.DEFAULT_CONFIG_TOML = exports.AUTH_FILE_NAME = exports.REGISTRY_FILE_NAME = void 0;
|
|
3
|
+
exports.MAX_CAPTURED_LINES = exports.QUOTA_ERROR_PATTERNS = exports.DEFAULT_CONFIG_TOML = exports.ACCOUNT_STATUS_FILE_NAME = exports.AUTH_FILE_NAME = exports.REGISTRY_FILE_NAME = void 0;
|
|
4
4
|
exports.REGISTRY_FILE_NAME = "registry.json";
|
|
5
5
|
exports.AUTH_FILE_NAME = "auth.json";
|
|
6
|
+
exports.ACCOUNT_STATUS_FILE_NAME = "account_status.json";
|
|
6
7
|
exports.DEFAULT_CONFIG_TOML = [
|
|
7
8
|
"# Codex config for this account",
|
|
8
9
|
"cli_auth_credentials_store = \"file\"",
|
|
@@ -8,15 +8,17 @@ export interface AccountState {
|
|
|
8
8
|
consecutiveFailures: number;
|
|
9
9
|
}
|
|
10
10
|
export declare class AccountPool {
|
|
11
|
+
private readonly baseDir;
|
|
11
12
|
private readonly accounts;
|
|
12
13
|
private readonly sessionAssignments;
|
|
13
|
-
constructor(accounts: AccountState[]);
|
|
14
|
+
constructor(baseDir: string, accounts: AccountState[]);
|
|
14
15
|
static loadFromRegistry(baseDir: string): AccountPool;
|
|
15
16
|
getAccounts(): AccountState[];
|
|
16
17
|
getStickyAccount(sessionKey: string): AccountState | undefined;
|
|
17
18
|
assignAccount(sessionKey: string, accountName: string): void;
|
|
18
19
|
clearAssignment(sessionKey: string): void;
|
|
19
20
|
pickNextAvailable(excluded: Set<string>): AccountState | undefined;
|
|
21
|
+
markAttempt(account: AccountState): void;
|
|
20
22
|
markQuota(account: AccountState, cooldownSeconds: number, resetsAtMs?: number): void;
|
|
21
23
|
markAuthFailure(account: AccountState, message: string): void;
|
|
22
24
|
markSuccess(account: AccountState): void;
|
|
@@ -6,14 +6,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.AccountPool = void 0;
|
|
7
7
|
const fs_1 = __importDefault(require("fs"));
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const account_status_store_1 = require("../account_status_store");
|
|
9
10
|
const paths_1 = require("../paths");
|
|
10
11
|
const registry_store_1 = require("../registry_store");
|
|
11
12
|
const account_manager_1 = require("../account_manager");
|
|
12
13
|
const token_utils_1 = require("./token_utils");
|
|
13
14
|
class AccountPool {
|
|
15
|
+
baseDir;
|
|
14
16
|
accounts;
|
|
15
17
|
sessionAssignments = new Map();
|
|
16
|
-
constructor(accounts) {
|
|
18
|
+
constructor(baseDir, accounts) {
|
|
19
|
+
this.baseDir = baseDir;
|
|
17
20
|
this.accounts = accounts;
|
|
18
21
|
}
|
|
19
22
|
static loadFromRegistry(baseDir) {
|
|
@@ -34,7 +37,7 @@ class AccountPool {
|
|
|
34
37
|
consecutiveFailures: 0
|
|
35
38
|
});
|
|
36
39
|
}
|
|
37
|
-
return new AccountPool(accounts);
|
|
40
|
+
return new AccountPool(baseDir, accounts);
|
|
38
41
|
}
|
|
39
42
|
getAccounts() {
|
|
40
43
|
return this.accounts;
|
|
@@ -65,24 +68,65 @@ class AccountPool {
|
|
|
65
68
|
}
|
|
66
69
|
return undefined;
|
|
67
70
|
}
|
|
71
|
+
markAttempt(account) {
|
|
72
|
+
const attemptAtMs = Date.now();
|
|
73
|
+
(0, account_status_store_1.updateAccountStatus)(this.baseDir, account.name, (previous) => ({
|
|
74
|
+
...previous,
|
|
75
|
+
lastAttemptAtMs: attemptAtMs
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
68
78
|
markQuota(account, cooldownSeconds, resetsAtMs) {
|
|
79
|
+
const quotaAtMs = Date.now();
|
|
69
80
|
account.consecutiveFailures += 1;
|
|
70
81
|
account.lastError = "usage_limit_reached";
|
|
71
|
-
const until = resetsAtMs && resetsAtMs >
|
|
82
|
+
const until = resetsAtMs && resetsAtMs > quotaAtMs ? resetsAtMs : quotaAtMs + cooldownSeconds * 1000;
|
|
72
83
|
account.cooldownUntilMs = until;
|
|
84
|
+
(0, account_status_store_1.updateAccountStatus)(this.baseDir, account.name, (previous) => ({
|
|
85
|
+
...previous,
|
|
86
|
+
lastAttemptAtMs: quotaAtMs,
|
|
87
|
+
lastQuotaAtMs: quotaAtMs,
|
|
88
|
+
cooldownUntilMs: until,
|
|
89
|
+
consecutiveFailures: (previous.consecutiveFailures ?? 0) + 1,
|
|
90
|
+
lastError: "usage_limit_reached"
|
|
91
|
+
}));
|
|
73
92
|
}
|
|
74
93
|
markAuthFailure(account, message) {
|
|
94
|
+
const failureAtMs = Date.now();
|
|
95
|
+
const cooldownUntilMs = failureAtMs + 60 * 1000;
|
|
75
96
|
account.consecutiveFailures += 1;
|
|
76
97
|
account.lastError = message;
|
|
77
|
-
account.cooldownUntilMs =
|
|
98
|
+
account.cooldownUntilMs = cooldownUntilMs;
|
|
99
|
+
(0, account_status_store_1.updateAccountStatus)(this.baseDir, account.name, (previous) => ({
|
|
100
|
+
...previous,
|
|
101
|
+
lastAttemptAtMs: failureAtMs,
|
|
102
|
+
cooldownUntilMs,
|
|
103
|
+
consecutiveFailures: (previous.consecutiveFailures ?? 0) + 1,
|
|
104
|
+
lastError: message
|
|
105
|
+
}));
|
|
78
106
|
}
|
|
79
107
|
markSuccess(account) {
|
|
108
|
+
const successAtMs = Date.now();
|
|
80
109
|
account.consecutiveFailures = 0;
|
|
81
110
|
account.lastError = undefined;
|
|
111
|
+
(0, account_status_store_1.updateAccountStatus)(this.baseDir, account.name, (previous) => ({
|
|
112
|
+
...previous,
|
|
113
|
+
lastAttemptAtMs: successAtMs,
|
|
114
|
+
lastSuccessAtMs: successAtMs,
|
|
115
|
+
consecutiveFailures: 0,
|
|
116
|
+
cooldownUntilMs: undefined,
|
|
117
|
+
lastError: undefined
|
|
118
|
+
}));
|
|
82
119
|
}
|
|
83
120
|
updateTokens(account, tokens) {
|
|
84
121
|
account.tokens = tokens;
|
|
85
122
|
persistTokens(account.accountDir, tokens);
|
|
123
|
+
(0, account_status_store_1.updateAccountStatus)(this.baseDir, account.name, (previous) => ({
|
|
124
|
+
...previous,
|
|
125
|
+
lastSuccessAtMs: Date.now(),
|
|
126
|
+
consecutiveFailures: 0,
|
|
127
|
+
cooldownUntilMs: undefined,
|
|
128
|
+
lastError: undefined
|
|
129
|
+
}));
|
|
86
130
|
}
|
|
87
131
|
isTokenFresh(account, bufferSeconds) {
|
|
88
132
|
return (0, token_utils_1.isTokenFresh)(account.tokens.expiresAtMs, bufferSeconds);
|
|
@@ -84,11 +84,13 @@ class OpenAiGateway {
|
|
|
84
84
|
selectAccount(sessionKey, excluded) {
|
|
85
85
|
const sticky = this.pool.getStickyAccount(sessionKey);
|
|
86
86
|
if (sticky && !excluded.has(sticky.name) && sticky.cooldownUntilMs <= Date.now()) {
|
|
87
|
+
this.pool.markAttempt(sticky);
|
|
87
88
|
return sticky;
|
|
88
89
|
}
|
|
89
90
|
const picked = this.pool.pickNextAvailable(excluded);
|
|
90
91
|
if (picked) {
|
|
91
92
|
this.pool.assignAccount(sessionKey, picked.name);
|
|
93
|
+
this.pool.markAttempt(picked);
|
|
92
94
|
}
|
|
93
95
|
return picked;
|
|
94
96
|
}
|
package/dist/paths.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export declare function getBaseDir(dataDir?: string): string;
|
|
2
2
|
export declare function getAccountDir(baseDir: string, accountName: string): string;
|
|
3
3
|
export declare function getRegistryPath(baseDir: string): string;
|
|
4
|
+
export declare function getAccountStatusPath(baseDir: string): string;
|
package/dist/paths.js
CHANGED
|
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.getBaseDir = getBaseDir;
|
|
7
7
|
exports.getAccountDir = getAccountDir;
|
|
8
8
|
exports.getRegistryPath = getRegistryPath;
|
|
9
|
+
exports.getAccountStatusPath = getAccountStatusPath;
|
|
9
10
|
const os_1 = __importDefault(require("os"));
|
|
10
11
|
const path_1 = __importDefault(require("path"));
|
|
11
12
|
const constants_1 = require("./constants");
|
|
@@ -21,3 +22,6 @@ function getAccountDir(baseDir, accountName) {
|
|
|
21
22
|
function getRegistryPath(baseDir) {
|
|
22
23
|
return path_1.default.join(baseDir, constants_1.REGISTRY_FILE_NAME);
|
|
23
24
|
}
|
|
25
|
+
function getAccountStatusPath(baseDir) {
|
|
26
|
+
return path_1.default.join(baseDir, constants_1.ACCOUNT_STATUS_FILE_NAME);
|
|
27
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codex-account-orchestrator",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Codex OAuth account fallback orchestrator with seamless gateway mode",
|
|
5
5
|
"main": "dist/cli_main.js",
|
|
6
6
|
"types": "dist/cli_main.d.ts",
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
"scripts": {
|
|
12
12
|
"build": "tsc",
|
|
13
13
|
"clean": "rm -rf dist",
|
|
14
|
+
"test": "npm run build && node --test test/*.test.js",
|
|
15
|
+
"check": "npm run test",
|
|
14
16
|
"prepublishOnly": "npm run clean && npm run build",
|
|
15
17
|
"start": "node dist/cli_main.js"
|
|
16
18
|
},
|