@bitkyc08/opencodex 2.10.2 → 2.11.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 +31 -0
- package/bin/ocx.mjs +10 -0
- package/gui/dist/assets/index-Bk-PN-70.css +1 -0
- package/gui/dist/assets/index-BynIEIV-.js +70 -0
- package/gui/dist/index.html +2 -2
- package/package.json +4 -2
- package/src/adapters/cursor/effort-map.ts +11 -0
- package/src/adapters/cursor/live-transport.ts +11 -0
- package/src/adapters/cursor/native-exec-fs.ts +9 -6
- package/src/adapters/cursor/native-exec.ts +4 -2
- package/src/adapters/cursor/protobuf-events.ts +176 -4
- package/src/adapters/cursor/request-builder.ts +15 -4
- package/src/adapters/cursor/tool-definitions.ts +118 -2
- package/src/adapters/google.ts +15 -5
- package/src/adapters/openai-chat.ts +24 -2
- package/src/adapters/openai-responses.ts +2 -1
- package/src/bridge.ts +9 -5
- package/src/chat/outbound.ts +4 -3
- package/src/claude/desktop-3p.ts +222 -2
- package/src/claude/outbound.ts +15 -6
- package/src/cli/account-api.ts +4 -0
- package/src/cli/account-extended.ts +112 -0
- package/src/cli/account.ts +23 -6
- package/src/cli/claude-desktop.ts +26 -3
- package/src/cli/config-command.ts +9 -0
- package/src/cli/help.ts +18 -2
- package/src/cli/index.ts +277 -55
- package/src/cli/models.ts +5 -1
- package/src/cli/provider.ts +8 -2
- package/src/cli/ready.ts +301 -0
- package/src/cli/system-restart-client.ts +146 -0
- package/src/cli/tray-proxy.ts +153 -6
- package/src/clients/config-export.ts +12 -19
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-namespaces.ts +49 -3
- package/src/codex/account-priority.ts +83 -0
- package/src/codex/auth-api.ts +83 -0
- package/src/codex/auth-context.ts +5 -2
- package/src/codex/catalog/provider-fetch.ts +11 -0
- package/src/codex/catalog/sync.ts +23 -1
- package/src/codex/codex-write-lock.ts +16 -4
- package/src/codex/desired-state.ts +37 -4
- package/src/codex/history-job.ts +15 -5
- package/src/codex/history-provider.ts +31 -14
- package/src/codex/history-worker.ts +28 -4
- package/src/codex/inject-coordination.ts +13 -1
- package/src/codex/inject.ts +360 -66
- package/src/codex/internal/history-writer.ts +1 -1
- package/src/codex/native-main-lock-file.ts +5 -1
- package/src/codex/native-main-owner.ts +17 -3
- package/src/codex/native-profile-manager.ts +19 -0
- package/src/codex/native-profile-startup.ts +8 -0
- package/src/codex/native-residue.ts +140 -27
- package/src/codex/pool-rotation.ts +74 -4
- package/src/codex/refresh.ts +7 -0
- package/src/codex/routing.ts +177 -36
- package/src/codex/subagent-model-fallback.ts +34 -4
- package/src/codex/sync.ts +61 -0
- package/src/codex/upstream-host-health.ts +329 -31
- package/src/combos/request.ts +2 -0
- package/src/config.ts +221 -2
- package/src/images/loop.ts +1 -1
- package/src/integrations/native/ownership-preflight.ts +39 -2
- package/src/lib/bun-stream-caps.ts +3 -3
- package/src/lib/sse-decoder.ts +41 -0
- package/src/lib/system-restart-contract.ts +73 -0
- package/src/lib/windows-secret-acl.ts +141 -39
- package/src/lib/windows-user-principal.ts +283 -0
- package/src/lib/winsw.ts +18 -2
- package/src/oauth/key-providers.ts +12 -0
- package/src/providers/derive.ts +54 -2
- package/src/providers/free-directory.ts +6 -5
- package/src/providers/model-discovery.ts +9 -3
- package/src/providers/quota.ts +592 -0
- package/src/providers/registry.ts +316 -13
- package/src/responses/parser.ts +26 -10
- package/src/responses/reasoning-replay-cache.ts +1 -0
- package/src/routing/profile-namespace.ts +15 -0
- package/src/routing/profile.ts +2 -1
- package/src/server/auth-cors.ts +44 -13
- package/src/server/chat-completions.ts +0 -4
- package/src/server/claude-messages.ts +73 -15
- package/src/server/github-copilot-responses-repair.ts +338 -0
- package/src/server/index.ts +328 -111
- package/src/server/lifecycle.ts +36 -0
- package/src/server/management/agent-settings-routes.ts +147 -56
- package/src/server/management/config-routes.ts +7 -2
- package/src/server/management/context.ts +4 -0
- package/src/server/management/native-integration-routes.ts +199 -20
- package/src/server/management/provider-routes.ts +41 -0
- package/src/server/management/routing-profile-routes.ts +234 -5
- package/src/server/management/system-restart.ts +12 -10
- package/src/server/management/system-routes.ts +20 -0
- package/src/server/management-auth.ts +51 -3
- package/src/server/ports.ts +41 -1
- package/src/server/proxy-liveness.ts +129 -4
- package/src/server/readiness.ts +99 -0
- package/src/server/relay.ts +113 -97
- package/src/server/request-log.ts +10 -4
- package/src/server/responses/compact.ts +107 -12
- package/src/server/responses/core.ts +220 -39
- package/src/server/responses-item-id-repair.ts +22 -3
- package/src/server/responses-model-rewrite.ts +29 -0
- package/src/server/sse-frame-buffer.ts +292 -0
- package/src/server/sse-payload-rewrite.ts +25 -14
- package/src/server/ws-bridge.ts +27 -22
- package/src/service-manager-probe.ts +520 -10
- package/src/service.ts +134 -2
- package/src/storage/worker-lifecycle.ts +14 -14
- package/src/tray/windows-tray.ps1 +74 -9
- package/src/types.ts +68 -2
- package/src/update/index.ts +12 -0
- package/src/update/job.ts +392 -18
- package/src/update/npm-cache-preflight.d.mts +47 -0
- package/src/update/npm-cache-preflight.mjs +201 -0
- package/src/usage/log.ts +1 -1
- package/src/vision/index.ts +77 -2
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/parse.ts +4 -1
- package/gui/dist/assets/index-BKVqyYqT.js +0 -70
- package/gui/dist/assets/index-Ca_3269W.css +0 -1
package/src/codex/routing.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { saveConfigPreservingClaudeCode } from "../config";
|
|
|
3
3
|
import { isCodexAccountGenerationLive, readCodexAccountRecord } from "./account-store";
|
|
4
4
|
import { codexAccountLogLabel } from "./account-label";
|
|
5
5
|
import { isCodexAccountPaused } from "./account-pause";
|
|
6
|
+
import { clearCodexAccountPin, codexAccountPriorityLookup, pinnedCodexAccountId } from "./account-priority";
|
|
6
7
|
import { isCodexAccountUsable, type CodexAccountUsabilityOptions } from "./account-usability";
|
|
7
8
|
import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
|
|
8
9
|
import {
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
peekRoundRobinAccount,
|
|
15
16
|
pickRoundRobinAccount,
|
|
16
17
|
seedPoolRotationAccount,
|
|
18
|
+
selectPriorityTier,
|
|
17
19
|
} from "./pool-rotation";
|
|
18
20
|
import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota";
|
|
19
21
|
import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "./main-account";
|
|
@@ -904,7 +906,7 @@ function getEligiblePoolAccounts(
|
|
|
904
906
|
now = Date.now(),
|
|
905
907
|
quotaScope?: CodexQuotaScope,
|
|
906
908
|
selectionOptions?: CodexAccountUsabilityOptions,
|
|
907
|
-
): string[] {
|
|
909
|
+
): readonly string[] {
|
|
908
910
|
const ids = (config.codexAccounts ?? [])
|
|
909
911
|
.filter(account => isSelectableCodexPoolAccount(account)
|
|
910
912
|
&& account.id !== excludeId
|
|
@@ -926,7 +928,15 @@ function getEligiblePoolAccounts(
|
|
|
926
928
|
) {
|
|
927
929
|
ids.unshift(MAIN_CODEX_ACCOUNT_ID);
|
|
928
930
|
}
|
|
929
|
-
|
|
931
|
+
// Single choke point for selection order: every strategy, failover, and preview
|
|
932
|
+
// reaches the pool through here, so tiering applies once rather than per picker.
|
|
933
|
+
// Eligibility above is unchanged — this only narrows an already-eligible list.
|
|
934
|
+
return selectPriorityTier(
|
|
935
|
+
ids,
|
|
936
|
+
codexAccountPriorityLookup(config),
|
|
937
|
+
id => hasCodexQuotaHeadroom(config, id),
|
|
938
|
+
pinnedCodexAccountId(config),
|
|
939
|
+
);
|
|
930
940
|
}
|
|
931
941
|
|
|
932
942
|
function listEligibleCodexAccountIds(
|
|
@@ -934,7 +944,7 @@ function listEligibleCodexAccountIds(
|
|
|
934
944
|
now: number,
|
|
935
945
|
quotaScope?: CodexQuotaScope,
|
|
936
946
|
selectionOptions?: CodexAccountUsabilityOptions,
|
|
937
|
-
): string[] {
|
|
947
|
+
): readonly string[] {
|
|
938
948
|
return getEligiblePoolAccounts(config, undefined, now, quotaScope, selectionOptions);
|
|
939
949
|
}
|
|
940
950
|
|
|
@@ -942,11 +952,20 @@ function stickyLimitForConfig(config: OcxConfig): number {
|
|
|
942
952
|
return normalizeAccountPoolStickyLimit(config.accountPoolStickyLimit);
|
|
943
953
|
}
|
|
944
954
|
|
|
945
|
-
|
|
955
|
+
/**
|
|
956
|
+
* Whether an account still has quota to give under the auto-switch threshold.
|
|
957
|
+
*
|
|
958
|
+
* Fill-first and the priority tier filter share this predicate, and share both of
|
|
959
|
+
* its escape hatches. A disabled threshold means only health, pause, and reauth
|
|
960
|
+
* may drain an account; unknown usage is a guess, so it must neither force
|
|
961
|
+
* fill-first off the active account nor drain a tier that was simply never
|
|
962
|
+
* primed. A genuinely exhausted account 429s into cooldown and leaves
|
|
963
|
+
* eligibility on its own.
|
|
964
|
+
*/
|
|
965
|
+
function hasCodexQuotaHeadroom(config: OcxConfig, accountId: string): boolean {
|
|
946
966
|
const threshold = config.autoSwitchThreshold ?? 80;
|
|
947
967
|
if (threshold <= 0) return true;
|
|
948
968
|
const usage = computeCodexUsageScore(getAccountQuota(accountId), getPoolAccountPlan(config, accountId));
|
|
949
|
-
// Unknown usage must not force fill-first to abandon the active account.
|
|
950
969
|
if (isUnknownUsage(usage)) return true;
|
|
951
970
|
return usage < threshold;
|
|
952
971
|
}
|
|
@@ -965,7 +984,7 @@ function pickFillFirstCodexAccount(
|
|
|
965
984
|
if (eligible.length === 0) return null;
|
|
966
985
|
|
|
967
986
|
const active = getEffectiveActiveCodexAccountId(config);
|
|
968
|
-
if (active && eligible.includes(active) &&
|
|
987
|
+
if (active && eligible.includes(active) && hasCodexQuotaHeadroom(config, active)) {
|
|
969
988
|
return active;
|
|
970
989
|
}
|
|
971
990
|
|
|
@@ -976,7 +995,7 @@ function pickFillFirstCodexAccount(
|
|
|
976
995
|
function pickNextFillFirstCodexAccount(
|
|
977
996
|
config: OcxConfig,
|
|
978
997
|
afterId: string | null,
|
|
979
|
-
eligible = listEligibleCodexAccountIds(config, Date.now()),
|
|
998
|
+
eligible: readonly string[] = listEligibleCodexAccountIds(config, Date.now()),
|
|
980
999
|
_now = Date.now(),
|
|
981
1000
|
selectionOptions?: CodexAccountUsabilityOptions,
|
|
982
1001
|
): string | null {
|
|
@@ -985,7 +1004,7 @@ function pickNextFillFirstCodexAccount(
|
|
|
985
1004
|
if (!afterId) {
|
|
986
1005
|
// Prefer an under-threshold account when starting with no active cursor.
|
|
987
1006
|
for (const id of ordered) {
|
|
988
|
-
if (
|
|
1007
|
+
if (hasCodexQuotaHeadroom(config, id)) return id;
|
|
989
1008
|
}
|
|
990
1009
|
return ordered[0] ?? null;
|
|
991
1010
|
}
|
|
@@ -1000,7 +1019,7 @@ function pickNextFillFirstCodexAccount(
|
|
|
1000
1019
|
const startIdx = stableAll.indexOf(afterId);
|
|
1001
1020
|
if (startIdx < 0) {
|
|
1002
1021
|
for (const id of ordered) {
|
|
1003
|
-
if (
|
|
1022
|
+
if (hasCodexQuotaHeadroom(config, id)) return id;
|
|
1004
1023
|
}
|
|
1005
1024
|
return ordered[0] ?? null;
|
|
1006
1025
|
}
|
|
@@ -1011,7 +1030,7 @@ function pickNextFillFirstCodexAccount(
|
|
|
1011
1030
|
const candidate = stableAll[(startIdx + step) % stableAll.length]!;
|
|
1012
1031
|
if (!eligible.includes(candidate)) continue;
|
|
1013
1032
|
if (!fallback) fallback = candidate;
|
|
1014
|
-
if (
|
|
1033
|
+
if (hasCodexQuotaHeadroom(config, candidate)) return candidate;
|
|
1015
1034
|
}
|
|
1016
1035
|
return fallback ?? ordered[0] ?? null;
|
|
1017
1036
|
}
|
|
@@ -1097,16 +1116,11 @@ function pickLowerUsageAccount(
|
|
|
1097
1116
|
return best;
|
|
1098
1117
|
}
|
|
1099
1118
|
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
excludeId?: string,
|
|
1103
|
-
now = Date.now(),
|
|
1104
|
-
quotaScope?: CodexQuotaScope,
|
|
1105
|
-
selectionOptions?: CodexAccountUsabilityOptions,
|
|
1106
|
-
): string | null {
|
|
1119
|
+
/** Coolest account in an already-selected candidate list; first index wins ties. */
|
|
1120
|
+
function pickLowestUsageAmong(config: OcxConfig, ids: readonly string[]): string | null {
|
|
1107
1121
|
let best: string | null = null;
|
|
1108
1122
|
let bestUsage = Number.POSITIVE_INFINITY;
|
|
1109
|
-
for (const id of
|
|
1123
|
+
for (const id of ids) {
|
|
1110
1124
|
const usage = computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id));
|
|
1111
1125
|
if (usage < bestUsage) {
|
|
1112
1126
|
best = id;
|
|
@@ -1116,6 +1130,16 @@ export function pickLowestUsageCodexAccount(
|
|
|
1116
1130
|
return best;
|
|
1117
1131
|
}
|
|
1118
1132
|
|
|
1133
|
+
export function pickLowestUsageCodexAccount(
|
|
1134
|
+
config: OcxConfig,
|
|
1135
|
+
excludeId?: string,
|
|
1136
|
+
now = Date.now(),
|
|
1137
|
+
quotaScope?: CodexQuotaScope,
|
|
1138
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
1139
|
+
): string | null {
|
|
1140
|
+
return pickLowestUsageAmong(config, getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions));
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1119
1143
|
/**
|
|
1120
1144
|
* Strategy-aware alternate after a cooled/excluded account (same-request 429 retry
|
|
1121
1145
|
* and active promotion). Quota keeps lowest-usage; fill-first advances stable order;
|
|
@@ -1129,12 +1153,16 @@ export function pickAlternateCodexAccount(
|
|
|
1129
1153
|
selectionOptions?: CodexAccountUsabilityOptions,
|
|
1130
1154
|
): string | null {
|
|
1131
1155
|
const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
|
|
1156
|
+
// The exclusion is passed into eligibility rather than post-filtered off its
|
|
1157
|
+
// result: when the excluded account is the only healthy member of the top
|
|
1158
|
+
// tier, the tier walk must be free to descend instead of selecting that tier
|
|
1159
|
+
// and then handing back an empty list.
|
|
1132
1160
|
if (strategy === "round-robin") {
|
|
1133
|
-
const eligible =
|
|
1161
|
+
const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions);
|
|
1134
1162
|
return pickRoundRobinAccount(codexPoolKeyForScope(quotaScope), eligible, stickyLimitForConfig(config));
|
|
1135
1163
|
}
|
|
1136
1164
|
if (strategy === "fill-first") {
|
|
1137
|
-
const eligible =
|
|
1165
|
+
const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions);
|
|
1138
1166
|
return pickNextFillFirstCodexAccount(config, excludeId, eligible, now, selectionOptions);
|
|
1139
1167
|
}
|
|
1140
1168
|
return pickLowestUsageCodexAccount(config, excludeId, now, quotaScope, selectionOptions);
|
|
@@ -1145,6 +1173,17 @@ export function getEffectiveActiveCodexAccountId(config: OcxConfig): string | un
|
|
|
1145
1173
|
return runtimeActiveCodexAccountId ?? config.activeCodexAccountId;
|
|
1146
1174
|
}
|
|
1147
1175
|
|
|
1176
|
+
/**
|
|
1177
|
+
* Whether the account routing is currently on is there because an operator asked
|
|
1178
|
+
* for it, rather than because a strategy landed on it. Surfaces read this instead
|
|
1179
|
+
* of comparing the stored pin themselves, which would report a pin that a later
|
|
1180
|
+
* automatic pick has already moved past.
|
|
1181
|
+
*/
|
|
1182
|
+
export function isEffectiveCodexAccountPinned(config: OcxConfig): boolean {
|
|
1183
|
+
const pinned = pinnedCodexAccountId(config);
|
|
1184
|
+
return pinned !== undefined && pinned === getEffectiveActiveCodexAccountId(config);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1148
1187
|
/**
|
|
1149
1188
|
* Automatic strategy / failover cursor only — never mutates `config.activeCodexAccountId`
|
|
1150
1189
|
* so an unrelated `saveConfig` cannot persist transient rotation as operator selection.
|
|
@@ -1153,10 +1192,22 @@ function rememberActiveCodexAccount(_config: OcxConfig, accountId: string): void
|
|
|
1153
1192
|
runtimeActiveCodexAccountId = accountId;
|
|
1154
1193
|
}
|
|
1155
1194
|
|
|
1195
|
+
/**
|
|
1196
|
+
* End the manual pin when routing moves to a different account. Returns whether
|
|
1197
|
+
* the pin changed so the caller can fold it into a write it was already making.
|
|
1198
|
+
*/
|
|
1199
|
+
function releaseCodexAccountPinFor(config: OcxConfig, accountId: string): boolean {
|
|
1200
|
+
const pinned = pinnedCodexAccountId(config);
|
|
1201
|
+
if (pinned === undefined || pinned === accountId) return false;
|
|
1202
|
+
clearCodexAccountPin(config);
|
|
1203
|
+
return true;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1156
1206
|
/** Persist operator (or quota-strategy) active selection to config + disk. */
|
|
1157
1207
|
function setActiveCodexAccount(config: OcxConfig, accountId: string): void {
|
|
1158
1208
|
runtimeActiveCodexAccountId = undefined;
|
|
1159
|
-
|
|
1209
|
+
const releasedPin = releaseCodexAccountPinFor(config, accountId);
|
|
1210
|
+
if (config.activeCodexAccountId === accountId && !releasedPin) return;
|
|
1160
1211
|
config.activeCodexAccountId = accountId;
|
|
1161
1212
|
saveConfigPreservingClaudeCode(config);
|
|
1162
1213
|
}
|
|
@@ -1167,6 +1218,10 @@ function promoteActiveCodexAccount(config: OcxConfig, accountId: string): void {
|
|
|
1167
1218
|
setActiveCodexAccount(config, accountId);
|
|
1168
1219
|
return;
|
|
1169
1220
|
}
|
|
1221
|
+
// Runtime-only, like the cursor itself: a caller that persists (pause, delete)
|
|
1222
|
+
// saves this release with its own write; a transient failover does not, so the
|
|
1223
|
+
// pin survives a restart that also clears the failure history behind it.
|
|
1224
|
+
releaseCodexAccountPinFor(config, accountId);
|
|
1170
1225
|
rememberActiveCodexAccount(config, accountId);
|
|
1171
1226
|
}
|
|
1172
1227
|
|
|
@@ -1185,6 +1240,10 @@ export function reconcileCodexActiveAfterExclusion(
|
|
|
1185
1240
|
if (config.activeCodexAccountId === excludedAccountId) {
|
|
1186
1241
|
config.activeCodexAccountId = undefined;
|
|
1187
1242
|
}
|
|
1243
|
+
// Excluding an account revokes any manual pin on it even when it was not the
|
|
1244
|
+
// effective active — otherwise a paused account keeps acting as a tier ceiling,
|
|
1245
|
+
// suppressing every higher-ordered account while being unusable itself.
|
|
1246
|
+
clearCodexAccountPin(config, excludedAccountId);
|
|
1188
1247
|
if (!wasEffective) return getEffectiveActiveCodexAccountId(config) ?? null;
|
|
1189
1248
|
|
|
1190
1249
|
runtimeActiveCodexAccountId = undefined;
|
|
@@ -1197,6 +1256,54 @@ function isUnknownUsage(usage: number): boolean {
|
|
|
1197
1256
|
return usage >= CODEX_UNKNOWN_USAGE_SCORE;
|
|
1198
1257
|
}
|
|
1199
1258
|
|
|
1259
|
+
/**
|
|
1260
|
+
* Move an unbound request back up when a higher tier regains headroom — the
|
|
1261
|
+
* weekly-reset case. Returns null when nothing should change.
|
|
1262
|
+
*
|
|
1263
|
+
* Downward moves are deliberately left to {@link applyQuotaAutoSwitch}: this only
|
|
1264
|
+
* fires when the tier filter has already excluded `active`, and only toward a
|
|
1265
|
+
* tier that strictly outranks it. Threads bound by affinity never reach here.
|
|
1266
|
+
*/
|
|
1267
|
+
function pickPriorityPreemption(
|
|
1268
|
+
config: OcxConfig,
|
|
1269
|
+
active: string,
|
|
1270
|
+
now: number,
|
|
1271
|
+
quotaScope?: CodexQuotaScope,
|
|
1272
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
1273
|
+
): string | null {
|
|
1274
|
+
const eligible = getEligiblePoolAccounts(config, undefined, now, quotaScope, selectionOptions);
|
|
1275
|
+
if (eligible.length === 0 || eligible.includes(active)) return null;
|
|
1276
|
+
const pinned = pinnedCodexAccountId(config);
|
|
1277
|
+
// A live pin already lowered the tier ceiling; never preempt past an explicit
|
|
1278
|
+
// operator choice. Same liveness test the tier filter applies, so preview and
|
|
1279
|
+
// resolve agree even before the pin is garbage-collected.
|
|
1280
|
+
if (pinned !== undefined && eligible.includes(pinned) && hasCodexQuotaHeadroom(config, pinned)) return null;
|
|
1281
|
+
const priorityOf = codexAccountPriorityLookup(config);
|
|
1282
|
+
if (priorityOf(eligible[0]!) <= priorityOf(active)) return null;
|
|
1283
|
+
// Members without headroom are in the tier only because a sibling has some;
|
|
1284
|
+
// picking one would hand the request straight back to a drained account.
|
|
1285
|
+
return pickLowestUsageAmong(config, eligible.filter(id => hasCodexQuotaHeadroom(config, id)));
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/**
|
|
1289
|
+
* Release a pin whose account is durably drained. "Use this account now" ends
|
|
1290
|
+
* when the account crosses the auto-switch threshold or stops being selectable
|
|
1291
|
+
* at all — never on a transient cooldown or soft-avoid, which it recovers from
|
|
1292
|
+
* on its own. Clearing the pin also removes the condition, so this writes at
|
|
1293
|
+
* most once per pin.
|
|
1294
|
+
*/
|
|
1295
|
+
function releaseDrainedCodexAccountPin(config: OcxConfig): void {
|
|
1296
|
+
const pinned = pinnedCodexAccountId(config);
|
|
1297
|
+
if (pinned === undefined) return;
|
|
1298
|
+
const drained = !isCodexAccountUsable(config, pinned)
|
|
1299
|
+
|| isAccountNeedsReauth(pinned)
|
|
1300
|
+
|| isCodexAccountPaused(config, pinned)
|
|
1301
|
+
|| !hasCodexQuotaHeadroom(config, pinned);
|
|
1302
|
+
if (!drained) return;
|
|
1303
|
+
clearCodexAccountPin(config);
|
|
1304
|
+
saveConfigPreservingClaudeCode(config);
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1200
1307
|
function applyQuotaAutoSwitch(
|
|
1201
1308
|
config: OcxConfig,
|
|
1202
1309
|
active: string,
|
|
@@ -1233,12 +1340,19 @@ function applyFailureFailover(
|
|
|
1233
1340
|
config: OcxConfig,
|
|
1234
1341
|
active: string,
|
|
1235
1342
|
now: number,
|
|
1343
|
+
quotaScope?: CodexQuotaScope,
|
|
1236
1344
|
selectionOptions?: CodexAccountUsabilityOptions,
|
|
1237
1345
|
): string {
|
|
1238
1346
|
if (!shouldFailover(config, active, now)) return active;
|
|
1239
|
-
const best = pickAlternateCodexAccount(config, active, now,
|
|
1347
|
+
const best = pickAlternateCodexAccount(config, active, now, quotaScope, selectionOptions);
|
|
1240
1348
|
if (best) {
|
|
1241
|
-
|
|
1349
|
+
// The scope still routes away from the failing account — that is this request's
|
|
1350
|
+
// own decision — but an independent one must not persist a new shared active
|
|
1351
|
+
// account. recordCodexUpstreamOutcome only suppresses the promotion it makes at
|
|
1352
|
+
// the moment of the failure; the streak outlives the soft avoid, so a later
|
|
1353
|
+
// scoped resolve reaches here with the streak still tripped and would otherwise
|
|
1354
|
+
// move the shared cursor after all.
|
|
1355
|
+
if (!isIndependentCodexQuotaScope(quotaScope)) promoteActiveCodexAccount(config, best);
|
|
1242
1356
|
return best;
|
|
1243
1357
|
}
|
|
1244
1358
|
return active;
|
|
@@ -1328,6 +1442,7 @@ export function previewCodexAccountForRequest(
|
|
|
1328
1442
|
) return active;
|
|
1329
1443
|
else return null;
|
|
1330
1444
|
}
|
|
1445
|
+
active = pickPriorityPreemption(config, active, now, quotaScope, selectionOptions) ?? active;
|
|
1331
1446
|
|
|
1332
1447
|
const threshold = config.autoSwitchThreshold ?? 80;
|
|
1333
1448
|
if (threshold > 0) {
|
|
@@ -1357,6 +1472,12 @@ export function resolveCodexAccountForThreadDetailed(
|
|
|
1357
1472
|
quotaScope?: CodexQuotaScope,
|
|
1358
1473
|
selectionOptions?: CodexAccountUsabilityOptions,
|
|
1359
1474
|
): CodexThreadResolution {
|
|
1475
|
+
// Retiring a spent manual pin is independent of affinity: an existing thread
|
|
1476
|
+
// keeps its account below, but the operator's tier ceiling must not silently
|
|
1477
|
+
// revive after quota resets. Independent model scopes must never persist a
|
|
1478
|
+
// change to shared routing state.
|
|
1479
|
+
if (!isIndependentCodexQuotaScope(quotaScope)) releaseDrainedCodexAccountPin(config);
|
|
1480
|
+
|
|
1360
1481
|
const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined;
|
|
1361
1482
|
if (threadId && entry) {
|
|
1362
1483
|
if (isThreadAffinityExpired(entry, now)) {
|
|
@@ -1430,8 +1551,19 @@ export function resolveCodexAccountForThreadDetailed(
|
|
|
1430
1551
|
return { status: "none" };
|
|
1431
1552
|
}
|
|
1432
1553
|
}
|
|
1554
|
+
// Before applyQuotaAutoSwitch: its sync disk write would otherwise persist a
|
|
1555
|
+
// move inside the drained tier that preemption immediately overrides.
|
|
1556
|
+
const preempted = pickPriorityPreemption(config, active, now, quotaScope, selectionOptions);
|
|
1557
|
+
if (preempted) {
|
|
1558
|
+
// Runtime-only, like every other automatic pick: config.activeCodexAccountId
|
|
1559
|
+
// stays the operator's selection and getEffectiveActiveCodexAccountId is what
|
|
1560
|
+
// surfaces this to the API and dashboard. An independent quota group must not
|
|
1561
|
+
// move the shared cursor at all — its ordering decision is its own.
|
|
1562
|
+
if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, preempted);
|
|
1563
|
+
active = preempted;
|
|
1564
|
+
}
|
|
1433
1565
|
active = applyQuotaAutoSwitch(config, active, now, quotaScope, selectionOptions);
|
|
1434
|
-
active = applyFailureFailover(config, active, now, selectionOptions);
|
|
1566
|
+
active = applyFailureFailover(config, active, now, quotaScope, selectionOptions);
|
|
1435
1567
|
if (!isCodexAccountUsable(config, active, selectionOptions)) {
|
|
1436
1568
|
return hasConfiguredPoolAccount(config, active, selectionOptions)
|
|
1437
1569
|
? { status: "selected", accountId: active }
|
|
@@ -1634,16 +1766,21 @@ export function recordCodexUpstreamOutcome(
|
|
|
1634
1766
|
});
|
|
1635
1767
|
if (!meta.fixedAccount) {
|
|
1636
1768
|
clearThreadAccountMapForAccount(accountId);
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
const
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1769
|
+
// An independent native quota request may discover an account-wide throttle,
|
|
1770
|
+
// but it still must not advance the shared RR ring or active cursor. The next
|
|
1771
|
+
// shared request observes the cooldown and chooses its own fallback.
|
|
1772
|
+
if (!isIndependentCodexQuotaScope(quotaScope)) {
|
|
1773
|
+
notePoolRotationFailure(POOL_KEY_CODEX, accountId);
|
|
1774
|
+
const effectiveActive = getEffectiveActiveCodexAccountId(config);
|
|
1775
|
+
if (effectiveActive === accountId) {
|
|
1776
|
+
// Same-request 429 retry already picked via excludeAccountId — reuse it so
|
|
1777
|
+
// round-robin does not advance the ring a second time.
|
|
1778
|
+
const reused = meta.promoteAccountId && meta.promoteAccountId !== accountId
|
|
1779
|
+
? meta.promoteAccountId
|
|
1780
|
+
: null;
|
|
1781
|
+
const fallback = reused ?? pickAlternateCodexAccount(config, accountId, now, quotaScope);
|
|
1782
|
+
if (fallback) promoteActiveCodexAccount(config, fallback);
|
|
1783
|
+
}
|
|
1647
1784
|
}
|
|
1648
1785
|
}
|
|
1649
1786
|
return;
|
|
@@ -1697,8 +1834,12 @@ export function recordCodexUpstreamOutcome(
|
|
|
1697
1834
|
if (!meta.fixedAccount && shouldFailover(config, accountId, now)) {
|
|
1698
1835
|
clearThreadAccountMapForAccount(accountId);
|
|
1699
1836
|
}
|
|
1700
|
-
if (
|
|
1701
|
-
|
|
1837
|
+
if (
|
|
1838
|
+
!meta.fixedAccount
|
|
1839
|
+
&& !isIndependentCodexQuotaScope(quotaScope)
|
|
1840
|
+
&& getEffectiveActiveCodexAccountId(config) === accountId
|
|
1841
|
+
) {
|
|
1842
|
+
applyFailureFailover(config, accountId, now, quotaScope);
|
|
1702
1843
|
}
|
|
1703
1844
|
}
|
|
1704
1845
|
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
codexQuotaScopeForModel,
|
|
19
19
|
computeCodexUsageScore,
|
|
20
20
|
getCodexQuotaHealthSnapshot,
|
|
21
|
+
getEffectiveActiveCodexAccountId,
|
|
21
22
|
getPoolAccountPlan,
|
|
22
23
|
isCodexAccountInCooldown,
|
|
23
24
|
} from "./routing";
|
|
@@ -29,12 +30,23 @@ import { isCodexAccountPaused } from "./account-pause";
|
|
|
29
30
|
import { slugEquals } from "../providers/slug-codec";
|
|
30
31
|
import { isThreadSpawnRequest } from "../server/effort-policy";
|
|
31
32
|
import { PROVIDER_REGISTRY } from "../providers/registry";
|
|
32
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
CODEX_FORWARD_BASE_URL,
|
|
35
|
+
OPENAI_CODEX_PROVIDER_ID,
|
|
36
|
+
isCanonicalOpenAiForwardProvider,
|
|
37
|
+
} from "../providers/openai-tiers";
|
|
33
38
|
import { routeModel, type RouteResult } from "../router";
|
|
34
39
|
import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
|
|
35
40
|
import { codexAccountNamespaceForModel } from "./account-namespace-match";
|
|
41
|
+
import {
|
|
42
|
+
getUpstreamHostHealth,
|
|
43
|
+
normalizeUpstreamHostCircuitThreshold,
|
|
44
|
+
upstreamHostHealthKey,
|
|
45
|
+
} from "./upstream-host-health";
|
|
36
46
|
export const DEFAULT_SUBAGENT_MODEL_FALLBACK_POLL_MS = 60_000;
|
|
37
47
|
|
|
48
|
+
const CODEX_FORWARD_ORIGIN = new URL(CODEX_FORWARD_BASE_URL).origin.toLowerCase();
|
|
49
|
+
|
|
38
50
|
type SubagentQuotaPrimeFn = (config: OcxConfig, reason: string) => Promise<void>;
|
|
39
51
|
let subagentQuotaPrimeForTests: SubagentQuotaPrimeFn | null = null;
|
|
40
52
|
let quotaPrimeInFlight: Promise<void> | null = null;
|
|
@@ -131,8 +143,14 @@ function quotaThreshold(config: OcxConfig): number {
|
|
|
131
143
|
return threshold > 0 ? threshold : Number.POSITIVE_INFINITY;
|
|
132
144
|
}
|
|
133
145
|
|
|
146
|
+
/**
|
|
147
|
+
* The account routing would actually use, not just the persisted operator
|
|
148
|
+
* selection: round-robin, fill-first, failover, and priority preemption all
|
|
149
|
+
* move the cursor in memory only, so reading the raw field would check quota
|
|
150
|
+
* against an account this request is not going to touch.
|
|
151
|
+
*/
|
|
134
152
|
function activeCodexAccountId(config: OcxConfig): string | null {
|
|
135
|
-
return config
|
|
153
|
+
return getEffectiveActiveCodexAccountId(config) ?? null;
|
|
136
154
|
}
|
|
137
155
|
|
|
138
156
|
/**
|
|
@@ -393,11 +411,19 @@ export function resolveAgentModelFallbackForPrimary(
|
|
|
393
411
|
return merged;
|
|
394
412
|
}
|
|
395
413
|
|
|
414
|
+
function subagentQuotaPrimeBlockedByHostCircuit(config: OcxConfig): boolean {
|
|
415
|
+
if (normalizeUpstreamHostCircuitThreshold(config.upstreamHostCircuitThreshold) === 0) return false;
|
|
416
|
+
const key = upstreamHostHealthKey(OPENAI_CODEX_PROVIDER_ID, CODEX_FORWARD_ORIGIN);
|
|
417
|
+
return getUpstreamHostHealth(key)?.cooldownUntil !== undefined;
|
|
418
|
+
}
|
|
419
|
+
|
|
396
420
|
/**
|
|
397
421
|
* Best-effort quota refresh before subagent model selection.
|
|
398
422
|
* Concurrent callers share one in-flight promise. The success TTL is updated only
|
|
399
423
|
* after a successful refresh so failures remain retryable. Errors are swallowed so
|
|
400
|
-
* spawn routing can continue.
|
|
424
|
+
* spawn routing can continue. When the canonical ChatGPT origin is circuit-blocked,
|
|
425
|
+
* cached quota is used instead of sending credential-bearing usage probes to the
|
|
426
|
+
* same origin before the request's final host admission check.
|
|
401
427
|
*/
|
|
402
428
|
export function maybePrimeSubagentQuota(
|
|
403
429
|
config: OcxConfig,
|
|
@@ -405,11 +431,15 @@ export function maybePrimeSubagentQuota(
|
|
|
405
431
|
options: { nativeMainReadsForbidden?: boolean } = {},
|
|
406
432
|
): Promise<void> {
|
|
407
433
|
if (options.nativeMainReadsForbidden) return Promise.resolve();
|
|
434
|
+
if (subagentQuotaPrimeBlockedByHostCircuit(config)) return Promise.resolve();
|
|
408
435
|
if (quotaPrimeInFlight) return quotaPrimeInFlight;
|
|
409
436
|
if (!shouldPrimeSubagentQuota(config, now)) return Promise.resolve();
|
|
410
437
|
|
|
411
438
|
quotaPrimeInFlight = (async () => {
|
|
412
439
|
try {
|
|
440
|
+
// Re-check after claiming single-flight ownership so a circuit opened by
|
|
441
|
+
// a concurrent request cannot race us into a fresh usage-probe pass.
|
|
442
|
+
if (subagentQuotaPrimeBlockedByHostCircuit(config)) return;
|
|
413
443
|
if (subagentQuotaPrimeForTests) {
|
|
414
444
|
await subagentQuotaPrimeForTests(config, "subagent-spawn");
|
|
415
445
|
} else {
|
|
@@ -527,4 +557,4 @@ export function listCodexAgentRoles(codexHome = CODEX_HOME): string[] {
|
|
|
527
557
|
export function shouldPrimeSubagentQuota(config: OcxConfig, now = Date.now()): boolean {
|
|
528
558
|
const last = quotaPrimedAt.get("global") ?? 0;
|
|
529
559
|
return now - last >= pollIntervalMs(config);
|
|
530
|
-
}
|
|
560
|
+
}
|
package/src/codex/sync.ts
CHANGED
|
@@ -5,9 +5,16 @@ import { applyProxyEnv, loadConfig } from "../config";
|
|
|
5
5
|
import type { OcxConfig } from "../types";
|
|
6
6
|
import { collectOrcaCodexHomeDiagnostic } from "./home";
|
|
7
7
|
import { summarizeComboCatalogOmissions, type ComboCatalogOmission } from "./catalog/aggregation";
|
|
8
|
+
import { shouldSyncCodexOnStart } from "./desired-state";
|
|
9
|
+
import { admitCodexWrite, type CodexAdmission } from "./admission";
|
|
8
10
|
|
|
9
11
|
export interface CodexSyncResult {
|
|
12
|
+
/** `skipped` is policy truth, never evidence that Codex was written. */
|
|
13
|
+
status: "applied" | "skipped" | "refused";
|
|
10
14
|
ok: boolean;
|
|
15
|
+
skippedReason?: "desired_disabled";
|
|
16
|
+
/** Present when unattended convergence refused another service's native home. */
|
|
17
|
+
authority?: "service-home";
|
|
11
18
|
added: number;
|
|
12
19
|
catalogPath: string | null;
|
|
13
20
|
catalogExists: boolean;
|
|
@@ -21,9 +28,13 @@ export interface CodexSyncResult {
|
|
|
21
28
|
projectConfigGrouped?: { path: string; issues: string[]; bypass: string }[];
|
|
22
29
|
}
|
|
23
30
|
|
|
31
|
+
type CodexSyncAdmission = Extract<CodexAdmission, { kind: "refused" }> | { readonly kind: "admitted" };
|
|
32
|
+
|
|
24
33
|
interface CodexSyncDeps {
|
|
25
34
|
refreshCodexModelCatalog: typeof refreshCodexModelCatalog;
|
|
26
35
|
injectCodexConfig: typeof injectCodexConfig;
|
|
36
|
+
/** The sync entry only needs this admission's service-home verdict. */
|
|
37
|
+
admitCodexWrite?: () => CodexSyncAdmission;
|
|
27
38
|
currentExternalCodexModelProvider?: typeof currentExternalCodexModelProvider;
|
|
28
39
|
collectCodexHomeDiagnostic?: typeof collectOrcaCodexHomeDiagnostic;
|
|
29
40
|
}
|
|
@@ -52,6 +63,40 @@ export async function syncModelsToCodex(
|
|
|
52
63
|
log: Pick<Console, "log" | "error"> | null = console,
|
|
53
64
|
deps: CodexSyncDeps = defaultDeps,
|
|
54
65
|
): Promise<CodexSyncResult> {
|
|
66
|
+
// `config` can be the server's startup object. The decision, however, is a
|
|
67
|
+
// durable user switch and must be read again at this production boundary: a
|
|
68
|
+
// PUT OFF while provider discovery is in flight cannot be allowed to commit
|
|
69
|
+
// through an older captured object.
|
|
70
|
+
if (!shouldSyncCodexOnStart(loadConfig())) {
|
|
71
|
+
return {
|
|
72
|
+
status: "skipped",
|
|
73
|
+
skippedReason: "desired_disabled",
|
|
74
|
+
ok: true,
|
|
75
|
+
added: 0,
|
|
76
|
+
catalogPath: null,
|
|
77
|
+
catalogExists: false,
|
|
78
|
+
catalogWritten: false,
|
|
79
|
+
cacheSynced: false,
|
|
80
|
+
message: "Codex integration is OFF; no Codex config, catalog, cache, or history was changed.",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
// Catalog gathering precedes injection and can itself write the native
|
|
84
|
+
// catalog/cache. It therefore needs the same unattended service-home veto as
|
|
85
|
+
// the injector, before it gets a chance to create any artifact.
|
|
86
|
+
const admission = (deps.admitCodexWrite ?? admitCodexWrite)();
|
|
87
|
+
if (admission.kind === "refused" && admission.authority === "service-home") {
|
|
88
|
+
return {
|
|
89
|
+
status: "refused",
|
|
90
|
+
authority: "service-home",
|
|
91
|
+
ok: false,
|
|
92
|
+
added: 0,
|
|
93
|
+
catalogPath: null,
|
|
94
|
+
catalogExists: false,
|
|
95
|
+
catalogWritten: false,
|
|
96
|
+
cacheSynced: false,
|
|
97
|
+
message: admission.message,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
55
100
|
const p = port ?? config.port ?? 10100;
|
|
56
101
|
const externalProvider = (deps.currentExternalCodexModelProvider ?? currentExternalCodexModelProvider)();
|
|
57
102
|
if (externalProvider) {
|
|
@@ -59,6 +104,7 @@ export async function syncModelsToCodex(
|
|
|
59
104
|
log?.log(result.message);
|
|
60
105
|
reportCodexHomeTarget(log, deps.collectCodexHomeDiagnostic ?? collectOrcaCodexHomeDiagnostic);
|
|
61
106
|
return {
|
|
107
|
+
status: "applied",
|
|
62
108
|
ok: result.success,
|
|
63
109
|
added: 0,
|
|
64
110
|
catalogPath: null,
|
|
@@ -108,10 +154,25 @@ export async function syncModelsToCodex(
|
|
|
108
154
|
}
|
|
109
155
|
|
|
110
156
|
const result = await deps.injectCodexConfig(p, config, { catalogPath: catalogPathForInjection });
|
|
157
|
+
if (result.status === "skipped") {
|
|
158
|
+
return {
|
|
159
|
+
status: "skipped",
|
|
160
|
+
// The apply direction's only under-lock policy skip is desired OFF.
|
|
161
|
+
skippedReason: "desired_disabled",
|
|
162
|
+
ok: true,
|
|
163
|
+
added: 0,
|
|
164
|
+
catalogPath: null,
|
|
165
|
+
catalogExists: false,
|
|
166
|
+
catalogWritten: false,
|
|
167
|
+
cacheSynced: false,
|
|
168
|
+
message: result.message,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
111
171
|
log?.log(result.message);
|
|
112
172
|
reportCodexHomeTarget(log, deps.collectCodexHomeDiagnostic ?? collectOrcaCodexHomeDiagnostic);
|
|
113
173
|
const projectConfigWarnings = printProjectCodexConfigWarnings(log, { cwd: process.cwd() });
|
|
114
174
|
return {
|
|
175
|
+
status: "applied",
|
|
115
176
|
ok: result.success,
|
|
116
177
|
added,
|
|
117
178
|
catalogPath,
|