@bitkyc08/opencodex 2.56.0 → 2.57.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/bin/ocx.mjs +10 -0
- package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
- package/gui/dist/assets/{index-D4zuyIxQ.js → index-Cz7CLdif.js} +21 -21
- package/gui/dist/index.html +2 -2
- package/package.json +3 -3
- package/src/adapters/codebuddy/adapter.ts +2 -1
- package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
- package/src/adapters/command-code.ts +1 -1
- package/src/adapters/cursor/envelope-echo.ts +8 -2
- package/src/adapters/google.ts +7 -7
- package/src/adapters/kiro/payload.ts +17 -3
- package/src/adapters/kiro/reasoning.ts +70 -7
- package/src/adapters/kiro/stream.ts +8 -2
- package/src/adapters/kiro/wire.ts +2 -1
- package/src/adapters/kiro-events.ts +21 -13
- package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
- package/src/adapters/openai-chat/tool-schema.ts +25 -7
- package/src/adapters/openai-chat.ts +8 -8
- package/src/adapters/openai-responses/passthrough.ts +32 -1
- package/src/bridge/errors.ts +26 -2
- package/src/bridge/response-json.ts +7 -1
- package/src/bridge/sse.ts +19 -1
- package/src/claude/desktop-profile.ts +66 -9
- package/src/claude/outbound.ts +18 -0
- package/src/cli/account-main.ts +1 -1
- package/src/cli/capabilities.ts +2 -2
- package/src/cli/combo.ts +10 -1
- package/src/cli/index.ts +48 -5
- package/src/cli/registry.ts +2 -1
- package/src/cli/system-command.ts +4 -4
- package/src/clients/config-export.ts +7 -3
- package/src/codex/account-label.ts +14 -3
- package/src/codex/account-store.ts +113 -26
- package/src/codex/account-usability.ts +21 -0
- package/src/codex/auth-api/login-flow.ts +14 -2
- package/src/codex/auth-api/reset-credit-service.ts +11 -2
- package/src/codex/auth-context.ts +157 -7
- package/src/codex/catalog/aggregation.ts +80 -1
- package/src/codex/catalog/model-visibility.ts +1 -0
- package/src/codex/catalog/remote.ts +30 -0
- package/src/codex/catalog/retained-sync.ts +9 -1
- package/src/codex/catalog/routed-gather.ts +38 -1
- package/src/codex/cli-install-provenance.ts +7 -1
- package/src/codex/convergence.ts +7 -2
- package/src/codex/desktop-app/types.ts +11 -2
- package/src/codex/desktop-app/windows.ts +5 -5
- package/src/codex/inject/restore.ts +29 -2
- package/src/codex/inject.ts +9 -9
- package/src/codex/model-entitlements.ts +152 -15
- package/src/codex/pool-refresh-backoff.ts +12 -3
- package/src/codex/quota-rejection.ts +104 -15
- package/src/codex/routing/cache-affinity.ts +70 -0
- package/src/codex/routing/cooldown-math.ts +10 -0
- package/src/codex/routing/selection.ts +79 -2
- package/src/codex/routing/thread-affinity.ts +50 -2
- package/src/codex/routing/transient-hold-dispatch.ts +141 -0
- package/src/codex/routing.ts +29 -49
- package/src/codex/warmup.ts +1 -1
- package/src/combos/failover.ts +85 -0
- package/src/combos/request.ts +17 -10
- package/src/combos/types.ts +23 -2
- package/src/config/pending-teardown.ts +31 -0
- package/src/generated/compatibility-version.json +163 -135
- package/src/images/loop.ts +1 -1
- package/src/lib/errors.ts +17 -0
- package/src/lib/request-execution-budget.ts +147 -21
- package/src/lib/spend-reservation-ledger.ts +18 -0
- package/src/lib/state-store-registrations.ts +6 -2
- package/src/lib/test-home-guard.ts +85 -1
- package/src/lib/upstream-retry.ts +77 -10
- package/src/lib/windows-elevation.ts +76 -14
- package/src/oauth/index.ts +2 -2
- package/src/oauth/key-providers.ts +2 -2
- package/src/providers/kiro-models.ts +4 -3
- package/src/providers/label.ts +19 -1
- package/src/providers/model-discovery.ts +16 -0
- package/src/providers/registry/entries-core.ts +7 -0
- package/src/providers/registry/entries-extended.ts +9 -0
- package/src/providers/registry/model-seeds.ts +4 -0
- package/src/responses/reasoning-envelope.ts +6 -3
- package/src/routing/identity-domains.ts +21 -14
- package/src/routing/probe-lease.ts +103 -1
- package/src/server/chat-completions.ts +3 -1
- package/src/server/chat-native.ts +37 -9
- package/src/server/index/live-sideband.ts +37 -1
- package/src/server/index/websocket-handler.ts +6 -2
- package/src/server/index.ts +5 -5
- package/src/server/inspection-tee.ts +107 -0
- package/src/server/live.ts +46 -1
- package/src/server/management/combo-routes.ts +10 -1
- package/src/server/relay-eager.ts +2 -0
- package/src/server/relay.ts +14 -19
- package/src/server/request-log.ts +127 -3
- package/src/server/response-log-body.ts +153 -0
- package/src/server/responses/account-change-state.ts +74 -0
- package/src/server/responses/adapter-continuation.ts +33 -7
- package/src/server/responses/adapter-delivery.ts +5 -11
- package/src/server/responses/adapter-dispatch.ts +84 -13
- package/src/server/responses/codex-ws-wire.ts +5 -0
- package/src/server/responses/collaboration.ts +74 -4
- package/src/server/responses/combo-session-recall.ts +68 -8
- package/src/server/responses/compact.ts +54 -13
- package/src/server/responses/core-auth.ts +2 -0
- package/src/server/responses/core-codex-account.ts +51 -3
- package/src/server/responses/core-combo.ts +103 -23
- package/src/server/responses/core-errors.ts +18 -0
- package/src/server/responses/core-replay.ts +105 -32
- package/src/server/responses/core.ts +3 -3
- package/src/server/responses/encrypted-payload.ts +0 -1
- package/src/server/responses/input-admission.ts +126 -6
- package/src/server/responses/passthrough-delivery.ts +19 -6
- package/src/server/responses/passthrough-dispatch.ts +28 -10
- package/src/server/responses/passthrough-error.ts +38 -2
- package/src/server/responses/request-prepare.ts +132 -22
- package/src/server/responses/request-send-budget.ts +97 -2
- package/src/server/responses/request-spend.ts +147 -0
- package/src/server/responses/request-transport.ts +62 -3
- package/src/server/responses/run-turn-execution.ts +59 -31
- package/src/server/responses/sidecar-execution.ts +7 -13
- package/src/server/responses/terminal-guard.ts +65 -4
- package/src/server/responses-undeclared-tool-guard.ts +9 -5
- package/src/service/windows-ops.ts +210 -16
- package/src/service/windows-scheduler.ts +28 -21
- package/src/service.ts +1 -1
- package/src/types/config.ts +4 -1
- package/src/types/request.ts +8 -5
- package/src/types/tools.ts +24 -0
- package/src/types.ts +2 -0
- package/src/update/index.ts +10 -0
- package/src/update/stop-contract.d.mts +1 -0
- package/src/update/stop-contract.mjs +19 -0
- package/src/update/stop-decision.d.mts +1 -1
- package/src/update/stop-decision.mjs +12 -3
- package/src/usage/log.ts +1 -1
- package/src/vision/anthropic-describe.ts +1 -1
- package/src/vision/describe.ts +5 -5
- package/src/web-search/anthropic-executor.ts +1 -1
- package/src/web-search/exa-executor.ts +1 -1
- package/src/web-search/executor.ts +1 -1
- package/src/web-search/gemini-executor.ts +1 -1
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/ollama-executor.ts +1 -1
- package/src/web-search/parse.ts +67 -14
- package/src/web-search/passthrough-bridge.ts +64 -31
- package/src/web-search/xai-executor.ts +1 -1
package/src/cli/combo.ts
CHANGED
|
@@ -15,7 +15,8 @@ const USAGE = `Usage:
|
|
|
15
15
|
ocx combo show <id> [--json]
|
|
16
16
|
ocx combo set <id> --targets <provider/model[:weight],...>
|
|
17
17
|
[--strategy <failover|round-robin|random|least-used|reset-window>] [--sticky <1-100>]
|
|
18
|
-
[--effort <low|medium|high|xhigh|max|ultra|->] [--
|
|
18
|
+
[--effort <low|medium|high|xhigh|max|ultra|->] [--effort-mode <fallback|force>]
|
|
19
|
+
(force overrides valid client effort and can increase cost/latency) [--alias <name|->]
|
|
19
20
|
[--native-alias] [--display-name <label|->]
|
|
20
21
|
[--rename-from <id>] [--json]
|
|
21
22
|
ocx combo remove <id> --yes [--json]`;
|
|
@@ -80,6 +81,10 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
80
81
|
if (strategy !== "round-robin") throw new CliUsageError("--sticky applies only to round-robin", USAGE);
|
|
81
82
|
}
|
|
82
83
|
const effort = takeOption(args, "--effort");
|
|
84
|
+
const effortMode = takeOption(args, "--effort-mode");
|
|
85
|
+
if (effortMode !== undefined && effortMode !== "fallback" && effortMode !== "force") {
|
|
86
|
+
throw new CliUsageError("--effort-mode must be fallback or force", USAGE);
|
|
87
|
+
}
|
|
83
88
|
const alias = takeOption(args, "--alias");
|
|
84
89
|
const nativeAlias = takeFlag(args, "--native-alias");
|
|
85
90
|
const displayName = takeOption(args, "--display-name");
|
|
@@ -91,12 +96,16 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
91
96
|
targets: parseTargets(targetsRaw),
|
|
92
97
|
};
|
|
93
98
|
if (effort !== undefined) combo.defaultEffort = effort === "-" ? null : effort;
|
|
99
|
+
if (effortMode !== undefined) combo.defaultEffortMode = effortMode;
|
|
94
100
|
if (alias !== undefined) combo.alias = alias === "-" ? "" : alias;
|
|
95
101
|
if (nativeAlias) combo.nativeAlias = true;
|
|
96
102
|
if (displayName !== undefined) combo.displayName = displayName === "-" ? "" : displayName;
|
|
97
103
|
const current = await runtimeRequest<{ combos?: ComboRow[] }>("/api/combos", {}, deps);
|
|
98
104
|
const existing = (current.combos ?? []).find(row => row.id === (renameFrom ?? id));
|
|
99
105
|
if (existing?.imageInput === "disabled") combo.imageInput = "disabled";
|
|
106
|
+
if (effortMode === undefined && existing?.defaultEffortMode === "force") {
|
|
107
|
+
combo.defaultEffortMode = effort === "-" ? "fallback" : "force";
|
|
108
|
+
}
|
|
100
109
|
const result = await runtimeRequest("/api/combos", {
|
|
101
110
|
method: "PUT",
|
|
102
111
|
body: JSON.stringify({ id, combo, ...(renameFrom ? { renameFrom } : {}) }),
|
package/src/cli/index.ts
CHANGED
|
@@ -15,7 +15,7 @@ try {
|
|
|
15
15
|
}
|
|
16
16
|
import { currentExternalCodexModelProvider, restoreNativeCodex, restoreNativeCodexAsync, shouldInjectApiAuthHeader } from "../codex/inject";
|
|
17
17
|
import { stripGrokConfig } from "../grok/inject";
|
|
18
|
-
import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../update/stop-contract.mjs";
|
|
18
|
+
import { STOP_HISTORY_DEFERRED_EXIT_CODE, STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../update/stop-contract.mjs";
|
|
19
19
|
import {
|
|
20
20
|
describeHistoryJobFailure,
|
|
21
21
|
resolveCodexHistoryJobTarget,
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
isPendingTeardownAbandoned,
|
|
47
47
|
listPendingTeardowns,
|
|
48
48
|
pendingTeardownPathFor,
|
|
49
|
+
pendingTeardownsAreExactly,
|
|
49
50
|
quarantinePendingTeardown,
|
|
50
51
|
} from "../config/pending-teardown";
|
|
51
52
|
import { collectStatus, hubStatusLines, remoteHubBannerLine, remoteHubStatusLines, unusedProxyWarningLines } from "./status";
|
|
@@ -785,9 +786,16 @@ async function handleRestartStartWhenStopped(): Promise<boolean | "skipped"> {
|
|
|
785
786
|
*
|
|
786
787
|
* The distinction exists because `ocx update` must proceed for the first and abort for the
|
|
787
788
|
* second, and it can only see an exit code (#3008).
|
|
789
|
+
*
|
|
790
|
+
* `historyDeferred` is the third kind (#4718). The Codex history preflight refuses BEFORE
|
|
791
|
+
* the config half runs, so nothing was restored at all: config, catalog, history and
|
|
792
|
+
* provenance are untouched and the client is still routed at the proxy that just stopped.
|
|
793
|
+
* Like `historyOnly` the proxy is genuinely down, so an update may replace package files.
|
|
794
|
+
* Unlike `historyOnly` the obligation was not performed, so the receipt must survive.
|
|
788
795
|
*/
|
|
789
|
-
async function restoreSharedClientStateAfterStop(): Promise<{ historyOnly: boolean; other: boolean }> {
|
|
796
|
+
async function restoreSharedClientStateAfterStop(): Promise<{ historyOnly: boolean; historyDeferred: boolean; other: boolean }> {
|
|
790
797
|
let historyOnly = false;
|
|
798
|
+
let historyDeferred = false;
|
|
791
799
|
let other = false;
|
|
792
800
|
try {
|
|
793
801
|
const result = await restoreNativeCodexAsync();
|
|
@@ -798,7 +806,16 @@ async function restoreSharedClientStateAfterStop(): Promise<{ historyOnly: boole
|
|
|
798
806
|
// not — a client reads those, so their failure is a real teardown failure.
|
|
799
807
|
const artifacts = result.artifacts;
|
|
800
808
|
const configOrCatalogFailed = artifacts.config.state === "failed" || artifacts.catalog.state === "failed";
|
|
801
|
-
|
|
809
|
+
// A preflight refusal reports every artifact as `skipped` because none of them were
|
|
810
|
+
// attempted. Reading the states alone cannot tell that apart from an ownership
|
|
811
|
+
// refusal, so the structured reason carries it and the states are still required to
|
|
812
|
+
// agree — a refusal that somehow reports a failed artifact is not this case.
|
|
813
|
+
const preflightRefused = result.historyPreflightRefusal !== undefined
|
|
814
|
+
&& artifacts.config.state === "skipped"
|
|
815
|
+
&& artifacts.catalog.state === "skipped"
|
|
816
|
+
&& artifacts.history.state === "skipped";
|
|
817
|
+
if (preflightRefused) historyDeferred = true;
|
|
818
|
+
else if (!configOrCatalogFailed && artifacts.history.state === "failed") historyOnly = true;
|
|
802
819
|
else other = true;
|
|
803
820
|
console.error(`⚠️ ${result.message}`);
|
|
804
821
|
}
|
|
@@ -816,7 +833,7 @@ async function restoreSharedClientStateAfterStop(): Promise<{ historyOnly: boole
|
|
|
816
833
|
other = true;
|
|
817
834
|
console.error(`⚠️ Grok config restore failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
818
835
|
}
|
|
819
|
-
return { historyOnly, other };
|
|
836
|
+
return { historyOnly, historyDeferred, other };
|
|
820
837
|
}
|
|
821
838
|
|
|
822
839
|
async function handleStop() {
|
|
@@ -860,6 +877,11 @@ async function handleStop() {
|
|
|
860
877
|
};
|
|
861
878
|
let stopFailed = false;
|
|
862
879
|
let historyOnlyFailure = false;
|
|
880
|
+
/**
|
|
881
|
+
* Obligations this run deliberately kept because the Codex history preflight refused
|
|
882
|
+
* before restoring anything (#4718). Non-null selects the deferred exit code.
|
|
883
|
+
*/
|
|
884
|
+
let historyDeferredNonces: string[] | null = null;
|
|
863
885
|
// Only Task Scheduler respawns after a successful stop (#764), so only it earns the
|
|
864
886
|
// restart-window wait; launchd, systemd and WinSW are down when they say so.
|
|
865
887
|
let schedulerCanRespawn = false;
|
|
@@ -1138,6 +1160,7 @@ async function handleStop() {
|
|
|
1138
1160
|
}
|
|
1139
1161
|
const restore = await restoreSharedClientStateAfterStop();
|
|
1140
1162
|
if (restore.other) stopFailed = true;
|
|
1163
|
+
else if (restore.historyDeferred) historyDeferredNonces = teardownNonce ? [teardownNonce, ...recoveredNonces] : recoveredNonces;
|
|
1141
1164
|
else if (restore.historyOnly) historyOnlyFailure = true;
|
|
1142
1165
|
// The obligation is discharged whether or not history metadata finalized: config and
|
|
1143
1166
|
// catalog are what a client reads, and `restore.other` already fails the stop.
|
|
@@ -1145,7 +1168,16 @@ async function handleStop() {
|
|
|
1145
1168
|
// Each nonce names its own file, so a clear can only ever remove the obligation it
|
|
1146
1169
|
// names — never one a concurrent stop wrote. Both this run's claim and every inherited
|
|
1147
1170
|
// receipt it proved discharged are released together.
|
|
1148
|
-
|
|
1171
|
+
//
|
|
1172
|
+
// A history-preflight refusal is the exception: it restored nothing, so there is
|
|
1173
|
+
// nothing to discharge. Clearing here would drop a real obligation on the floor and
|
|
1174
|
+
// leave the client config pointing at a proxy that is gone, with nothing on disk
|
|
1175
|
+
// saying so — which is the whole failure the receipt exists to prevent (#4718).
|
|
1176
|
+
if (restore.historyDeferred) {
|
|
1177
|
+
console.error(" The shared teardown was refused before it changed anything, so it is still owed.");
|
|
1178
|
+
console.error(" Its receipt is preserved; run 'ocx stop' again once Codex is closed to retry the restore.");
|
|
1179
|
+
}
|
|
1180
|
+
if (!restore.other && !restore.historyDeferred) {
|
|
1149
1181
|
const discharged = teardownNonce ? [teardownNonce, ...recoveredNonces] : recoveredNonces;
|
|
1150
1182
|
for (const nonce of discharged) {
|
|
1151
1183
|
// A receipt that survives its discharge re-triggers recovery forever, so a failed
|
|
@@ -1185,6 +1217,17 @@ async function handleStop() {
|
|
|
1185
1217
|
// still wins: it is the stronger signal.
|
|
1186
1218
|
if (stopFailed) process.exitCode = 1;
|
|
1187
1219
|
else if (historyOnlyFailure) process.exitCode = STOP_HISTORY_INCOMPLETE_EXIT_CODE;
|
|
1220
|
+
// The deferred code says "the only obligations left are the ones I just decided to
|
|
1221
|
+
// keep". It is read across a process boundary by an updater that will replace package
|
|
1222
|
+
// files on the strength of it, so this run has to be able to prove the claim: if any
|
|
1223
|
+
// other obligation is sitting in the home — quarantined, or a concurrent stop's — the
|
|
1224
|
+
// claim is false and the ordinary failure code is the honest answer. That is also the
|
|
1225
|
+
// behaviour before #4718, so the fallback loses nothing that used to work.
|
|
1226
|
+
else if (historyDeferredNonces) {
|
|
1227
|
+
process.exitCode = pendingTeardownsAreExactly(historyDeferredNonces)
|
|
1228
|
+
? STOP_HISTORY_DEFERRED_EXIT_CODE
|
|
1229
|
+
: 1;
|
|
1230
|
+
}
|
|
1188
1231
|
return !stopFailed;
|
|
1189
1232
|
}
|
|
1190
1233
|
|
package/src/cli/registry.ts
CHANGED
|
@@ -407,7 +407,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
|
|
|
407
407
|
"Ensures the proxy is running, then execs `claude` with ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN,",
|
|
408
408
|
"CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 and model slots from config.claudeCode.",
|
|
409
409
|
"When Claude routing is explicitly disabled, it launches natively after removing proven OpenCodex-owned proxy state.",
|
|
410
|
-
"Routed models appear in the native /model picker with stable claude-opus-4-8-
|
|
410
|
+
"Routed models appear in the native /model picker with stable claude-opus-4-8-YYYYMMDD slot aliases,",
|
|
411
|
+
"where the year runs 2026-2035 and 2026 slots are allocated first (Claude Code >= 2.1.129).",
|
|
411
412
|
"Older versions: pick models via ANTHROPIC_MODEL or /model <id> directly (any string passes through).",
|
|
412
413
|
"User-exported ANTHROPIC_* variables take precedence for routed launches; native fallback removes only proven OpenCodex-owned proxy values.",
|
|
413
414
|
"",
|
|
@@ -130,14 +130,14 @@ export async function handleSystemCommand(argv: string[], deps: RuntimeApiDeps =
|
|
|
130
130
|
const args = [...rest]; const wantsJson = takeFlag(args, "--json"); rejectArgs(args, USAGE);
|
|
131
131
|
printData(await runtimeRequest("/api/system/codex-app-server", {}, deps), wantsJson);
|
|
132
132
|
} else if (sub === "codex-restart") {
|
|
133
|
-
// --yes required: this
|
|
134
|
-
//
|
|
133
|
+
// --yes required: this fully quits and relaunches the user's Codex desktop app as well as
|
|
134
|
+
// restarting app-servers; an agent guessing a subcommand must not interrupt that session.
|
|
135
135
|
const args = [...rest];
|
|
136
136
|
const wantsJson = takeFlag(args, "--json");
|
|
137
137
|
const yes = takeFlag(args, "--yes");
|
|
138
|
-
if (!yes) throw new CliUsageError("system codex-restart requires --yes", USAGE);
|
|
138
|
+
if (!yes) throw new CliUsageError("system codex-restart requires --yes: this fully quits and relaunches the Codex desktop app and restarts its app-servers", USAGE);
|
|
139
139
|
rejectArgs(args, USAGE);
|
|
140
|
-
printData(await runtimeRequest("/api/system/codex-restart", { method: "POST" }, deps), wantsJson, ["Codex app-server restart requested."]);
|
|
140
|
+
printData(await runtimeRequest("/api/system/codex-restart", { method: "POST" }, deps), wantsJson, ["Codex desktop app and app-server restart requested."]);
|
|
141
141
|
} else if (sub === "update") await update(rest, deps);
|
|
142
142
|
else throw new CliUsageError(`unknown system command ${sub}`, USAGE);
|
|
143
143
|
});
|
|
@@ -812,6 +812,7 @@ export interface OpenclawModelEntry {
|
|
|
812
812
|
id: string;
|
|
813
813
|
name: string;
|
|
814
814
|
contextWindow?: number;
|
|
815
|
+
input?: string[];
|
|
815
816
|
}
|
|
816
817
|
|
|
817
818
|
export interface OpenclawProviderBlock {
|
|
@@ -839,15 +840,15 @@ export interface KimiProviderBlock {
|
|
|
839
840
|
/**
|
|
840
841
|
* `max_context_size` is mandatory and must be positive, so a model with no
|
|
841
842
|
* authoritative context window is omitted from the document entirely rather
|
|
842
|
-
* than guessed at.
|
|
843
|
-
*
|
|
844
|
-
* that a routed selector will not match.
|
|
843
|
+
* than guessed at. Catalog image input becomes `image_in`; other capabilities
|
|
844
|
+
* are not inferred from routed model names.
|
|
845
845
|
*/
|
|
846
846
|
export interface KimiModelBlock {
|
|
847
847
|
provider: string;
|
|
848
848
|
model: string;
|
|
849
849
|
max_context_size: number;
|
|
850
850
|
display_name?: string;
|
|
851
|
+
capabilities?: ["image_in"];
|
|
851
852
|
}
|
|
852
853
|
|
|
853
854
|
export interface KimiGeneratedConfig {
|
|
@@ -974,10 +975,12 @@ function buildHermesClientConfig(ctx: ExportContext): HermesGeneratedConfig {
|
|
|
974
975
|
function buildOpenclawClientConfig(ctx: ExportContext): OpenclawGeneratedConfig {
|
|
975
976
|
const models: OpenclawModelEntry[] = normalizeExportModels(ctx.models).map(model => {
|
|
976
977
|
const context = authoritativeContextWindow(model.contextWindow);
|
|
978
|
+
const input = [...new Set(model.inputModalities?.filter(value => ["text", "image", "video", "audio"].includes(value)))];
|
|
977
979
|
return {
|
|
978
980
|
id: model.namespaced,
|
|
979
981
|
name: exportModelLabel(model),
|
|
980
982
|
...(context !== undefined ? { contextWindow: context } : {}),
|
|
983
|
+
...(input.length > 0 ? { input } : {}),
|
|
981
984
|
};
|
|
982
985
|
});
|
|
983
986
|
const headers = proxyAdmissionHeaders(ctx.config, OPENCLAW_API_KEY_ENV_REF);
|
|
@@ -1015,6 +1018,7 @@ function buildKimiClientConfig(ctx: ExportContext): KimiGeneratedConfig {
|
|
|
1015
1018
|
model: model.namespaced,
|
|
1016
1019
|
max_context_size: context,
|
|
1017
1020
|
...(model.displayName ? { display_name: model.displayName } : {}),
|
|
1021
|
+
...(model.inputModalities?.includes("image") ? { capabilities: ["image_in"] as ["image_in"] } : {}),
|
|
1018
1022
|
};
|
|
1019
1023
|
}
|
|
1020
1024
|
return {
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import type { CodexAccount, OcxConfig } from "../types";
|
|
3
3
|
import type { CodexAuthContext } from "./auth-context";
|
|
4
|
+
import type { ProviderApiKeySelection } from "../types/provider";
|
|
4
5
|
import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
|
|
5
6
|
|
|
6
7
|
export const CODEX_ACCOUNT_LOG_LABEL_RE = /^p[a-f0-9]{6}$/;
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
|
-
* Account log labels come in
|
|
10
|
+
* Account log labels come in three families:
|
|
10
11
|
*
|
|
11
12
|
* - `p<hex6>` (plus the literal `main`) — a Codex pool account.
|
|
12
13
|
* - `o<hex6>` — a non-Codex OAuth provider account (xai, cursor, and siblings).
|
|
14
|
+
* - `k<hex32>` — a request-owned API-key selection, scoped to provider and reference.
|
|
13
15
|
*
|
|
14
|
-
*
|
|
16
|
+
* Labels never contain an email, raw key/reference, or raw provider account id. That is
|
|
15
17
|
* a privacy requirement, not a formatting preference: these labels are written to the usage log
|
|
16
18
|
* and served over the management API.
|
|
17
19
|
*
|
|
@@ -20,7 +22,16 @@ export const CODEX_ACCOUNT_LOG_LABEL_RE = /^p[a-f0-9]{6}$/;
|
|
|
20
22
|
* accepted cost of keeping the existing `p` format byte-compatible.
|
|
21
23
|
*/
|
|
22
24
|
export const OAUTH_ACCOUNT_LOG_LABEL_RE = /^o[a-f0-9]{6}$/;
|
|
23
|
-
export const
|
|
25
|
+
export const KEY_ACCOUNT_LOG_LABEL_RE = /^k[a-f0-9]{32}$/;
|
|
26
|
+
export const ACCOUNT_LOG_LABEL_RE = /^(?:main|[po][a-f0-9]{6}|k[a-f0-9]{32})$/;
|
|
27
|
+
|
|
28
|
+
/** Digest the request-owned configured selection, never serialize its key/reference. */
|
|
29
|
+
export function apiKeyAccountLogLabel(provider: string, selection: ProviderApiKeySelection | undefined): `k${string}` | undefined {
|
|
30
|
+
if (!selection || typeof selection.reference !== "string" || !selection.reference.length) return undefined;
|
|
31
|
+
return `k${createHash("sha256").update(JSON.stringify([
|
|
32
|
+
"ocx-key-account-v1", provider, selection.entryId ?? null, selection.reference,
|
|
33
|
+
])).digest("hex").slice(0, 32)}`;
|
|
34
|
+
}
|
|
24
35
|
|
|
25
36
|
export function oauthAccountLogLabel(accountId: string, provider = ""): string {
|
|
26
37
|
return `o${createHash("sha256").update(`${provider}\0${accountId}`).digest("hex").slice(0, 6)}`;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { closeSync, existsSync, readFileSync, mkdirSync, openSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { closeSync, existsSync, fstatSync, readFileSync, mkdirSync, openSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import {
|
|
5
5
|
ConfigMutationLockError,
|
|
@@ -268,6 +268,23 @@ export function readCodexAccountRecord(id: string): CodexAccountCredentialRecord
|
|
|
268
268
|
return loadCodexAccountRecordStore()[id] ?? null;
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
/**
|
|
272
|
+
* One store load, every record, for a caller that resolves MANY ids in a single synchronous pass.
|
|
273
|
+
*
|
|
274
|
+
* `readCodexAccountRecord` reloads, reparses and renormalizes the whole file per id. That is the
|
|
275
|
+
* right shape for one lookup and the wrong shape for a loop: the entitlement denial reader holds
|
|
276
|
+
* up to 64 accounts with four client versions each, so scoring one warm flagship request could
|
|
277
|
+
* perform up to 256 full-store reads on the request path.
|
|
278
|
+
*
|
|
279
|
+
* These are the same normalized records `readCodexAccountRecord` hands out, tombstones included,
|
|
280
|
+
* so the caller keeps its own `deletedAt` and `generation` checks instead of trusting a filtered
|
|
281
|
+
* view. That is the difference from `loadCodexAccountStore`, which drops both and cannot answer a
|
|
282
|
+
* question about credential generation.
|
|
283
|
+
*/
|
|
284
|
+
export function loadCodexAccountRecordSnapshot(): Readonly<Record<string, CodexAccountCredentialRecord>> {
|
|
285
|
+
return loadCodexAccountRecordStore();
|
|
286
|
+
}
|
|
287
|
+
|
|
271
288
|
const QUOTA_HISTORY_IDENTITY_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
272
289
|
|
|
273
290
|
function validQuotaHistoryIdentity(value: unknown): value is string {
|
|
@@ -655,10 +672,43 @@ function isRefreshLockStale(path: string): boolean {
|
|
|
655
672
|
const parsed = JSON.parse(readFileSync(path, "utf-8")) as { acquiredAt?: unknown };
|
|
656
673
|
return typeof parsed.acquiredAt !== "number" || Date.now() - parsed.acquiredAt > REFRESH_LOCK_STALE_MS;
|
|
657
674
|
} catch {
|
|
658
|
-
|
|
675
|
+
// The owner creates the file and writes its metadata in two steps, so a live lock is
|
|
676
|
+
// briefly unreadable. Age the file itself instead of calling that window stale, which
|
|
677
|
+
// let a waiter delete a lock whose owner was still inside its critical section.
|
|
678
|
+
try {
|
|
679
|
+
return Date.now() - statSync(path).mtimeMs > REFRESH_LOCK_STALE_MS;
|
|
680
|
+
} catch {
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
659
683
|
}
|
|
660
684
|
}
|
|
661
685
|
|
|
686
|
+
function releaseCodexRefreshFileLock(path: string, fd: number): void {
|
|
687
|
+
let owned: { dev: bigint; ino: bigint } | null = null;
|
|
688
|
+
try {
|
|
689
|
+
const info = fstatSync(fd, { bigint: true });
|
|
690
|
+
if (info.dev >= 0n && info.ino > 0n) owned = { dev: info.dev, ino: info.ino };
|
|
691
|
+
} catch { /* Unknown descriptor identity never authorizes unlink. */ }
|
|
692
|
+
try {
|
|
693
|
+
withConfigMutationLockSync(() => {
|
|
694
|
+
let current: { dev: bigint; ino: bigint } | null = null;
|
|
695
|
+
try {
|
|
696
|
+
const info = statSync(path, { bigint: true });
|
|
697
|
+
if (info.dev >= 0n && info.ino > 0n) current = { dev: info.dev, ino: info.ino };
|
|
698
|
+
} catch { /* Keep the lock and the callback outcome when the path probe fails. */ }
|
|
699
|
+
if (owned && current && current.dev === owned.dev && current.ino === owned.ino) {
|
|
700
|
+
try { unlinkSync(path); } catch (err) {
|
|
701
|
+
if (errCode(err) !== "ENOENT") throw err;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
} catch (err) {
|
|
706
|
+
// Keep the descriptor alive through comparison/unlink so its inode cannot be recycled.
|
|
707
|
+
// Unavailable coordination leaves the path without masking the completed refresh.
|
|
708
|
+
if (!(err instanceof ConfigMutationLockError)) throw err;
|
|
709
|
+
} finally { closeSync(fd); }
|
|
710
|
+
}
|
|
711
|
+
|
|
662
712
|
export async function withCodexRefreshFileLock<T>(lockKey: string, signal: AbortSignal, fn: () => Promise<T>): Promise<T> {
|
|
663
713
|
hardenConfigDir();
|
|
664
714
|
const dir = getConfigDir();
|
|
@@ -670,33 +720,45 @@ export async function withCodexRefreshFileLock<T>(lockKey: string, signal: Abort
|
|
|
670
720
|
while (fd == null) {
|
|
671
721
|
if (signal.aborted) throw signal.reason;
|
|
672
722
|
try {
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
} catch (err) {
|
|
677
|
-
if (errCode(err) !== "EEXIST") throw err;
|
|
678
|
-
if (isRefreshLockStale(path)) {
|
|
723
|
+
// Serialize only metadata operations, never the async refresh callback. Cooperating
|
|
724
|
+
// contenders cannot reclaim a successor between stale observation and path mutation.
|
|
725
|
+
withConfigMutationLockSync(() => {
|
|
679
726
|
try {
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
727
|
+
fd = openSync(path, "wx", 0o600);
|
|
728
|
+
writeFileSync(fd, JSON.stringify({ acquiredAt: Date.now(), pid: process.pid }) + "\n");
|
|
729
|
+
} catch (err) {
|
|
730
|
+
if (fd != null) {
|
|
731
|
+
const failedFd = fd;
|
|
732
|
+
fd = null;
|
|
733
|
+
try { releaseCodexRefreshFileLock(path, failedFd); } catch { /* Preserve write failure. */ }
|
|
734
|
+
throw err;
|
|
735
|
+
}
|
|
736
|
+
if (errCode(err) !== "EEXIST") throw err;
|
|
737
|
+
if (isRefreshLockStale(path)) {
|
|
738
|
+
try { unlinkSync(path); } catch (unlinkErr) {
|
|
739
|
+
if (errCode(unlinkErr) !== "ENOENT") throw unlinkErr;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
683
742
|
}
|
|
684
|
-
|
|
743
|
+
});
|
|
744
|
+
} catch (err) {
|
|
745
|
+
// A failed SQLite commit can follow successful file creation; it still owns an fd.
|
|
746
|
+
if (fd != null) {
|
|
747
|
+
const failedFd = fd;
|
|
748
|
+
fd = null;
|
|
749
|
+
try { releaseCodexRefreshFileLock(path, failedFd); } catch { /* Preserve admission failure. */ }
|
|
685
750
|
}
|
|
686
|
-
if (
|
|
687
|
-
await sleep(REFRESH_LOCK_POLL_MS, signal);
|
|
751
|
+
if (!(err instanceof ConfigMutationLockError)) throw err;
|
|
688
752
|
}
|
|
753
|
+
if (fd != null) break;
|
|
754
|
+
if (Date.now() >= deadline) throw new CodexCredentialRefreshLockTimeoutError();
|
|
755
|
+
await sleep(REFRESH_LOCK_POLL_MS, signal);
|
|
689
756
|
}
|
|
690
757
|
|
|
691
758
|
try {
|
|
692
759
|
return await fn();
|
|
693
760
|
} finally {
|
|
694
|
-
|
|
695
|
-
try {
|
|
696
|
-
unlinkSync(path);
|
|
697
|
-
} catch (err) {
|
|
698
|
-
if (errCode(err) !== "ENOENT") throw err;
|
|
699
|
-
}
|
|
761
|
+
releaseCodexRefreshFileLock(path, fd);
|
|
700
762
|
}
|
|
701
763
|
}
|
|
702
764
|
|
|
@@ -1137,9 +1199,20 @@ async function resolveCodexToken(
|
|
|
1137
1199
|
let errDesc: string;
|
|
1138
1200
|
let errCodeExact: string | undefined;
|
|
1139
1201
|
try {
|
|
1140
|
-
const parsed = JSON.parse(errText) as {
|
|
1141
|
-
|
|
1142
|
-
|
|
1202
|
+
const parsed = JSON.parse(errText) as {
|
|
1203
|
+
error?: string | { code?: string; message?: string };
|
|
1204
|
+
error_description?: string;
|
|
1205
|
+
};
|
|
1206
|
+
if (typeof parsed.error === "string") {
|
|
1207
|
+
errCodeExact = parsed.error.trim();
|
|
1208
|
+
errDesc = [parsed.error, parsed.error_description].filter(Boolean).join(": ");
|
|
1209
|
+
} else if (parsed.error && typeof parsed.error === "object") {
|
|
1210
|
+
errCodeExact = typeof parsed.error.code === "string" ? parsed.error.code.trim() : undefined;
|
|
1211
|
+
errDesc = [parsed.error.code, parsed.error.message, parsed.error_description].filter(Boolean).join(": ");
|
|
1212
|
+
} else {
|
|
1213
|
+
errDesc = parsed.error_description || `HTTP ${res.status}`;
|
|
1214
|
+
}
|
|
1215
|
+
if (!errDesc) errDesc = `HTTP ${res.status}`;
|
|
1143
1216
|
} catch { errDesc = `HTTP ${res.status}`; }
|
|
1144
1217
|
// `invalid_grant` is the standard OAuth code for a refresh token that is no longer
|
|
1145
1218
|
// usable, and upstream sends it bare with no description. Without it here the dead
|
|
@@ -1149,9 +1222,23 @@ async function resolveCodexToken(
|
|
|
1149
1222
|
// Matched on the exact `error` CODE, not anywhere in the combined text: a transient
|
|
1150
1223
|
// `server_error` whose description happens to mention invalid_grant would otherwise
|
|
1151
1224
|
// retire a healthy account, which is the failure this whole change exists to remove.
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1225
|
+
//
|
|
1226
|
+
// That rule binds the DESCRIPTION words too. "invalidated", "revoked" and "expired" read
|
|
1227
|
+
// as terminal prose, but upstream puts arbitrary text there: a `server_error` whose
|
|
1228
|
+
// description says "token was revoked" or "session expired" is still a 5xx blip, and
|
|
1229
|
+
// retiring the account on it is exactly the false quarantine #2887 exists to prevent.
|
|
1230
|
+
// So a body that carries a structured code is classified by that code ALONE. The
|
|
1231
|
+
// substring fallback survives only where there is no structured code to read at all --
|
|
1232
|
+
// a description-only body, or one this parser could not decode -- because there the
|
|
1233
|
+
// prose is the only signal upstream gave us.
|
|
1234
|
+
const structuredCode = errCodeExact ? errCodeExact : undefined;
|
|
1235
|
+
const proseIsOnlySignal = structuredCode === undefined;
|
|
1236
|
+
const reason = structuredCode === "invalid_grant"
|
|
1237
|
+
|| structuredCode === "refresh_token_invalidated"
|
|
1238
|
+
|| (proseIsOnlySignal
|
|
1239
|
+
&& (errDesc.includes("invalidated") || errDesc.includes("revoked"))) ? "revoked" as const
|
|
1240
|
+
: structuredCode === "refresh_token_expired"
|
|
1241
|
+
|| (proseIsOnlySignal && errDesc.includes("expired")) ? "expired" as const
|
|
1155
1242
|
: "unknown" as const;
|
|
1156
1243
|
throw new TokenRefreshError(reason, `Codex token refresh failed (${reason}); reauthenticate the account.`);
|
|
1157
1244
|
}
|
|
@@ -18,6 +18,27 @@ export interface CodexAccountUsabilityOptions {
|
|
|
18
18
|
isMainAccountTokenLive?: typeof isMainAccountTokenLive;
|
|
19
19
|
/** Confirmed account ids for an account-gated model; omitted for ordinary native models. */
|
|
20
20
|
modelEligibleAccountIds?: ReadonlySet<string>;
|
|
21
|
+
/**
|
|
22
|
+
* Accounts whose own confirmed roster definitively omits the requested model (#4768).
|
|
23
|
+
*
|
|
24
|
+
* Deliberately NOT read by this module. `modelEligibleAccountIds` is an eligibility boundary and
|
|
25
|
+
* produces `model_not_entitled`; this is an ORDERING preference applied once, in
|
|
26
|
+
* `getEligiblePoolAccounts`, and dropped whenever honouring it would leave no candidate. Reading
|
|
27
|
+
* it here would turn a preference into a refusal and re-create the fail-closed behaviour the
|
|
28
|
+
* flagships were deliberately taken out of.
|
|
29
|
+
*/
|
|
30
|
+
deniedModelAccountIds?: ReadonlySet<string>;
|
|
31
|
+
/**
|
|
32
|
+
* This request's conversation carries live uploaded-file references (#4778).
|
|
33
|
+
*
|
|
34
|
+
* Also not read by this module, and for the same reason: it is a retention preference, never an
|
|
35
|
+
* eligibility boundary. Uploaded files are scoped to the account that issued them, so moving
|
|
36
|
+
* such a conversation orphans the reference and every later turn is refused with
|
|
37
|
+
* `409 account_change_file_scope` -- the reference stays in history, so the conversation is
|
|
38
|
+
* effectively dead. Retention makes that refusal rarer; it can never replace it, because an
|
|
39
|
+
* account can always become unable to serve.
|
|
40
|
+
*/
|
|
41
|
+
retainAccountForUploadedFiles?: boolean;
|
|
21
42
|
}
|
|
22
43
|
|
|
23
44
|
/**
|
|
@@ -10,7 +10,7 @@ import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth }
|
|
|
10
10
|
import { clearCodexPoolRefreshFailure } from "../pool-refresh-backoff";
|
|
11
11
|
import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
|
|
12
12
|
import { emailMaskingEnabled, projectEmail } from "../../lib/privacy";
|
|
13
|
-
import { codexWarmupFailureReason, isCodexWarmupProvisioningFailure, warmCodexAccount } from "../warmup";
|
|
13
|
+
import { CodexWarmupError, codexWarmupFailureReason, isCodexWarmupProvisioningFailure, warmCodexAccount } from "../warmup";
|
|
14
14
|
import type { CodexAccount, CodexAccountCredentials, OcxConfig } from "../../types";
|
|
15
15
|
import type { CatalogDisposition } from "../convergence-types";
|
|
16
16
|
import { isValidCodexAccountId } from "../account-id";
|
|
@@ -51,6 +51,17 @@ export async function verifyCodexAccountWarmup(
|
|
|
51
51
|
return { ok: true, validatedAt: Date.now() };
|
|
52
52
|
} catch (err) {
|
|
53
53
|
const reason = codexWarmupFailureReason(err);
|
|
54
|
+
if (err instanceof CodexWarmupError && err.code === "http_status" && err.status === 429) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
response: jsonResponse({
|
|
58
|
+
error: "Codex account warmup was rate limited. Retry later or after the account's usage limit resets.",
|
|
59
|
+
code: "codex_warmup_rate_limited",
|
|
60
|
+
reason,
|
|
61
|
+
accountId,
|
|
62
|
+
}, 429),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
54
65
|
return {
|
|
55
66
|
ok: false,
|
|
56
67
|
response: jsonResponse({
|
|
@@ -322,10 +333,11 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
|
|
|
322
333
|
? { ok: true as const, validatedAt: undefined }
|
|
323
334
|
: await verifyCodexAccountWarmup(accountId, cred.access, oauthAccountId);
|
|
324
335
|
if (!warmup.ok) {
|
|
325
|
-
const body = await warmup.response.json().catch(() => ({})) as { error?: string; reason?: string };
|
|
336
|
+
const body = await warmup.response.json().catch(() => ({})) as { error?: string; code?: string; reason?: string };
|
|
326
337
|
setCodexLoginState(flowId, {
|
|
327
338
|
status: "error",
|
|
328
339
|
error: body.reason ? `${body.error ?? "Codex account warmup failed"} (${body.reason})` : body.error ?? "Codex account warmup failed",
|
|
340
|
+
code: body.code,
|
|
329
341
|
doneAt: Date.now(),
|
|
330
342
|
});
|
|
331
343
|
completed = true;
|
|
@@ -153,7 +153,9 @@ export function createResetCreditWhamClient(config: OcxConfig, accountId: string
|
|
|
153
153
|
signal: AbortSignal.timeout(10_000),
|
|
154
154
|
});
|
|
155
155
|
if (!resp.ok) { await resp.body?.cancel().catch(() => {}); throw new Error(`upstream ${resp.status}`); }
|
|
156
|
-
|
|
156
|
+
const parsed = await readResetCreditJson(resp, AbortSignal.timeout(10_000));
|
|
157
|
+
if (!parsed.ok) throw new Error("invalid upstream reset-credit consume response");
|
|
158
|
+
return safeResetCreditConsumeDto(parsed.value);
|
|
157
159
|
}),
|
|
158
160
|
};
|
|
159
161
|
}
|
|
@@ -383,7 +385,14 @@ export async function consumeResetCredits(config: OcxConfig, accountId: string,
|
|
|
383
385
|
if (identity) markManualResetCreditOperationAmbiguous(identity);
|
|
384
386
|
return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status);
|
|
385
387
|
}
|
|
386
|
-
const
|
|
388
|
+
const consumed = await readResetCreditJson(resp, AbortSignal.timeout(10_000));
|
|
389
|
+
if (!consumed.ok) {
|
|
390
|
+
// The spend may already have landed upstream and its outcome code is unreadable,
|
|
391
|
+
// so this id must never come back as a new operation.
|
|
392
|
+
if (identity) markManualResetCreditOperationAmbiguous(identity);
|
|
393
|
+
return jsonResponse({ error: "Invalid upstream reset-credit consume response" }, 502);
|
|
394
|
+
}
|
|
395
|
+
const result = safeResetCreditConsumeDto(consumed.value);
|
|
387
396
|
if (identity) {
|
|
388
397
|
// Narrow explicitly rather than casting: `safeResetCreditConsumeDto`
|
|
389
398
|
// normalizes anything unrecognized to "unknown", and settling that
|