codex-account-orchestrator 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/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/dist/account_manager.d.ts +9 -0
- package/dist/account_manager.js +103 -0
- package/dist/cli_main.d.ts +2 -0
- package/dist/cli_main.js +334 -0
- package/dist/codex_auth.d.ts +2 -0
- package/dist/codex_auth.js +29 -0
- package/dist/constants.d.ts +5 -0
- package/dist/constants.js +20 -0
- package/dist/gateway/account_pool.d.ts +25 -0
- package/dist/gateway/account_pool.js +125 -0
- package/dist/gateway/codex_config.d.ts +6 -0
- package/dist/gateway/codex_config.js +73 -0
- package/dist/gateway/codex_shim.d.ts +7 -0
- package/dist/gateway/codex_shim.js +83 -0
- package/dist/gateway/gateway_config.d.ts +14 -0
- package/dist/gateway/gateway_config.js +46 -0
- package/dist/gateway/openai_gateway.d.ts +15 -0
- package/dist/gateway/openai_gateway.js +478 -0
- package/dist/gateway/server.d.ts +4 -0
- package/dist/gateway/server.js +23 -0
- package/dist/gateway/token_utils.d.ts +18 -0
- package/dist/gateway/token_utils.js +73 -0
- package/dist/output_capture.d.ts +6 -0
- package/dist/output_capture.js +25 -0
- package/dist/paths.d.ts +3 -0
- package/dist/paths.js +23 -0
- package/dist/process_runner.d.ts +5 -0
- package/dist/process_runner.js +40 -0
- package/dist/quota_detector.d.ts +1 -0
- package/dist/quota_detector.js +7 -0
- package/dist/registry_store.d.ts +6 -0
- package/dist/registry_store.js +26 -0
- package/package.json +55 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [1.0.0] - 2025-01-27
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Account management with separate CODEX_HOME directories per account
|
|
13
|
+
- OAuth login support with browser and device auth flows
|
|
14
|
+
- Automatic fallback to next account on quota exhaustion
|
|
15
|
+
- Gateway mode for seamless account switching without session drops
|
|
16
|
+
- Codex shim installation for transparent traffic routing
|
|
17
|
+
- Token refresh and session management
|
|
18
|
+
- Configurable cooldown and retry settings
|
|
19
|
+
- Debug logging with header sanitization
|
|
20
|
+
|
|
21
|
+
### Commands
|
|
22
|
+
|
|
23
|
+
- `cao add <name>` - Register a new account
|
|
24
|
+
- `cao list` - List registered accounts
|
|
25
|
+
- `cao use <name>` - Set default account
|
|
26
|
+
- `cao remove <name>` - Remove an account
|
|
27
|
+
- `cao run` - Run codex with automatic fallback
|
|
28
|
+
- `cao gateway start` - Start local gateway server
|
|
29
|
+
- `cao gateway enable` - Install codex routing shim
|
|
30
|
+
- `cao gateway disable` - Remove shim and restore config
|
|
31
|
+
- `cao gateway status` - Show gateway and account status
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 DAWNCR0W
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# codex-account-orchestrator
|
|
2
|
+
|
|
3
|
+
Codex OAuth account fallback orchestrator. It keeps **separate `CODEX_HOME` directories per account** and automatically falls back to the next account when quota is exhausted.
|
|
4
|
+
|
|
5
|
+
## Install (local dev)
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install
|
|
9
|
+
npm run build
|
|
10
|
+
npm link # Makes 'cao' command available globally
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
### Add accounts
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
cao add accountA
|
|
19
|
+
cao add accountB
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`cao add` starts OAuth login by default and should open a browser window. Use `--no-login` to skip:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
cao add accountA --no-login
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
If you prefer device auth:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
cao add accountA --device-auth
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Set default account
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
cao use accountA
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### List accounts
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
cao list
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Remove an account
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
cao remove accountB
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
To keep files on disk:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
cao remove accountB --keep-files
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Run with fallback
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
cao run
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
To pass arguments to Codex, put them after `--`:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
cao run -- exec "summarize README"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
To recheck all accounts when everyone is quota-limited, use multiple passes:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
cao run --max-passes 2 --retry-delay 5
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Run a specific account (no fallback)
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
cao run --no-fallback --account accountB -- codex
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Custom data directory
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
cao --data-dir /path/to/data run -- codex
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## How it works
|
|
89
|
+
|
|
90
|
+
- Each account is stored in its own `CODEX_HOME` directory under:
|
|
91
|
+
- `~/.codex-account-orchestrator/<account>/`
|
|
92
|
+
- A `config.toml` is created with:
|
|
93
|
+
- `cli_auth_credentials_store = "file"`
|
|
94
|
+
- `forced_login_method = "chatgpt"`
|
|
95
|
+
- 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.
|
|
96
|
+
|
|
97
|
+
## Gateway mode (no session drop)
|
|
98
|
+
|
|
99
|
+
Gateway mode keeps the Codex session open while switching accounts on quota errors. It requires routing Codex traffic through the local gateway.
|
|
100
|
+
|
|
101
|
+
### Start the gateway
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
cao gateway start
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
For troubleshooting, you can pass through the current Codex auth without overriding it:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
cao gateway start --passthrough-auth
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Enable routing for Codex
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
cao gateway enable
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
This installs a small `codex` shim (in `~/.local/bin`) that sets `OPENAI_BASE_URL` to the gateway. You can revert with:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
cao gateway disable
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
If `~/.local/bin` is not in your PATH, add it so the shim is used.
|
|
126
|
+
|
|
127
|
+
### Check gateway status
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
cao gateway status
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Debug logging
|
|
134
|
+
|
|
135
|
+
Set `CAO_DEBUG_HEADERS=1` to log sanitized request/response headers in the gateway process. To capture the last request body for inspection, also set `CAO_CAPTURE_BODY=1` (saved to `/tmp/cao-last-body.json` by default).
|
|
136
|
+
|
|
137
|
+
## Notes
|
|
138
|
+
|
|
139
|
+
- Fallback requires capturing output; this may make Codex detect a non-TTY stdout. If you want a pure TTY session, use `--no-fallback`.
|
|
140
|
+
- The quota detector is keyword-based and can be extended in `src/constants.ts`.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Registry } from "./registry_store";
|
|
2
|
+
export declare function validateAccountName(name: string): string;
|
|
3
|
+
export declare function ensureBaseDir(baseDir: string): void;
|
|
4
|
+
export declare function ensureAccountDir(baseDir: string, accountName: string): string;
|
|
5
|
+
export declare function ensureAccountConfig(accountDir: string): void;
|
|
6
|
+
export declare function addAccount(baseDir: string, accountName: string): Registry;
|
|
7
|
+
export declare function setDefaultAccount(baseDir: string, accountName: string): Registry;
|
|
8
|
+
export declare function removeAccount(baseDir: string, accountName: string, removeFiles: boolean): Registry;
|
|
9
|
+
export declare function getAccountOrder(registry: Registry): string[];
|
|
@@ -0,0 +1,103 @@
|
|
|
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.validateAccountName = validateAccountName;
|
|
7
|
+
exports.ensureBaseDir = ensureBaseDir;
|
|
8
|
+
exports.ensureAccountDir = ensureAccountDir;
|
|
9
|
+
exports.ensureAccountConfig = ensureAccountConfig;
|
|
10
|
+
exports.addAccount = addAccount;
|
|
11
|
+
exports.setDefaultAccount = setDefaultAccount;
|
|
12
|
+
exports.removeAccount = removeAccount;
|
|
13
|
+
exports.getAccountOrder = getAccountOrder;
|
|
14
|
+
const fs_1 = __importDefault(require("fs"));
|
|
15
|
+
const path_1 = __importDefault(require("path"));
|
|
16
|
+
const constants_1 = require("./constants");
|
|
17
|
+
const paths_1 = require("./paths");
|
|
18
|
+
const registry_store_1 = require("./registry_store");
|
|
19
|
+
function validateAccountName(name) {
|
|
20
|
+
const trimmed = name.trim();
|
|
21
|
+
if (trimmed.length === 0) {
|
|
22
|
+
throw new Error("Account name cannot be empty.");
|
|
23
|
+
}
|
|
24
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
|
25
|
+
throw new Error("Account name must use letters, numbers, underscores, or hyphens only.");
|
|
26
|
+
}
|
|
27
|
+
return trimmed;
|
|
28
|
+
}
|
|
29
|
+
function ensureBaseDir(baseDir) {
|
|
30
|
+
if (!fs_1.default.existsSync(baseDir)) {
|
|
31
|
+
fs_1.default.mkdirSync(baseDir, { recursive: true });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function ensureAccountDir(baseDir, accountName) {
|
|
35
|
+
const accountDir = (0, paths_1.getAccountDir)(baseDir, accountName);
|
|
36
|
+
if (!fs_1.default.existsSync(accountDir)) {
|
|
37
|
+
fs_1.default.mkdirSync(accountDir, { recursive: true });
|
|
38
|
+
}
|
|
39
|
+
return accountDir;
|
|
40
|
+
}
|
|
41
|
+
function ensureAccountConfig(accountDir) {
|
|
42
|
+
const configPath = path_1.default.join(accountDir, "config.toml");
|
|
43
|
+
if (!fs_1.default.existsSync(configPath)) {
|
|
44
|
+
fs_1.default.writeFileSync(configPath, constants_1.DEFAULT_CONFIG_TOML, "utf8");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function addAccount(baseDir, accountName) {
|
|
48
|
+
ensureBaseDir(baseDir);
|
|
49
|
+
const registry = (0, registry_store_1.loadRegistry)(baseDir);
|
|
50
|
+
const normalizedName = validateAccountName(accountName);
|
|
51
|
+
ensureAccountConfig(ensureAccountDir(baseDir, normalizedName));
|
|
52
|
+
if (!registry.accounts.includes(normalizedName)) {
|
|
53
|
+
registry.accounts.push(normalizedName);
|
|
54
|
+
}
|
|
55
|
+
if (!registry.default_account) {
|
|
56
|
+
registry.default_account = normalizedName;
|
|
57
|
+
}
|
|
58
|
+
(0, registry_store_1.saveRegistry)(baseDir, registry);
|
|
59
|
+
return registry;
|
|
60
|
+
}
|
|
61
|
+
function setDefaultAccount(baseDir, accountName) {
|
|
62
|
+
ensureBaseDir(baseDir);
|
|
63
|
+
const registry = (0, registry_store_1.loadRegistry)(baseDir);
|
|
64
|
+
const normalizedName = validateAccountName(accountName);
|
|
65
|
+
if (!registry.accounts.includes(normalizedName)) {
|
|
66
|
+
throw new Error(`Account not found: ${normalizedName}`);
|
|
67
|
+
}
|
|
68
|
+
registry.default_account = normalizedName;
|
|
69
|
+
(0, registry_store_1.saveRegistry)(baseDir, registry);
|
|
70
|
+
return registry;
|
|
71
|
+
}
|
|
72
|
+
function removeAccount(baseDir, accountName, removeFiles) {
|
|
73
|
+
ensureBaseDir(baseDir);
|
|
74
|
+
const registry = (0, registry_store_1.loadRegistry)(baseDir);
|
|
75
|
+
const normalizedName = validateAccountName(accountName);
|
|
76
|
+
const index = registry.accounts.indexOf(normalizedName);
|
|
77
|
+
if (index === -1) {
|
|
78
|
+
throw new Error(`Account not found: ${normalizedName}`);
|
|
79
|
+
}
|
|
80
|
+
registry.accounts.splice(index, 1);
|
|
81
|
+
if (registry.default_account === normalizedName) {
|
|
82
|
+
registry.default_account = registry.accounts.length > 0 ? registry.accounts[0] : null;
|
|
83
|
+
}
|
|
84
|
+
if (removeFiles) {
|
|
85
|
+
const accountDir = (0, paths_1.getAccountDir)(baseDir, normalizedName);
|
|
86
|
+
if (fs_1.default.existsSync(accountDir)) {
|
|
87
|
+
fs_1.default.rmSync(accountDir, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
(0, registry_store_1.saveRegistry)(baseDir, registry);
|
|
91
|
+
return registry;
|
|
92
|
+
}
|
|
93
|
+
function getAccountOrder(registry) {
|
|
94
|
+
if (registry.accounts.length === 0) {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
const defaultName = registry.default_account;
|
|
98
|
+
if (!defaultName) {
|
|
99
|
+
return [...registry.accounts];
|
|
100
|
+
}
|
|
101
|
+
const rest = registry.accounts.filter((name) => name !== defaultName);
|
|
102
|
+
return [defaultName, ...rest];
|
|
103
|
+
}
|
package/dist/cli_main.js
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
const commander_1 = require("commander");
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const account_manager_1 = require("./account_manager");
|
|
11
|
+
const codex_auth_1 = require("./codex_auth");
|
|
12
|
+
const constants_1 = require("./constants");
|
|
13
|
+
const codex_config_1 = require("./gateway/codex_config");
|
|
14
|
+
const account_pool_1 = require("./gateway/account_pool");
|
|
15
|
+
const gateway_config_1 = require("./gateway/gateway_config");
|
|
16
|
+
const server_1 = require("./gateway/server");
|
|
17
|
+
const codex_shim_1 = require("./gateway/codex_shim");
|
|
18
|
+
const paths_1 = require("./paths");
|
|
19
|
+
const registry_store_1 = require("./registry_store");
|
|
20
|
+
const process_runner_1 = require("./process_runner");
|
|
21
|
+
const program = new commander_1.Command();
|
|
22
|
+
program
|
|
23
|
+
.name("codex-account-orchestrator")
|
|
24
|
+
.description("Codex OAuth account fallback orchestrator")
|
|
25
|
+
.option("--data-dir <path>", "Custom data directory");
|
|
26
|
+
program
|
|
27
|
+
.command("add")
|
|
28
|
+
.argument("<name>", "Account name")
|
|
29
|
+
.description("Register a new account and create its config")
|
|
30
|
+
.option("--codex <path>", "Path to the codex binary", "codex")
|
|
31
|
+
.option("--no-login", "Skip OAuth login")
|
|
32
|
+
.option("--device-auth", "Use device auth flow")
|
|
33
|
+
.action(async (name, options) => {
|
|
34
|
+
const baseDir = (0, paths_1.getBaseDir)(program.opts().dataDir);
|
|
35
|
+
const registry = (0, account_manager_1.addAccount)(baseDir, name);
|
|
36
|
+
const normalizedName = (0, account_manager_1.validateAccountName)(name);
|
|
37
|
+
const accountDir = (0, paths_1.getAccountDir)(baseDir, normalizedName);
|
|
38
|
+
process.stdout.write(`Added account: ${normalizedName}\n`);
|
|
39
|
+
process.stdout.write(`Account directory: ${accountDir}\n`);
|
|
40
|
+
process.stdout.write(`Default account: ${registry.default_account ?? "(none)"}\n`);
|
|
41
|
+
process.stdout.write("Run `cao run` to start with fallback.\n");
|
|
42
|
+
if (!options.login) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const alreadyLoggedIn = await (0, codex_auth_1.isCodexLoggedIn)(options.codex, accountDir);
|
|
46
|
+
if (alreadyLoggedIn) {
|
|
47
|
+
process.stdout.write("OAuth already configured for this account.\n");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
process.stdout.write("Starting OAuth login...\n");
|
|
51
|
+
const exitCode = await (0, codex_auth_1.runCodexLogin)(options.codex, accountDir, options.deviceAuth);
|
|
52
|
+
if (exitCode !== 0) {
|
|
53
|
+
process.stderr.write("OAuth login failed.\n");
|
|
54
|
+
process.exit(exitCode);
|
|
55
|
+
}
|
|
56
|
+
const authPath = getAuthFilePath(accountDir);
|
|
57
|
+
if (!fs_1.default.existsSync(authPath)) {
|
|
58
|
+
process.stderr.write("Warning: OAuth login completed but auth.json was not found. Check your Codex auth store.\n");
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
program
|
|
62
|
+
.command("list")
|
|
63
|
+
.description("List registered accounts")
|
|
64
|
+
.action(() => {
|
|
65
|
+
const baseDir = (0, paths_1.getBaseDir)(program.opts().dataDir);
|
|
66
|
+
(0, account_manager_1.ensureBaseDir)(baseDir);
|
|
67
|
+
const registry = (0, registry_store_1.loadRegistry)(baseDir);
|
|
68
|
+
if (registry.accounts.length === 0) {
|
|
69
|
+
process.stdout.write("No accounts registered. Use `cao add <name>` first.\n");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
for (const name of registry.accounts) {
|
|
73
|
+
const marker = registry.default_account === name ? "*" : " ";
|
|
74
|
+
const accountDir = (0, paths_1.getAccountDir)(baseDir, name);
|
|
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`);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
program
|
|
81
|
+
.command("use")
|
|
82
|
+
.argument("<name>", "Account name")
|
|
83
|
+
.description("Set the default account")
|
|
84
|
+
.action((name) => {
|
|
85
|
+
const baseDir = (0, paths_1.getBaseDir)(program.opts().dataDir);
|
|
86
|
+
const registry = (0, account_manager_1.setDefaultAccount)(baseDir, name);
|
|
87
|
+
process.stdout.write(`Default account set to: ${registry.default_account}\n`);
|
|
88
|
+
});
|
|
89
|
+
program
|
|
90
|
+
.command("remove")
|
|
91
|
+
.argument("<name>", "Account name")
|
|
92
|
+
.option("--keep-files", "Keep account files on disk")
|
|
93
|
+
.description("Remove an account from fallback rotation")
|
|
94
|
+
.action((name, options) => {
|
|
95
|
+
const baseDir = (0, paths_1.getBaseDir)(program.opts().dataDir);
|
|
96
|
+
const registry = (0, account_manager_1.removeAccount)(baseDir, name, !options.keepFiles);
|
|
97
|
+
process.stdout.write(`Removed account: ${name}\n`);
|
|
98
|
+
process.stdout.write(`Default account: ${registry.default_account ?? "(none)"}\n`);
|
|
99
|
+
});
|
|
100
|
+
const gateway = program.command("gateway").description("Manage CAO gateway");
|
|
101
|
+
gateway
|
|
102
|
+
.command("start")
|
|
103
|
+
.description("Start the local gateway for seamless account switching")
|
|
104
|
+
.option("--bind <address>", "Bind address", "127.0.0.1")
|
|
105
|
+
.option("--port <port>", "Port", "4319")
|
|
106
|
+
.option("--base-url <url>", "Upstream OpenAI base URL", "https://chatgpt.com/backend-api/codex")
|
|
107
|
+
.option("--cooldown-seconds <seconds>", "Cooldown for quota-hit accounts", "900")
|
|
108
|
+
.option("--max-retry-passes <count>", "Max retry passes per request", "1")
|
|
109
|
+
.option("--timeout-ms <ms>", "Request timeout in milliseconds", "120000")
|
|
110
|
+
.option("--passthrough-auth", "Do not override Authorization header")
|
|
111
|
+
.option("--save", "Persist options to gateway.json")
|
|
112
|
+
.action((options) => {
|
|
113
|
+
const baseDir = (0, paths_1.getBaseDir)(program.opts().dataDir);
|
|
114
|
+
(0, account_manager_1.ensureBaseDir)(baseDir);
|
|
115
|
+
const overrides = buildGatewayOverrides(options);
|
|
116
|
+
const merged = (0, gateway_config_1.resolveGatewayConfig)({
|
|
117
|
+
...(0, gateway_config_1.loadGatewayConfig)(),
|
|
118
|
+
...overrides
|
|
119
|
+
});
|
|
120
|
+
if (options.save) {
|
|
121
|
+
(0, gateway_config_1.saveGatewayConfig)({
|
|
122
|
+
bindAddress: merged.bindAddress,
|
|
123
|
+
port: merged.port,
|
|
124
|
+
baseUrl: merged.baseUrl,
|
|
125
|
+
cooldownSeconds: merged.cooldownSeconds,
|
|
126
|
+
maxRetryPasses: merged.maxRetryPasses,
|
|
127
|
+
requestTimeoutMs: merged.requestTimeoutMs,
|
|
128
|
+
overrideAuth: merged.overrideAuth
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
const pool = account_pool_1.AccountPool.loadFromRegistry(baseDir);
|
|
132
|
+
if (pool.getAccounts().length === 0) {
|
|
133
|
+
process.stderr.write("No accounts with auth.json were found. Run `cao add` first.\n");
|
|
134
|
+
process.exit(1);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const server = (0, server_1.startGatewayServer)(pool, merged);
|
|
138
|
+
process.stdout.write(`Gateway started on http://${merged.bindAddress}:${merged.port} (upstream ${merged.baseUrl})\n`);
|
|
139
|
+
process.on("SIGINT", () => {
|
|
140
|
+
server.close(() => {
|
|
141
|
+
process.stdout.write("Gateway stopped.\n");
|
|
142
|
+
process.exit(0);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
gateway
|
|
147
|
+
.command("status")
|
|
148
|
+
.description("Show gateway config and account readiness")
|
|
149
|
+
.action(() => {
|
|
150
|
+
const baseDir = (0, paths_1.getBaseDir)(program.opts().dataDir);
|
|
151
|
+
const config = (0, gateway_config_1.resolveGatewayConfig)((0, gateway_config_1.loadGatewayConfig)());
|
|
152
|
+
const pool = account_pool_1.AccountPool.loadFromRegistry(baseDir);
|
|
153
|
+
process.stdout.write(`Bind: ${config.bindAddress}:${config.port}\n`);
|
|
154
|
+
process.stdout.write(`Upstream: ${config.baseUrl}\n`);
|
|
155
|
+
process.stdout.write(`Override auth: ${config.overrideAuth ? "yes" : "no"}\n`);
|
|
156
|
+
process.stdout.write(`Accounts: ${pool.getAccounts().length}\n`);
|
|
157
|
+
for (const account of pool.getAccounts()) {
|
|
158
|
+
const cooldown = account.cooldownUntilMs > Date.now()
|
|
159
|
+
? `cooldown ${Math.ceil((account.cooldownUntilMs - Date.now()) / 1000)}s`
|
|
160
|
+
: "ready";
|
|
161
|
+
process.stdout.write(`- ${account.name} (${cooldown})\n`);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
gateway
|
|
165
|
+
.command("enable")
|
|
166
|
+
.description("Install a codex shim that routes traffic through the gateway")
|
|
167
|
+
.option("--base-url <url>", "Gateway base URL", "http://127.0.0.1:4319")
|
|
168
|
+
.action((options) => {
|
|
169
|
+
(0, codex_config_1.disableGatewayConfig)();
|
|
170
|
+
const result = (0, codex_shim_1.enableGatewayShim)(options.baseUrl);
|
|
171
|
+
process.stdout.write(`Codex shim installed: ${result.shimPath}\n`);
|
|
172
|
+
process.stdout.write(`Real codex: ${result.realCodexPath}\n`);
|
|
173
|
+
if (!result.inPath) {
|
|
174
|
+
process.stdout.write(`Warning: ${path_1.default.dirname(result.shimPath)} is not in PATH. Update PATH to use the shim.\n`);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
gateway
|
|
178
|
+
.command("disable")
|
|
179
|
+
.description("Remove the codex shim and restore config backup if present")
|
|
180
|
+
.action(() => {
|
|
181
|
+
const removed = (0, codex_shim_1.disableGatewayShim)();
|
|
182
|
+
(0, codex_config_1.disableGatewayConfig)();
|
|
183
|
+
process.stdout.write(`Codex config restored: ${(0, codex_config_1.getCodexConfigPath)()}\n`);
|
|
184
|
+
process.stdout.write(removed ? "Codex shim removed.\n" : "No codex shim found.\n");
|
|
185
|
+
});
|
|
186
|
+
program
|
|
187
|
+
.command("run")
|
|
188
|
+
.option("--account <name>", "Run with a specific account")
|
|
189
|
+
.option("--codex <path>", "Path to the codex binary", "codex")
|
|
190
|
+
.option("--no-fallback", "Disable automatic fallback")
|
|
191
|
+
.option("--max-passes <count>", "Retry passes when all accounts hit quota", "2")
|
|
192
|
+
.option("--retry-delay <seconds>", "Delay between retry passes in seconds", "0")
|
|
193
|
+
.description("Run codex with OAuth fallback across accounts")
|
|
194
|
+
.allowUnknownOption(true)
|
|
195
|
+
.allowExcessArguments(true)
|
|
196
|
+
.action(async (options) => {
|
|
197
|
+
const baseDir = (0, paths_1.getBaseDir)(program.opts().dataDir);
|
|
198
|
+
(0, account_manager_1.ensureBaseDir)(baseDir);
|
|
199
|
+
const registry = (0, registry_store_1.loadRegistry)(baseDir);
|
|
200
|
+
const orderedAccounts = (0, account_manager_1.getAccountOrder)(registry);
|
|
201
|
+
const codexArgs = normalizeCodexArgs(getCodexArgs(process.argv), options.codex);
|
|
202
|
+
if (orderedAccounts.length === 0) {
|
|
203
|
+
process.stderr.write("No accounts registered. Use `cao add <name>` first.\n");
|
|
204
|
+
process.exit(1);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const resolvedAccounts = resolveAccounts(orderedAccounts, options.account);
|
|
208
|
+
if (resolvedAccounts.length === 0) {
|
|
209
|
+
process.stderr.write("No matching accounts found.\n");
|
|
210
|
+
process.exit(1);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
await runWithFallback(options, baseDir, resolvedAccounts, codexArgs);
|
|
214
|
+
});
|
|
215
|
+
program.parse(process.argv);
|
|
216
|
+
function resolveAccounts(ordered, requested) {
|
|
217
|
+
if (!requested) {
|
|
218
|
+
return ordered;
|
|
219
|
+
}
|
|
220
|
+
const normalized = (0, account_manager_1.validateAccountName)(requested);
|
|
221
|
+
if (ordered.includes(normalized)) {
|
|
222
|
+
return [normalized];
|
|
223
|
+
}
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
function getCodexArgs(argv) {
|
|
227
|
+
const separatorIndex = argv.indexOf("--");
|
|
228
|
+
if (separatorIndex === -1) {
|
|
229
|
+
return [];
|
|
230
|
+
}
|
|
231
|
+
return argv.slice(separatorIndex + 1);
|
|
232
|
+
}
|
|
233
|
+
function normalizeCodexArgs(args, codexBin) {
|
|
234
|
+
if (args.length === 0) {
|
|
235
|
+
return args;
|
|
236
|
+
}
|
|
237
|
+
if (codexBin === "codex" && args[0] === "codex") {
|
|
238
|
+
return args.slice(1);
|
|
239
|
+
}
|
|
240
|
+
return args;
|
|
241
|
+
}
|
|
242
|
+
function getAuthFilePath(accountDir) {
|
|
243
|
+
return path_1.default.join(accountDir, constants_1.AUTH_FILE_NAME);
|
|
244
|
+
}
|
|
245
|
+
function buildGatewayOverrides(options) {
|
|
246
|
+
const overrides = {};
|
|
247
|
+
overrides.bindAddress = options.bind;
|
|
248
|
+
overrides.baseUrl = options.baseUrl;
|
|
249
|
+
overrides.overrideAuth = !options.passthroughAuth;
|
|
250
|
+
const port = Number.parseInt(options.port, 10);
|
|
251
|
+
if (!Number.isNaN(port)) {
|
|
252
|
+
overrides.port = port;
|
|
253
|
+
}
|
|
254
|
+
const cooldown = Number.parseInt(options.cooldownSeconds, 10);
|
|
255
|
+
if (!Number.isNaN(cooldown)) {
|
|
256
|
+
overrides.cooldownSeconds = cooldown;
|
|
257
|
+
}
|
|
258
|
+
const maxRetries = Number.parseInt(options.maxRetryPasses, 10);
|
|
259
|
+
if (!Number.isNaN(maxRetries)) {
|
|
260
|
+
overrides.maxRetryPasses = maxRetries;
|
|
261
|
+
}
|
|
262
|
+
const timeout = Number.parseInt(options.timeoutMs, 10);
|
|
263
|
+
if (!Number.isNaN(timeout)) {
|
|
264
|
+
overrides.requestTimeoutMs = timeout;
|
|
265
|
+
}
|
|
266
|
+
return overrides;
|
|
267
|
+
}
|
|
268
|
+
async function runWithFallback(options, baseDir, accounts, codexArgs) {
|
|
269
|
+
const codexBin = options.codex;
|
|
270
|
+
const maxPasses = normalizeMaxPasses(options.maxPasses);
|
|
271
|
+
const retryDelayMs = normalizeDelay(options.retryDelay);
|
|
272
|
+
for (let passIndex = 0; passIndex < maxPasses; passIndex += 1) {
|
|
273
|
+
let quotaFailures = 0;
|
|
274
|
+
let lastExitCode = 1;
|
|
275
|
+
for (let index = 0; index < accounts.length; index += 1) {
|
|
276
|
+
const name = accounts[index];
|
|
277
|
+
const accountDir = (0, account_manager_1.ensureAccountDir)(baseDir, name);
|
|
278
|
+
(0, account_manager_1.ensureAccountConfig)(accountDir);
|
|
279
|
+
process.stderr.write(`Using account: ${name}\n`);
|
|
280
|
+
const result = await (0, process_runner_1.runCodexOnce)(codexBin, codexArgs, accountDir, options.fallback);
|
|
281
|
+
lastExitCode = result.exitCode;
|
|
282
|
+
if (result.exitCode === 0) {
|
|
283
|
+
process.exit(0);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (!options.fallback) {
|
|
287
|
+
process.exit(result.exitCode);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (!result.quotaError) {
|
|
291
|
+
process.exit(result.exitCode);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
quotaFailures += 1;
|
|
295
|
+
const nextName = accounts[index + 1];
|
|
296
|
+
if (nextName) {
|
|
297
|
+
process.stderr.write(`Quota exhausted. Falling back to: ${nextName}\n`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (!options.fallback) {
|
|
301
|
+
process.exit(lastExitCode);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (quotaFailures === accounts.length) {
|
|
305
|
+
if (passIndex < maxPasses - 1) {
|
|
306
|
+
process.stderr.write("All accounts hit quota. Rechecking...\n");
|
|
307
|
+
if (retryDelayMs > 0) {
|
|
308
|
+
await delay(retryDelayMs);
|
|
309
|
+
}
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
process.stderr.write("All accounts exhausted due to quota.\n");
|
|
313
|
+
process.exit(lastExitCode);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
function normalizeMaxPasses(value) {
|
|
319
|
+
const parsed = Number.parseInt(value, 10);
|
|
320
|
+
if (Number.isNaN(parsed) || parsed < 1) {
|
|
321
|
+
return 1;
|
|
322
|
+
}
|
|
323
|
+
return parsed;
|
|
324
|
+
}
|
|
325
|
+
function normalizeDelay(value) {
|
|
326
|
+
const parsed = Number.parseFloat(value);
|
|
327
|
+
if (Number.isNaN(parsed) || parsed <= 0) {
|
|
328
|
+
return 0;
|
|
329
|
+
}
|
|
330
|
+
return Math.floor(parsed * 1000);
|
|
331
|
+
}
|
|
332
|
+
function delay(ms) {
|
|
333
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
334
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isCodexLoggedIn = isCodexLoggedIn;
|
|
4
|
+
exports.runCodexLogin = runCodexLogin;
|
|
5
|
+
const child_process_1 = require("child_process");
|
|
6
|
+
function runCodexCommand(codexBin, args, accountDir, stdio) {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
const child = (0, child_process_1.spawn)(codexBin, args, {
|
|
9
|
+
env: { ...process.env, CODEX_HOME: accountDir },
|
|
10
|
+
stdio
|
|
11
|
+
});
|
|
12
|
+
child.on("close", (code) => {
|
|
13
|
+
resolve({ exitCode: code ?? 1 });
|
|
14
|
+
});
|
|
15
|
+
child.on("error", (error) => {
|
|
16
|
+
process.stderr.write(`Failed to start codex: ${error.message}\n`);
|
|
17
|
+
resolve({ exitCode: 1 });
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
async function isCodexLoggedIn(codexBin, accountDir) {
|
|
22
|
+
const result = await runCodexCommand(codexBin, ["login", "status"], accountDir, "ignore");
|
|
23
|
+
return result.exitCode === 0;
|
|
24
|
+
}
|
|
25
|
+
async function runCodexLogin(codexBin, accountDir, useDeviceAuth) {
|
|
26
|
+
const args = useDeviceAuth ? ["login", "--device-auth"] : ["login"];
|
|
27
|
+
const result = await runCodexCommand(codexBin, args, accountDir, "inherit");
|
|
28
|
+
return result.exitCode;
|
|
29
|
+
}
|