@bitkyc08/opencodex 2.49.0 → 2.51.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/AGENTS_INSTALL.md +9 -1
- package/README.md +3 -0
- package/bin/ocx.mjs +222 -71
- package/gui/dist/assets/index-D7BdZpZm.js +115 -0
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/qoder/adapter.ts +69 -1
- package/src/adapters/qoder/scaffold-guard.ts +233 -0
- package/src/claude/agents-inject.ts +29 -5
- package/src/claude/desktop-3p.ts +31 -3
- package/src/claude/gateway-cache.ts +12 -21
- package/src/claude/inbound.ts +17 -5
- package/src/cli/account-api.ts +18 -3
- package/src/cli/account-auth.ts +8 -1
- package/src/cli/account-extended.ts +2 -1
- package/src/cli/account.ts +1 -0
- package/src/cli/capabilities.ts +43 -1
- package/src/cli/claude-agent-startup-sync.ts +26 -1
- package/src/cli/claude.ts +138 -20
- package/src/cli/config-command.ts +67 -1
- package/src/cli/connect.ts +181 -14
- package/src/cli/dispatch.ts +53 -9
- package/src/cli/doctor.ts +9 -2
- package/src/cli/ensure-desired-integrations.ts +10 -0
- package/src/cli/gui-pair-client.ts +1 -12
- package/src/cli/help.ts +4 -1
- package/src/cli/hub.ts +367 -0
- package/src/cli/index.ts +99 -31
- package/src/cli/launcher-context.ts +1 -1
- package/src/cli/models-runtime.ts +8 -3
- package/src/cli/observe.ts +13 -3
- package/src/cli/registry.ts +43 -3
- package/src/cli/status.ts +325 -5
- package/src/cli/version-skew.ts +4 -1
- package/src/cli.ts +2 -2
- package/src/client/catalog-compatibility.ts +192 -0
- package/src/client/connect.ts +31 -0
- package/src/client/hub-client.ts +52 -0
- package/src/client/hub-state.ts +214 -0
- package/src/clients/config-export/zcode.ts +24 -0
- package/src/codex/account-runtime-state.ts +6 -1
- package/src/codex/account-store.ts +72 -9
- package/src/codex/account-usability.ts +50 -13
- package/src/codex/auth-api.ts +156 -28
- package/src/codex/auth-context.ts +21 -0
- package/src/codex/catalog/effort.ts +67 -8
- package/src/codex/catalog/parsing.ts +23 -0
- package/src/codex/catalog/provider-fetch.ts +71 -2
- package/src/codex/catalog/sync.ts +99 -0
- package/src/codex/codex-write-lock.ts +11 -2
- package/src/codex/desired-state.ts +47 -1
- package/src/codex/inject-coordination.ts +10 -5
- package/src/codex/inject.ts +29 -12
- package/src/codex/loopback-target.ts +45 -0
- package/src/codex/quota-auto-refresh.ts +6 -1
- package/src/codex/quota.ts +54 -8
- package/src/codex/routing.ts +48 -1
- package/src/codex/runtime.ts +37 -3
- package/src/codex/sync.ts +29 -9
- package/src/codex/warmup.ts +21 -4
- package/src/combos/index.ts +2 -0
- package/src/combos/resolve.ts +52 -0
- package/src/config/pending-teardown.ts +1 -1
- package/src/config.ts +184 -12
- package/src/generated/compatibility-version.json +188 -116
- package/src/grok/status.ts +9 -1
- package/src/integrations/config-io.ts +54 -1
- package/src/lib/bun-runtime.ts +1 -1
- package/src/lib/errors.ts +8 -0
- package/src/lib/gui-pair-capability.ts +27 -0
- package/src/lib/local-destinations.ts +162 -0
- package/src/lib/package-tree-integrity.ts +1 -1
- package/src/lib/privacy.ts +25 -0
- package/src/lib/process-control.ts +130 -20
- package/src/lib/service-secrets.ts +28 -0
- package/src/lib/test-home-guard.ts +49 -0
- package/src/oauth/health.ts +47 -12
- package/src/oauth/index.ts +46 -8
- package/src/oauth/token-guardian.ts +32 -6
- package/src/providers/google-ai-studio-model-discovery.ts +74 -0
- package/src/providers/opencode-go-transport.ts +9 -1
- package/src/providers/opencode-zen-rate-limit.ts +75 -0
- package/src/providers/quota.ts +20 -1
- package/src/providers/registry.ts +35 -6
- package/src/remote/hub-state.ts +182 -0
- package/src/server/auth-cors.ts +11 -0
- package/src/server/chat-completions.ts +10 -7
- package/src/server/chat-native.ts +10 -1
- package/src/server/claude-messages.ts +12 -6
- package/src/server/hub-state.ts +98 -0
- package/src/server/images.ts +2 -2
- package/src/server/index.ts +149 -8
- package/src/server/management/api-access.ts +14 -3
- package/src/server/management/config-routes.ts +2 -2
- package/src/server/management/cursor-integration-routes.ts +13 -4
- package/src/server/management/logs-usage-routes.ts +4 -1
- package/src/server/management/model-rows.ts +16 -1
- package/src/server/management/oauth-account-routes.ts +6 -2
- package/src/server/management/provider-routes.ts +9 -2
- package/src/server/management/request-history-routes.ts +4 -2
- package/src/server/management/route-registry.ts +5 -4
- package/src/server/management/shared.ts +66 -3
- package/src/server/management-api.ts +1 -1
- package/src/server/proxy-liveness.ts +7 -1
- package/src/server/request-decompress.ts +91 -3
- package/src/server/request-log-conversation.ts +41 -1
- package/src/server/request-log.ts +10 -0
- package/src/server/responses/codex-auth-error.ts +18 -1
- package/src/server/responses/codex-ws-exchange.ts +36 -4
- package/src/server/responses/codex-ws-wire.ts +76 -5
- package/src/server/responses/compact.ts +28 -11
- package/src/server/responses/context-overflow.ts +11 -0
- package/src/server/responses/core.ts +201 -48
- package/src/server/responses/policy-fallback.ts +13 -3
- package/src/server/search.ts +2 -2
- package/src/server/system-env-shell.ts +14 -2
- package/src/server/system-env.ts +106 -14
- package/src/service.ts +965 -68
- package/src/types/accounts.ts +18 -0
- package/src/types/config.ts +93 -4
- package/src/types/provider.ts +56 -0
- package/src/types.ts +4 -0
- package/src/update/badge.ts +3 -2
- package/src/update/index.ts +317 -64
- package/src/update/install-detection.d.mts +6 -0
- package/src/update/install-detection.mjs +73 -0
- package/src/update/job.ts +101 -49
- package/src/update/pnpm-global-install.d.mts +144 -0
- package/src/update/pnpm-global-install.mjs +591 -0
- package/src/update/pnpm-invocation.d.mts +43 -0
- package/src/update/pnpm-invocation.mjs +141 -0
- package/src/update/registry-integrity.d.mts +16 -0
- package/src/update/registry-integrity.mjs +37 -0
- package/src/update/transactional-install.d.mts +1 -1
- package/src/update/transactional-install.mjs +101 -7
- package/src/update/tray-update-plan.mjs +1 -1
- package/src/vision/plan.ts +13 -3
- package/src/vision/routed-describe.ts +51 -20
- package/src/web-search/ollama-executor.ts +127 -0
- package/src/web-search/passthrough-bridge.ts +761 -0
- package/gui/dist/assets/index-BtyONQrZ.js +0 -115
package/src/codex/inject.ts
CHANGED
|
@@ -9,7 +9,11 @@ import {
|
|
|
9
9
|
withConfigMutationLockSync,
|
|
10
10
|
} from "../config";
|
|
11
11
|
import { CodexWriteLockSkipped, withCodexWriteLock } from "./codex-write-lock";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
localClientSkipMessage,
|
|
14
|
+
localClientSkipReason,
|
|
15
|
+
shouldSyncCodexOnStart,
|
|
16
|
+
} from "./desired-state";
|
|
13
17
|
import { resolveCodexHistoryTransition } from "./history-transition";
|
|
14
18
|
import {
|
|
15
19
|
buildInjectWitness,
|
|
@@ -79,9 +83,9 @@ import {
|
|
|
79
83
|
type ManagedSubagentDefaults,
|
|
80
84
|
} from "./subagent-defaults";
|
|
81
85
|
import type { OcxConfig } from "../types";
|
|
82
|
-
import { isLoopbackHostname, shouldInjectApiAuthHeader } from "./loopback-target";
|
|
86
|
+
import { effectiveLoopbackListenerPort, isLoopbackHostname, shouldInjectApiAuthHeader } from "./loopback-target";
|
|
83
87
|
|
|
84
|
-
export { isLoopbackHostname, shouldInjectApiAuthHeader } from "./loopback-target";
|
|
88
|
+
export { effectiveLoopbackListenerPort, isLoopbackHostname, shouldInjectApiAuthHeader } from "./loopback-target";
|
|
85
89
|
|
|
86
90
|
// Ownership predicates live in `./injected-marker` so `journal.ts` can reach them
|
|
87
91
|
// without importing this module back. Re-exported for existing external callers.
|
|
@@ -214,8 +218,11 @@ export function standaloneCodexRoutingTarget(
|
|
|
214
218
|
"hostname" | "unauthenticatedLoopbackListener" | "codexDesktopAuthless" | "codexClientCompaction"
|
|
215
219
|
>,
|
|
216
220
|
): CodexRoutingTarget {
|
|
221
|
+
// An enabled listener with no `port` is the companion form: it answers on `port` itself,
|
|
222
|
+
// bound to 127.0.0.1 (#4236). Resolving it through the shared helper is what makes the
|
|
223
|
+
// one-port hub work without every writer repeating `?? port`.
|
|
217
224
|
const loopback = config?.unauthenticatedLoopbackListener;
|
|
218
|
-
const effectivePort =
|
|
225
|
+
const effectivePort = effectiveLoopbackListenerPort(config, port) ?? port;
|
|
219
226
|
const hostname = loopback?.enabled ? undefined : config?.hostname;
|
|
220
227
|
const requiresAdmissionToken = loopback?.enabled ? false : shouldInjectApiAuthHeader(config);
|
|
221
228
|
return {
|
|
@@ -887,7 +894,8 @@ export interface CodexInjectResult {
|
|
|
887
894
|
success: boolean;
|
|
888
895
|
message: string;
|
|
889
896
|
status?: "skipped";
|
|
890
|
-
|
|
897
|
+
/** `hub-gated` is the hub-role gate (#4236), distinct from the user's own OFF switch. */
|
|
898
|
+
skippedReason?: "desired_disabled" | "desired_enabled" | "hub-gated";
|
|
891
899
|
nativeSubagentDefaultsWarning?: string;
|
|
892
900
|
}
|
|
893
901
|
|
|
@@ -1261,12 +1269,17 @@ export async function injectCodexConfig(
|
|
|
1261
1269
|
|
|
1262
1270
|
if (eligibility.kind === "legacy-uncoordinated") {
|
|
1263
1271
|
const applyLegacy = (): CodexInjectResult | undefined => {
|
|
1264
|
-
|
|
1272
|
+
const legacyGateSnapshot = loadConfig();
|
|
1273
|
+
if (!shouldSyncCodexOnStart(legacyGateSnapshot)) {
|
|
1265
1274
|
return {
|
|
1266
1275
|
success: true,
|
|
1267
1276
|
status: "skipped",
|
|
1268
|
-
skippedReason:
|
|
1269
|
-
message:
|
|
1277
|
+
skippedReason: localClientSkipReason(legacyGateSnapshot),
|
|
1278
|
+
message: localClientSkipMessage(
|
|
1279
|
+
legacyGateSnapshot,
|
|
1280
|
+
"Codex integration is OFF; no Codex config, catalog, cache, or history was changed.",
|
|
1281
|
+
"No Codex config, catalog, cache, or history was changed.",
|
|
1282
|
+
),
|
|
1270
1283
|
};
|
|
1271
1284
|
}
|
|
1272
1285
|
runClientWriteGuard(options.beforeClientWrite);
|
|
@@ -1295,8 +1308,11 @@ export async function injectCodexConfig(
|
|
|
1295
1308
|
}),
|
|
1296
1309
|
},
|
|
1297
1310
|
(ctx) => {
|
|
1298
|
-
|
|
1299
|
-
|
|
1311
|
+
const gateSnapshot = loadConfig();
|
|
1312
|
+
if (!shouldSyncCodexOnStart(gateSnapshot)) {
|
|
1313
|
+
// Carry WHY under the lock: "the hub does not write its own clients" and "the user
|
|
1314
|
+
// turned Codex off" produce the same no-write and must not produce the same sentence.
|
|
1315
|
+
throw new CodexWriteLockSkipped(localClientSkipReason(gateSnapshot));
|
|
1300
1316
|
}
|
|
1301
1317
|
// N and C are held here. Reject stale client work before publishing a
|
|
1302
1318
|
// transition or capturing preimages; rejection must not compensate over
|
|
@@ -1430,7 +1446,7 @@ export async function injectCodexConfig(
|
|
|
1430
1446
|
const historyMessage =
|
|
1431
1447
|
keepRootOverrideAlongsideTable
|
|
1432
1448
|
? (keptUserBaseUrl
|
|
1433
|
-
? ` Codex resume history: left unchanged; threads already tagged openai follow your
|
|
1449
|
+
? ` Codex resume history: left unchanged; threads already tagged openai follow your configured root openai_base_url.\n`
|
|
1434
1450
|
: ` Codex resume history: left unchanged; existing threads keep reaching the proxy through the retained openai_base_url override.\n`)
|
|
1435
1451
|
: config?.syncResumeHistory === false
|
|
1436
1452
|
? ` Codex resume history: left unchanged (syncResumeHistory=false).\n`
|
|
@@ -1447,6 +1463,7 @@ export async function injectCodexConfig(
|
|
|
1447
1463
|
// The client-compaction form writes a provider table as well, so "nothing was injected" would
|
|
1448
1464
|
// misdescribe the file it just produced: new threads do use the injected table. Report that
|
|
1449
1465
|
// mixed result on its own terms, and never tell the operator to delete a setting of theirs.
|
|
1466
|
+
// Ownership alone says nothing about destination: their line may already target this proxy.
|
|
1450
1467
|
if (keptUserBaseUrl && keepRootOverrideAlongsideTable) {
|
|
1451
1468
|
return {
|
|
1452
1469
|
success: true,
|
|
@@ -1459,7 +1476,7 @@ export async function injectCodexConfig(
|
|
|
1459
1476
|
managedDefaultsMessage +
|
|
1460
1477
|
` New threads use the injected opencodex provider and route through the proxy.\n` +
|
|
1461
1478
|
` Threads already tagged openai resolve through Codex's built-in provider, which your root openai_base_url points at.\n` +
|
|
1462
|
-
`
|
|
1479
|
+
` No root URL change is required to enable client-side compaction for new threads.\n` +
|
|
1463
1480
|
` Fallback: codex --profile opencodex (same behavior)`,
|
|
1464
1481
|
};
|
|
1465
1482
|
}
|
|
@@ -36,6 +36,51 @@ export function isLoopbackHostname(hostname: string | undefined): boolean {
|
|
|
36
36
|
);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Bind scope again, from the other side: a wildcard listener already answers on 127.0.0.1.
|
|
41
|
+
*
|
|
42
|
+
* This matters only for the port-less "companion" loopback listener. On `0.0.0.0`/`::` the
|
|
43
|
+
* public socket owns loopback on that port, so a companion bound to the same port would
|
|
44
|
+
* collide; on a specific non-loopback address (a tailnet or LAN IP) 127.0.0.1 is free.
|
|
45
|
+
*/
|
|
46
|
+
export function isWildcardHostname(hostname: string | undefined): boolean {
|
|
47
|
+
const normalized = (hostname ?? "").trim().toLowerCase().replace(/\.$/, "").replace(/^\[(.*)\]$/, "$1");
|
|
48
|
+
if (normalized === "*" || normalized === "0") return true;
|
|
49
|
+
// Every spelling of the IPv4 unspecified address ("0.0.0.0", "00.0.0.000", …).
|
|
50
|
+
if (/^(0+\.){3}0+$/.test(normalized)) return true;
|
|
51
|
+
// Every spelling of the IPv6 unspecified address ("::", "::0", "0::", "0:0:0:0:0:0:0:0", …):
|
|
52
|
+
// nothing but zero groups and colons. A dual-stack `::` bind answers on 127.0.0.1 as well.
|
|
53
|
+
return normalized.includes(":") && /^[0:]+$/.test(normalized);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Is `{ enabled: true }` with no port legal for this bind address?
|
|
58
|
+
*
|
|
59
|
+
* The companion form binds `127.0.0.1:<proxy port>`, which is exactly the one-port hub shape:
|
|
60
|
+
* remote clients dial `hostname:port`, local processes dial `127.0.0.1:port`, and every
|
|
61
|
+
* hardcoded `http://127.0.0.1:<proxy port>` integration works with no rewriting. It is legal
|
|
62
|
+
* only when the public listener is NOT already holding that loopback address.
|
|
63
|
+
*/
|
|
64
|
+
export function loopbackCompanionAllowed(hostname: string | undefined): boolean {
|
|
65
|
+
return !isLoopbackHostname(hostname) && !isWildcardHostname(hostname);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The port local callers reach the unauthenticated listener on, or null when it is off.
|
|
70
|
+
*
|
|
71
|
+
* One resolver for every reader (#4236): an enabled listener with no `port` is the companion
|
|
72
|
+
* form and answers on the public port. Callers must not repeat `?? port` — the day the default
|
|
73
|
+
* changes, a forgotten site points a client config at a closed socket.
|
|
74
|
+
*/
|
|
75
|
+
export function effectiveLoopbackListenerPort(
|
|
76
|
+
config: Pick<OcxConfig, "unauthenticatedLoopbackListener"> | undefined,
|
|
77
|
+
publicPort: number,
|
|
78
|
+
): number | null {
|
|
79
|
+
const listener = config?.unauthenticatedLoopbackListener;
|
|
80
|
+
if (!listener?.enabled) return null;
|
|
81
|
+
return listener.port ?? publicPort;
|
|
82
|
+
}
|
|
83
|
+
|
|
39
84
|
export function shouldInjectApiAuthHeader(
|
|
40
85
|
config: Pick<OcxConfig, "hostname" | "unauthenticatedLoopbackListener"> | undefined,
|
|
41
86
|
): boolean {
|
|
@@ -8,7 +8,7 @@ import { isSelectableCodexPoolAccount } from "./account-id";
|
|
|
8
8
|
import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
|
|
9
9
|
import { isCodexAccountPaused } from "./account-pause";
|
|
10
10
|
import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
|
|
11
|
-
import { getValidCodexToken, isCodexAccountGenerationLive } from "./account-store";
|
|
11
|
+
import { getValidCodexToken, isCodexAccountGenerationLive, readCodexAccountRecord } from "./account-store";
|
|
12
12
|
import { codexAccountLogLabel } from "./account-label";
|
|
13
13
|
import { getMainAccountToken, getValidMainAccountToken, MAIN_CODEX_ACCOUNT_ID } from "./main-account";
|
|
14
14
|
import { isMainAccountHardLocked } from "./main-account-hard-lock";
|
|
@@ -164,7 +164,11 @@ function mainWarmupRestricted(config: OcxConfig): boolean {
|
|
|
164
164
|
async function warmAccount(config: OcxConfig, accountId: string): Promise<void | false> {
|
|
165
165
|
const writerGeneration = captureConfigGeneration();
|
|
166
166
|
if (accountId !== MAIN_CODEX_ACCOUNT_ID) {
|
|
167
|
+
if (readCodexAccountRecord(accountId)?.codexValidationPending) return false;
|
|
167
168
|
const token = await getValidCodexToken(accountId);
|
|
169
|
+
const record = readCodexAccountRecord(accountId);
|
|
170
|
+
if (!record?.credential || record.deletedAt != null || record.codexValidationPending
|
|
171
|
+
|| record.generation !== token.generation) return false;
|
|
168
172
|
if (isCodexAccountPaused(config, accountId) || isAccountNeedsReauth(accountId)) return false;
|
|
169
173
|
try {
|
|
170
174
|
await warmCodexAccount({ ...token, onCompleted: headers => {
|
|
@@ -294,6 +298,7 @@ export async function runCodexQuotaAutoRefresh(
|
|
|
294
298
|
&& (accountId === MAIN_CODEX_ACCOUNT_ID || config.codexAccounts?.some(
|
|
295
299
|
account => account.id === accountId && isSelectableCodexPoolAccount(account)))
|
|
296
300
|
&& (setting?.fiveHour === true || setting?.weekly === true)
|
|
301
|
+
&& !(accountId !== MAIN_CODEX_ACCOUNT_ID && readCodexAccountRecord(accountId)?.codexValidationPending)
|
|
297
302
|
&& !isCodexAccountPaused(config, accountId) && !isAccountNeedsReauth(accountId)
|
|
298
303
|
&& !(accountId === MAIN_CODEX_ACCOUNT_ID && isMainAccountHardLocked(config));
|
|
299
304
|
};
|
package/src/codex/quota.ts
CHANGED
|
@@ -407,7 +407,20 @@ export function flushQuotaObservationsForTests(): Promise<void> {
|
|
|
407
407
|
return pendingObservation;
|
|
408
408
|
}
|
|
409
409
|
|
|
410
|
-
|
|
410
|
+
/** Wire marker shared by Spark-family models, whose upstream limit family is model-specific. */
|
|
411
|
+
const SPARK_MODEL_MARKER = "codex-spark";
|
|
412
|
+
/**
|
|
413
|
+
* Custom-window label for the Spark 5h window. The WHAM parser and the response-header path
|
|
414
|
+
* must write the SAME label so a header refresh replaces the WHAM reading instead of doubling it.
|
|
415
|
+
*/
|
|
416
|
+
const SPARK_SHORT_WINDOW_LABEL = "GPT-5.3-Codex-Spark 5h";
|
|
417
|
+
|
|
418
|
+
/** True when the routed model belongs to the Spark family, which carries its own rate limit. */
|
|
419
|
+
function isCodexSparkModel(modelId: string | undefined): boolean {
|
|
420
|
+
return typeof modelId === "string" && modelId.includes(SPARK_MODEL_MARKER);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function parseUpstreamQuotaHeaders(headers: Headers, options?: { modelId?: string }): Omit<StoredAccountQuota, "updatedAt"> | null {
|
|
411
424
|
const primaryRaw = headers.get("x-codex-primary-used-percent");
|
|
412
425
|
const secondaryRaw = headers.get("x-codex-secondary-used-percent");
|
|
413
426
|
const tertiaryRaw = headers.get("x-codex-tertiary-used-percent");
|
|
@@ -430,6 +443,10 @@ export function parseUpstreamQuotaHeaders(headers: Headers): Omit<StoredAccountQ
|
|
|
430
443
|
// it into weeklyPercent both discards the real weekly reading and leaves the account looking
|
|
431
444
|
// exhausted long after the burst window resets. Duration decides, exactly as parseUsageQuota
|
|
432
445
|
// already does for the WHAM payload — the two parsers must not disagree about the same data.
|
|
446
|
+
// One more attribution layer (#4122): on a Spark-family model response the sub-day primary is
|
|
447
|
+
// the MODEL-SPECIFIC limit, not an account window. Filing it as the account short tuple made
|
|
448
|
+
// one pool account display a 5h bar its identically-limited peers did not have, and fed a
|
|
449
|
+
// model limit to the account-policy readers (main-account hard lock, five-hour auto-refresh).
|
|
433
450
|
const primaryIsShort = isExplicitShortWindowMinutes(primaryWindowMinutes);
|
|
434
451
|
|
|
435
452
|
if (primaryIsMonthly) {
|
|
@@ -446,10 +463,21 @@ export function parseUpstreamQuotaHeaders(headers: Headers): Omit<StoredAccountQ
|
|
|
446
463
|
if (secondaryResetAt !== undefined) quota.weeklyResetAt = secondaryResetAt;
|
|
447
464
|
}
|
|
448
465
|
} else if (primaryIsShort) {
|
|
449
|
-
if (
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
466
|
+
if (isCodexSparkModel(options?.modelId)) {
|
|
467
|
+
if (primaryPercent !== undefined) {
|
|
468
|
+
const sparkWindow: { label: string; percent: number; resetAt?: number } = {
|
|
469
|
+
label: SPARK_SHORT_WINDOW_LABEL,
|
|
470
|
+
percent: primaryPercent,
|
|
471
|
+
};
|
|
472
|
+
if (primaryResetAt !== undefined) sparkWindow.resetAt = primaryResetAt;
|
|
473
|
+
quota.customWindows = [sparkWindow];
|
|
474
|
+
}
|
|
475
|
+
} else {
|
|
476
|
+
if (primaryPercent !== undefined) quota.shortPercent = primaryPercent;
|
|
477
|
+
if (primaryResetAt !== undefined) quota.shortResetAt = primaryResetAt;
|
|
478
|
+
const minutes = windowMinutes_(primaryWindowMinutes);
|
|
479
|
+
if (minutes !== undefined) quota.shortWindowSeconds = Math.round(minutes * 60);
|
|
480
|
+
}
|
|
453
481
|
// The burst window vacates the primary slot, so the weekly reading is the secondary — which
|
|
454
482
|
// is where it was all along. Without this the true weekly value is silently dropped.
|
|
455
483
|
if (secondaryPercent !== undefined) {
|
|
@@ -480,13 +508,31 @@ export function applyAccountQuotaFromUpstreamHeaders(
|
|
|
480
508
|
headers: Headers,
|
|
481
509
|
writerGeneration = captureConfigGeneration(),
|
|
482
510
|
mainWriter?: MainQuotaWriter,
|
|
511
|
+
options?: { modelId?: string },
|
|
483
512
|
): void {
|
|
484
|
-
const quota = parseUpstreamQuotaHeaders(headers);
|
|
513
|
+
const quota = parseUpstreamQuotaHeaders(headers, options);
|
|
485
514
|
if (!quota) return;
|
|
486
515
|
const policyQuota = [
|
|
487
516
|
"x-codex-primary-used-percent", "x-codex-secondary-used-percent", "x-codex-tertiary-used-percent",
|
|
488
517
|
].some(name => isInvalidPolicyUsagePercent(headers.get(name))) ? null : filterMainPolicyMonthlyQuota(quota);
|
|
489
|
-
|
|
518
|
+
// A header-observed Spark window is a partial update against the WHAM-recorded custom windows:
|
|
519
|
+
// merge by label so the weekly Spark entry survives, and hydrate first so the first call in a
|
|
520
|
+
// process does not merge against an empty map. The merged list goes only to the legacy
|
|
521
|
+
// snapshot — the identity-bound policy evidence keeps exactly what this response said.
|
|
522
|
+
let legacyQuota = quota;
|
|
523
|
+
if (quota.customWindows !== undefined) {
|
|
524
|
+
hydrateAccountQuotasFromDisk();
|
|
525
|
+
const existing = accountQuota.get(accountId)?.customWindows;
|
|
526
|
+
if (existing !== undefined) {
|
|
527
|
+
const incoming = new Map(quota.customWindows.map(window => [window.label, window]));
|
|
528
|
+
const merged = existing.map(window => incoming.get(window.label) ?? window);
|
|
529
|
+
for (const window of quota.customWindows) {
|
|
530
|
+
if (!existing.some(entry => entry.label === window.label)) merged.push(window);
|
|
531
|
+
}
|
|
532
|
+
legacyQuota = { ...quota, customWindows: merged };
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
setAccountQuotaFromParsed(accountId, legacyQuota, writerGeneration, mainWriter, policyQuota);
|
|
490
536
|
}
|
|
491
537
|
|
|
492
538
|
export function updateAccountQuota(
|
|
@@ -812,7 +858,7 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
|
|
|
812
858
|
});
|
|
813
859
|
const sparkCustomWindows: Array<{ label: string; percent: number; resetAt?: number }> = [];
|
|
814
860
|
for (const [label, window] of [
|
|
815
|
-
[
|
|
861
|
+
[SPARK_SHORT_WINDOW_LABEL, sparkShort],
|
|
816
862
|
["GPT-5.3-Codex-Spark Weekly", sparkWeekly],
|
|
817
863
|
] as const) {
|
|
818
864
|
const percent = normalizeUsagePercent(window?.used_percent);
|
package/src/codex/routing.ts
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
selectPriorityTier,
|
|
20
20
|
} from "./pool-rotation";
|
|
21
21
|
import { CODEX_EXHAUSTED_USAGE_PERCENT, CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota";
|
|
22
|
-
import { isThirtyDayOnlyCodexPlan } from "./plan";
|
|
22
|
+
import { codexPlanKey, isThirtyDayOnlyCodexPlan } from "./plan";
|
|
23
23
|
import {
|
|
24
24
|
MAIN_CODEX_ACCOUNT_ID,
|
|
25
25
|
getMainAccountPlan,
|
|
@@ -1004,6 +1004,50 @@ export function isCodexAccountSoftAvoided(accountId: string, now = Date.now()):
|
|
|
1004
1004
|
return getCodexAccountSoftAvoidUntil(accountId, now) !== null;
|
|
1005
1005
|
}
|
|
1006
1006
|
|
|
1007
|
+
/**
|
|
1008
|
+
* Plan keys the operator excluded from automatic rotation. Absent or empty means no policy, so an
|
|
1009
|
+
* existing install rotates exactly as before. Compared with `codexPlanKey` because the stored plan
|
|
1010
|
+
* is an unrestricted provider string whose casing this repository does not control.
|
|
1011
|
+
*/
|
|
1012
|
+
function excludedCodexPoolPlanKeys(config: OcxConfig): ReadonlySet<string> | undefined {
|
|
1013
|
+
const configured = config.codexPool?.excludedPlans;
|
|
1014
|
+
if (!configured?.length) return undefined;
|
|
1015
|
+
const keys = configured
|
|
1016
|
+
.map(plan => codexPlanKey(plan))
|
|
1017
|
+
.filter((key): key is string => key !== undefined);
|
|
1018
|
+
return keys.length > 0 ? new Set(keys) : undefined;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* Whether the operator's plan policy removes this account from automatic selection.
|
|
1023
|
+
*
|
|
1024
|
+
* Modelled on pause rather than usability: an excluded account keeps its credential, quota history,
|
|
1025
|
+
* and affinity, stays visible on the account surface, and is still reachable by explicit account
|
|
1026
|
+
* selection. Only automatic rotation skips it, which is the distinction #4211 asked for.
|
|
1027
|
+
*
|
|
1028
|
+
* It is checked in the same two places pause is checked, and that is not redundancy. The eligible
|
|
1029
|
+
* list is consulted only when routing picks a NEW account; an already-active or already-affined
|
|
1030
|
+
* account is served straight from {@link isCodexAccountSelectable}. A lapsed subscription leaves
|
|
1031
|
+
* behind exactly that account, so a policy that filtered only the eligible list would miss the case
|
|
1032
|
+
* it exists for.
|
|
1033
|
+
*
|
|
1034
|
+
* `__main__` is exempt. {@link getPoolAccountPlanForSelection} withholds the main plan during a
|
|
1035
|
+
* selection-only drain so routing never reads the fenced native credential for it, so a rule that
|
|
1036
|
+
* covered main would disagree with itself between drain and ordinary routing.
|
|
1037
|
+
*/
|
|
1038
|
+
function isCodexAccountPlanExcluded(
|
|
1039
|
+
config: OcxConfig,
|
|
1040
|
+
accountId: string,
|
|
1041
|
+
precomputed?: ReadonlySet<string>,
|
|
1042
|
+
): boolean {
|
|
1043
|
+
if (accountId === MAIN_CODEX_ACCOUNT_ID) return false;
|
|
1044
|
+
// Callers that test a whole list pass the set once rather than rebuilding it per row.
|
|
1045
|
+
const excluded = precomputed ?? excludedCodexPoolPlanKeys(config);
|
|
1046
|
+
if (!excluded) return false;
|
|
1047
|
+
const plan = codexPlanKey(getPoolAccountPlan(config, accountId));
|
|
1048
|
+
return plan !== undefined && excluded.has(plan);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1007
1051
|
function isCodexAccountSelectable(
|
|
1008
1052
|
config: OcxConfig,
|
|
1009
1053
|
accountId: string,
|
|
@@ -1012,6 +1056,7 @@ function isCodexAccountSelectable(
|
|
|
1012
1056
|
selectionOptions?: CodexAccountUsabilityOptions,
|
|
1013
1057
|
): boolean {
|
|
1014
1058
|
return !isCodexAccountPaused(config, accountId)
|
|
1059
|
+
&& !isCodexAccountPlanExcluded(config, accountId)
|
|
1015
1060
|
&& getCodexQuotaHealthSnapshot(accountId, quotaScope, now) === null
|
|
1016
1061
|
&& !isCodexAccountSoftAvoided(accountId, now)
|
|
1017
1062
|
&& isCodexAccountUsable(config, accountId, selectionOptions);
|
|
@@ -1242,10 +1287,12 @@ function getEligiblePoolAccounts(
|
|
|
1242
1287
|
selectionOptions?: CodexAccountUsabilityOptions,
|
|
1243
1288
|
skipFailoverReadyCandidates = false,
|
|
1244
1289
|
): readonly string[] {
|
|
1290
|
+
const excludedPlans = excludedCodexPoolPlanKeys(config);
|
|
1245
1291
|
const ids = (config.codexAccounts ?? [])
|
|
1246
1292
|
.filter(account => isSelectableCodexPoolAccount(account)
|
|
1247
1293
|
&& account.id !== excludeId
|
|
1248
1294
|
&& !isCodexAccountPaused(config, account.id)
|
|
1295
|
+
&& !isCodexAccountPlanExcluded(config, account.id, excludedPlans)
|
|
1249
1296
|
&& !isAccountNeedsReauth(account.id)
|
|
1250
1297
|
&& (!skipFailoverReadyCandidates || !shouldFailover(config, account.id, now)))
|
|
1251
1298
|
.filter(account => getCodexQuotaHealthSnapshot(account.id, quotaScope, now) === null)
|
package/src/codex/runtime.ts
CHANGED
|
@@ -427,13 +427,47 @@ function sameRuntimeCommand(a: string, b: string): boolean {
|
|
|
427
427
|
return a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
428
428
|
}
|
|
429
429
|
|
|
430
|
-
/**
|
|
430
|
+
/**
|
|
431
|
+
* Rungs OpenCodex no longer lets the observed-runtime intersection remove, so a persisted
|
|
432
|
+
* diagnostic naming only these describes a policy that is gone rather than a live restriction.
|
|
433
|
+
* This is the single copy of the exemption: `catalog/effort.ts` imports it for the clamp
|
|
434
|
+
* predicate, and a leftover file written before the exemption must not keep warning about
|
|
435
|
+
* rungs the next sync will stop removing.
|
|
436
|
+
*/
|
|
437
|
+
export const UNCLAMPABLE_REASONING_EFFORTS: ReadonlySet<string> = new Set(["max", "ultra"]);
|
|
438
|
+
|
|
439
|
+
/** Removals that still describe a real restriction, ignoring rungs nothing clamps any more. */
|
|
440
|
+
export function liveRemovedEfforts(
|
|
441
|
+
diagnostic: EffortClampDiagnostic | null | undefined,
|
|
442
|
+
): readonly string[] {
|
|
443
|
+
if (!diagnostic) return [];
|
|
444
|
+
return diagnostic.removedEfforts.filter(effort => !UNCLAMPABLE_REASONING_EFFORTS.has(effort));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* True when a persisted clamp diagnostic still applies to the currently selected runtime.
|
|
449
|
+
*
|
|
450
|
+
* Two ways a stored diagnostic stops describing reality:
|
|
451
|
+
*
|
|
452
|
+
* 1. Every rung it names is one nothing clamps any more, so the file is inert until the next
|
|
453
|
+
* sync unlinks it.
|
|
454
|
+
* 2. The binary at that path was upgraded in place. Windows updates Codex without moving the
|
|
455
|
+
* executable, so path equality alone kept a 0.135.0 observation "current" for a 0.154.0
|
|
456
|
+
* runtime whose own bundled catalog carried the rungs the diagnostic claimed were missing.
|
|
457
|
+
* A known version mismatch therefore wins over a path match; an unknown version on either
|
|
458
|
+
* side stays conservative, because absence of a version is not evidence of an upgrade.
|
|
459
|
+
*/
|
|
431
460
|
export function effortClampAppliesToRuntime(
|
|
432
461
|
diagnostic: EffortClampDiagnostic | null | undefined,
|
|
433
462
|
runtime: Pick<ResolvedCodexRuntime, "command" | "version">,
|
|
434
463
|
): boolean {
|
|
435
|
-
if (!diagnostic || diagnostic.
|
|
436
|
-
if (sameRuntimeCommand(diagnostic.runtimePath, runtime.command))
|
|
464
|
+
if (!diagnostic || liveRemovedEfforts(diagnostic).length === 0) return false;
|
|
465
|
+
if (sameRuntimeCommand(diagnostic.runtimePath, runtime.command)) {
|
|
466
|
+
if (diagnostic.runtimeVersion && runtime.version) {
|
|
467
|
+
return diagnostic.runtimeVersion === runtime.version;
|
|
468
|
+
}
|
|
469
|
+
return true;
|
|
470
|
+
}
|
|
437
471
|
return Boolean(
|
|
438
472
|
diagnostic.runtimeVersion
|
|
439
473
|
&& runtime.version
|
package/src/codex/sync.ts
CHANGED
|
@@ -5,7 +5,12 @@ 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 {
|
|
8
|
+
import {
|
|
9
|
+
localClientSkipMessage,
|
|
10
|
+
localClientSkipReason,
|
|
11
|
+
shouldSyncCodexOnStart,
|
|
12
|
+
type LocalClientSkipReason,
|
|
13
|
+
} from "./desired-state";
|
|
9
14
|
import { admitCodexWrite, type CodexAdmission } from "./admission";
|
|
10
15
|
import type { CodexCatalogSyncOptions } from "./catalog/sync";
|
|
11
16
|
import { resetCodexAppServerCatalogStateCache } from "./app-server-processes";
|
|
@@ -18,7 +23,8 @@ export interface CodexSyncResult {
|
|
|
18
23
|
*/
|
|
19
24
|
status: "applied" | "skipped" | "catalog-only" | "refused";
|
|
20
25
|
ok: boolean;
|
|
21
|
-
|
|
26
|
+
/** `hub-gated` is the hub-role gate, not the user's toggle — the two read very differently. */
|
|
27
|
+
skippedReason?: LocalClientSkipReason;
|
|
22
28
|
/** Present when unattended convergence refused another service's native home. */
|
|
23
29
|
authority?: "service-home";
|
|
24
30
|
added: number;
|
|
@@ -85,19 +91,24 @@ export async function syncModelsToCodex(
|
|
|
85
91
|
// durable user switch and must be read again at this production boundary: a
|
|
86
92
|
// PUT OFF while provider discovery is in flight cannot be allowed to commit
|
|
87
93
|
// through an older captured object.
|
|
88
|
-
const
|
|
94
|
+
const gateSnapshot = loadConfig();
|
|
95
|
+
const desiredDisabled = !shouldSyncCodexOnStart(gateSnapshot);
|
|
89
96
|
const catalogEvenWhenNotInjected = options.catalogEvenWhenNotInjected === true;
|
|
90
97
|
if (desiredDisabled && !catalogEvenWhenNotInjected) {
|
|
91
98
|
return {
|
|
92
99
|
status: "skipped",
|
|
93
|
-
skippedReason:
|
|
100
|
+
skippedReason: localClientSkipReason(gateSnapshot),
|
|
94
101
|
ok: true,
|
|
95
102
|
added: 0,
|
|
96
103
|
catalogPath: null,
|
|
97
104
|
catalogExists: false,
|
|
98
105
|
catalogWritten: false,
|
|
99
106
|
cacheSynced: false,
|
|
100
|
-
message:
|
|
107
|
+
message: localClientSkipMessage(
|
|
108
|
+
gateSnapshot,
|
|
109
|
+
"Codex integration is OFF; no Codex config, catalog, cache, or history was changed.",
|
|
110
|
+
"No Codex config, catalog, cache, or history was changed.",
|
|
111
|
+
),
|
|
101
112
|
};
|
|
102
113
|
}
|
|
103
114
|
// Catalog gathering precedes injection and can itself write the native
|
|
@@ -131,8 +142,16 @@ export async function syncModelsToCodex(
|
|
|
131
142
|
applyProxyEnv(config);
|
|
132
143
|
const refreshed = await refreshCatalogForSync(config, deps, { allowWhenDesiredDisabled: true }, log);
|
|
133
144
|
const message = refreshed.catalogWritten || refreshed.cacheSynced
|
|
134
|
-
?
|
|
135
|
-
|
|
145
|
+
? localClientSkipMessage(
|
|
146
|
+
gateSnapshot,
|
|
147
|
+
"Codex integration is OFF; catalog and models cache refreshed, Codex config untouched.",
|
|
148
|
+
"Catalog and models cache refreshed, Codex config untouched.",
|
|
149
|
+
)
|
|
150
|
+
: localClientSkipMessage(
|
|
151
|
+
gateSnapshot,
|
|
152
|
+
"Codex integration is OFF; catalog refresh skipped, Codex config untouched.",
|
|
153
|
+
"Catalog refresh skipped, Codex config untouched.",
|
|
154
|
+
);
|
|
136
155
|
return {
|
|
137
156
|
status: "catalog-only",
|
|
138
157
|
ok: true,
|
|
@@ -242,8 +261,9 @@ export async function syncModelsToCodex(
|
|
|
242
261
|
if (result.status === "skipped") {
|
|
243
262
|
return {
|
|
244
263
|
status: "skipped",
|
|
245
|
-
// The apply direction's only under-lock policy
|
|
246
|
-
|
|
264
|
+
// The apply direction's only under-lock policy skips are desired OFF and the hub gate;
|
|
265
|
+
// carry whichever the injector reported so the caller can say the honest thing.
|
|
266
|
+
skippedReason: result.skippedReason === "hub-gated" ? "hub-gated" : "desired_disabled",
|
|
247
267
|
ok: true,
|
|
248
268
|
added: 0,
|
|
249
269
|
catalogPath: null,
|
package/src/codex/warmup.ts
CHANGED
|
@@ -28,7 +28,8 @@ export interface CodexWarmupOptions {
|
|
|
28
28
|
|
|
29
29
|
const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
|
|
30
30
|
const DEFAULT_MODEL = "gpt-5.4-mini";
|
|
31
|
-
const FALLBACK_MODELS = ["gpt-5.5"];
|
|
31
|
+
const FALLBACK_MODELS = ["gpt-5.5", "gpt-5.6-luna"];
|
|
32
|
+
const isRetryableWarmupStatus = (status?: number): boolean => status === 400 || status === 404;
|
|
32
33
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
33
34
|
const MAX_TIMEOUT_MS = 0x7fff_ffff;
|
|
34
35
|
const MAX_ERROR_BODY_BYTES = 2048;
|
|
@@ -63,6 +64,17 @@ export function codexWarmupFailureReason(err: unknown): string {
|
|
|
63
64
|
return safeWarmupReason(err);
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
/**
|
|
68
|
+
* 400 and 404 mean the model or the account is not provisioned for this request, not that the
|
|
69
|
+
* credential is bad. Sharing the retry predicate keeps the operator-facing diagnostic from
|
|
70
|
+
* disagreeing with the fallback policy that produced the final error.
|
|
71
|
+
*/
|
|
72
|
+
export function isCodexWarmupProvisioningFailure(err: unknown): boolean {
|
|
73
|
+
return err instanceof CodexWarmupError
|
|
74
|
+
&& err.code === "http_status"
|
|
75
|
+
&& isRetryableWarmupStatus(err.status);
|
|
76
|
+
}
|
|
77
|
+
|
|
66
78
|
function eventTypeFromData(data: unknown): string | undefined {
|
|
67
79
|
if (!data || typeof data !== "object") return undefined;
|
|
68
80
|
const record = data as Record<string, unknown>;
|
|
@@ -286,8 +298,8 @@ export async function warmCodexAccount(options: CodexWarmupOptions): Promise<voi
|
|
|
286
298
|
await tryWarmup(options, primaryModel);
|
|
287
299
|
return;
|
|
288
300
|
} catch (err) {
|
|
289
|
-
// Retry with fallback models on 400 (model may not be available for this account).
|
|
290
|
-
if (!(err instanceof CodexWarmupError) || err.status
|
|
301
|
+
// Retry with fallback models on 400 or 404 (model may not be provisioned or available for this account).
|
|
302
|
+
if (!(err instanceof CodexWarmupError) || !isRetryableWarmupStatus(err.status)) throw err;
|
|
291
303
|
let lastErr = err;
|
|
292
304
|
for (const fallback of FALLBACK_MODELS) {
|
|
293
305
|
if (fallback === primaryModel) continue;
|
|
@@ -295,7 +307,12 @@ export async function warmCodexAccount(options: CodexWarmupOptions): Promise<voi
|
|
|
295
307
|
await tryWarmup(options, fallback);
|
|
296
308
|
return;
|
|
297
309
|
} catch (retryErr) {
|
|
298
|
-
if (retryErr instanceof CodexWarmupError)
|
|
310
|
+
if (retryErr instanceof CodexWarmupError) {
|
|
311
|
+
lastErr = retryErr;
|
|
312
|
+
if (!isRetryableWarmupStatus(retryErr.status)) throw retryErr;
|
|
313
|
+
} else {
|
|
314
|
+
throw retryErr;
|
|
315
|
+
}
|
|
299
316
|
}
|
|
300
317
|
}
|
|
301
318
|
throw lastErr;
|
package/src/combos/index.ts
CHANGED
|
@@ -27,9 +27,11 @@ export {
|
|
|
27
27
|
noteComboSuccess,
|
|
28
28
|
pickComboTarget,
|
|
29
29
|
pickComboTargetWithWait,
|
|
30
|
+
quotaInactiveReason,
|
|
30
31
|
tryPickComboModel,
|
|
31
32
|
UnknownComboError,
|
|
32
33
|
type ComboPick,
|
|
34
|
+
type QuotaInactiveReason,
|
|
33
35
|
} from "./resolve";
|
|
34
36
|
export {
|
|
35
37
|
clearComboTargetCooldowns,
|
package/src/combos/resolve.ts
CHANGED
|
@@ -92,6 +92,58 @@ export function cachedProviderQuotaIsExhausted(
|
|
|
92
92
|
return false;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Why a catalog row is offered but cannot currently serve a request (#1711).
|
|
97
|
+
*
|
|
98
|
+
* Only one reason exists today. It is a string rather than a boolean so a later cause — a
|
|
99
|
+
* cooldown, a revoked key — can be told apart by a consumer that already reads the field.
|
|
100
|
+
*/
|
|
101
|
+
export type QuotaInactiveReason = "no_credit";
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* `"no_credit"` when every USABLE target of a catalog row has positive exhaustion evidence
|
|
105
|
+
* (#1711), otherwise undefined.
|
|
106
|
+
*
|
|
107
|
+
* This deliberately reuses the runtime rules in `targetProviderIsUsable` above rather than the
|
|
108
|
+
* Dashboard's `quotaStateFromReport`, which is harsher: it treats `remaining <= 0` as exhausted
|
|
109
|
+
* without requiring `percent >= 100` and ignores an elapsed `resetAt`. A catalog row marked
|
|
110
|
+
* inactive on the harsher rule would contradict the router, which would still happily send the
|
|
111
|
+
* request.
|
|
112
|
+
*
|
|
113
|
+
* Three rules carry the correctness, all inherited rather than restated:
|
|
114
|
+
*
|
|
115
|
+
* - A target the operator has removed or disabled is not usable and is not evidence either way;
|
|
116
|
+
* it drops out before the vote. If nothing is left, the row is unavailable for an operator
|
|
117
|
+
* reason rather than a quota one, so this returns undefined.
|
|
118
|
+
* - The canonical ChatGPT forward provider is exempt. Native account selection owns model-scoped
|
|
119
|
+
* quota, and a provider-level summary cannot veto it.
|
|
120
|
+
* - A stale cache is NOT exhaustion. `getCachedProviderQuota` returns null past its 30-minute
|
|
121
|
+
* window, and a null reading ends the vote rather than counting as evidence, so an unprobed
|
|
122
|
+
* provider is never marked inactive.
|
|
123
|
+
*
|
|
124
|
+
* "Every" is the bar on purpose: one target that can still serve makes the row serviceable, which
|
|
125
|
+
* is exactly what the combo loop concludes at request time.
|
|
126
|
+
*/
|
|
127
|
+
export function quotaInactiveReason(
|
|
128
|
+
config: OcxConfig,
|
|
129
|
+
targets: readonly { provider: string }[],
|
|
130
|
+
now = Date.now(),
|
|
131
|
+
): QuotaInactiveReason | undefined {
|
|
132
|
+
const usable = targets.filter(target => {
|
|
133
|
+
if (!Object.hasOwn(config.providers, target.provider)) return false;
|
|
134
|
+
const provider = config.providers[target.provider];
|
|
135
|
+
return !!provider && provider.disabled !== true;
|
|
136
|
+
});
|
|
137
|
+
if (usable.length === 0) return undefined;
|
|
138
|
+
for (const target of usable) {
|
|
139
|
+
const provider = config.providers[target.provider]!;
|
|
140
|
+
if (isCanonicalOpenAiForwardProvider(provider)) return undefined;
|
|
141
|
+
const quota = getCachedProviderQuota(target.provider, now);
|
|
142
|
+
if (!quota || !cachedProviderQuotaIsExhausted(quota, now)) return undefined;
|
|
143
|
+
}
|
|
144
|
+
return "no_credit";
|
|
145
|
+
}
|
|
146
|
+
|
|
95
147
|
function smoothWeightedIndex(
|
|
96
148
|
targets: Required<OcxComboTarget>[],
|
|
97
149
|
state: SelectionState,
|
|
@@ -175,7 +175,7 @@ export function listPendingTeardowns(): OutstandingTeardown[] {
|
|
|
175
175
|
}
|
|
176
176
|
const out: OutstandingTeardown[] = [];
|
|
177
177
|
for (const name of names) {
|
|
178
|
-
// One naming rule, shared with the
|
|
178
|
+
// One naming rule, shared with the package launcher: the two lanes drifting apart is
|
|
179
179
|
// exactly how the Node updater stopped seeing receipts at all.
|
|
180
180
|
if (!isPendingTeardownFileName(name)) continue;
|
|
181
181
|
const nonce = pendingTeardownNonceFromFileName(name)!;
|