@bitkyc08/opencodex 2.7.28 → 2.7.29
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.ko.md +1 -0
- package/README.md +1 -0
- package/README.zh-CN.md +1 -0
- package/bin/ocx.mjs +41 -3
- package/gui/dist/assets/{index-TZysP4q4.js → index-CMKZnkG9.js} +5 -5
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/cli/account-api.ts +266 -0
- package/src/cli/account-extended.ts +282 -0
- package/src/cli/account.ts +265 -0
- package/src/cli/claude.ts +3 -1
- package/src/cli/help.ts +15 -0
- package/src/cli/index.ts +58 -17
- package/src/cli/init.ts +11 -1
- package/src/lib/winsw.ts +14 -3
- package/src/oauth/login-cli.ts +15 -4
- package/src/providers/registry.ts +21 -1
- package/src/server/index.ts +5 -0
- package/src/server/ports.ts +20 -0
- package/src/server/request-log.ts +98 -0
- package/src/service.ts +24 -4
- package/src/update/index.ts +35 -8
- package/src/update/job.ts +34 -7
- package/src/usage/log.ts +98 -1
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/** `ocx account` — list and switch provider credentials (issue #180). */
|
|
2
|
+
import { loadConfig } from "../config";
|
|
3
|
+
import { providerCodexAccountMode } from "../providers/registry";
|
|
4
|
+
import type { OcxConfig } from "../types";
|
|
5
|
+
import { cmdAddKey, cmdAutoSwitch, cmdRefresh, cmdRemove } from "./account-extended";
|
|
6
|
+
import { apiError, apiJson, classifyAccount, fetchRows, proxyUnreachable, resolveBaseUrl, type AccountDeps, type AccountRow, type AccountType, type ApiResult }
|
|
7
|
+
from "./account-api";
|
|
8
|
+
|
|
9
|
+
export { classifyAccount } from "./account-api";
|
|
10
|
+
export type { AccountDeps, AccountRow, AccountType, ClassifyResult } from "./account-api";
|
|
11
|
+
type TargetProvenance = "live-oauth-list" | "config" | "codex";
|
|
12
|
+
|
|
13
|
+
const MAIN_ALIAS = "main";
|
|
14
|
+
const MAIN_CODEX_ID = "__main__";
|
|
15
|
+
/** Replacement-style single-slot OAuth (no stable identity; not HTTP-derivable). */
|
|
16
|
+
const REPLACEMENT_STYLE_OAUTH = new Set(["kiro"]);
|
|
17
|
+
|
|
18
|
+
const ACCOUNT_USAGE = `Usage:
|
|
19
|
+
ocx account list [provider] [--json] [--all]
|
|
20
|
+
ocx account current <provider> [--json]
|
|
21
|
+
ocx account use <provider> <account-or-key-id|main> [--json]
|
|
22
|
+
ocx account refresh <provider> [--json]
|
|
23
|
+
ocx account auto-switch <provider> <on|off|status|threshold <0-100>> [--json]
|
|
24
|
+
ocx account remove <provider> <account-or-key-id|main> --yes [--json]
|
|
25
|
+
ocx account add-key <provider> [--label <label>] [--json]
|
|
26
|
+
|
|
27
|
+
List and switch provider accounts and API-key pools (masked output only).
|
|
28
|
+
'main' selects the Codex App login for the openai account pool.`;
|
|
29
|
+
|
|
30
|
+
function consumeFlag(args: string[], flag: string): boolean {
|
|
31
|
+
const idx = args.indexOf(flag);
|
|
32
|
+
if (idx === -1) return false;
|
|
33
|
+
args.splice(idx, 1);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Returns an error message for leftover args, or null when clean. */
|
|
38
|
+
function leftoverArgsError(args: string[]): string | null {
|
|
39
|
+
if (args.length === 0) return null;
|
|
40
|
+
const unknown = args.filter(a => a.startsWith("-"));
|
|
41
|
+
return unknown.length > 0
|
|
42
|
+
? `Unknown flag(s): ${unknown.join(", ")}`
|
|
43
|
+
: `Unexpected argument(s): ${args.join(", ")}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function candidateNames(config: OcxConfig): string {
|
|
47
|
+
const names = new Set<string>(["openai"]);
|
|
48
|
+
for (const n of Object.keys(config.providers ?? {})) names.add(n);
|
|
49
|
+
return [...names].join(", ");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function displayId(id: string): string {
|
|
53
|
+
return id === MAIN_CODEX_ID ? MAIN_ALIAS : id;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function statusText(row: AccountRow): string {
|
|
57
|
+
const parts: string[] = [];
|
|
58
|
+
if (row.active) parts.push(row.type === "codex" ? "next session" : "active");
|
|
59
|
+
if (row.needsReauth) parts.push("needs-reauth");
|
|
60
|
+
return parts.join(" ");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function formatAccountTable(rows: AccountRow[]): string {
|
|
64
|
+
const header = ["PROVIDER", "TYPE", "ID", "PLAN/LABEL", "STATUS"];
|
|
65
|
+
const data = rows.map(r => {
|
|
66
|
+
const keyLabel = r.masked && r.label !== r.masked ? `${r.masked} (${r.label})` : r.masked;
|
|
67
|
+
return [r.provider, r.type, displayId(r.id), r.type === "api-key" ? keyLabel ?? "-" : r.label ?? "-", statusText(r)];
|
|
68
|
+
});
|
|
69
|
+
const widths = header.map((h, i) => Math.max(h.length, ...data.map(d => d[i]!.length)));
|
|
70
|
+
const line = (cols: string[]) => cols.map((c, i) => c.padEnd(widths[i]!)).join(" ").trimEnd();
|
|
71
|
+
return [line(header), ...data.map(line)].join("\n");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function cmdList(rest: string[], deps: AccountDeps): Promise<number> {
|
|
75
|
+
const wantsJson = consumeFlag(rest, "--json");
|
|
76
|
+
const showAll = consumeFlag(rest, "--all");
|
|
77
|
+
const name = rest.shift();
|
|
78
|
+
const leftover = leftoverArgsError(rest);
|
|
79
|
+
if (leftover) {
|
|
80
|
+
console.error(leftover);
|
|
81
|
+
console.error(ACCOUNT_USAGE);
|
|
82
|
+
return 1;
|
|
83
|
+
}
|
|
84
|
+
const config = deps.loadConfigImpl?.() ?? loadConfig();
|
|
85
|
+
const baseUrl = await resolveBaseUrl(deps);
|
|
86
|
+
if (!baseUrl) return proxyUnreachable();
|
|
87
|
+
|
|
88
|
+
const targets: { name: string; type: AccountType; provenance: TargetProvenance }[] = [];
|
|
89
|
+
if (name) {
|
|
90
|
+
const c = classifyAccount(config, name);
|
|
91
|
+
if ("error" in c) {
|
|
92
|
+
console.error(`Error: ${c.error}. Known candidates: ${candidateNames(config)}`);
|
|
93
|
+
return 1;
|
|
94
|
+
}
|
|
95
|
+
targets.push({ name, type: c.type, provenance: "config" });
|
|
96
|
+
} else {
|
|
97
|
+
const seen = new Set<string>();
|
|
98
|
+
const push = (n: string, provenance: TargetProvenance) => {
|
|
99
|
+
if (seen.has(n)) return;
|
|
100
|
+
seen.add(n);
|
|
101
|
+
const c = classifyAccount(config, n);
|
|
102
|
+
if ("error" in c) return; // fan-out silently skips no-credential providers
|
|
103
|
+
targets.push({ name: n, type: c.type, provenance });
|
|
104
|
+
};
|
|
105
|
+
push("openai", "codex");
|
|
106
|
+
const providersRes = await apiJson(deps, baseUrl, "GET", "/api/oauth/providers");
|
|
107
|
+
if (providersRes.status === 0) return proxyUnreachable();
|
|
108
|
+
if (providersRes.status !== 200) return apiError(providersRes.json, "failed to list OAuth providers");
|
|
109
|
+
if (Array.isArray(providersRes.json.providers)) {
|
|
110
|
+
for (const p of providersRes.json.providers) {
|
|
111
|
+
if (typeof p === "string") push(p, "live-oauth-list");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const n of Object.keys(config.providers ?? {})) push(n, "config");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const rows: AccountRow[] = [];
|
|
118
|
+
const notes: string[] = [];
|
|
119
|
+
for (const t of targets) {
|
|
120
|
+
const r = await fetchRows(deps, baseUrl, t.name, t.type);
|
|
121
|
+
if (r.networkDown) return proxyUnreachable();
|
|
122
|
+
if (r.errorJson) {
|
|
123
|
+
if (name) return apiError(r.errorJson, `failed to list ${t.name}`);
|
|
124
|
+
const errorText = typeof r.errorJson.error === "string" ? r.errorJson.error : "";
|
|
125
|
+
const skipUnknownKey = t.type === "api-key"
|
|
126
|
+
&& r.status === 404
|
|
127
|
+
&& errorText.includes("unknown provider");
|
|
128
|
+
const skipConfigOAuth = t.type === "oauth"
|
|
129
|
+
&& t.provenance === "config"
|
|
130
|
+
&& r.status === 400
|
|
131
|
+
&& errorText.includes("unknown oauth provider");
|
|
132
|
+
if (skipUnknownKey || skipConfigOAuth) continue;
|
|
133
|
+
return apiError(r.errorJson, `failed to list ${t.name}`);
|
|
134
|
+
}
|
|
135
|
+
if (r.rows.length === 0) {
|
|
136
|
+
if (showAll) notes.push(`${t.name}: no stored accounts or keys`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
rows.push(...r.rows);
|
|
140
|
+
if (t.type === "codex") {
|
|
141
|
+
if (r.activeId === null) notes.push("openai: auto (no pin — lowest-usage account is selected per request)");
|
|
142
|
+
if (providerCodexAccountMode("openai", config.providers?.openai) === "direct") {
|
|
143
|
+
notes.push("openai is in direct mode — the selection takes effect when pool mode is enabled");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (t.type === "oauth" && REPLACEMENT_STYLE_OAUTH.has(t.name)) {
|
|
147
|
+
notes.push(`${t.name}: single login slot — re-login replaces the current account`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (wantsJson) {
|
|
152
|
+
console.log(JSON.stringify({ accounts: rows, notes }, null, 2));
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
if (rows.length > 0) console.log(formatAccountTable(rows));
|
|
156
|
+
for (const n of notes) console.log(n);
|
|
157
|
+
if (rows.length === 0 && notes.length === 0) console.log("No stored accounts or keys.");
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function cmdCurrent(rest: string[], deps: AccountDeps): Promise<number> {
|
|
162
|
+
const wantsJson = consumeFlag(rest, "--json");
|
|
163
|
+
const name = rest.shift();
|
|
164
|
+
const leftover = leftoverArgsError(rest);
|
|
165
|
+
if (!name || leftover) {
|
|
166
|
+
if (leftover) console.error(leftover);
|
|
167
|
+
console.error(ACCOUNT_USAGE);
|
|
168
|
+
return 1;
|
|
169
|
+
}
|
|
170
|
+
const config = deps.loadConfigImpl?.() ?? loadConfig();
|
|
171
|
+
const c = classifyAccount(config, name);
|
|
172
|
+
if ("error" in c) {
|
|
173
|
+
console.error(`Error: ${c.error}. Known candidates: ${candidateNames(config)}`);
|
|
174
|
+
return 1;
|
|
175
|
+
}
|
|
176
|
+
const baseUrl = await resolveBaseUrl(deps);
|
|
177
|
+
if (!baseUrl) return proxyUnreachable();
|
|
178
|
+
const r = await fetchRows(deps, baseUrl, name, c.type);
|
|
179
|
+
if (r.networkDown) return proxyUnreachable();
|
|
180
|
+
if (r.errorJson) return apiError(r.errorJson, `failed to read ${name}`);
|
|
181
|
+
|
|
182
|
+
const activeRow = r.rows.find(row => row.active) ?? null;
|
|
183
|
+
if (wantsJson) {
|
|
184
|
+
console.log(JSON.stringify({
|
|
185
|
+
provider: name,
|
|
186
|
+
type: c.type,
|
|
187
|
+
activeId: r.activeId,
|
|
188
|
+
autoSwitchThreshold: r.autoSwitchThreshold,
|
|
189
|
+
account: activeRow,
|
|
190
|
+
}, null, 2));
|
|
191
|
+
return 0;
|
|
192
|
+
}
|
|
193
|
+
if (activeRow) {
|
|
194
|
+
console.log(formatAccountTable([activeRow]));
|
|
195
|
+
} else if (c.type === "codex" && r.activeId === null) {
|
|
196
|
+
console.log("openai: auto (no pin — lowest-usage account is selected per request)");
|
|
197
|
+
} else {
|
|
198
|
+
console.log(`${name}: no active account or key`);
|
|
199
|
+
}
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function cmdUse(rest: string[], deps: AccountDeps): Promise<number> {
|
|
204
|
+
const wantsJson = consumeFlag(rest, "--json");
|
|
205
|
+
const name = rest.shift();
|
|
206
|
+
const id = rest.shift();
|
|
207
|
+
const leftover = leftoverArgsError(rest);
|
|
208
|
+
if (!name || !id || leftover) {
|
|
209
|
+
if (leftover) console.error(leftover);
|
|
210
|
+
console.error(ACCOUNT_USAGE);
|
|
211
|
+
return 1;
|
|
212
|
+
}
|
|
213
|
+
const config = deps.loadConfigImpl?.() ?? loadConfig();
|
|
214
|
+
const c = classifyAccount(config, name);
|
|
215
|
+
if ("error" in c) {
|
|
216
|
+
console.error(`Error: ${c.error}. Known candidates: ${candidateNames(config)}`);
|
|
217
|
+
return 1;
|
|
218
|
+
}
|
|
219
|
+
const baseUrl = await resolveBaseUrl(deps);
|
|
220
|
+
if (!baseUrl) return proxyUnreachable();
|
|
221
|
+
|
|
222
|
+
let res: ApiResult;
|
|
223
|
+
let activeId: string;
|
|
224
|
+
if (c.type === "codex") {
|
|
225
|
+
activeId = id === MAIN_ALIAS ? MAIN_CODEX_ID : id;
|
|
226
|
+
res = await apiJson(deps, baseUrl, "PUT", "/api/codex-auth/active", { accountId: activeId });
|
|
227
|
+
} else if (c.type === "oauth") {
|
|
228
|
+
activeId = id;
|
|
229
|
+
res = await apiJson(deps, baseUrl, "PUT", "/api/oauth/accounts/active", { provider: name, accountId: id });
|
|
230
|
+
} else {
|
|
231
|
+
activeId = id;
|
|
232
|
+
res = await apiJson(deps, baseUrl, "PUT", "/api/providers/keys/active", { name, id });
|
|
233
|
+
}
|
|
234
|
+
if (res.status === 0) return proxyUnreachable();
|
|
235
|
+
if (res.status !== 200) return apiError(res.json, `failed to switch ${name}`);
|
|
236
|
+
|
|
237
|
+
if (wantsJson) console.log(JSON.stringify({ ok: true, provider: name, type: c.type, activeId }, null, 2));
|
|
238
|
+
else console.log(`${name}: active ${c.type === "api-key" ? "key" : "account"} is now ${displayId(activeId)}`);
|
|
239
|
+
if (c.type === "codex") {
|
|
240
|
+
console.error("Applies to new Codex sessions; running threads keep their current account.");
|
|
241
|
+
const active = await apiJson(deps, baseUrl, "GET", "/api/codex-auth/active");
|
|
242
|
+
if (active.status === 200 && typeof active.json.autoSwitchThreshold === "number" && active.json.autoSwitchThreshold > 0) {
|
|
243
|
+
console.error(`Note: auto-switch (threshold ${active.json.autoSwitchThreshold}%) may override this pin.`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export async function cmdAccount(args: string[], deps: AccountDeps = {}): Promise<number> {
|
|
250
|
+
const [sub, ...rest] = args;
|
|
251
|
+
try {
|
|
252
|
+
if (sub === "list") return await cmdList(rest, deps);
|
|
253
|
+
if (sub === "current") return await cmdCurrent(rest, deps);
|
|
254
|
+
if (sub === "use") return await cmdUse(rest, deps);
|
|
255
|
+
if (sub === "refresh") return await cmdRefresh(rest, deps);
|
|
256
|
+
if (sub === "auto-switch") return await cmdAutoSwitch(rest, deps);
|
|
257
|
+
if (sub === "remove") return await cmdRemove(rest, deps);
|
|
258
|
+
if (sub === "add-key") return await cmdAddKey(rest, deps);
|
|
259
|
+
console.error(ACCOUNT_USAGE);
|
|
260
|
+
return 1;
|
|
261
|
+
} catch (err) {
|
|
262
|
+
console.error(`account: ${err instanceof Error ? err.message : String(err)}`);
|
|
263
|
+
return 1;
|
|
264
|
+
}
|
|
265
|
+
}
|
package/src/cli/claude.ts
CHANGED
|
@@ -135,7 +135,9 @@ export async function fetchClaudeContextWindows(config: OcxConfig, port: number,
|
|
|
135
135
|
async function ensureProxyForClaude(): Promise<number | null> {
|
|
136
136
|
const live = await findLiveProxy();
|
|
137
137
|
if (live) return live.port;
|
|
138
|
-
const
|
|
138
|
+
const cfgPort = loadConfig().port;
|
|
139
|
+
const pinPort = typeof cfgPort === "number" && cfgPort > 0 ? cfgPort : 10100;
|
|
140
|
+
const child = spawn(process.execPath, [process.argv[1], "start", "--port", String(pinPort)], {
|
|
139
141
|
detached: true,
|
|
140
142
|
stdio: "ignore",
|
|
141
143
|
windowsHide: true,
|
package/src/cli/help.ts
CHANGED
|
@@ -79,6 +79,20 @@ const helpEntries: Record<string, HelpEntry> = {
|
|
|
79
79
|
"Run `ocx provider --help` for full usage and examples.",
|
|
80
80
|
],
|
|
81
81
|
},
|
|
82
|
+
account: {
|
|
83
|
+
usage: "ocx account <list|current|use|refresh|auto-switch|remove|add-key> ...",
|
|
84
|
+
summary: "List and switch provider accounts and API-key pools (GUI parity).",
|
|
85
|
+
details: [
|
|
86
|
+
"list [provider] Codex account pool, OAuth accounts and API keys (identifiers shown masked as the API returns them).",
|
|
87
|
+
"current <provider> Show the active account or key.",
|
|
88
|
+
"use <provider> <id> Switch the active credential; 'main' selects the Codex App login.",
|
|
89
|
+
"refresh <provider> Force-refresh Codex or provider quota reports.",
|
|
90
|
+
"auto-switch <provider> <on|off|status|threshold N> Control the Codex pool threshold.",
|
|
91
|
+
"remove <provider> <id> --yes Remove a stored account or key after an existence check.",
|
|
92
|
+
"add-key <provider> [--label <label>] Add a key read only from piped stdin.",
|
|
93
|
+
"Codex pool switches apply to new sessions; running threads keep their account.",
|
|
94
|
+
],
|
|
95
|
+
},
|
|
82
96
|
models: {
|
|
83
97
|
usage: "ocx models [--provider <name>] [--json]",
|
|
84
98
|
summary: "List available models from configured providers.",
|
|
@@ -144,6 +158,7 @@ Usage:
|
|
|
144
158
|
ocx restart Stop and restart the proxy
|
|
145
159
|
ocx health [--json] Check proxy health (exit 0=healthy, 1=not)
|
|
146
160
|
ocx provider <sub> Manage providers (list|add|remove|show|set-default)
|
|
161
|
+
ocx account <sub> Accounts/keys (list|current|use|refresh|auto-switch|remove|add-key)
|
|
147
162
|
ocx models [--json] List available models from configured providers
|
|
148
163
|
ocx claude [args...] Launch Claude Code wired to the proxy (model discovery on)
|
|
149
164
|
ocx help [command] Show help
|
package/src/cli/index.ts
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
import { collectStatus } from "./status";
|
|
23
23
|
import { installCrashGuards } from "../lib/crash-guard";
|
|
24
24
|
import { hasHelpFlag, printSubcommandUsage, printUsage, printVersion } from "./help";
|
|
25
|
-
import { findAvailablePort, isAddrInUse, shouldPersistSelectedPort } from "../server/ports";
|
|
25
|
+
import { findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports";
|
|
26
26
|
import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness";
|
|
27
27
|
import { stopProxy } from "../lib/process-control";
|
|
28
28
|
import { loadServiceTokenFromFile } from "../lib/service-secrets";
|
|
@@ -85,23 +85,43 @@ async function waitForProxy(timeoutMs = 8_000): Promise<LiveProxy | null> {
|
|
|
85
85
|
return null;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/** Argv for detached `start`, optionally hard-pinning the listen port. */
|
|
89
|
+
function startArgv(port?: number): string[] {
|
|
90
|
+
const args = [process.argv[1], "start"];
|
|
91
|
+
if (typeof port === "number" && Number.isFinite(port) && port > 0 && port <= 65535) {
|
|
92
|
+
args.push("--port", String(Math.trunc(port)));
|
|
93
|
+
}
|
|
94
|
+
return args;
|
|
95
|
+
}
|
|
96
|
+
|
|
88
97
|
async function chooseListenPort(requestedPort?: number): Promise<number> {
|
|
89
98
|
const config = loadConfig();
|
|
90
99
|
const preferred = requestedPort ?? config.port ?? 10100;
|
|
91
|
-
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
const hardPin = requestedPort !== undefined && requestedPort > 0;
|
|
101
|
+
// Soft start: brief prefer-retry then ephemeral hop.
|
|
102
|
+
// Explicit `--port` (service wrappers / update restart): longer prefer-retry, never hop.
|
|
103
|
+
try {
|
|
104
|
+
const selected = await findAvailablePort(preferred, config.hostname ?? "127.0.0.1", {
|
|
105
|
+
preferRetryMs: hardPin ? 8_000 : 750,
|
|
106
|
+
preferRetryIntervalMs: 50,
|
|
107
|
+
allowEphemeralFallback: !hardPin,
|
|
108
|
+
});
|
|
109
|
+
if (selected !== preferred) {
|
|
110
|
+
console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`);
|
|
111
|
+
}
|
|
112
|
+
if (shouldPersistSelectedPort(config.port, selected, preferred)) {
|
|
113
|
+
config.port = selected;
|
|
114
|
+
saveConfig(config);
|
|
115
|
+
}
|
|
116
|
+
return selected;
|
|
117
|
+
} catch (err) {
|
|
118
|
+
if (err instanceof PortUnavailableError) {
|
|
119
|
+
console.error(`❌ ${err.message}`);
|
|
120
|
+
console.error(" Stop whatever holds that port, or change config.port, then retry.");
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
throw err;
|
|
103
124
|
}
|
|
104
|
-
return selected;
|
|
105
125
|
}
|
|
106
126
|
|
|
107
127
|
async function handleStart(options: { block?: boolean } = {}) {
|
|
@@ -128,7 +148,8 @@ async function handleStart(options: { block?: boolean } = {}) {
|
|
|
128
148
|
await maybeShowUpdatePrompt();
|
|
129
149
|
|
|
130
150
|
// Port selection is check-then-bind: a concurrent `ocx start`/`ensure` can win the port
|
|
131
|
-
// between the probe and Bun.serve.
|
|
151
|
+
// between the probe and Bun.serve. Soft starts may re-pick; hard-pinned `--port` retries
|
|
152
|
+
// the same port only (never hop — that was the remaining PR #152 gap).
|
|
132
153
|
let port = await chooseListenPort(requestedPort);
|
|
133
154
|
let server: ReturnType<typeof startServer>;
|
|
134
155
|
for (let attempt = 0; ; attempt++) {
|
|
@@ -137,6 +158,16 @@ async function handleStart(options: { block?: boolean } = {}) {
|
|
|
137
158
|
break;
|
|
138
159
|
} catch (err) {
|
|
139
160
|
if (!isAddrInUse(err) || attempt >= 2) throw err;
|
|
161
|
+
if (requestedPort !== undefined) {
|
|
162
|
+
console.log(`⚠️ Port ${port} was taken while starting; waiting to retry the same port...`);
|
|
163
|
+
const hostname = loadConfig().hostname ?? "127.0.0.1";
|
|
164
|
+
const freed = await waitForPortAvailable(port, hostname, { timeoutMs: 3_000, intervalMs: 50 });
|
|
165
|
+
if (!freed) {
|
|
166
|
+
console.error(`❌ Port ${port} stayed busy; refusing to hop to an ephemeral port.`);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
140
171
|
console.log(`⚠️ Port ${port} was taken while starting; picking another...`);
|
|
141
172
|
port = await chooseListenPort(requestedPort);
|
|
142
173
|
}
|
|
@@ -249,7 +280,8 @@ async function handleEnsure() {
|
|
|
249
280
|
return;
|
|
250
281
|
}
|
|
251
282
|
|
|
252
|
-
const
|
|
283
|
+
const pinPort = config.port ?? 10100;
|
|
284
|
+
const child = spawn(process.execPath, startArgv(pinPort > 0 ? pinPort : undefined), {
|
|
253
285
|
detached: true,
|
|
254
286
|
stdio: "ignore",
|
|
255
287
|
windowsHide: true,
|
|
@@ -537,7 +569,7 @@ switch (command) {
|
|
|
537
569
|
let live = await findLiveProxy();
|
|
538
570
|
if (!live) {
|
|
539
571
|
console.log("Proxy not running. Starting...");
|
|
540
|
-
const child = spawn(process.execPath,
|
|
572
|
+
const child = spawn(process.execPath, startArgv((config.port ?? 10100) > 0 ? (config.port ?? 10100) : undefined), {
|
|
541
573
|
detached: true,
|
|
542
574
|
stdio: "ignore",
|
|
543
575
|
windowsHide: true,
|
|
@@ -545,6 +577,10 @@ switch (command) {
|
|
|
545
577
|
});
|
|
546
578
|
child.unref();
|
|
547
579
|
live = await waitForProxy();
|
|
580
|
+
if (!live) {
|
|
581
|
+
console.error("❌ Proxy did not become healthy after starting. Not opening the GUI.");
|
|
582
|
+
process.exit(1);
|
|
583
|
+
}
|
|
548
584
|
}
|
|
549
585
|
// Open the host the proxy actually binds — `localhost` only answers for
|
|
550
586
|
// loopback/wildcard binds, not a concrete LAN/IPv6 hostname.
|
|
@@ -628,6 +664,11 @@ switch (command) {
|
|
|
628
664
|
await handleProviderCommand(args.slice(1));
|
|
629
665
|
break;
|
|
630
666
|
}
|
|
667
|
+
case "account": {
|
|
668
|
+
const { cmdAccount } = await import("./account");
|
|
669
|
+
process.exitCode = await cmdAccount(args.slice(1));
|
|
670
|
+
break;
|
|
671
|
+
}
|
|
631
672
|
case "models": {
|
|
632
673
|
const { handleModels } = await import("./models");
|
|
633
674
|
handleModels(args.slice(1));
|
package/src/cli/init.ts
CHANGED
|
@@ -82,6 +82,16 @@ export async function runInit(): Promise<void> {
|
|
|
82
82
|
} else {
|
|
83
83
|
// key + local: collect a key (local usually blank).
|
|
84
84
|
if (p.dashboardUrl) console.log(` 🔑 Get your key: ${p.dashboardUrl}`);
|
|
85
|
+
// Template URL with placeholders (e.g. Cloudflare's {account_id}) needs a resolved value.
|
|
86
|
+
let baseUrl = p.baseUrl;
|
|
87
|
+
if (/\{[^}]*\}/.test(baseUrl)) {
|
|
88
|
+
const resolved = (await prompt.ask(` Your endpoint URL (${baseUrl}): `)).trim();
|
|
89
|
+
if (!resolved) {
|
|
90
|
+
console.error(" A resolved URL is required — replace the {placeholder} with your actual value.");
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
baseUrl = resolved;
|
|
94
|
+
}
|
|
85
95
|
const env = envKeyFor(p.id);
|
|
86
96
|
const hint = p.kind === "local" ? "API key (usually blank — press Enter): " : `API key (paste, or env var $${env}): `;
|
|
87
97
|
const apiKey = (await prompt.ask(`\n${hint}`)).trim();
|
|
@@ -89,7 +99,7 @@ export async function runInit(): Promise<void> {
|
|
|
89
99
|
const defaultModel = modelChoice || p.defaultModel;
|
|
90
100
|
providerConfig = {
|
|
91
101
|
adapter: p.adapter,
|
|
92
|
-
baseUrl
|
|
102
|
+
baseUrl,
|
|
93
103
|
...(p.kind === "key" ? { apiKey: apiKey || `\${${env}}` } : apiKey ? { apiKey } : {}),
|
|
94
104
|
...(defaultModel ? { defaultModel } : {}),
|
|
95
105
|
};
|
package/src/lib/winsw.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { execFileSync } from "node:child_process";
|
|
|
18
18
|
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
19
19
|
import { homedir } from "node:os";
|
|
20
20
|
import { join, resolve } from "node:path";
|
|
21
|
-
import { expandUserPath, getConfigDir } from "../config";
|
|
21
|
+
import { expandUserPath, getConfigDir, loadConfig } from "../config";
|
|
22
22
|
import { durableBunPath } from "./bun-runtime";
|
|
23
23
|
import { serviceApiTokenFilePath } from "./service-secrets";
|
|
24
24
|
|
|
@@ -72,9 +72,20 @@ export interface WinswEntry {
|
|
|
72
72
|
* Task Scheduler wrapper / launchd / systemd: the SCM service environment lacks the
|
|
73
73
|
* user's interactive PATH, which provider subprocesses may need.
|
|
74
74
|
*/
|
|
75
|
-
export function buildWinswXml(entry: WinswEntry, env: NodeJS.ProcessEnv = process.env): string {
|
|
75
|
+
export function buildWinswXml(entry: WinswEntry, env: NodeJS.ProcessEnv = process.env, port?: number): string {
|
|
76
76
|
const domain = env.USERDOMAIN?.trim() || ".";
|
|
77
77
|
const user = env.USERNAME?.trim() || "";
|
|
78
|
+
const listenPort = (() => {
|
|
79
|
+
if (typeof port === "number" && Number.isFinite(port) && port > 0 && port <= 65535) return Math.trunc(port);
|
|
80
|
+
const baked = env.OCX_BAKE_PORT?.trim();
|
|
81
|
+
if (baked && /^\d+$/.test(baked)) {
|
|
82
|
+
const n = Number(baked);
|
|
83
|
+
if (n > 0 && n <= 65535) return n;
|
|
84
|
+
}
|
|
85
|
+
return loadConfig().port ?? 10100;
|
|
86
|
+
})();
|
|
87
|
+
// Services never bake `--port 0` (parsePortOption rejects it); treat as default.
|
|
88
|
+
const safeListenPort = listenPort > 0 && listenPort <= 65535 ? listenPort : 10100;
|
|
78
89
|
const envLines = [
|
|
79
90
|
` <env name="OCX_SERVICE" value="1"/>`,
|
|
80
91
|
` <env name="OCX_API_TOKEN_FILE" value="${xmlEscape(serviceApiTokenFilePath())}"/>`,
|
|
@@ -88,7 +99,7 @@ export function buildWinswXml(entry: WinswEntry, env: NodeJS.ProcessEnv = proces
|
|
|
88
99
|
<name>OpenCodex Proxy (native)</name>
|
|
89
100
|
<description>OpenCodex proxy running as a native Windows service (windowless, starts at boot).</description>
|
|
90
101
|
<executable>${xmlEscape(entry.bun)}</executable>
|
|
91
|
-
<arguments>${xmlEscape(`"${entry.cli}" start`)}</arguments>
|
|
102
|
+
<arguments>${xmlEscape(`"${entry.cli}" start --port ${safeListenPort}`)}</arguments>
|
|
92
103
|
${envLines.join("\n")}
|
|
93
104
|
<logpath>${xmlEscape(winswLogDir())}</logpath>
|
|
94
105
|
<log mode="roll-by-size">
|
package/src/oauth/login-cli.ts
CHANGED
|
@@ -62,10 +62,10 @@ async function handleOAuthLogin(name: string): Promise<void> {
|
|
|
62
62
|
console.log(`\n✅ Logged in to ${name}. Try: ocx sync`);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
export function providerConfigFromKeyLoginProvider(def: KeyLoginProvider, key: string): OcxProviderConfig {
|
|
65
|
+
export function providerConfigFromKeyLoginProvider(def: KeyLoginProvider, key: string, baseUrlOverride?: string): OcxProviderConfig {
|
|
66
66
|
return {
|
|
67
67
|
adapter: def.adapter,
|
|
68
|
-
baseUrl: def.baseUrl,
|
|
68
|
+
baseUrl: baseUrlOverride ?? def.baseUrl,
|
|
69
69
|
apiKey: key,
|
|
70
70
|
...(def.defaultModel ? { defaultModel: def.defaultModel } : {}),
|
|
71
71
|
...(def.models ? { models: [...def.models] } : {}),
|
|
@@ -94,19 +94,30 @@ async function handleKeyLogin(name: string): Promise<void> {
|
|
|
94
94
|
openUrl(def.dashboardUrl);
|
|
95
95
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
96
96
|
const key = (await new Promise<string>((res) => rl.question(`Paste your ${def.label} API key: `, res))).trim();
|
|
97
|
+
// Template URL with placeholders needs resolution before saving.
|
|
98
|
+
let baseUrl = def.baseUrl;
|
|
99
|
+
if (/\{[^}]*\}/.test(baseUrl)) {
|
|
100
|
+
const resolved = (await new Promise<string>((res) => rl.question(`Your endpoint URL (${baseUrl}): `, res))).trim();
|
|
101
|
+
if (!resolved) {
|
|
102
|
+
rl.close();
|
|
103
|
+
console.error("A resolved URL is required — replace the {placeholder} with your actual value.");
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
baseUrl = resolved;
|
|
107
|
+
}
|
|
97
108
|
rl.close();
|
|
98
109
|
if (!key) {
|
|
99
110
|
console.error("No key entered.");
|
|
100
111
|
process.exit(1);
|
|
101
112
|
}
|
|
102
113
|
process.stdout.write(" validating… ");
|
|
103
|
-
const valid = await validateApiKey(def, key);
|
|
114
|
+
const valid = await validateApiKey({ ...def, baseUrl }, key);
|
|
104
115
|
console.log(valid === true ? "valid ✅" : valid === false ? "INVALID ❌" : "couldn't validate (may still work)");
|
|
105
116
|
if (valid === false) {
|
|
106
117
|
console.error("Provider rejected the key. Not saved.");
|
|
107
118
|
process.exit(1);
|
|
108
119
|
}
|
|
109
|
-
const provider = providerConfigFromKeyLoginProvider(def, key);
|
|
120
|
+
const provider = providerConfigFromKeyLoginProvider(def, key, baseUrl);
|
|
110
121
|
const config = loadConfig();
|
|
111
122
|
config.providers[name] = provider;
|
|
112
123
|
saveConfig(config);
|
|
@@ -691,6 +691,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
691
691
|
id: "alibaba-token-plan",
|
|
692
692
|
label: "Alibaba Token Plan (Beijing)",
|
|
693
693
|
baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
|
|
694
|
+
allowBaseUrlOverride: true,
|
|
694
695
|
adapter: "openai-chat",
|
|
695
696
|
authKind: "key",
|
|
696
697
|
dashboardUrl: "https://bailian.console.aliyun.com/cn-beijing?tab=plan",
|
|
@@ -706,7 +707,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
706
707
|
},
|
|
707
708
|
modelReasoningEffortMap: { "deepseek-v4-pro": DEEPSEEK_THINKING_REASONING_MAP },
|
|
708
709
|
thinkingBudgetModels: ALIBABA_TOKEN_PLAN_QWEN_MODELS,
|
|
709
|
-
preserveReasoningContentModels: ["glm-5.2", "deepseek-v4-pro"],
|
|
710
|
+
preserveReasoningContentModels: ["glm-5.2", "deepseek-v4-pro", "qwen3.8-max-preview"],
|
|
710
711
|
},
|
|
711
712
|
// NEEDS_HUMAN 2026-07-10: kept for config compatibility, but this is a dashboard URL,
|
|
712
713
|
// no /models endpoint is documented, and tools are silently ignored upstream per docs.parallel.ai.
|
|
@@ -816,6 +817,25 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
816
817
|
note: "No key needed — uses Xiaomi MiMo's free public tier (limited-time offer). A JWT is bootstrapped automatically with an anonymous random client id stored locally. The endpoint contract mirrors the official MiMoCode client and is not publicly documented — Xiaomi may change or restrict it at any time. Prompts may be processed/retained by Xiaomi; do not send confidential material.",
|
|
817
818
|
},
|
|
818
819
|
{ id: "cloudflare-ai-gateway", label: "Cloudflare AI Gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://dash.cloudflare.com/?to=/:account/ai/ai-gateway" },
|
|
820
|
+
{
|
|
821
|
+
// Cloudflare Workers AI: OpenAI-compatible endpoint. The base URL contains {account_id}
|
|
822
|
+
// which must be resolved by the user at setup time. Model IDs use the @cf/ prefix.
|
|
823
|
+
// Live-verified 2026-07-21 against https://developers.cloudflare.com/workers-ai/models/
|
|
824
|
+
id: "cloudflare-workers-ai", label: "Cloudflare Workers AI",
|
|
825
|
+
baseUrl: "https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1",
|
|
826
|
+
adapter: "openai-chat", authKind: "key", freeTier: true,
|
|
827
|
+
dashboardUrl: "https://dash.cloudflare.com/?to=/:account/ai/workers-ai",
|
|
828
|
+
defaultModel: "@cf/meta/llama-3.3-70b-instruct-fp8-fast",
|
|
829
|
+
models: [
|
|
830
|
+
"@cf/meta/llama-3.3-70b-instruct-fp8-fast",
|
|
831
|
+
"@cf/qwen/qwq-32b",
|
|
832
|
+
"@cf/deepseek-ai/deepseek-r1-distill-qwen-32b",
|
|
833
|
+
"@cf/moonshotai/kimi-k2.7-code",
|
|
834
|
+
"@cf/zai-org/glm-5.2",
|
|
835
|
+
"@cf/mistralai/mistral-small-3.1-24b-instruct",
|
|
836
|
+
],
|
|
837
|
+
note: "Workers AI · Free tier included · Account ID required in base URL",
|
|
838
|
+
},
|
|
819
839
|
// FREEZE 2026-07-10: /models was auth-gated under key login. OAuth device-flow + copilot_internal
|
|
820
840
|
// exchange (issue #151) unlocks live discovery; static seed is a cold-start fallback only.
|
|
821
841
|
{
|
package/src/server/index.ts
CHANGED
|
@@ -54,6 +54,7 @@ export {
|
|
|
54
54
|
} from "./lifecycle";
|
|
55
55
|
import {
|
|
56
56
|
addFinalRequestLog,
|
|
57
|
+
hydrateRequestLogsFromDisk,
|
|
57
58
|
httpStatusForRequestLogTerminal,
|
|
58
59
|
httpStatusForTerminalStatus,
|
|
59
60
|
inspectResponseLogSsePayload,
|
|
@@ -65,6 +66,7 @@ import {
|
|
|
65
66
|
export {
|
|
66
67
|
addFinalRequestLog,
|
|
67
68
|
filterRequestLogs,
|
|
69
|
+
hydrateRequestLogsFromDisk,
|
|
68
70
|
httpStatusForTerminalStatus,
|
|
69
71
|
httpStatusFromTerminalError,
|
|
70
72
|
nextRequestLogId,
|
|
@@ -175,6 +177,9 @@ export function startServer(port?: number) {
|
|
|
175
177
|
}
|
|
176
178
|
}
|
|
177
179
|
invalidateCodexModelsCache();
|
|
180
|
+
// usage.jsonl already persists every request; rehydrate the in-memory Logs ring so
|
|
181
|
+
// /api/logs (and the GUI) survive `ocx stop` / `ocx start` process restarts.
|
|
182
|
+
hydrateRequestLogsFromDisk();
|
|
178
183
|
|
|
179
184
|
const listenPort = port ?? config.port ?? 10100;
|
|
180
185
|
setCorsOrigin(listenPort);
|
package/src/server/ports.ts
CHANGED
|
@@ -49,14 +49,30 @@ export type FindAvailablePortOptions = {
|
|
|
49
49
|
/** How long to keep retrying the preferred port before falling back to an ephemeral port. */
|
|
50
50
|
preferRetryMs?: number;
|
|
51
51
|
preferRetryIntervalMs?: number;
|
|
52
|
+
/**
|
|
53
|
+
* When false, never bind `port: 0` — prefer-retry then throw if the preferred port
|
|
54
|
+
* stays busy. Used for explicit `ocx start --port N` and service-baked pins so an
|
|
55
|
+
* update restart cannot hop to a random ephemeral listener (PR #152 gap).
|
|
56
|
+
*/
|
|
57
|
+
allowEphemeralFallback?: boolean;
|
|
52
58
|
};
|
|
53
59
|
|
|
60
|
+
export class PortUnavailableError extends Error {
|
|
61
|
+
readonly port: number;
|
|
62
|
+
constructor(port: number, hostname: string) {
|
|
63
|
+
super(`Port ${port} on ${hostname} is still busy after prefer-retry; refusing ephemeral fallback.`);
|
|
64
|
+
this.name = "PortUnavailableError";
|
|
65
|
+
this.port = port;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
54
69
|
export async function findAvailablePort(
|
|
55
70
|
preferredPort: number,
|
|
56
71
|
hostname = "127.0.0.1",
|
|
57
72
|
opts: FindAvailablePortOptions = {},
|
|
58
73
|
): Promise<number> {
|
|
59
74
|
const preferRetryMs = opts.preferRetryMs ?? 0;
|
|
75
|
+
const allowEphemeral = opts.allowEphemeralFallback !== false;
|
|
60
76
|
if (preferRetryMs > 0) {
|
|
61
77
|
if (await waitForPortAvailable(preferredPort, hostname, {
|
|
62
78
|
timeoutMs: preferRetryMs,
|
|
@@ -68,6 +84,10 @@ export async function findAvailablePort(
|
|
|
68
84
|
return preferredPort;
|
|
69
85
|
}
|
|
70
86
|
|
|
87
|
+
if (!allowEphemeral) {
|
|
88
|
+
throw new PortUnavailableError(preferredPort, hostname);
|
|
89
|
+
}
|
|
90
|
+
|
|
71
91
|
return await new Promise((resolve, reject) => {
|
|
72
92
|
const server = createServer();
|
|
73
93
|
server.once("error", reject);
|