@bitkyc08/opencodex 2.39.0 → 2.40.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/README.md +49 -21
- package/gui/dist/assets/index-BHe2rl_C.js +112 -0
- package/gui/dist/assets/index-CJSb3HPe.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/cursor/catalog.ts +140 -7
- package/src/adapters/cursor/discovery.ts +79 -87
- package/src/adapters/cursor/effort-map.ts +11 -0
- package/src/adapters/cursor/request-builder.ts +31 -4
- package/src/adapters/cursor.ts +17 -0
- package/src/adapters/openai-chat.ts +95 -9
- package/src/adapters/openai-responses.ts +73 -9
- package/src/bridge.ts +58 -13
- package/src/claude/auth-mode.ts +6 -9
- package/src/claude/gateway-cache.ts +23 -3
- package/src/claude/model-info.ts +13 -2
- package/src/cli/access.ts +30 -0
- package/src/cli/account-extended.ts +19 -10
- package/src/cli/capabilities.ts +40 -1
- package/src/cli/claude-agent-startup-sync.ts +3 -0
- package/src/cli/claude.ts +134 -36
- package/src/cli/connect.ts +226 -0
- package/src/cli/dispatch.ts +126 -21
- package/src/cli/gui-pair-client.ts +170 -0
- package/src/cli/gui.ts +87 -0
- package/src/cli/help.ts +4 -1
- package/src/cli/index.ts +76 -22
- package/src/cli/models-runtime-subcommands.ts +34 -0
- package/src/cli/models-runtime.ts +4 -0
- package/src/cli/models.ts +2 -1
- package/src/cli/opencode.ts +2 -1
- package/src/cli/provider-runtime.ts +31 -1
- package/src/cli/registry.ts +32 -2
- package/src/cli/runtime-api.ts +10 -1
- package/src/cli/status.ts +26 -0
- package/src/cli/system-command.ts +9 -3
- package/src/client/connect.ts +650 -0
- package/src/client/hub-client.ts +457 -0
- package/src/client/hub-relay.ts +288 -0
- package/src/client/machine-api.ts +139 -0
- package/src/client/machine-auth.ts +54 -0
- package/src/client/machine-listener.ts +143 -0
- package/src/client/runtime.ts +93 -0
- package/src/client/state.ts +175 -0
- package/src/clients/config-export.ts +16 -5
- package/src/codex/auth-api.ts +169 -15
- package/src/codex/auth-context.ts +61 -6
- package/src/codex/catalog/aggregation.ts +17 -13
- package/src/codex/catalog/metadata.ts +28 -0
- package/src/codex/catalog/provider-fetch.ts +38 -5
- package/src/codex/catalog.ts +1 -1
- package/src/codex/desired-state.ts +13 -3
- package/src/codex/inject.ts +212 -31
- package/src/codex/journal.ts +54 -6
- package/src/codex/main-account.ts +105 -33
- package/src/codex/native-main-claim.ts +23 -1
- package/src/codex/native-residue.ts +7 -0
- package/src/codex/reset-credit-auto-redeem.ts +237 -0
- package/src/combos/request.ts +15 -3
- package/src/combos/types.ts +12 -0
- package/src/config/paths.ts +15 -1
- package/src/config/provider-validation.ts +40 -0
- package/src/config.ts +447 -3
- package/src/generated/compatibility-version.json +224 -108
- package/src/images/artifacts.ts +73 -37
- package/src/images/fulfill.ts +6 -1
- package/src/images/index.ts +1 -1
- package/src/images/plan.ts +16 -2
- package/src/images/synthetic-tool.ts +5 -0
- package/src/images/xai-client.ts +34 -1
- package/src/integrations/cursor-detect.ts +133 -0
- package/src/integrations/cursor-seen.ts +31 -0
- package/src/lib/errors.ts +18 -0
- package/src/lib/gui-pair-capability.ts +104 -0
- package/src/lib/lab-live-route-production.ts +2 -1
- package/src/lib/process-control.ts +4 -3
- package/src/lib/service-secrets.ts +161 -1
- package/src/lib/windows-system-proxy.ts +115 -0
- package/src/oauth/index.ts +3 -2
- package/src/oauth/pool-settings-capability.ts +55 -0
- package/src/providers/api-keys.ts +3 -1
- package/src/providers/codex-capacity.ts +34 -4
- package/src/providers/derive.ts +10 -0
- package/src/providers/fastwire.ts +10 -2
- package/src/providers/key-store.ts +197 -0
- package/src/providers/model-rename-migration.ts +3 -0
- package/src/providers/openai-sidecar.ts +3 -3
- package/src/providers/quota.ts +91 -42
- package/src/providers/registry.ts +33 -3
- package/src/reasoning-effort.ts +32 -0
- package/src/remote/protocol.ts +109 -0
- package/src/responses/citation-markers.ts +101 -0
- package/src/responses/parser.ts +43 -3
- package/src/responses/spill-store.ts +9 -4
- package/src/router.ts +72 -8
- package/src/server/auth-cors.ts +37 -1
- package/src/server/gui-session.ts +449 -0
- package/src/server/gui-static.ts +24 -7
- package/src/server/images.ts +237 -10
- package/src/server/index.ts +356 -30
- package/src/server/management/agent-settings-routes.ts +7 -1
- package/src/server/management/api-key-rotation.ts +74 -0
- package/src/server/management/combo-routes.ts +18 -6
- package/src/server/management/config-routes.ts +22 -3
- package/src/server/management/context.ts +5 -1
- package/src/server/management/cursor-integration-routes.ts +98 -0
- package/src/server/management/logs-usage-routes.ts +2 -1
- package/src/server/management/model-routes.ts +79 -1
- package/src/server/management/model-rows.ts +23 -1
- package/src/server/management/oauth-account-routes.ts +135 -2
- package/src/server/management/provider-routes.ts +143 -9
- package/src/server/management/route-registry.ts +10 -0
- package/src/server/management/session-routes.ts +13 -0
- package/src/server/management/system-routes.ts +13 -1
- package/src/server/management-api.ts +8 -3
- package/src/server/management-auth.ts +124 -82
- package/src/server/models-capabilities.ts +124 -0
- package/src/server/ports.ts +7 -0
- package/src/server/proxy-liveness.ts +7 -0
- package/src/server/request-log.ts +11 -5
- package/src/server/responses/agent-task-recovery.ts +1 -0
- package/src/server/responses/codex-auth-error.ts +3 -1
- package/src/server/responses/collaboration.ts +26 -1
- package/src/server/responses/combo-stream-preflight.ts +25 -6
- package/src/server/responses/compact.ts +32 -12
- package/src/server/responses/context-overflow.ts +49 -0
- package/src/server/responses/core.ts +196 -12
- package/src/server/responses/fetch-helpers.ts +2 -1
- package/src/server/responses/outbound-body-guard.ts +110 -0
- package/src/server/responses/ws-upstream.ts +87 -6
- package/src/server/responses-self-named-namespace-scrub.ts +181 -0
- package/src/server/system-env.ts +78 -25
- package/src/service.ts +218 -42
- package/src/types/config.ts +134 -0
- package/src/types/provider.ts +54 -1
- package/src/types/request.ts +2 -0
- package/src/types.ts +5 -0
- package/src/update/job.ts +33 -5
- package/src/usage/cost.ts +32 -1
- package/src/usage/expected-prices.ts +16 -0
- package/src/usage/log.ts +6 -2
- package/src/usage/summary.ts +48 -6
- package/gui/dist/assets/index-D-lchsPw.js +0 -112
- package/gui/dist/assets/index-uvENYLin.css +0 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import type { Server } from "bun";
|
|
3
|
+
import { loadConfig } from "../config";
|
|
4
|
+
import { removePid, removeRuntimePort, writePid, writeRuntimePort } from "../config/process-state";
|
|
5
|
+
import { installCrashGuards } from "../lib/crash-guard";
|
|
6
|
+
import { selfLaunchArgv } from "../lib/self-launch-argv";
|
|
7
|
+
import { findAvailablePort } from "../server/ports";
|
|
8
|
+
import { startMachineListener } from "./machine-listener";
|
|
9
|
+
import { readClientConnectionState } from "./state";
|
|
10
|
+
|
|
11
|
+
let activeServer: Server<unknown> | null = null;
|
|
12
|
+
let activePort: number | null = null;
|
|
13
|
+
let recycleScheduled = false;
|
|
14
|
+
|
|
15
|
+
function cleanup(): void {
|
|
16
|
+
removePid(process.pid);
|
|
17
|
+
removeRuntimePort(process.pid);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function scheduleStandaloneRecycle(): void {
|
|
21
|
+
if (recycleScheduled) return;
|
|
22
|
+
recycleScheduled = true;
|
|
23
|
+
const timer = setTimeout(() => {
|
|
24
|
+
const port = activePort;
|
|
25
|
+
try { activeServer?.stop(true); } catch { /* best effort */ }
|
|
26
|
+
cleanup();
|
|
27
|
+
// Recycling back to standalone after `ocx disconnect` must actually bring a standalone
|
|
28
|
+
// proxy back, under either launch shape.
|
|
29
|
+
//
|
|
30
|
+
// Unsupervised: spawn the replacement ourselves and exit 0.
|
|
31
|
+
//
|
|
32
|
+
// Supervised (`OCX_SERVICE=1`): do NOT spawn — the supervisor owns the process, and a
|
|
33
|
+
// second copy would fight it for the port. But exit 0 does not work either: the real
|
|
34
|
+
// supervisor configs are failure-only (systemd `Restart=on-failure`, WinSW
|
|
35
|
+
// `<onfailure action="restart"/>`, the Task Scheduler ERRORLEVEL loop), so a clean exit
|
|
36
|
+
// reads as "the service finished" and nothing restarts. The client stayed down until the
|
|
37
|
+
// operator noticed. Exit 1 is what those configs are watching for, and it is the same
|
|
38
|
+
// policy the dashboard recycle already uses (src/server/management/system-restart.ts).
|
|
39
|
+
//
|
|
40
|
+
// launchd's KeepAlive restarts on any exit, so it is correct under both branches.
|
|
41
|
+
if (process.env.OCX_SERVICE === "1") {
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
if (port) {
|
|
45
|
+
const child = spawn(process.execPath, selfLaunchArgv(["start", "--port", String(port)]), {
|
|
46
|
+
detached: true,
|
|
47
|
+
stdio: "ignore",
|
|
48
|
+
windowsHide: true,
|
|
49
|
+
env: { ...process.env },
|
|
50
|
+
});
|
|
51
|
+
child.unref();
|
|
52
|
+
}
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}, 50);
|
|
55
|
+
if (typeof timer === "object" && "unref" in timer) timer.unref();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function startClientRuntime(
|
|
59
|
+
options: { port?: number; block?: boolean } = {},
|
|
60
|
+
): Promise<void> {
|
|
61
|
+
const state = readClientConnectionState();
|
|
62
|
+
if (state.kind !== "connected") throw new Error(`client runtime refused: client state is ${state.kind}`);
|
|
63
|
+
const config = loadConfig();
|
|
64
|
+
const preferred = options.port ?? config.port ?? 10100;
|
|
65
|
+
const port = await findAvailablePort(preferred, "127.0.0.1", {
|
|
66
|
+
preferRetryMs: options.port === undefined ? 750 : 5_000,
|
|
67
|
+
preferRetryIntervalMs: 50,
|
|
68
|
+
allowEphemeralFallback: options.port === undefined,
|
|
69
|
+
});
|
|
70
|
+
const server = startMachineListener(port, { state: state.value });
|
|
71
|
+
const boundPort = server.port ?? port;
|
|
72
|
+
activeServer = server;
|
|
73
|
+
activePort = boundPort;
|
|
74
|
+
installCrashGuards();
|
|
75
|
+
writePid(process.pid);
|
|
76
|
+
writeRuntimePort({ pid: process.pid, port: boundPort, hostname: "127.0.0.1" });
|
|
77
|
+
|
|
78
|
+
let shuttingDown = false;
|
|
79
|
+
const shutdown = () => {
|
|
80
|
+
if (shuttingDown) return;
|
|
81
|
+
shuttingDown = true;
|
|
82
|
+
try { server.stop(true); } finally {
|
|
83
|
+
cleanup();
|
|
84
|
+
process.exit(0);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
process.on("SIGINT", shutdown);
|
|
88
|
+
process.on("SIGTERM", shutdown);
|
|
89
|
+
if (process.platform !== "win32") process.on("SIGHUP", shutdown);
|
|
90
|
+
process.on("exit", cleanup);
|
|
91
|
+
|
|
92
|
+
if (options.block ?? true) await new Promise<void>(() => {});
|
|
93
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
getConfigPath,
|
|
4
|
+
deleteConfigTopLevelKey,
|
|
5
|
+
getDefaultConfig,
|
|
6
|
+
mutatePersistedConfig,
|
|
7
|
+
readConfigDiagnostics,
|
|
8
|
+
saveConfig,
|
|
9
|
+
} from "../config";
|
|
10
|
+
import type { OcxClientConnectionConfig } from "../types";
|
|
11
|
+
import {
|
|
12
|
+
readServiceApiTokenState,
|
|
13
|
+
readTokenBackupState,
|
|
14
|
+
removeOrphanTokenBackup,
|
|
15
|
+
} from "../lib/service-secrets";
|
|
16
|
+
|
|
17
|
+
export type ClientConnectionState =
|
|
18
|
+
| { kind: "disconnected" }
|
|
19
|
+
| { kind: "connected"; value: OcxClientConnectionConfig }
|
|
20
|
+
| { kind: "invalid"; reason: string }
|
|
21
|
+
| { kind: "mismatched"; reason: string };
|
|
22
|
+
|
|
23
|
+
export type ClientRotationRecoveryGate =
|
|
24
|
+
| { kind: "clean" }
|
|
25
|
+
| { kind: "orphan-cleaned" }
|
|
26
|
+
| { kind: "recovery-required"; reason: string }
|
|
27
|
+
| { kind: "unsafe"; reason: string };
|
|
28
|
+
|
|
29
|
+
function rawTopLevelConfig(): Record<string, unknown> | null {
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(readFileSync(getConfigPath(), "utf8").replace(/^\uFEFF/, "")) as unknown;
|
|
32
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
33
|
+
? parsed as Record<string, unknown>
|
|
34
|
+
: null;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function readClientConnectionState(): ClientConnectionState {
|
|
41
|
+
const raw = rawTopLevelConfig();
|
|
42
|
+
const diagnostics = readConfigDiagnostics();
|
|
43
|
+
if (!raw) {
|
|
44
|
+
return diagnostics.source === "default"
|
|
45
|
+
? { kind: "disconnected" }
|
|
46
|
+
: { kind: "invalid", reason: "config.json is missing or unreadable" };
|
|
47
|
+
}
|
|
48
|
+
const hasClient = Object.hasOwn(raw, "client") && raw.client !== undefined;
|
|
49
|
+
const role = raw.runtimeRole;
|
|
50
|
+
if (role !== undefined && role !== "standalone" && role !== "hub" && role !== "client") {
|
|
51
|
+
return { kind: "invalid", reason: "config.json.runtimeRole is invalid" };
|
|
52
|
+
}
|
|
53
|
+
if (!hasClient && (role === undefined || role === "standalone")) return { kind: "disconnected" };
|
|
54
|
+
// A hub is a server role, not a broken client: without client state it simply is not
|
|
55
|
+
// connected, and refusing here blocked `ocx start` on every hub (found on the first
|
|
56
|
+
// clisu-oracle dogfood boot). Hub role WITH client state remains mismatched below.
|
|
57
|
+
if (!hasClient && role === "hub") return { kind: "disconnected" };
|
|
58
|
+
if (!hasClient || role !== "client") {
|
|
59
|
+
return {
|
|
60
|
+
kind: "mismatched",
|
|
61
|
+
reason: hasClient
|
|
62
|
+
? "config.json.client is present without runtimeRole=client"
|
|
63
|
+
: "runtimeRole=client is present without config.json.client",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const client = diagnostics.config.client;
|
|
67
|
+
if (!client) {
|
|
68
|
+
const warning = diagnostics.warnings?.find(value => value.startsWith("client"));
|
|
69
|
+
return { kind: "invalid", reason: warning ?? "config.json.client is malformed" };
|
|
70
|
+
}
|
|
71
|
+
return { kind: "connected", value: client };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Does the persisted config record a rotation that has not finished?
|
|
76
|
+
*
|
|
77
|
+
* Read fresh rather than taken from a caller-supplied snapshot: the whole point is to see a
|
|
78
|
+
* `pendingOperation` that landed after that snapshot was taken.
|
|
79
|
+
*/
|
|
80
|
+
function rotationInFlight(): boolean {
|
|
81
|
+
const current = readClientConnectionState();
|
|
82
|
+
return current.kind === "connected" && current.value.pendingOperation !== undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function inspectClientRotationRecoveryGate(
|
|
86
|
+
state: ClientConnectionState = readClientConnectionState(),
|
|
87
|
+
): ClientRotationRecoveryGate {
|
|
88
|
+
const current = readServiceApiTokenState();
|
|
89
|
+
const backup = readTokenBackupState();
|
|
90
|
+
if (state.kind === "connected" && state.value.pendingOperation) {
|
|
91
|
+
if (current.kind !== "present" || backup.kind !== "present") {
|
|
92
|
+
return {
|
|
93
|
+
kind: "unsafe",
|
|
94
|
+
reason: "pending key rotation requires owner-only service-api-token and service-api-token.prev files",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
kind: "recovery-required",
|
|
99
|
+
reason: "rerun ocx connect rotate with --pairing-code-stdin or --admin-token-stdin",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (backup.kind === "unsafe") return { kind: "unsafe", reason: backup.reason };
|
|
103
|
+
if (backup.kind === "present" && current.kind === "present") {
|
|
104
|
+
// Only an ORPHAN backup is cleanable, and this branch cannot always tell an orphan from
|
|
105
|
+
// a backup belonging to a rotation that is mid-flight.
|
|
106
|
+
//
|
|
107
|
+
// `rotateConnectedClientKey` writes the .prev backup BEFORE it persists
|
|
108
|
+
// `pendingOperation`. A concurrent `ocx connect status` landing in that window sees
|
|
109
|
+
// "backup present, token present, no pending marker" — indistinguishable from a stale
|
|
110
|
+
// leftover — and deleted the live rollback target. If the rotation then failed, its
|
|
111
|
+
// restore had nothing to restore from.
|
|
112
|
+
//
|
|
113
|
+
// Re-reading the persisted state closes most of the window: the caller's `state` may
|
|
114
|
+
// have been captured before the marker landed, while a fresh read sees it. The
|
|
115
|
+
// remaining window is narrow enough that the rotation's own lock is the right owner,
|
|
116
|
+
// and deleting nothing is the safe side of it.
|
|
117
|
+
if (rotationInFlight()) {
|
|
118
|
+
return { kind: "recovery-required", reason: "a key rotation is in flight; leave service-api-token.prev in place" };
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
removeOrphanTokenBackup();
|
|
122
|
+
return { kind: "orphan-cleaned" };
|
|
123
|
+
} catch (error) {
|
|
124
|
+
return { kind: "unsafe", reason: error instanceof Error ? error.message : "token backup cleanup failed" };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { kind: "clean" };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function commitClientConnection(
|
|
131
|
+
|
|
132
|
+
state: OcxClientConnectionConfig,
|
|
133
|
+
): "committed" | "unchanged" {
|
|
134
|
+
const outcome = mutatePersistedConfig(config => {
|
|
135
|
+
const unchanged = config.runtimeRole === "client"
|
|
136
|
+
&& JSON.stringify(config.client) === JSON.stringify(state);
|
|
137
|
+
if (!unchanged) {
|
|
138
|
+
config.runtimeRole = "client";
|
|
139
|
+
config.client = structuredClone(state);
|
|
140
|
+
}
|
|
141
|
+
return { changed: !unchanged, value: undefined };
|
|
142
|
+
});
|
|
143
|
+
if (outcome.status === "committed" || outcome.status === "unchanged") return outcome.status;
|
|
144
|
+
if (outcome.status === "unavailable" && outcome.reason === "missing") {
|
|
145
|
+
// First ocx run on a fresh machine: ocx connect is the expected first command in
|
|
146
|
+
// client mode, so there is no config.json yet. mutatePersistedConfig correctly
|
|
147
|
+
// refuses to invent one (a lost config must fail closed), but a genuinely absent
|
|
148
|
+
// file is the bootstrap case, not corruption — seed defaults plus the client
|
|
149
|
+
// block atomically. Found on the first MacBook↔oracle dogfood connect.
|
|
150
|
+
const seeded = getDefaultConfig();
|
|
151
|
+
seeded.runtimeRole = "client";
|
|
152
|
+
seeded.client = structuredClone(state);
|
|
153
|
+
saveConfig(seeded);
|
|
154
|
+
return "committed";
|
|
155
|
+
}
|
|
156
|
+
throw new Error(`client state commit unavailable: ${"reason" in outcome ? outcome.reason : "unknown"}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function clearClientConnection(
|
|
160
|
+
expectedApiKeyId: string,
|
|
161
|
+
): "committed" | "absent" | "conflict" {
|
|
162
|
+
const outcome = mutatePersistedConfig(config => {
|
|
163
|
+
if (!config.client && config.runtimeRole !== "client") {
|
|
164
|
+
return { changed: false, value: "absent" as const };
|
|
165
|
+
}
|
|
166
|
+
if (!config.client || config.runtimeRole !== "client" || config.client.apiKeyId !== expectedApiKeyId) {
|
|
167
|
+
return { changed: false, value: "conflict" as const };
|
|
168
|
+
}
|
|
169
|
+
deleteConfigTopLevelKey(config, "client");
|
|
170
|
+
deleteConfigTopLevelKey(config, "runtimeRole");
|
|
171
|
+
return { changed: true, value: "committed" as const };
|
|
172
|
+
});
|
|
173
|
+
if (outcome.status === "unavailable") return "conflict";
|
|
174
|
+
return outcome.value;
|
|
175
|
+
}
|
|
@@ -1034,10 +1034,15 @@ export interface HermesProviderBlock {
|
|
|
1034
1034
|
api_mode: "chat_completions";
|
|
1035
1035
|
/** We supply the list, so skip their live `/models` probe. */
|
|
1036
1036
|
discover_models: false;
|
|
1037
|
-
models: string
|
|
1037
|
+
models: Record<string, HermesModelEntry>;
|
|
1038
1038
|
extra_headers?: Record<string, string>;
|
|
1039
1039
|
}
|
|
1040
1040
|
|
|
1041
|
+
/** Capability metadata Hermes cannot discover for a custom local provider. */
|
|
1042
|
+
export interface HermesModelEntry {
|
|
1043
|
+
supports_vision?: boolean;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1041
1046
|
export interface HermesGeneratedConfig {
|
|
1042
1047
|
providers: Record<string, HermesProviderBlock>;
|
|
1043
1048
|
}
|
|
@@ -1311,7 +1316,13 @@ function proxyAdmissionHeaders(config: OcxConfig | undefined, envRef: string): R
|
|
|
1311
1316
|
}
|
|
1312
1317
|
|
|
1313
1318
|
function buildHermesClientConfig(ctx: ExportContext): HermesGeneratedConfig {
|
|
1314
|
-
const models
|
|
1319
|
+
const models: Record<string, HermesModelEntry> = {};
|
|
1320
|
+
for (const model of normalizeExportModels(ctx.models)) {
|
|
1321
|
+
const declared = model.inputModalities;
|
|
1322
|
+
models[model.namespaced] = declared && declared.length > 0
|
|
1323
|
+
? { supports_vision: declared.includes("image") }
|
|
1324
|
+
: {};
|
|
1325
|
+
}
|
|
1315
1326
|
const headers = proxyAdmissionHeaders(ctx.config, HERMES_API_KEY_ENV_REF);
|
|
1316
1327
|
return {
|
|
1317
1328
|
providers: {
|
|
@@ -1606,9 +1617,9 @@ function summarizeOmp(document: unknown): { modelCount: number; modelsWithoutLim
|
|
|
1606
1617
|
}
|
|
1607
1618
|
|
|
1608
1619
|
function summarizeHermes(document: unknown): { modelCount: number; modelsWithoutLimits: number } {
|
|
1609
|
-
const models = (document as HermesGeneratedConfig | undefined)?.providers?.[OPENCODE_PROVIDER_ID]?.models ??
|
|
1610
|
-
// Hermes carries
|
|
1611
|
-
return { modelCount: models.length, modelsWithoutLimits: 0 };
|
|
1620
|
+
const models = (document as HermesGeneratedConfig | undefined)?.providers?.[OPENCODE_PROVIDER_ID]?.models ?? {};
|
|
1621
|
+
// Hermes carries capability metadata but no per-model limit to be missing.
|
|
1622
|
+
return { modelCount: Object.keys(models).length, modelsWithoutLimits: 0 };
|
|
1612
1623
|
}
|
|
1613
1624
|
|
|
1614
1625
|
function summarizeOpenclaw(document: unknown): { modelCount: number; modelsWithoutLimits: number } {
|
package/src/codex/auth-api.ts
CHANGED
|
@@ -389,6 +389,49 @@ function safeResetCreditConsumeDto(input: unknown): { code: string } {
|
|
|
389
389
|
return { code: typeof obj.code === "string" ? obj.code : "unknown" };
|
|
390
390
|
}
|
|
391
391
|
|
|
392
|
+
/**
|
|
393
|
+
* Background reset-credit access for the auto-redeemer (#822). Goes through the same
|
|
394
|
+
* account/lease wrapper as the management routes, but takes a caller-owned
|
|
395
|
+
* `redeem_request_id` so a journaled id can be replayed idempotently after a crash.
|
|
396
|
+
* Throws on any auth or upstream failure; the caller treats a throw on consume as ambiguous.
|
|
397
|
+
*/
|
|
398
|
+
export function createResetCreditWhamClient(config: OcxConfig, accountId: string): {
|
|
399
|
+
inspect: () => Promise<{ credits: { granted_at: string; expires_at: string }[] }>;
|
|
400
|
+
consume: (redeemRequestId: string) => Promise<{ code: string }>;
|
|
401
|
+
} {
|
|
402
|
+
const run = async <T>(operation: (auth: ResetCreditAuth) => Promise<T>): Promise<T> => {
|
|
403
|
+
const result = await withResetCreditAuth(getRuntimeConfig(config), accountId, operation);
|
|
404
|
+
if (result.ok) return result.value;
|
|
405
|
+
throw new Error(`reset-credit auth unavailable (${result.response.status})`);
|
|
406
|
+
};
|
|
407
|
+
return {
|
|
408
|
+
inspect: () => run(async auth => {
|
|
409
|
+
const resp = await fetch("https://chatgpt.com/backend-api/wham/rate-limit-reset-credits", {
|
|
410
|
+
headers: { Authorization: `Bearer ${auth.accessToken}`, "ChatGPT-Account-Id": auth.chatgptAccountId },
|
|
411
|
+
signal: AbortSignal.timeout(8000),
|
|
412
|
+
});
|
|
413
|
+
if (!resp.ok) { await resp.body?.cancel().catch(() => {}); throw new Error(`upstream ${resp.status}`); }
|
|
414
|
+
const parsed = await readResetCreditJson(resp, AbortSignal.timeout(8000));
|
|
415
|
+
if (!parsed.ok) throw new Error("invalid upstream reset-credit response");
|
|
416
|
+
return { credits: safeResetCreditsDto(parsed.value).credits };
|
|
417
|
+
}),
|
|
418
|
+
consume: redeemRequestId => run(async auth => {
|
|
419
|
+
const resp = await fetch("https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume", {
|
|
420
|
+
method: "POST",
|
|
421
|
+
headers: {
|
|
422
|
+
Authorization: `Bearer ${auth.accessToken}`,
|
|
423
|
+
"ChatGPT-Account-Id": auth.chatgptAccountId,
|
|
424
|
+
"Content-Type": "application/json",
|
|
425
|
+
},
|
|
426
|
+
body: JSON.stringify({ redeem_request_id: redeemRequestId }),
|
|
427
|
+
signal: AbortSignal.timeout(10_000),
|
|
428
|
+
});
|
|
429
|
+
if (!resp.ok) { await resp.body?.cancel().catch(() => {}); throw new Error(`upstream ${resp.status}`); }
|
|
430
|
+
return safeResetCreditConsumeDto(await resp.json());
|
|
431
|
+
}),
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
392
435
|
type ResetCreditJsonRead =
|
|
393
436
|
| { ok: true; value: unknown }
|
|
394
437
|
| { ok: false };
|
|
@@ -843,6 +886,23 @@ interface PoolQuotaResult {
|
|
|
843
886
|
/** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */
|
|
844
887
|
freshResetCredits?: number;
|
|
845
888
|
quotaProbeSkipped?: true;
|
|
889
|
+
/** Positive evidence captured immediately before an upstream WHAM dispatch. */
|
|
890
|
+
quotaProbeAttempted?: { at: number; credentialGeneration: number };
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
interface PoolQuotaProbeEvidence {
|
|
894
|
+
attempted?: NonNullable<PoolQuotaResult["quotaProbeAttempted"]>;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function markQuotaProbeAttempted(evidence: PoolQuotaProbeEvidence, credentialGeneration: number): void {
|
|
898
|
+
evidence.attempted = { at: Date.now(), credentialGeneration };
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function withQuotaProbeEvidence(
|
|
902
|
+
result: PoolQuotaResult,
|
|
903
|
+
evidence: PoolQuotaProbeEvidence,
|
|
904
|
+
): PoolQuotaResult {
|
|
905
|
+
return evidence.attempted ? { ...result, quotaProbeAttempted: evidence.attempted } : result;
|
|
846
906
|
}
|
|
847
907
|
|
|
848
908
|
interface PoolQuotaRefreshFlight {
|
|
@@ -988,6 +1048,7 @@ async function recoverPoolQuotaFrom401(ctx: {
|
|
|
988
1048
|
rejectedAccessToken: string;
|
|
989
1049
|
rejectedGeneration: number;
|
|
990
1050
|
resp: Response;
|
|
1051
|
+
quotaProbeEvidence: PoolQuotaProbeEvidence;
|
|
991
1052
|
onCredentialGeneration?: (generation: number) => void;
|
|
992
1053
|
}): Promise<PoolQuotaResult> {
|
|
993
1054
|
const { accountId, existing, configuredPlan, rejectedAccessToken, rejectedGeneration, resp } = ctx;
|
|
@@ -1061,6 +1122,7 @@ async function recoverPoolQuotaFrom401(ctx: {
|
|
|
1061
1122
|
ctx.onCredentialGeneration?.(refreshed.generation);
|
|
1062
1123
|
|
|
1063
1124
|
const writerGeneration = captureConfigGeneration();
|
|
1125
|
+
markQuotaProbeAttempted(ctx.quotaProbeEvidence, refreshed.generation);
|
|
1064
1126
|
const replay = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
1065
1127
|
headers: {
|
|
1066
1128
|
Authorization: `Bearer ${refreshed.accessToken}`,
|
|
@@ -1147,57 +1209,75 @@ async function fetchFreshPoolAccountQuota(
|
|
|
1147
1209
|
existing: StoredAccountQuota | null,
|
|
1148
1210
|
configuredPlan?: string,
|
|
1149
1211
|
onCredentialGeneration?: (generation: number) => void,
|
|
1212
|
+
getValidToken: typeof getValidCodexToken = getValidCodexToken,
|
|
1150
1213
|
): Promise<PoolQuotaResult> {
|
|
1151
1214
|
const writerGeneration = captureConfigGeneration();
|
|
1152
1215
|
let requestCredentialGeneration = readCodexAccountRecord(accountId)?.generation;
|
|
1216
|
+
const quotaProbeEvidence: PoolQuotaProbeEvidence = {};
|
|
1153
1217
|
try {
|
|
1154
|
-
const { accessToken, chatgptAccountId, generation } = await
|
|
1218
|
+
const { accessToken, chatgptAccountId, generation } = await getValidToken(accountId);
|
|
1155
1219
|
requestCredentialGeneration = generation;
|
|
1156
1220
|
onCredentialGeneration?.(generation);
|
|
1221
|
+
markQuotaProbeAttempted(quotaProbeEvidence, generation);
|
|
1157
1222
|
const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
1158
1223
|
headers: { Authorization: `Bearer ${accessToken}`, "ChatGPT-Account-Id": chatgptAccountId },
|
|
1159
1224
|
signal: AbortSignal.timeout(8000),
|
|
1160
1225
|
});
|
|
1161
1226
|
if (!resp.ok) {
|
|
1162
1227
|
if (resp.status !== 401) {
|
|
1163
|
-
return
|
|
1228
|
+
return withQuotaProbeEvidence(
|
|
1229
|
+
{ quota: existing ?? null, needsReauth: false, credentialGeneration: generation },
|
|
1230
|
+
quotaProbeEvidence,
|
|
1231
|
+
);
|
|
1164
1232
|
}
|
|
1165
1233
|
// A bare 401 is what a stale-but-refreshable bearer produces after a plan change, so
|
|
1166
1234
|
// quarantining on it tells the operator to re-authenticate an account that was fine
|
|
1167
1235
|
// (#3019). Refresh once, replay once, and only then decide.
|
|
1168
|
-
|
|
1236
|
+
const recovered = await recoverPoolQuotaFrom401({
|
|
1169
1237
|
accountId,
|
|
1170
1238
|
existing,
|
|
1171
1239
|
configuredPlan,
|
|
1172
1240
|
rejectedAccessToken: accessToken,
|
|
1173
1241
|
rejectedGeneration: generation,
|
|
1174
1242
|
resp,
|
|
1243
|
+
quotaProbeEvidence,
|
|
1175
1244
|
onCredentialGeneration,
|
|
1176
1245
|
});
|
|
1246
|
+
return withQuotaProbeEvidence(recovered, quotaProbeEvidence);
|
|
1177
1247
|
}
|
|
1178
|
-
|
|
1248
|
+
const committed = await commitPoolQuotaResponse(resp, {
|
|
1179
1249
|
accountId, existing, configuredPlan, generation, writerGeneration,
|
|
1180
1250
|
});
|
|
1251
|
+
return withQuotaProbeEvidence(committed, quotaProbeEvidence);
|
|
1181
1252
|
} catch (e) {
|
|
1182
1253
|
if (e instanceof CodexCredentialGenerationConflictError || e instanceof CodexCredentialRefreshLockTimeoutError
|
|
1183
1254
|
|| e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError) {
|
|
1184
|
-
return {
|
|
1255
|
+
return withQuotaProbeEvidence({
|
|
1185
1256
|
quota: existing ?? null,
|
|
1186
1257
|
needsReauth: false,
|
|
1187
1258
|
credentialGeneration: requestCredentialGeneration,
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
: {}),
|
|
1191
|
-
};
|
|
1259
|
+
quotaProbeSkipped: true,
|
|
1260
|
+
}, quotaProbeEvidence);
|
|
1192
1261
|
}
|
|
1193
1262
|
if (e instanceof TokenRefreshError) {
|
|
1194
|
-
return
|
|
1263
|
+
return withQuotaProbeEvidence(
|
|
1264
|
+
{ quota: existing ?? null, needsReauth: true, credentialGeneration: requestCredentialGeneration },
|
|
1265
|
+
quotaProbeEvidence,
|
|
1266
|
+
);
|
|
1195
1267
|
}
|
|
1196
|
-
return
|
|
1268
|
+
return withQuotaProbeEvidence(
|
|
1269
|
+
{ quota: existing ?? null, needsReauth: false, credentialGeneration: requestCredentialGeneration },
|
|
1270
|
+
quotaProbeEvidence,
|
|
1271
|
+
);
|
|
1197
1272
|
}
|
|
1198
1273
|
}
|
|
1199
1274
|
|
|
1200
|
-
async function fetchPoolAccountQuota(
|
|
1275
|
+
async function fetchPoolAccountQuota(
|
|
1276
|
+
accountId: string,
|
|
1277
|
+
forceRefresh = false,
|
|
1278
|
+
configuredPlan?: string,
|
|
1279
|
+
getValidToken: typeof getValidCodexToken = getValidCodexToken,
|
|
1280
|
+
): Promise<PoolQuotaResult> {
|
|
1201
1281
|
const existing = getAccountQuota(accountId);
|
|
1202
1282
|
if (!forceRefresh && existing && Date.now() - existing.updatedAt < POOL_CACHE_TTL) {
|
|
1203
1283
|
return {
|
|
@@ -1227,6 +1307,7 @@ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, co
|
|
|
1227
1307
|
existing,
|
|
1228
1308
|
configuredPlan,
|
|
1229
1309
|
generation => { state.resolvedCredentialGeneration = generation; },
|
|
1310
|
+
getValidToken,
|
|
1230
1311
|
);
|
|
1231
1312
|
const flight: PoolQuotaRefreshFlight = { state, promise: refresh };
|
|
1232
1313
|
const activeFlights = flights ?? new Set<PoolQuotaRefreshFlight>();
|
|
@@ -1243,6 +1324,16 @@ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, co
|
|
|
1243
1324
|
}
|
|
1244
1325
|
|
|
1245
1326
|
let primeInFlight: Promise<void> | null = null;
|
|
1327
|
+
/**
|
|
1328
|
+
* Last prime attempt per pool account. A failed WHAM lookup stores no quota, so
|
|
1329
|
+
* without this the account stays "unknown" and every later prime trigger re-selects
|
|
1330
|
+
* it as stale and repeats the same failing request. Successful lookups are already
|
|
1331
|
+
* throttled by their stored updatedAt; this gives failures the same TTL backoff.
|
|
1332
|
+
*
|
|
1333
|
+
* Keyed by credential generation so a re-authentication, refresh, or account removal
|
|
1334
|
+
* retries immediately instead of waiting out a backoff earned by the old credential.
|
|
1335
|
+
*/
|
|
1336
|
+
const poolQuotaPrimeAttemptedAt = new Map<string, { generation: number; at: number }>();
|
|
1246
1337
|
let cooldownRecoveryInFlight: Promise<void> | null = null;
|
|
1247
1338
|
|
|
1248
1339
|
export async function runCodexCooldownRecoveryProbes(config: OcxConfig, now = Date.now()): Promise<void> {
|
|
@@ -1294,6 +1385,19 @@ export interface PrimeCodexPoolQuotasOptions {
|
|
|
1294
1385
|
fetchMainInfo?: typeof fetchMainAccountInfo;
|
|
1295
1386
|
}
|
|
1296
1387
|
|
|
1388
|
+
let getValidPoolTokenForPrime = getValidCodexToken;
|
|
1389
|
+
|
|
1390
|
+
/** Test-only: inject a deterministic pre-dispatch credential outcome for quota priming. */
|
|
1391
|
+
export function setCodexPoolQuotaTokenResolverForTests(
|
|
1392
|
+
resolver: typeof getValidCodexToken,
|
|
1393
|
+
): () => void {
|
|
1394
|
+
const previous = getValidPoolTokenForPrime;
|
|
1395
|
+
getValidPoolTokenForPrime = resolver;
|
|
1396
|
+
return () => {
|
|
1397
|
+
if (getValidPoolTokenForPrime === resolver) getValidPoolTokenForPrime = previous;
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1297
1401
|
function tryAcquireNativeMainPrimeLease(): AdmissionLease | null {
|
|
1298
1402
|
return tryAcquireNativeMainProfileClaim();
|
|
1299
1403
|
}
|
|
@@ -1319,6 +1423,16 @@ export async function primeCodexPoolQuotas(
|
|
|
1319
1423
|
options: PrimeCodexPoolQuotasOptions = {},
|
|
1320
1424
|
): Promise<void> {
|
|
1321
1425
|
const openai = config.providers[OPENAI_CODEX_PROVIDER_ID];
|
|
1426
|
+
// Prune attempt markers for accounts that no longer exist BEFORE the eligibility
|
|
1427
|
+
// return. A removal that happens while the provider is disabled or out of pool mode
|
|
1428
|
+
// would otherwise leave a stale failure marker behind; restoring the same account id
|
|
1429
|
+
// within POOL_CACHE_TTL would then read that old failure as current and skip the
|
|
1430
|
+
// retry the restored credential is entitled to.
|
|
1431
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
1432
|
+
const configuredPoolIds = new Set((runtimeConfig.codexAccounts ?? []).map(account => account.id));
|
|
1433
|
+
for (const accountId of poolQuotaPrimeAttemptedAt.keys()) {
|
|
1434
|
+
if (!configuredPoolIds.has(accountId)) poolQuotaPrimeAttemptedAt.delete(accountId);
|
|
1435
|
+
}
|
|
1322
1436
|
if (
|
|
1323
1437
|
!openai
|
|
1324
1438
|
|| openai.disabled === true
|
|
@@ -1327,11 +1441,18 @@ export async function primeCodexPoolQuotas(
|
|
|
1327
1441
|
) return;
|
|
1328
1442
|
if (primeInFlight) return primeInFlight;
|
|
1329
1443
|
primeInFlight = (async () => {
|
|
1330
|
-
const runtimeConfig = getRuntimeConfig(config);
|
|
1331
1444
|
const pool = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
|
|
1332
1445
|
const stale = pool.filter(a => {
|
|
1333
1446
|
const q = getAccountQuota(a.id);
|
|
1334
|
-
|
|
1447
|
+
if (q) return Date.now() - q.updatedAt >= POOL_CACHE_TTL;
|
|
1448
|
+
// No stored quota: either never primed, or the last attempt failed. Retry only
|
|
1449
|
+
// once per TTL window so an unreachable or rejecting account cannot turn every
|
|
1450
|
+
// prime trigger into another upstream request.
|
|
1451
|
+
const lastAttempt = poolQuotaPrimeAttemptedAt.get(a.id);
|
|
1452
|
+
if (!lastAttempt) return true;
|
|
1453
|
+
// A newer credential invalidates the previous failure: retry without waiting.
|
|
1454
|
+
if (lastAttempt.generation !== readCodexAccountRecord(a.id)?.generation) return true;
|
|
1455
|
+
return Date.now() - lastAttempt.at >= POOL_CACHE_TTL;
|
|
1335
1456
|
});
|
|
1336
1457
|
const primeMain = async () => {
|
|
1337
1458
|
const mainLease = tryAcquireNativeMainPrimeLease();
|
|
@@ -1359,7 +1480,31 @@ export async function primeCodexPoolQuotas(
|
|
|
1359
1480
|
primeMain(),
|
|
1360
1481
|
mapWithConcurrency(stale, POOL_QUOTA_REFRESH_CONCURRENCY, async a => {
|
|
1361
1482
|
if (!getCodexAccountCredential(a.id)) return;
|
|
1362
|
-
|
|
1483
|
+
let result: PoolQuotaResult;
|
|
1484
|
+
try {
|
|
1485
|
+
result = await fetchPoolAccountQuota(a.id, false, a.plan, getValidPoolTokenForPrime);
|
|
1486
|
+
} catch (error) {
|
|
1487
|
+
// Local quota-flight saturation proves no WHAM request existed for this account.
|
|
1488
|
+
// Consume it per item so sibling workers remain inside the shared prime lifetime.
|
|
1489
|
+
if (error instanceof PoolQuotaProbeBusyError) return;
|
|
1490
|
+
throw error;
|
|
1491
|
+
}
|
|
1492
|
+
// Only the data-plane function knows whether upstream dispatch began. Any
|
|
1493
|
+
// cache hit, credential deferral, or local admission failure remains eligible.
|
|
1494
|
+
const attempted = result.quotaProbeAttempted;
|
|
1495
|
+
if (!attempted) return;
|
|
1496
|
+
if (!configuredPoolAccount(getRuntimeConfig(config), a.id)) {
|
|
1497
|
+
poolQuotaPrimeAttemptedAt.delete(a.id);
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
poolQuotaPrimeAttemptedAt.set(a.id, {
|
|
1501
|
+
// getValidCodexToken may rotate the credential before WHAM is sent.
|
|
1502
|
+
// Bind the backoff to the generation that actually made the request;
|
|
1503
|
+
// otherwise the next prime sees a false generation change and retries
|
|
1504
|
+
// the same failed WHAM call immediately.
|
|
1505
|
+
generation: attempted.credentialGeneration,
|
|
1506
|
+
at: attempted.at,
|
|
1507
|
+
});
|
|
1363
1508
|
}),
|
|
1364
1509
|
]);
|
|
1365
1510
|
} catch {
|
|
@@ -1376,6 +1521,15 @@ export async function primeCodexPoolQuotas(
|
|
|
1376
1521
|
* from another suite cannot coalesce into the next prime. */
|
|
1377
1522
|
export function clearCodexQuotaPrimeState(): void {
|
|
1378
1523
|
primeInFlight = null;
|
|
1524
|
+
poolQuotaPrimeAttemptedAt.clear();
|
|
1525
|
+
getValidPoolTokenForPrime = getValidCodexToken;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
/** Test-only: drop the shared single-flight promise while keeping the per-account
|
|
1529
|
+
* failure backoff, so a test can trigger a second real prime pass and still observe
|
|
1530
|
+
* the throttle a production caller would see. */
|
|
1531
|
+
export function clearCodexQuotaPrimeSingleFlightForTests(): void {
|
|
1532
|
+
primeInFlight = null;
|
|
1379
1533
|
}
|
|
1380
1534
|
|
|
1381
1535
|
/** Test-only reset for the worker-level single-flight. */
|