@bitkyc08/opencodex 2.54.0-preview.20260914 → 2.55.0-preview.20260914
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/gui/dist/assets/{index-B4VYfZcY.js → index-DH2PUHqr.js} +10 -10
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic-image-codec.ts +57 -0
- package/src/adapters/anthropic-image-normalize.ts +28 -1
- package/src/adapters/anthropic.ts +68 -6
- package/src/adapters/base.ts +8 -0
- package/src/adapters/coding-agent/protocol.ts +41 -16
- package/src/adapters/cursor/cursor-errors.ts +1 -1
- package/src/adapters/cursor/live-transport.ts +5 -1
- package/src/adapters/cursor/native-exec-fs.ts +10 -10
- package/src/adapters/cursor/native-exec-network.ts +2 -2
- package/src/adapters/cursor/native-exec-shell.ts +13 -12
- package/src/adapters/cursor/native-exec.ts +51 -10
- package/src/adapters/cursor/policy-error.ts +75 -0
- package/src/adapters/cursor/protobuf-request.ts +105 -1
- package/src/adapters/devin/cloud-direct/catalog.ts +34 -2
- package/src/adapters/devin/live-models.ts +33 -2
- package/src/adapters/google-wire-compiler.ts +8 -0
- package/src/adapters/google.ts +46 -0
- package/src/adapters/input-media-guard.ts +45 -0
- package/src/adapters/kiro/adapter.ts +8 -0
- package/src/adapters/kiro/payload.ts +28 -6
- package/src/adapters/kiro-events.ts +25 -6
- package/src/adapters/kiro-images.ts +30 -0
- package/src/adapters/kiro-retry.ts +8 -0
- package/src/adapters/openai-chat.ts +33 -4
- package/src/adapters/openai-responses.ts +26 -0
- package/src/adapters/registry.ts +4 -0
- package/src/bridge.ts +163 -116
- package/src/chat/image-parts.ts +151 -0
- package/src/chat/inbound.ts +70 -33
- package/src/cli/connect.ts +30 -9
- package/src/cli/dispatch.ts +7 -3
- package/src/cli/index.ts +3 -0
- package/src/cli/runtime-api.ts +25 -0
- package/src/cli/status.ts +21 -19
- package/src/cli/system-restart-client.ts +25 -0
- package/src/clients/config-export.ts +14 -4
- package/src/codex/app-server-processes.ts +25 -0
- package/src/codex/auth-context.ts +8 -0
- package/src/codex/autostart-health.ts +36 -2
- package/src/codex/catalog/provider-fetch.ts +41 -0
- package/src/codex/catalog-auto-refresh.ts +182 -0
- package/src/codex/catalog-refresh-status.ts +93 -0
- package/src/codex/history-provider.ts +55 -0
- package/src/codex/model-entitlements.ts +78 -0
- package/src/codex/native-profile-processes.ts +114 -15
- package/src/codex/prompt-text-probe.ts +274 -41
- package/src/codex/routing-adoption.ts +189 -0
- package/src/codex/routing.ts +520 -48
- package/src/codex/runtime.ts +249 -7
- package/src/combos/failover.ts +45 -0
- package/src/config.ts +124 -4
- package/src/generated/compatibility-version.json +110 -74
- package/src/generated/model-metadata.ts +1 -0
- package/src/lib/request-execution-budget.ts +202 -0
- package/src/lib/upstream-retry.ts +95 -8
- package/src/lib/workflow-budget.ts +172 -0
- package/src/oauth/devin.ts +57 -12
- package/src/providers/quota.ts +37 -6
- package/src/providers/registry.ts +53 -6
- package/src/responses/input-media.ts +65 -0
- package/src/responses/parser-content.ts +42 -0
- package/src/responses/schema.ts +12 -2
- package/src/server/audio-live.ts +1 -2
- package/src/server/audio-transcriptions.ts +1 -2
- package/src/server/auth-cors.ts +1 -1
- package/src/server/background-lifecycle.ts +18 -0
- package/src/server/chat-completions.ts +23 -8
- package/src/server/chat-native.ts +17 -17
- package/src/server/index.ts +24 -0
- package/src/server/management/request-history-routes.ts +5 -0
- package/src/server/request-log.ts +8 -2
- package/src/server/responses/compact.ts +51 -3
- package/src/server/responses/core.ts +238 -28
- package/src/server/search.ts +7 -9
- package/src/types/config.ts +51 -6
- package/src/usage/log.ts +37 -0
- package/src/vision/eligibility.ts +37 -4
- package/src/vision/index.ts +1 -0
- package/src/vision/plan.ts +45 -10
- package/src/web-search/alpha-search.ts +324 -0
- package/src/web-search/index.ts +13 -22
- package/src/web-search/passthrough-bridge.ts +195 -22
- package/src/web-search/sidecar-providers.ts +22 -0
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
tryAcquireCodexQuotaScopeProbeLease,
|
|
39
39
|
pickAlternateCodexAccount,
|
|
40
40
|
resolveCodexAccountForThreadDetailed,
|
|
41
|
+
type CodexAffinityDecision,
|
|
41
42
|
} from "./routing";
|
|
42
43
|
import {
|
|
43
44
|
entitledCodexAccountIdsForModel,
|
|
@@ -137,6 +138,8 @@ export type CodexAuthContext =
|
|
|
137
138
|
probeLeaseId?: string;
|
|
138
139
|
/** Native model quota group selected for this request, when known. */
|
|
139
140
|
quotaScope?: CodexQuotaScope;
|
|
141
|
+
/** What happened to this thread's binding on this request (#4546). */
|
|
142
|
+
affinityDecision?: CodexAffinityDecision;
|
|
140
143
|
/** Scope that owns `probeLeaseId`, when it is a scoped recovery probe. */
|
|
141
144
|
probeQuotaScope?: CodexQuotaScope;
|
|
142
145
|
}
|
|
@@ -798,6 +801,9 @@ export async function resolveCodexAuthContext(
|
|
|
798
801
|
const affinityKey = fixedAccountId === undefined && !requestScopedMainCredential
|
|
799
802
|
? codexPoolAffinityKey(headers)
|
|
800
803
|
: undefined;
|
|
804
|
+
// Why this request is on this account, carried to the request log so a move reads as an event
|
|
805
|
+
// instead of something inferred from account labels across lines (#4546).
|
|
806
|
+
let affinityDecision: CodexAffinityDecision | undefined;
|
|
801
807
|
// Retained startup recovery makes the physical main identity ineligible. Routing
|
|
802
808
|
// can still preserve service by selecting a healthy configured pool account. A
|
|
803
809
|
// request-owned bearer likewise cannot inspect or reconcile file-main state.
|
|
@@ -870,6 +876,7 @@ export async function resolveCodexAuthContext(
|
|
|
870
876
|
);
|
|
871
877
|
if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId);
|
|
872
878
|
const selected = resolution.status === "selected" ? resolution.accountId : null;
|
|
879
|
+
affinityDecision = "affinity" in resolution ? resolution.affinity : undefined;
|
|
873
880
|
if (!selected) {
|
|
874
881
|
// A retry that excluded a failed Pool account may still use the validated caller-owned
|
|
875
882
|
// main credential. Treating every exclusion as if main itself had failed strands a healthy
|
|
@@ -1066,6 +1073,7 @@ export async function resolveCodexAuthContext(
|
|
|
1066
1073
|
...(quotaScope ? { quotaScope } : {}),
|
|
1067
1074
|
...(probeLeaseId ? { probeLeaseId } : {}),
|
|
1068
1075
|
...(probeQuotaScope ? { probeQuotaScope } : {}),
|
|
1076
|
+
...(affinityDecision ? { affinityDecision } : {}),
|
|
1069
1077
|
};
|
|
1070
1078
|
} catch (cause) {
|
|
1071
1079
|
if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
|
|
@@ -2,6 +2,7 @@ import { codexAutoStartEnabled } from "../config";
|
|
|
2
2
|
import { diagnoseService, type ServiceDiagnostic } from "../service";
|
|
3
3
|
import type { OcxConfig } from "../types";
|
|
4
4
|
import { getCodexRoutingKind, type CodexRoutingKind } from "./inject";
|
|
5
|
+
import { collectRoutingAdoption, type RoutingAdoptionEvidence } from "./routing-adoption";
|
|
5
6
|
import { diagnoseCodexShim, type CodexShimDiagnostic } from "./shim";
|
|
6
7
|
|
|
7
8
|
export type StartupProtection = "service" | "shim" | "none";
|
|
@@ -22,6 +23,7 @@ export interface StartupHealthInputs {
|
|
|
22
23
|
shimHealthy: boolean;
|
|
23
24
|
platform: NodeJS.Platform;
|
|
24
25
|
diagnosticStale?: boolean;
|
|
26
|
+
routingAdoption?: RoutingAdoptionEvidence;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export interface StartupHealth {
|
|
@@ -51,6 +53,7 @@ export interface StartupHealth {
|
|
|
51
53
|
installShim: string;
|
|
52
54
|
restoreNative: string;
|
|
53
55
|
};
|
|
56
|
+
routingAdoption?: RoutingAdoptionEvidence;
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
const COMMANDS = {
|
|
@@ -115,6 +118,7 @@ export interface StartupHealthDiagnostics {
|
|
|
115
118
|
routingKind?: CodexRoutingKind;
|
|
116
119
|
service?: ServiceDiagnostic;
|
|
117
120
|
shim?: CodexShimDiagnostic;
|
|
121
|
+
routingAdoption?: RoutingAdoptionEvidence;
|
|
118
122
|
}
|
|
119
123
|
|
|
120
124
|
/** Collect current machine state without mutating config, services, or shims. */
|
|
@@ -124,8 +128,11 @@ export function collectStartupHealth(
|
|
|
124
128
|
): StartupHealth {
|
|
125
129
|
const shim = diagnostics.shim ?? diagnoseCodexShim();
|
|
126
130
|
const service = diagnostics.service ?? diagnoseService();
|
|
131
|
+
const routingKind = diagnostics.routingKind ?? getCodexRoutingKind();
|
|
132
|
+
const routingAdoption = diagnostics.routingAdoption
|
|
133
|
+
?? (routingKind === "opencodex-local" ? collectRoutingAdoption({ routingKind }) : undefined);
|
|
127
134
|
return deriveStartupHealth({
|
|
128
|
-
routingKind
|
|
135
|
+
routingKind,
|
|
129
136
|
autostartEnabled: codexAutoStartEnabled(config),
|
|
130
137
|
serviceInstalled: service.installed,
|
|
131
138
|
serviceViable: service.viable,
|
|
@@ -137,10 +144,17 @@ export function collectStartupHealth(
|
|
|
137
144
|
shimInstalled: shim.installed,
|
|
138
145
|
shimHealthy: shim.healthy,
|
|
139
146
|
platform: process.platform,
|
|
147
|
+
...(routingAdoption ? { routingAdoption } : {}),
|
|
140
148
|
});
|
|
141
149
|
}
|
|
142
150
|
|
|
143
151
|
export function startupHealthSummary(health: StartupHealth): string {
|
|
152
|
+
const summary = classifyStartupHealthSummary(health);
|
|
153
|
+
const action = pendingClientRestartAction(health);
|
|
154
|
+
return action ? `${summary}; ${action}` : summary;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function classifyStartupHealthSummary(health: StartupHealth): string {
|
|
144
158
|
if (health.status === "native") return health.routingKind === "custom-remote"
|
|
145
159
|
? "custom remote Codex routing (no local restart dependency)"
|
|
146
160
|
: "native Codex routing (no opencodex restart dependency)";
|
|
@@ -155,6 +169,24 @@ export function startupHealthSummary(health: StartupHealth): string {
|
|
|
155
169
|
return `AT RISK after restart (no viable background service; run '${command}')`;
|
|
156
170
|
}
|
|
157
171
|
|
|
172
|
+
function pendingClientRestartAction(health: StartupHealth): string | null {
|
|
173
|
+
const adoption = health.routingAdoption;
|
|
174
|
+
if (adoption?.adoption !== "pending-client-restart") return null;
|
|
175
|
+
const pids = adoption.staleClients.map(client => client.pid);
|
|
176
|
+
if (pids.length === 0) return null;
|
|
177
|
+
const pidList = pids.join(", ");
|
|
178
|
+
return pids.length === 1
|
|
179
|
+
? `restart Codex client pid ${pidList} so it adopts the injected proxy route`
|
|
180
|
+
: `restart Codex clients pid ${pidList} so they adopt the injected proxy route`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function pendingClientRestartDetail(adoption: RoutingAdoptionEvidence | undefined): string | null {
|
|
184
|
+
if (adoption?.adoption !== "pending-client-restart") return null;
|
|
185
|
+
const pids = adoption.staleClients.map(client => client.pid);
|
|
186
|
+
if (pids.length === 0) return null;
|
|
187
|
+
return `clients=pending-restart(pid ${pids.join(", ")})`;
|
|
188
|
+
}
|
|
189
|
+
|
|
158
190
|
/**
|
|
159
191
|
* The routing/service/shim token `ocx doctor` prints under restart safety.
|
|
160
192
|
* Extracted so `ocx status` can show the same string rather than growing a
|
|
@@ -168,5 +200,7 @@ export function formatStartupRoutingDetail(health: StartupHealth): string {
|
|
|
168
200
|
const shim = health.shimHealthy
|
|
169
201
|
? "healthy"
|
|
170
202
|
: health.shimInstalled ? "stale" : "absent";
|
|
171
|
-
|
|
203
|
+
const base = `routing=${health.routingKind}, service=${service}, shim=${shim}`;
|
|
204
|
+
const token = pendingClientRestartDetail(health.routingAdoption);
|
|
205
|
+
return token ? `${base}, ${token}` : base;
|
|
172
206
|
}
|
|
@@ -418,6 +418,40 @@ function captureModelsRequest(
|
|
|
418
418
|
});
|
|
419
419
|
}
|
|
420
420
|
|
|
421
|
+
/**
|
|
422
|
+
* Fill the registry seed's per-model numeric capability maps beneath the provider's own
|
|
423
|
+
* values, mutating `prov` in place. The merge is per key — an operator's entry always
|
|
424
|
+
* wins; a model the persisted map never mentions picks up its seed value — matching
|
|
425
|
+
* `mergeRecordFill` in src/router.ts exactly.
|
|
426
|
+
*
|
|
427
|
+
* Routing already performs this fill at resolve time (routedProviderConfig in
|
|
428
|
+
* src/router.ts) and the catalog did not, and that divergence is #4570:
|
|
429
|
+
* zhipu-bigmodel-coding/glm-5.3-flash reached the live catalog with correct modalities
|
|
430
|
+
* but no context window, because an install persisted before Flash joined the seed map
|
|
431
|
+
* held a truthy partial `modelContextWindows` that shadowed the whole seed.
|
|
432
|
+
*
|
|
433
|
+
* This lives here and not in enrichProviderFromRegistry because enrichment output is
|
|
434
|
+
* persisted on a management POST, and #1409 (pinned by
|
|
435
|
+
* tests/server/management-provider-validation.test.ts) requires that a save never write
|
|
436
|
+
* registry seed keys into the operator's config. The gather clone is detached and
|
|
437
|
+
* frozen, never saved, so the catalog can see the seed without the config gaining it.
|
|
438
|
+
*/
|
|
439
|
+
export function applyRegistryCapabilitySeedFill(name: string, prov: OcxProviderConfig): void {
|
|
440
|
+
// router.ts resolves the canonical OpenAI API provider's token maps with
|
|
441
|
+
// mergePositiveNumberCaps (user values cap the seed rather than replace it), so a
|
|
442
|
+
// plain fill here would give that one provider catalog semantics routing never has.
|
|
443
|
+
if (name === OPENAI_API_PROVIDER_ID) return;
|
|
444
|
+
if (!providerMatchesRegistryTransport(name, prov)) return;
|
|
445
|
+
const entry = getProviderRegistryEntry(name);
|
|
446
|
+
if (!entry) return;
|
|
447
|
+
if (entry.modelContextWindows || prov.modelContextWindows) {
|
|
448
|
+
prov.modelContextWindows = { ...(entry.modelContextWindows ?? {}), ...(prov.modelContextWindows ?? {}) };
|
|
449
|
+
}
|
|
450
|
+
if (entry.modelMaxOutputTokens || prov.modelMaxOutputTokens) {
|
|
451
|
+
prov.modelMaxOutputTokens = { ...(entry.modelMaxOutputTokens ?? {}), ...(prov.modelMaxOutputTokens ?? {}) };
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
421
455
|
function captureProviderGather(
|
|
422
456
|
name: string,
|
|
423
457
|
configured: OcxProviderConfig,
|
|
@@ -427,6 +461,7 @@ function captureProviderGather(
|
|
|
427
461
|
): CapturedProviderGather {
|
|
428
462
|
const enriched = detachedClone(withCanonicalOpenAiForwardAuthDefault(name, configured));
|
|
429
463
|
enrichProviderFromRegistry(name, enriched);
|
|
464
|
+
applyRegistryCapabilitySeedFill(name, enriched);
|
|
430
465
|
const registryTransportMatch = providerMatchesRegistryTransport(name, enriched);
|
|
431
466
|
const provider = recursivelyFreeze(enriched);
|
|
432
467
|
const fastPolicyAuthority = captureFastPolicyAuthority(
|
|
@@ -1745,6 +1780,12 @@ async function fetchProviderModelsWithAuth(
|
|
|
1745
1780
|
// away, and every client that keys an effort control off this field —
|
|
1746
1781
|
// the Pi-shaped exports — renders no control at all.
|
|
1747
1782
|
...(liveResult.efforts[id]?.length ? { reasoningEfforts: liveResult.efforts[id] } : {}),
|
|
1783
|
+
// The account catalog's per-base supportsImages vote collapses to one
|
|
1784
|
+
// modalities value. It spreads before the hints so exact
|
|
1785
|
+
// modelCapabilities declarations, the legacy modelInputModalities
|
|
1786
|
+
// record and the vision-sidecar rewrite keep winning — the live
|
|
1787
|
+
// value survives only when none of them applies.
|
|
1788
|
+
...(liveResult.inputModalities[id]?.length ? { inputModalities: liveResult.inputModalities[id] } : {}),
|
|
1748
1789
|
...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias),
|
|
1749
1790
|
} as CatalogModel;
|
|
1750
1791
|
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in periodic catalog refresh so newly released models appear without a
|
|
3
|
+
* manual `ocx sync` (issue #3630).
|
|
4
|
+
*
|
|
5
|
+
* This is load-bearing, not a convenience. The served model set is otherwise
|
|
6
|
+
* only rewritten by an explicit sync, a management mutation, or startup
|
|
7
|
+
* convergence, so an overnight provider release stays invisible until someone
|
|
8
|
+
* happens to run one of those. The overnight case is the whole reason the
|
|
9
|
+
* scheduler exists.
|
|
10
|
+
*
|
|
11
|
+
* Shape follows src/quota/reset-poller.ts: a module-singleton unref'd interval
|
|
12
|
+
* whose config gate lives in the callee, so toggling `enabled` or changing the
|
|
13
|
+
* cadence takes effect on the next tick without a restart (the rationale
|
|
14
|
+
* spelled out at src/oauth/token-guardian.ts:276). Importing this module at
|
|
15
|
+
* startup must cost nothing — src/server/background-lifecycle.ts loads it
|
|
16
|
+
* statically — so the config barrel, the admission snapshot, and the
|
|
17
|
+
* convergence funnel are all dynamic import()s inside the tick.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Keep these numeric literals aligned with CATALOG_AUTO_REFRESH_* in src/config.ts.
|
|
22
|
+
* They cannot be imported from there: this module is a static edge from
|
|
23
|
+
* background-lifecycle, and the config barrel is a heavy import reserved for the tick.
|
|
24
|
+
*/
|
|
25
|
+
const DEFAULT_INTERVAL_MS = 60 * 60_000;
|
|
26
|
+
const MIN_INTERVAL_MS = 15 * 60_000;
|
|
27
|
+
/**
|
|
28
|
+
* Commit-lock wait only. Gather already has per-provider timeouts, and automatic
|
|
29
|
+
* callers fail fast and defer (ConvergeRequest.mode) rather than holding the
|
|
30
|
+
* write lock across a slow tick.
|
|
31
|
+
*/
|
|
32
|
+
const TICK_DEADLINE_MS = 1_000;
|
|
33
|
+
|
|
34
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
35
|
+
let detachShutdownHook: (() => void) | null = null;
|
|
36
|
+
/** The bounded cadence the live timer was created with, so a tick can notice config drift. */
|
|
37
|
+
let liveIntervalMs: number | null = null;
|
|
38
|
+
/**
|
|
39
|
+
* Bumped by every start and stop. A tick captures it on entry and re-checks before publishing,
|
|
40
|
+
* so a converge still in flight when the timer stops cannot publish into the next generation.
|
|
41
|
+
*/
|
|
42
|
+
let generation = 0;
|
|
43
|
+
/** setInterval does not skip a firing while the previous callback is still awaiting. */
|
|
44
|
+
let inFlight = false;
|
|
45
|
+
|
|
46
|
+
/** Number of ticks that have run. Test-only observability; carries no catalog data. */
|
|
47
|
+
let tickCount = 0;
|
|
48
|
+
|
|
49
|
+
function boundedInterval(value: number): number {
|
|
50
|
+
return Math.max(MIN_INTERVAL_MS, Math.floor(value));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Re-arm the timer when the operator changed the cadence since it was created. */
|
|
54
|
+
function restartIfCadenceChanged(configured: number): void {
|
|
55
|
+
if (timer === null || boundedInterval(configured) === liveIntervalMs) return;
|
|
56
|
+
stopCatalogAutoRefresh();
|
|
57
|
+
startCatalogAutoRefresh(configured);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function tick(): Promise<void> {
|
|
61
|
+
// An interval firing while the previous converge is still awaiting would stack
|
|
62
|
+
// provider fetches precisely when a slow /models call is already in flight.
|
|
63
|
+
if (inFlight) return;
|
|
64
|
+
inFlight = true;
|
|
65
|
+
const entryGeneration = generation;
|
|
66
|
+
try {
|
|
67
|
+
const {
|
|
68
|
+
loadConfig,
|
|
69
|
+
isCatalogAutoRefreshEnabled,
|
|
70
|
+
resolveCatalogAutoRefreshIntervalMs,
|
|
71
|
+
} = await import("../config");
|
|
72
|
+
const config = loadConfig();
|
|
73
|
+
if (!isCatalogAutoRefreshEnabled(config)) return;
|
|
74
|
+
const configured = resolveCatalogAutoRefreshIntervalMs(config);
|
|
75
|
+
// 0 is dormant: the section stays configured but this tick must not converge,
|
|
76
|
+
// and the unref'd timer is left running so flipping the minutes back on is
|
|
77
|
+
// picked up without a process restart.
|
|
78
|
+
if (configured === 0) return;
|
|
79
|
+
// A stop or restart landed while the config resolved: this tick no longer owns the timer,
|
|
80
|
+
// so it must neither count as a refresh nor adopt a cadence for a generation that is gone.
|
|
81
|
+
if (entryGeneration !== generation) return;
|
|
82
|
+
// Adopt a changed cadence without a restart, which is why the config gate lives in the
|
|
83
|
+
// callee at all. Only while this tick still owns the timer.
|
|
84
|
+
restartIfCadenceChanged(configured);
|
|
85
|
+
tickCount += 1;
|
|
86
|
+
const [{ createManagementConvergeCodex }, { createCatalogConvergeRequest }] = await Promise.all([
|
|
87
|
+
import("./management-convergence"),
|
|
88
|
+
import("./catalog-admission"),
|
|
89
|
+
]);
|
|
90
|
+
// A stop or restart landed while the funnel was loading: the result belongs to a
|
|
91
|
+
// generation that no longer owns the timer, so it must not publish.
|
|
92
|
+
if (entryGeneration !== generation) return;
|
|
93
|
+
const converge = createManagementConvergeCodex(config);
|
|
94
|
+
const outcome = await converge(createCatalogConvergeRequest({ deadlineMs: TICK_DEADLINE_MS }));
|
|
95
|
+
if (entryGeneration !== generation) return;
|
|
96
|
+
// createManagementConvergeCodex always projects catalog-only. Any other kind is a
|
|
97
|
+
// funnel contract break, not something this scheduler should re-classify.
|
|
98
|
+
if (outcome.kind !== "catalog-only") return;
|
|
99
|
+
const { recordCatalogAutoRefreshOutcome } = await import("./catalog-refresh-status");
|
|
100
|
+
recordCatalogAutoRefreshOutcome(outcome.catalogRefresh, outcome.changed);
|
|
101
|
+
if (outcome.changed) {
|
|
102
|
+
// Privacy scan: no provider names, model ids, paths, or account identifiers.
|
|
103
|
+
console.info("[catalog-auto-refresh] served model set changed");
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// A failed refresh is not an error worth surfacing: the next tick tries again.
|
|
107
|
+
} finally {
|
|
108
|
+
inFlight = false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Idempotent. A second call while running is a no-op, matching startQuotaResetPoller. */
|
|
113
|
+
export function startCatalogAutoRefresh(intervalMs = DEFAULT_INTERVAL_MS): void {
|
|
114
|
+
if (timer) return;
|
|
115
|
+
const bounded = boundedInterval(intervalMs);
|
|
116
|
+
generation += 1;
|
|
117
|
+
liveIntervalMs = bounded;
|
|
118
|
+
timer = setInterval(() => void tick(), bounded);
|
|
119
|
+
// Never keep the process alive for a catalog refresh.
|
|
120
|
+
timer.unref?.();
|
|
121
|
+
void import("../lib/optional-shutdown-hooks")
|
|
122
|
+
.then(hooks => {
|
|
123
|
+
detachShutdownHook = hooks.registerOptionalShutdownHook(
|
|
124
|
+
"catalog-auto-refresh",
|
|
125
|
+
stopCatalogAutoRefresh,
|
|
126
|
+
);
|
|
127
|
+
})
|
|
128
|
+
.catch(() => {
|
|
129
|
+
// Without the hook the unref'd timer still cannot delay exit.
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function stopCatalogAutoRefresh(): void {
|
|
134
|
+
if (timer) {
|
|
135
|
+
clearInterval(timer);
|
|
136
|
+
timer = null;
|
|
137
|
+
}
|
|
138
|
+
liveIntervalMs = null;
|
|
139
|
+
generation += 1;
|
|
140
|
+
detachShutdownHook?.();
|
|
141
|
+
detachShutdownHook = null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function isCatalogAutoRefreshRunning(): boolean {
|
|
145
|
+
return timer !== null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Adopt the operator's configured cadence at startup.
|
|
150
|
+
*
|
|
151
|
+
* The caller starts the scheduler synchronously with the default interval, because
|
|
152
|
+
* resolving the config here would mean a static edge to ../config from a module
|
|
153
|
+
* background-lifecycle imports at load time. Resolving it through import() keeps
|
|
154
|
+
* that edge dynamic, at the cost of the timer running at the default for the few
|
|
155
|
+
* microtasks before this settles.
|
|
156
|
+
*/
|
|
157
|
+
export async function syncCatalogAutoRefreshCadence(): Promise<void> {
|
|
158
|
+
const { loadConfig, resolveCatalogAutoRefreshIntervalMs } = await import("../config");
|
|
159
|
+
const configured = resolveCatalogAutoRefreshIntervalMs(loadConfig());
|
|
160
|
+
// 0 is dormant: tick() already returns before converging, and the timer stays unref'd.
|
|
161
|
+
if (configured === 0) return;
|
|
162
|
+
restartIfCadenceChanged(configured);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Test-only: run one tick synchronously rather than waiting out the interval. */
|
|
166
|
+
export async function runCatalogAutoRefreshTickForTests(): Promise<void> {
|
|
167
|
+
await tick();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function catalogAutoRefreshTickCountForTests(): number {
|
|
171
|
+
return tickCount;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Test-only: the bounded cadence the live timer is running at, or null when stopped. */
|
|
175
|
+
export function catalogAutoRefreshIntervalForTests(): number | null {
|
|
176
|
+
return liveIntervalMs;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function resetCatalogAutoRefreshForTests(): void {
|
|
180
|
+
stopCatalogAutoRefresh();
|
|
181
|
+
tickCount = 0;
|
|
182
|
+
}
|
|
@@ -103,3 +103,96 @@ function normalizeCatalogFailureCause(value: unknown): CatalogFailureCause | und
|
|
|
103
103
|
export function catalogRefreshIsPending(disposition: CatalogDisposition): boolean {
|
|
104
104
|
return disposition.status !== "committed";
|
|
105
105
|
}
|
|
106
|
+
|
|
107
|
+
export interface CatalogAutoRefreshOutcome {
|
|
108
|
+
readonly at: number;
|
|
109
|
+
readonly disposition: CatalogDisposition;
|
|
110
|
+
readonly changed: boolean;
|
|
111
|
+
/**
|
|
112
|
+
* A refresh that has failed repeatedly is the signal an operator needs, and the
|
|
113
|
+
* boolean disposition alone cannot express it: skipped and failed look the same
|
|
114
|
+
* as a one-off busy skip until this count climbs.
|
|
115
|
+
*/
|
|
116
|
+
readonly consecutiveFailures: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let lastAutoRefreshOutcome: CatalogAutoRefreshOutcome | null = null;
|
|
120
|
+
|
|
121
|
+
/** Rebuild and freeze so a management reader cannot mutate scheduler state. */
|
|
122
|
+
function freezeCatalogDisposition(disposition: CatalogDisposition): CatalogDisposition {
|
|
123
|
+
if (disposition.status === "committed") {
|
|
124
|
+
return Object.freeze({
|
|
125
|
+
status: "committed" as const,
|
|
126
|
+
changed: disposition.changed,
|
|
127
|
+
degraded: disposition.degraded,
|
|
128
|
+
notices: Object.freeze([...disposition.notices]),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if (disposition.status === "skipped") {
|
|
132
|
+
return Object.freeze({
|
|
133
|
+
status: "skipped" as const,
|
|
134
|
+
reason: disposition.reason,
|
|
135
|
+
retryable: disposition.retryable,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
const cause = disposition.cause
|
|
139
|
+
? Object.freeze({
|
|
140
|
+
kind: disposition.cause.kind,
|
|
141
|
+
...(disposition.cause.code ? { code: disposition.cause.code } : {}),
|
|
142
|
+
})
|
|
143
|
+
: undefined;
|
|
144
|
+
return Object.freeze({
|
|
145
|
+
status: "failed" as const,
|
|
146
|
+
reason: disposition.reason,
|
|
147
|
+
phase: disposition.phase,
|
|
148
|
+
retryable: disposition.retryable,
|
|
149
|
+
partialWrite: disposition.partialWrite,
|
|
150
|
+
...(cause ? { cause } : {}),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function freezeCatalogAutoRefreshOutcome(
|
|
155
|
+
outcome: CatalogAutoRefreshOutcome,
|
|
156
|
+
): CatalogAutoRefreshOutcome {
|
|
157
|
+
return Object.freeze({
|
|
158
|
+
at: outcome.at,
|
|
159
|
+
disposition: freezeCatalogDisposition(outcome.disposition),
|
|
160
|
+
changed: outcome.changed,
|
|
161
|
+
consecutiveFailures: outcome.consecutiveFailures,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Record one auto-refresh tick. The disposition is rebuilt through
|
|
167
|
+
* normalizeCatalogDisposition before anything is stored: an unnormalizable
|
|
168
|
+
* value is exactly the case this privacy boundary exists for, so it is dropped
|
|
169
|
+
* rather than copied through into a management response.
|
|
170
|
+
*/
|
|
171
|
+
export function recordCatalogAutoRefreshOutcome(
|
|
172
|
+
disposition: CatalogDisposition,
|
|
173
|
+
changed: boolean,
|
|
174
|
+
): CatalogAutoRefreshOutcome | null {
|
|
175
|
+
const normalized = normalizeCatalogDisposition(disposition);
|
|
176
|
+
if (normalized === null) return null;
|
|
177
|
+
const consecutiveFailures = catalogRefreshIsPending(normalized)
|
|
178
|
+
? (lastAutoRefreshOutcome?.consecutiveFailures ?? 0) + 1
|
|
179
|
+
: 0;
|
|
180
|
+
const outcome = freezeCatalogAutoRefreshOutcome({
|
|
181
|
+
at: Date.now(),
|
|
182
|
+
disposition: normalized,
|
|
183
|
+
changed: changed === true,
|
|
184
|
+
consecutiveFailures,
|
|
185
|
+
});
|
|
186
|
+
lastAutoRefreshOutcome = outcome;
|
|
187
|
+
return freezeCatalogAutoRefreshOutcome(outcome);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function lastCatalogAutoRefreshOutcome(): CatalogAutoRefreshOutcome | null {
|
|
191
|
+
return lastAutoRefreshOutcome === null
|
|
192
|
+
? null
|
|
193
|
+
: freezeCatalogAutoRefreshOutcome(lastAutoRefreshOutcome);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function resetCatalogAutoRefreshStatusForTests(): void {
|
|
197
|
+
lastAutoRefreshOutcome = null;
|
|
198
|
+
}
|
|
@@ -171,6 +171,41 @@ function readFirstRolloutLine(fd: number): string | null {
|
|
|
171
171
|
return nlIndex === -1 ? null : collected.subarray(0, nlIndex).toString("utf8");
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Bounded tail of complete JSONL lines, newest-last.
|
|
176
|
+
*
|
|
177
|
+
* Used to refuse a rollout that *became* paginated after a legacy first line
|
|
178
|
+
* (#4311). Line 1 can still look writable after a newer Codex migrates the
|
|
179
|
+
* thread in place, and the native projector then dies on the first
|
|
180
|
+
* out-of-sequence ordinal a legacy append introduces. Every record written
|
|
181
|
+
* after such a migration carries an ordinal, so the newest records are where
|
|
182
|
+
* the evidence is.
|
|
183
|
+
*
|
|
184
|
+
* One read of a fixed window from EOF, split once. An earlier draft grew the
|
|
185
|
+
* window chunk by chunk and re-decoded the accumulated buffer on every
|
|
186
|
+
* iteration, which is quadratic: a rollout whose only `session_meta` sits at
|
|
187
|
+
* the top would have decoded and split up to the whole window ~256 times. The
|
|
188
|
+
* window is a cap, not a target — it is not walked and it is not the file.
|
|
189
|
+
*
|
|
190
|
+
* Returns `null` only when the file cannot be measured, which the caller
|
|
191
|
+
* treats as an unreadable record rather than a writable rollout.
|
|
192
|
+
*/
|
|
193
|
+
const ROLLOUT_TAIL_WINDOW_BYTES = 1 << 20;
|
|
194
|
+
|
|
195
|
+
function readRolloutTailCompleteLines(fd: number): string[] | null {
|
|
196
|
+
const size = Number(fstatSync(fd).size);
|
|
197
|
+
if (!Number.isFinite(size) || size < 0) return null;
|
|
198
|
+
if (size === 0) return [];
|
|
199
|
+
const start = Math.max(0, size - ROLLOUT_TAIL_WINDOW_BYTES);
|
|
200
|
+
const window = Buffer.alloc(size - start);
|
|
201
|
+
const read = readSync(fd, window, 0, window.length, start);
|
|
202
|
+
if (read === 0) return [];
|
|
203
|
+
const lines = window.subarray(0, read).toString("utf8").split("\n");
|
|
204
|
+
// Unless the window reached BOF, the first element starts mid-record (and
|
|
205
|
+
// possibly mid-codepoint), so it is not a complete line.
|
|
206
|
+
return (start === 0 ? lines : lines.slice(1)).filter(line => line.length > 0);
|
|
207
|
+
}
|
|
208
|
+
|
|
174
209
|
function planFirstLineProvider(firstLine: string, expectedId: string, provider: string): FirstLineProviderPlan {
|
|
175
210
|
const meta = parseSessionMetaLine(firstLine);
|
|
176
211
|
if (!meta || meta.record.payload.id !== expectedId) return { state: "unsafe" };
|
|
@@ -315,6 +350,26 @@ function assertLegacyHistoryWritable(path: string, heldFd?: number): void {
|
|
|
315
350
|
const first = readFirstRolloutLine(fd);
|
|
316
351
|
if (!first) throw new CodexHistoryIntegrityError("history_rollout_record_invalid");
|
|
317
352
|
assertLegacyHistoryRecord(first);
|
|
353
|
+
// Line 1 is not enough: a newer Codex can migrate a live rollout in place,
|
|
354
|
+
// leaving the original session_meta and writing ordinals / history_mode only
|
|
355
|
+
// onto later records (#4311). The native projector then stops at the first
|
|
356
|
+
// cloned ordinal-0 append. Inspect a bounded window of the newest records
|
|
357
|
+
// and refuse before any mutation of the rollout, the row, or the manifest.
|
|
358
|
+
const tail = readRolloutTailCompleteLines(fd);
|
|
359
|
+
if (tail === null) throw new CodexHistoryIntegrityError("history_rollout_record_invalid");
|
|
360
|
+
if (tail.length === 0) return;
|
|
361
|
+
const last = tail[tail.length - 1];
|
|
362
|
+
if (!last) throw new CodexHistoryIntegrityError("history_rollout_record_invalid");
|
|
363
|
+
if (last !== first) assertLegacyHistoryRecord(last);
|
|
364
|
+
// Cheap filter: only re-parse tail lines that look paginated. Needed because
|
|
365
|
+
// a compensating append can make the last line look legacy again while an
|
|
366
|
+
// earlier-in-tail native conversion still carries ordinals (#4311).
|
|
367
|
+
for (const line of tail) {
|
|
368
|
+
if (line === first || line === last) continue;
|
|
369
|
+
if (line.includes("\"ordinal\"") || line.includes("\"history_mode\"")) {
|
|
370
|
+
assertLegacyHistoryRecord(line);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
318
373
|
} finally {
|
|
319
374
|
if (heldFd === undefined) closeSync(fd);
|
|
320
375
|
}
|
|
@@ -317,6 +317,37 @@ const MODEL_ROSTER_VERSIONS_PER_ACCOUNT_MAX = 4;
|
|
|
317
317
|
* roster.
|
|
318
318
|
*/
|
|
319
319
|
const MODEL_ROSTER_FLIGHTS_PER_ACCOUNT_MAX = 4;
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Distinct caller-selected roster versions admitted per account in one roster window.
|
|
323
|
+
*
|
|
324
|
+
* The cache budget and the flight budget both bound STATE, not WORK. A caller that cycles
|
|
325
|
+
* `client_version` and waits for each answer misses the cache by design and misses the flight
|
|
326
|
+
* key by design, so it can renew an authenticated upstream request under EVERY stored account
|
|
327
|
+
* token as often as it likes, and the gated-model checks it displaces fail closed while it does.
|
|
328
|
+
*
|
|
329
|
+
* DISTINCT VERSIONS are counted, never attempts. One legitimate client retrying a single version
|
|
330
|
+
* through an upstream outage comes back every 15s on the failure TTL; charging each attempt would
|
|
331
|
+
* spend the whole allowance on that one version and then refuse it for the rest of the 5-minute
|
|
332
|
+
* window, turning a recovered upstream into several more minutes without gated models.
|
|
333
|
+
*/
|
|
334
|
+
const MODEL_ROSTER_VERSION_MISSES_PER_ACCOUNT_MAX = 4;
|
|
335
|
+
|
|
336
|
+
interface AccountVersionMissBudget {
|
|
337
|
+
credentialIdentity: string;
|
|
338
|
+
/** Version -> when this version stops occupying the allowance. */
|
|
339
|
+
versions: Map<string, number>;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* One row per ACCOUNT, not per credential identity.
|
|
344
|
+
*
|
|
345
|
+
* A Pool access-token refresh increments the generation, so an identity-keyed map would gain a
|
|
346
|
+
* permanent row per generation for the lifetime of the process: a protection against renewable
|
|
347
|
+
* work would have introduced an unbounded cache. A generation change replaces the row instead,
|
|
348
|
+
* which is also the right budget semantics — new credential, new allowance.
|
|
349
|
+
*/
|
|
350
|
+
const accountModelsMisses = new Map<string, AccountVersionMissBudget>();
|
|
320
351
|
const DIRECT_CALLER_ACCOUNT_PREFIX = "__direct_codex__:";
|
|
321
352
|
|
|
322
353
|
export interface CodexModelEntitlementCredentialSnapshot {
|
|
@@ -670,6 +701,7 @@ async function modelsForCredential(
|
|
|
670
701
|
fetcher: typeof fetch,
|
|
671
702
|
now: number,
|
|
672
703
|
clientVersion: string,
|
|
704
|
+
trustedClientVersion: string,
|
|
673
705
|
credentialMutationEpoch?: number,
|
|
674
706
|
): Promise<CachedAccountModels> {
|
|
675
707
|
const cached = accountModelsCache.get(cacheKeyFor(credential.accountId, clientVersion));
|
|
@@ -698,6 +730,21 @@ async function modelsForCredential(
|
|
|
698
730
|
confirmed: false,
|
|
699
731
|
};
|
|
700
732
|
}
|
|
733
|
+
// Cache hits, joined flights and capacity refusals start no upstream request. Charge only
|
|
734
|
+
// after capacity admission; the locally selected runtime version remains exempt.
|
|
735
|
+
if (
|
|
736
|
+
!credential.accountId.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX)
|
|
737
|
+
&& clientVersion !== trustedClientVersion
|
|
738
|
+
&& !admitVersionMiss(credential, clientVersion, now)
|
|
739
|
+
) {
|
|
740
|
+
return {
|
|
741
|
+
credentialIdentity: credential.credentialIdentity,
|
|
742
|
+
clientVersion,
|
|
743
|
+
expiresAt: now,
|
|
744
|
+
models: new Set(),
|
|
745
|
+
confirmed: false,
|
|
746
|
+
};
|
|
747
|
+
}
|
|
701
748
|
const flight = fetchAccountModels(credential, fetcher, now, clientVersion)
|
|
702
749
|
.then(result => {
|
|
703
750
|
if (
|
|
@@ -716,6 +763,30 @@ async function modelsForCredential(
|
|
|
716
763
|
return flight;
|
|
717
764
|
}
|
|
718
765
|
|
|
766
|
+
/** Whether this caller-selected version may open a new upstream request for the account. */
|
|
767
|
+
function admitVersionMiss(
|
|
768
|
+
credential: CodexModelEntitlementCredentialSnapshot,
|
|
769
|
+
clientVersion: string,
|
|
770
|
+
now: number,
|
|
771
|
+
): boolean {
|
|
772
|
+
const stored = accountModelsMisses.get(credential.accountId);
|
|
773
|
+
const budget = stored && stored.credentialIdentity === credential.credentialIdentity
|
|
774
|
+
? stored
|
|
775
|
+
: { credentialIdentity: credential.credentialIdentity, versions: new Map<string, number>() };
|
|
776
|
+
for (const [version, expiresAt] of budget.versions) {
|
|
777
|
+
if (expiresAt <= now) budget.versions.delete(version);
|
|
778
|
+
}
|
|
779
|
+
const alreadyCharged = budget.versions.has(clientVersion);
|
|
780
|
+
const admitted = alreadyCharged
|
|
781
|
+
|| budget.versions.size < MODEL_ROSTER_VERSION_MISSES_PER_ACCOUNT_MAX;
|
|
782
|
+
// A repeat keeps its ORIGINAL expiry. Refreshing it here would let a caller hold one version
|
|
783
|
+
// open indefinitely, and it is the retry case this distinction exists to protect.
|
|
784
|
+
if (admitted && !alreadyCharged) budget.versions.set(clientVersion, now + MODEL_ROSTER_TTL_MS);
|
|
785
|
+
if (budget.versions.size === 0) accountModelsMisses.delete(credential.accountId);
|
|
786
|
+
else accountModelsMisses.set(credential.accountId, budget);
|
|
787
|
+
return admitted;
|
|
788
|
+
}
|
|
789
|
+
|
|
719
790
|
function candidateAccountIds(config: Pick<OcxConfig, "codexAccounts">): string[] {
|
|
720
791
|
return [
|
|
721
792
|
MAIN_CODEX_ACCOUNT_ID,
|
|
@@ -992,6 +1063,10 @@ export async function resolveCodexModelEntitlements(
|
|
|
992
1063
|
fetcher,
|
|
993
1064
|
now,
|
|
994
1065
|
clientVersion,
|
|
1066
|
+
resolveCodexEntitlementClientVersion(
|
|
1067
|
+
null,
|
|
1068
|
+
options.loadPersistedRuntime ?? loadPersistedCodexRuntime,
|
|
1069
|
+
),
|
|
995
1070
|
options.credentialMutationEpoch,
|
|
996
1071
|
),
|
|
997
1072
|
})));
|
|
@@ -1050,6 +1125,7 @@ export async function isDirectCallerEntitledToCodexModel(
|
|
|
1050
1125
|
options.fetcher ?? fetch,
|
|
1051
1126
|
options.now ?? Date.now(),
|
|
1052
1127
|
clientVersion,
|
|
1128
|
+
clientVersion,
|
|
1053
1129
|
);
|
|
1054
1130
|
return codexModelEntitlementStateForRoster(
|
|
1055
1131
|
result.models,
|
|
@@ -1128,11 +1204,13 @@ export function invalidateCodexModelEntitlementsForAccount(accountId: string | n
|
|
|
1128
1204
|
for (const key of [...accountModelsCache.keys()]) {
|
|
1129
1205
|
if (accountIdOfCacheKey(key) === accountId) accountModelsCache.delete(key);
|
|
1130
1206
|
}
|
|
1207
|
+
accountModelsMisses.delete(accountId);
|
|
1131
1208
|
}
|
|
1132
1209
|
|
|
1133
1210
|
export function resetCodexModelEntitlementCacheForTests(): void {
|
|
1134
1211
|
accountModelsCache.clear();
|
|
1135
1212
|
accountModelsFlights.clear();
|
|
1213
|
+
accountModelsMisses.clear();
|
|
1136
1214
|
negativeCredentialMemo.clear();
|
|
1137
1215
|
entitlementEnsureFlights.clear();
|
|
1138
1216
|
runtimeVersionMemo = null;
|