@bitkyc08/opencodex 2.8.0 → 2.9.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 +24 -0
- package/bin/ocx.mjs +32 -4
- package/gui/dist/assets/index-CHwf3tTD.css +1 -0
- package/gui/dist/assets/index-u5eFOv2y.js +67 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-normalize.ts +114 -20
- package/src/adapters/anthropic.ts +126 -10
- package/src/adapters/azure.ts +3 -3
- package/src/adapters/base.ts +7 -3
- package/src/adapters/cursor/discovery.ts +14 -4
- package/src/adapters/cursor/effort-map.ts +18 -6
- package/src/adapters/cursor/framing.ts +102 -27
- package/src/adapters/cursor/kv-store.ts +30 -3
- package/src/adapters/cursor/live-models.ts +22 -2
- package/src/adapters/cursor/live-transport.ts +245 -49
- package/src/adapters/cursor/mcp-manager.ts +105 -8
- package/src/adapters/cursor/native-exec-mcp.ts +5 -3
- package/src/adapters/cursor/native-exec-shell.ts +296 -14
- package/src/adapters/cursor/native-exec.ts +381 -33
- package/src/adapters/cursor/protobuf-events.ts +28 -1
- package/src/adapters/cursor/protobuf-request.ts +71 -39
- package/src/adapters/cursor/request-builder.ts +2 -2
- package/src/adapters/cursor/transport.ts +2 -0
- package/src/adapters/cursor.ts +13 -2
- package/src/adapters/google-antigravity-replay.ts +184 -17
- package/src/adapters/google.ts +58 -8
- package/src/adapters/kiro-thinking.ts +23 -9
- package/src/adapters/kiro-tools.ts +49 -18
- package/src/adapters/kiro.ts +377 -133
- package/src/adapters/mimo-free.ts +36 -4
- package/src/adapters/openai-chat.ts +143 -17
- package/src/adapters/openai-responses.ts +130 -14
- package/src/adapters/run-turn-queue.ts +7 -1
- package/src/bridge.ts +466 -69
- package/src/chat/outbound.ts +144 -38
- package/src/claude/inbound-debug.ts +53 -8
- package/src/claude/outbound.ts +224 -38
- package/src/cli/agent-driven.ts +34 -1
- package/src/cli/catalog-prewarm.ts +5 -2
- package/src/cli/claude-desktop.ts +2 -2
- package/src/cli/doctor.ts +12 -0
- package/src/cli/export-command.ts +187 -0
- package/src/cli/help.ts +11 -0
- package/src/cli/index.ts +13 -3
- package/src/cli/init.ts +129 -102
- package/src/cli/opencode.ts +36 -151
- package/src/cli/star-prompt.ts +13 -4
- package/src/cli/status-oauth.ts +12 -2
- package/src/clients/config-export.ts +377 -0
- package/src/codex/account-runtime-state.ts +19 -1
- package/src/codex/account-store.ts +162 -82
- package/src/codex/auth-api.ts +467 -159
- package/src/codex/auth-context.ts +15 -2
- package/src/codex/catalog/aggregation.ts +15 -0
- package/src/codex/catalog/effort.ts +16 -6
- package/src/codex/catalog/metadata.ts +6 -0
- package/src/codex/catalog/parsing.ts +3 -1
- package/src/codex/catalog/provider-fetch.ts +29 -0
- package/src/codex/catalog/sync.ts +64 -7
- package/src/codex/catalog.ts +2 -2
- package/src/codex/inject.ts +5 -5
- package/src/codex/main-account-cache.ts +8 -1
- package/src/codex/model-cache.ts +81 -2
- package/src/codex/pool-rotation.ts +39 -0
- package/src/codex/project-config-warnings.ts +12 -1
- package/src/codex/quota.ts +35 -3
- package/src/codex/routing.ts +46 -1
- package/src/codex/shim.ts +10 -4
- package/src/codex/subagent-model-fallback.ts +12 -0
- package/src/codex/websocket-registry.ts +27 -0
- package/src/combos/failover.ts +31 -1
- package/src/combos/request.ts +9 -0
- package/src/combos/resolve.ts +60 -4
- package/src/combos/types.ts +12 -0
- package/src/config.ts +510 -55
- package/src/github/star-state.ts +13 -1
- package/src/images/fulfill.ts +39 -1
- package/src/images/loop.ts +52 -12
- package/src/lib/admission.ts +83 -0
- package/src/lib/app-owned-memory-stores.ts +173 -0
- package/src/lib/app-owned-memory.ts +265 -0
- package/src/lib/bun-stream-caps.ts +31 -7
- package/src/lib/config-ownership.ts +33 -0
- package/src/lib/crash-guard.ts +65 -5
- package/src/lib/debug-log-buffer.ts +47 -6
- package/src/lib/destination-policy.ts +12 -1
- package/src/lib/errors.ts +3 -0
- package/src/lib/gcp-adc.ts +40 -2
- package/src/lib/injection-debug-log.ts +26 -2
- package/src/lib/provider-outbound.ts +3 -0
- package/src/lib/sidecar-tracker.ts +5 -2
- package/src/lib/sse-decoder.ts +257 -37
- package/src/lib/state-store-registrations.ts +109 -0
- package/src/lib/state-store-sweeper.ts +184 -0
- package/src/lib/translator-budget.ts +356 -0
- package/src/lib/windows-secret-acl.ts +33 -12
- package/src/lib/winsw.ts +14 -1
- package/src/oauth/anthropic-routing.ts +31 -7
- package/src/oauth/google-antigravity.ts +2 -1
- package/src/oauth/health.ts +30 -12
- package/src/oauth/index.ts +127 -23
- package/src/oauth/kiro-credentials.ts +72 -1
- package/src/oauth/kiro.ts +23 -4
- package/src/oauth/store.ts +165 -18
- package/src/oauth/token-guardian.ts +43 -4
- package/src/oauth/types.ts +2 -1
- package/src/providers/base-url-choices.ts +10 -0
- package/src/providers/derive.ts +12 -0
- package/src/providers/free-directory.ts +4 -1
- package/src/providers/key-failover.ts +12 -0
- package/src/providers/openai-sidecar.ts +4 -1
- package/src/providers/quota.ts +68 -7
- package/src/providers/registry.ts +279 -3
- package/src/responses/parser.ts +5 -1
- package/src/responses/spill-store.ts +394 -0
- package/src/responses/state.ts +520 -102
- package/src/router.ts +18 -1
- package/src/server/adapter-resolve.ts +20 -3
- package/src/server/auth-cors.ts +121 -28
- package/src/server/chat-completions.ts +57 -12
- package/src/server/claude-messages.ts +85 -13
- package/src/server/index.ts +242 -100
- package/src/server/lifecycle.ts +155 -25
- package/src/server/management/agent-settings-routes.ts +79 -36
- package/src/server/management/api-key-usage.ts +167 -0
- package/src/server/management/body.ts +35 -0
- package/src/server/management/combo-routes.ts +5 -1
- package/src/server/management/config-routes.ts +42 -12
- package/src/server/management/logs-usage-routes.ts +41 -21
- package/src/server/management/model-routes.ts +188 -54
- package/src/server/management/oauth-account-routes.ts +115 -26
- package/src/server/management/provider-routes.ts +56 -6
- package/src/server/management/shared.ts +16 -3
- package/src/server/management/sidebar-routes.ts +50 -1
- package/src/server/management/system-restart.ts +13 -6
- package/src/server/management/system-routes.ts +15 -3
- package/src/server/management/usage-summary-cache.ts +86 -0
- package/src/server/management-api.ts +39 -5
- package/src/server/management-auth.ts +65 -14
- package/src/server/port-reclaim.ts +58 -12
- package/src/server/ports.ts +2 -0
- package/src/server/proxy-liveness.ts +60 -14
- package/src/server/relay-eager.ts +20 -4
- package/src/server/relay.ts +548 -154
- package/src/server/request-decompress.ts +51 -4
- package/src/server/request-log.ts +134 -15
- package/src/server/responses/collaboration.ts +15 -4
- package/src/server/responses/compact.ts +3 -0
- package/src/server/responses/core.ts +241 -66
- package/src/server/responses-image-gen-repair.ts +19 -5
- package/src/server/responses-item-id-repair.ts +23 -5
- package/src/server/sse-payload-rewrite.ts +71 -12
- package/src/server/startup-health-cache.ts +14 -1
- package/src/server/system-env.ts +8 -1
- package/src/server/windows-tcp-drop.ts +15 -5
- package/src/server/ws-bridge.ts +25 -0
- package/src/service.ts +179 -13
- package/src/storage/policy-job.ts +93 -23
- package/src/storage/policy-worker.ts +6 -0
- package/src/storage/restore-job.ts +62 -16
- package/src/storage/restore-worker.ts +6 -0
- package/src/storage/storage-mutation-coordinator.ts +36 -6
- package/src/storage/worker-lifecycle.ts +181 -47
- package/src/tray/windows.ts +97 -25
- package/src/types.ts +39 -6
- package/src/update/index.ts +24 -5
- package/src/update/job.ts +598 -73
- package/src/usage/log.ts +115 -17
- package/src/usage/summary.ts +67 -2
- package/src/vision/index.ts +112 -22
- package/src/web-search/loop.ts +38 -6
- package/src/web-search/progress-stream.ts +14 -3
- package/gui/dist/assets/index-BDjpkcRN.js +0 -67
- package/gui/dist/assets/index-BHsKRFh9.css +0 -1
package/src/codex/quota.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { atomicWriteFile, getConfigDir } from "../config";
|
|
4
|
+
import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper";
|
|
4
5
|
|
|
5
6
|
export type StoredAccountQuota = {
|
|
6
7
|
weeklyPercent?: number;
|
|
@@ -49,8 +50,16 @@ const MONTHLY_WINDOW_MIN_SECONDS = 28 * 24 * 60 * 60;
|
|
|
49
50
|
const MONTHLY_WINDOW_MIN_MINUTES = MONTHLY_WINDOW_MIN_SECONDS / 60;
|
|
50
51
|
|
|
51
52
|
const accountQuota = new Map<string, StoredAccountQuota>();
|
|
53
|
+
let lastReconciledGeneration = 0;
|
|
54
|
+
let liveAccountIds = new Set<string>();
|
|
52
55
|
|
|
53
|
-
|
|
56
|
+
function mayCommitAccountQuota(accountId: string, writerGeneration: number): boolean {
|
|
57
|
+
return writerGeneration >= lastReconciledGeneration || liveAccountIds.has(accountId);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Valid upstream percentages are normalized to 0..100. Keep "unknown" outside that domain so an
|
|
61
|
+
// actually exhausted account is still eligible for threshold rotation.
|
|
62
|
+
export const CODEX_UNKNOWN_USAGE_SCORE = 101;
|
|
54
63
|
export const CODEX_EXHAUSTED_USAGE_PERCENT = 100;
|
|
55
64
|
|
|
56
65
|
export function isCodexQuotaExhausted(
|
|
@@ -125,8 +134,10 @@ function snapshotHasUsage(quota: Omit<StoredAccountQuota, "updatedAt">): boolean
|
|
|
125
134
|
export function setAccountQuotaFromParsed(
|
|
126
135
|
accountId: string,
|
|
127
136
|
quota: Omit<StoredAccountQuota, "updatedAt"> | null,
|
|
137
|
+
writerGeneration = captureConfigGeneration(),
|
|
128
138
|
): void {
|
|
129
139
|
if (!quota) return;
|
|
140
|
+
if (!mayCommitAccountQuota(accountId, writerGeneration)) return;
|
|
130
141
|
const existing = accountQuota.get(accountId);
|
|
131
142
|
const next: StoredAccountQuota = { updatedAt: Date.now() };
|
|
132
143
|
const creditsOnly = quota.resetCredits !== undefined && !snapshotHasUsage(quota);
|
|
@@ -214,10 +225,14 @@ export function parseUpstreamQuotaHeaders(headers: Headers): Omit<StoredAccountQ
|
|
|
214
225
|
return hasKnownQuotaValue(quota) ? quota : null;
|
|
215
226
|
}
|
|
216
227
|
|
|
217
|
-
export function applyAccountQuotaFromUpstreamHeaders(
|
|
228
|
+
export function applyAccountQuotaFromUpstreamHeaders(
|
|
229
|
+
accountId: string,
|
|
230
|
+
headers: Headers,
|
|
231
|
+
writerGeneration = captureConfigGeneration(),
|
|
232
|
+
): void {
|
|
218
233
|
const quota = parseUpstreamQuotaHeaders(headers);
|
|
219
234
|
if (!quota) return;
|
|
220
|
-
setAccountQuotaFromParsed(accountId, quota);
|
|
235
|
+
setAccountQuotaFromParsed(accountId, quota, writerGeneration);
|
|
221
236
|
}
|
|
222
237
|
|
|
223
238
|
export function updateAccountQuota(
|
|
@@ -227,7 +242,9 @@ export function updateAccountQuota(
|
|
|
227
242
|
monthly?: unknown,
|
|
228
243
|
monthlyResetAt?: unknown,
|
|
229
244
|
resetCredits?: number,
|
|
245
|
+
writerGeneration = captureConfigGeneration(),
|
|
230
246
|
): void {
|
|
247
|
+
if (!mayCommitAccountQuota(accountId, writerGeneration)) return;
|
|
231
248
|
const existing = accountQuota.get(accountId);
|
|
232
249
|
const nextWeekly = normalizeUsagePercent(weekly);
|
|
233
250
|
const nextMonthly = normalizeUsagePercent(monthly);
|
|
@@ -325,6 +342,21 @@ export function clearAccountQuota(accountId?: string): void {
|
|
|
325
342
|
}
|
|
326
343
|
}
|
|
327
344
|
|
|
345
|
+
export function reconcileCodexQuotaAccounts(context: GenerationContext): number {
|
|
346
|
+
if (context.generation <= lastReconciledGeneration) return 0;
|
|
347
|
+
hydrateAccountQuotasFromDisk();
|
|
348
|
+
let removed = 0;
|
|
349
|
+
for (const accountId of accountQuota.keys()) {
|
|
350
|
+
if (context.codexAccountIds.has(accountId)) continue;
|
|
351
|
+
accountQuota.delete(accountId);
|
|
352
|
+
removed += 1;
|
|
353
|
+
}
|
|
354
|
+
liveAccountIds = new Set(context.codexAccountIds);
|
|
355
|
+
lastReconciledGeneration = context.generation;
|
|
356
|
+
if (removed > 0) schedulePersistAccountQuotas();
|
|
357
|
+
return removed;
|
|
358
|
+
}
|
|
359
|
+
|
|
328
360
|
export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuota, "updatedAt"> | null {
|
|
329
361
|
const resetCredits = typeof data.rate_limit_reset_credits?.available_count === "number"
|
|
330
362
|
? data.rate_limit_reset_credits.available_count
|
package/src/codex/routing.ts
CHANGED
|
@@ -19,6 +19,9 @@ import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota";
|
|
|
19
19
|
import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "./main-account";
|
|
20
20
|
import { isSelectableCodexPoolAccount } from "./account-id";
|
|
21
21
|
import type { OcxConfig } from "../types";
|
|
22
|
+
import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper";
|
|
23
|
+
import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
|
|
24
|
+
import { retainedUtf8Bytes } from "../lib/admission";
|
|
22
25
|
|
|
23
26
|
type ThreadAffinityEntry = {
|
|
24
27
|
accountId: string;
|
|
@@ -104,6 +107,7 @@ const CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS = [
|
|
|
104
107
|
] as const;
|
|
105
108
|
export const CODEX_THREAD_AFFINITY_IDLE_TTL_MS = 24 * 60 * 60_000;
|
|
106
109
|
export const CODEX_THREAD_AFFINITY_MAX_ENTRIES = 2048;
|
|
110
|
+
const MAX_AFFINITY_COMPONENT_BYTES = 512;
|
|
107
111
|
// Min interval between quota threshold re-evaluations for a single bound thread.
|
|
108
112
|
// Well under the 5h/weekly quota windows, but enough to stop per-request flapping.
|
|
109
113
|
export const CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS = 60_000;
|
|
@@ -115,6 +119,8 @@ const upstreamHealth = new Map<string, CodexUpstreamHealth>();
|
|
|
115
119
|
* from account-wide Retry-After/default throttles and transient health.
|
|
116
120
|
*/
|
|
117
121
|
const quotaScopedHealth = new Map<string, Map<CodexQuotaScope, CodexUpstreamHealth>>();
|
|
122
|
+
let lastReconciledGeneration = 0;
|
|
123
|
+
let liveHealthAccountIds = new Set<string>();
|
|
118
124
|
|
|
119
125
|
export type CodexUpstreamOutcome = number | "connect_error" | "timeout";
|
|
120
126
|
export type CodexUpstreamOutcomeClass = "success" | "credential" | "quota" | "transient" | "caller" | "unknown";
|
|
@@ -185,6 +191,8 @@ export type CodexUpstreamOutcomeMeta = {
|
|
|
185
191
|
* again (which would advance a round-robin ring twice).
|
|
186
192
|
*/
|
|
187
193
|
promoteAccountId?: string;
|
|
194
|
+
/** Generation captured when this routed account was selected. */
|
|
195
|
+
writerGeneration?: number;
|
|
188
196
|
};
|
|
189
197
|
|
|
190
198
|
function hasConfiguredPoolAccount(config: OcxConfig, accountId: string): boolean {
|
|
@@ -193,6 +201,15 @@ function hasConfiguredPoolAccount(config: OcxConfig, accountId: string): boolean
|
|
|
193
201
|
.some(account => isSelectableCodexPoolAccount(account) && account.id === accountId);
|
|
194
202
|
}
|
|
195
203
|
|
|
204
|
+
export function listLiveCodexAccountIds(config: OcxConfig): ReadonlySet<string> {
|
|
205
|
+
const ids = new Set((config.codexAccounts ?? []).map(account => account.id));
|
|
206
|
+
const openai = config.providers.openai;
|
|
207
|
+
if (openai && openai.disabled !== true && isCanonicalOpenAiForwardProvider(openai)) {
|
|
208
|
+
ids.add(MAIN_CODEX_ACCOUNT_ID);
|
|
209
|
+
}
|
|
210
|
+
return ids;
|
|
211
|
+
}
|
|
212
|
+
|
|
196
213
|
export function clearThreadAccountMap(): void {
|
|
197
214
|
threadAccountMap.clear();
|
|
198
215
|
}
|
|
@@ -217,6 +234,24 @@ export function clearCodexUpstreamHealthForAccount(accountId: string): void {
|
|
|
217
234
|
quotaScopedHealth.delete(accountId);
|
|
218
235
|
}
|
|
219
236
|
|
|
237
|
+
export function reconcileCodexRoutingHealth(context: GenerationContext): number {
|
|
238
|
+
if (context.generation <= lastReconciledGeneration) return 0;
|
|
239
|
+
let removed = 0;
|
|
240
|
+
for (const accountId of upstreamHealth.keys()) {
|
|
241
|
+
if (context.codexAccountIds.has(accountId)) continue;
|
|
242
|
+
upstreamHealth.delete(accountId);
|
|
243
|
+
removed += 1;
|
|
244
|
+
}
|
|
245
|
+
for (const accountId of quotaScopedHealth.keys()) {
|
|
246
|
+
if (context.codexAccountIds.has(accountId)) continue;
|
|
247
|
+
quotaScopedHealth.delete(accountId);
|
|
248
|
+
removed += 1;
|
|
249
|
+
}
|
|
250
|
+
liveHealthAccountIds = new Set(context.codexAccountIds);
|
|
251
|
+
lastReconciledGeneration = context.generation;
|
|
252
|
+
return removed;
|
|
253
|
+
}
|
|
254
|
+
|
|
220
255
|
export function getCodexUpstreamHealth(
|
|
221
256
|
accountId: string,
|
|
222
257
|
): CodexUpstreamHealth | null {
|
|
@@ -600,11 +635,17 @@ function threadAffinityScope(quotaScope?: CodexQuotaScope): ThreadAffinityScope
|
|
|
600
635
|
return quotaScope ?? LEGACY_THREAD_AFFINITY_SCOPE;
|
|
601
636
|
}
|
|
602
637
|
|
|
638
|
+
function admissibleAffinityComponent(value: string): boolean {
|
|
639
|
+
return retainedUtf8Bytes(value) <= MAX_AFFINITY_COMPONENT_BYTES;
|
|
640
|
+
}
|
|
641
|
+
|
|
603
642
|
function getThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): ThreadAffinityEntry | undefined {
|
|
643
|
+
if (!admissibleAffinityComponent(threadId)) return undefined;
|
|
604
644
|
return threadAccountMap.get(threadId)?.get(threadAffinityScope(quotaScope));
|
|
605
645
|
}
|
|
606
646
|
|
|
607
647
|
function deleteThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): void {
|
|
648
|
+
if (!admissibleAffinityComponent(threadId)) return;
|
|
608
649
|
const affinities = threadAccountMap.get(threadId);
|
|
609
650
|
if (!affinities) return;
|
|
610
651
|
affinities.delete(threadAffinityScope(quotaScope));
|
|
@@ -613,6 +654,7 @@ function deleteThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): v
|
|
|
613
654
|
|
|
614
655
|
/** Remove only the matching failed account's affinities for one thread. */
|
|
615
656
|
function deleteThreadAffinitiesForAccount(threadId: string, accountId: string): void {
|
|
657
|
+
if (!admissibleAffinityComponent(threadId) || !admissibleAffinityComponent(accountId)) return;
|
|
616
658
|
const affinities = threadAccountMap.get(threadId);
|
|
617
659
|
if (!affinities) return;
|
|
618
660
|
for (const [scope, entry] of affinities) {
|
|
@@ -671,6 +713,7 @@ function bindThreadAffinity(
|
|
|
671
713
|
now: number,
|
|
672
714
|
quotaScope?: CodexQuotaScope,
|
|
673
715
|
): void {
|
|
716
|
+
if (!admissibleAffinityComponent(threadId) || !admissibleAffinityComponent(accountId)) return;
|
|
674
717
|
const record = accountId === MAIN_CODEX_ACCOUNT_ID ? undefined : readCodexAccountRecord(accountId);
|
|
675
718
|
if (accountId !== MAIN_CODEX_ACCOUNT_ID && (!record?.credential || record.deletedAt != null)) return;
|
|
676
719
|
pruneExpiredThreadAffinities(now);
|
|
@@ -1204,6 +1247,8 @@ export function recordCodexUpstreamOutcome(
|
|
|
1204
1247
|
meta: CodexUpstreamOutcomeMeta = {},
|
|
1205
1248
|
): void {
|
|
1206
1249
|
if (!accountId) return;
|
|
1250
|
+
const writerGeneration = meta.writerGeneration ?? captureConfigGeneration();
|
|
1251
|
+
if (writerGeneration < lastReconciledGeneration && !liveHealthAccountIds.has(accountId)) return;
|
|
1207
1252
|
const now = meta.now ?? Date.now();
|
|
1208
1253
|
const outcomeClass = classifyCodexUpstreamOutcome(outcome);
|
|
1209
1254
|
const quotaScope = codexQuotaScopeForModel(meta.modelId);
|
|
@@ -1275,7 +1320,7 @@ export function recordCodexUpstreamOutcome(
|
|
|
1275
1320
|
lastFailureAt: now,
|
|
1276
1321
|
});
|
|
1277
1322
|
quotaScopedHealth.delete(accountId);
|
|
1278
|
-
markAccountNeedsReauth(accountId);
|
|
1323
|
+
markAccountNeedsReauth(accountId, writerGeneration);
|
|
1279
1324
|
clearThreadAccountMapForAccount(accountId);
|
|
1280
1325
|
return;
|
|
1281
1326
|
}
|
package/src/codex/shim.ts
CHANGED
|
@@ -25,12 +25,14 @@ import { serviceApiTokenFilePath } from "../lib/service-secrets";
|
|
|
25
25
|
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
26
26
|
import { windowsEnvIndirectBatchValue } from "../lib/win-paths";
|
|
27
27
|
import { isWslRuntime, wslAutomountRoot } from "./home";
|
|
28
|
+
import { truncateRetainedUtf8 } from "../lib/admission";
|
|
28
29
|
|
|
29
30
|
const SHIM_MARKER = "opencodex codex autostart shim";
|
|
30
31
|
const CODEX_SHIM_PROBE_BYTES = 16 * 1024;
|
|
31
32
|
export const CODEX_SHIM_REPLACEMENT_STABLE_MS = 100;
|
|
32
33
|
export const CODEX_SHIM_STATE_MAX_BYTES = 1024 * 1024;
|
|
33
34
|
const CODEX_SHIM_RESTORE_LOCK_STALE_MS = 30_000;
|
|
35
|
+
const MAX_DIAGNOSTIC_VALUE_BYTES = 8 * 1024;
|
|
34
36
|
let lastShimDiscoveryError: string | null = null;
|
|
35
37
|
/** Last human-readable reason discovery returned null (exposed for doctor/tests). */
|
|
36
38
|
export function lastCodexDiscoveryError(): string | null {
|
|
@@ -309,10 +311,12 @@ export function findCodexOnPath(deps: CodexPathScanDeps = {}): string | null {
|
|
|
309
311
|
}
|
|
310
312
|
|
|
311
313
|
if (skippedInterop) {
|
|
312
|
-
lastShimDiscoveryError =
|
|
314
|
+
lastShimDiscoveryError = truncateRetainedUtf8(
|
|
313
315
|
`Found a Windows codex at ${skippedInterop} via WSL PATH interop, but no Linux-side codex. ` +
|
|
314
316
|
"Refusing to shim a Windows launcher from WSL (a WSL shim breaks Windows invocations). " +
|
|
315
|
-
"Install codex inside WSL (npm i -g @openai/codex), or run 'ocx ensure' from Windows to shim the Windows side."
|
|
317
|
+
"Install codex inside WSL (npm i -g @openai/codex), or run 'ocx ensure' from Windows to shim the Windows side.",
|
|
318
|
+
MAX_DIAGNOSTIC_VALUE_BYTES,
|
|
319
|
+
);
|
|
316
320
|
}
|
|
317
321
|
return null;
|
|
318
322
|
}
|
|
@@ -324,9 +328,11 @@ function findWindowsCodexTargets(): ShimFileState[] | null {
|
|
|
324
328
|
if (existsSync(exe) && !isShim(exe)) {
|
|
325
329
|
try {
|
|
326
330
|
if (!lstatSync(exe).isDirectory()) {
|
|
327
|
-
lastShimDiscoveryError =
|
|
331
|
+
lastShimDiscoveryError = truncateRetainedUtf8(
|
|
328
332
|
`Found codex.exe at ${exe}. Refusing to rename a real .exe because exact codex.exe invocations would break; ` +
|
|
329
|
-
"install a codex.cmd/codex.ps1 launcher or use `ocx service install` for autostart."
|
|
333
|
+
"install a codex.cmd/codex.ps1 launcher or use `ocx service install` for autostart.",
|
|
334
|
+
MAX_DIAGNOSTIC_VALUE_BYTES,
|
|
335
|
+
);
|
|
330
336
|
return null;
|
|
331
337
|
}
|
|
332
338
|
} catch { /* keep scanning */ }
|
|
@@ -26,6 +26,7 @@ import { isThreadSpawnRequest } from "../server/effort-policy";
|
|
|
26
26
|
import { PROVIDER_REGISTRY } from "../providers/registry";
|
|
27
27
|
import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
|
|
28
28
|
import { routeModel, type RouteResult } from "../router";
|
|
29
|
+
import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
|
|
29
30
|
export const DEFAULT_SUBAGENT_MODEL_FALLBACK_POLL_MS = 60_000;
|
|
30
31
|
|
|
31
32
|
type SubagentQuotaPrimeFn = (config: OcxConfig, reason: string) => Promise<void>;
|
|
@@ -245,6 +246,17 @@ export function noteSubagentModelFailure(
|
|
|
245
246
|
reason: "quota_exhausted",
|
|
246
247
|
},
|
|
247
248
|
);
|
|
249
|
+
sweepExpiredOnWrite(now);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function sweepExpiredSubagentModelHealth(now = Date.now()): number {
|
|
253
|
+
let removed = 0;
|
|
254
|
+
for (const [key, health] of modelHealth) {
|
|
255
|
+
if (health.unavailableUntil > now) continue;
|
|
256
|
+
modelHealth.delete(key);
|
|
257
|
+
removed += 1;
|
|
258
|
+
}
|
|
259
|
+
return removed;
|
|
248
260
|
}
|
|
249
261
|
|
|
250
262
|
export function resetSubagentModelFallbackStateForTests(): void {
|
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
import type { ServerWebSocket } from "bun";
|
|
2
2
|
import type { WsData } from "../server/ws-bridge";
|
|
3
|
+
import { createAdmissionGate, type AdmissionMetrics, type AdmissionReservation } from "../lib/admission";
|
|
4
|
+
|
|
5
|
+
export const MAX_TRACKED_CODEX_WEBSOCKETS = 128;
|
|
6
|
+
const websocketGate = createAdmissionGate("codex_websockets", MAX_TRACKED_CODEX_WEBSOCKETS);
|
|
7
|
+
|
|
8
|
+
export function tryReserveCodexWebSocket(): AdmissionReservation<ServerWebSocket<WsData>> | null {
|
|
9
|
+
const gateLease = websocketGate.tryAcquire();
|
|
10
|
+
if (!gateLease) return null;
|
|
11
|
+
let socket: ServerWebSocket<WsData> | undefined;
|
|
12
|
+
let active = true;
|
|
13
|
+
return {
|
|
14
|
+
bind(value) {
|
|
15
|
+
if (!active) return;
|
|
16
|
+
socket = value;
|
|
17
|
+
},
|
|
18
|
+
release() {
|
|
19
|
+
if (!active) return;
|
|
20
|
+
active = false;
|
|
21
|
+
socket = undefined;
|
|
22
|
+
gateLease.release();
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function codexWebSocketAdmissionMetrics(): AdmissionMetrics {
|
|
28
|
+
return websocketGate.metrics();
|
|
29
|
+
}
|
|
3
30
|
|
|
4
31
|
const socketsByAccount = new Map<string, Set<ServerWebSocket<WsData>>>();
|
|
5
32
|
|
package/src/combos/failover.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { classifyError, isCyberPolicyCode } from "../lib/errors";
|
|
2
2
|
import type { OcxComboTarget } from "../types";
|
|
3
3
|
import { targetKey } from "./types";
|
|
4
|
+
import {
|
|
5
|
+
captureConfigGeneration,
|
|
6
|
+
sweepExpiredOnWrite,
|
|
7
|
+
type GenerationContext,
|
|
8
|
+
} from "../lib/state-store-sweeper";
|
|
4
9
|
|
|
5
10
|
interface TargetCooldown {
|
|
6
11
|
cooldownUntil: number;
|
|
@@ -11,6 +16,8 @@ const MAX_COOLDOWN_MS = 10 * 60_000;
|
|
|
11
16
|
|
|
12
17
|
/** Map<`${comboId}\0${provider/model}`, TargetCooldown> */
|
|
13
18
|
const targetCooldowns = new Map<string, TargetCooldown>();
|
|
19
|
+
let lastReconciledGeneration = 0;
|
|
20
|
+
let liveComboTargets = new Set<string>();
|
|
14
21
|
|
|
15
22
|
function cooldownMapKey(
|
|
16
23
|
comboId: string,
|
|
@@ -55,20 +62,43 @@ export function isComboTargetInCooldown(
|
|
|
55
62
|
export function coolComboTarget(
|
|
56
63
|
comboId: string,
|
|
57
64
|
target: Pick<OcxComboTarget, "provider" | "model">,
|
|
58
|
-
options?: { retryAfter?: string | null; now?: number; cooldownMs?: number },
|
|
65
|
+
options?: { retryAfter?: string | null; now?: number; cooldownMs?: number; writerGeneration?: number },
|
|
59
66
|
): void {
|
|
60
67
|
const now = options?.now ?? Date.now();
|
|
68
|
+
const writerGeneration = options?.writerGeneration ?? captureConfigGeneration();
|
|
69
|
+
const ownerKey = `${comboId}::${targetKey(target)}`;
|
|
70
|
+
if (writerGeneration < lastReconciledGeneration && !liveComboTargets.has(ownerKey)) return;
|
|
61
71
|
const cooldownMs = options?.cooldownMs
|
|
62
72
|
?? parseRetryAfterMs(options?.retryAfter, now)
|
|
63
73
|
?? DEFAULT_COOLDOWN_MS;
|
|
64
74
|
targetCooldowns.set(cooldownMapKey(comboId, target), {
|
|
65
75
|
cooldownUntil: now + Math.min(Math.max(cooldownMs, 1), MAX_COOLDOWN_MS),
|
|
66
76
|
});
|
|
77
|
+
sweepExpiredOnWrite(now);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function reconcileComboTargetCooldowns(context: GenerationContext): number {
|
|
81
|
+
if (context.generation <= lastReconciledGeneration) return 0;
|
|
82
|
+
liveComboTargets = new Set(context.comboTargets);
|
|
83
|
+
lastReconciledGeneration = context.generation;
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function sweepExpiredComboTargetCooldowns(now = Date.now()): number {
|
|
88
|
+
let removed = 0;
|
|
89
|
+
for (const [key, cooldown] of targetCooldowns) {
|
|
90
|
+
if (cooldown.cooldownUntil > now) continue;
|
|
91
|
+
targetCooldowns.delete(key);
|
|
92
|
+
removed += 1;
|
|
93
|
+
}
|
|
94
|
+
return removed;
|
|
67
95
|
}
|
|
68
96
|
|
|
69
97
|
export function clearComboTargetCooldowns(comboId?: string): void {
|
|
70
98
|
if (comboId === undefined) {
|
|
71
99
|
targetCooldowns.clear();
|
|
100
|
+
liveComboTargets.clear();
|
|
101
|
+
lastReconciledGeneration = 0;
|
|
72
102
|
return;
|
|
73
103
|
}
|
|
74
104
|
const prefix = `${comboId}\0`;
|
package/src/combos/request.ts
CHANGED
|
@@ -2,6 +2,15 @@ import type { OcxComboDefaultEffort, OcxComboTarget, OcxConfig } from "../types"
|
|
|
2
2
|
import { resolveComboId } from "./types";
|
|
3
3
|
|
|
4
4
|
const warnedUnsupportedDefaults = new Set<string>();
|
|
5
|
+
let lastWarningReconciledGeneration = 0;
|
|
6
|
+
|
|
7
|
+
export function reconcileComboWarningMemos(generation: number): number {
|
|
8
|
+
if (generation <= lastWarningReconciledGeneration) return 0;
|
|
9
|
+
const removed = warnedUnsupportedDefaults.size;
|
|
10
|
+
warnedUnsupportedDefaults.clear();
|
|
11
|
+
lastWarningReconciledGeneration = generation;
|
|
12
|
+
return removed;
|
|
13
|
+
}
|
|
5
14
|
|
|
6
15
|
export function resetComboEffortWarningStateForTests(): void {
|
|
7
16
|
warnedUnsupportedDefaults.clear();
|
package/src/combos/resolve.ts
CHANGED
|
@@ -2,12 +2,17 @@ import type { OcxComboTarget, OcxConfig } from "../types";
|
|
|
2
2
|
import { coolComboTarget, isComboTargetInCooldown } from "./failover";
|
|
3
3
|
import { getCombo, resolveComboId, targetKey } from "./types";
|
|
4
4
|
import type { NormalizedComboConfig } from "./types";
|
|
5
|
+
import {
|
|
6
|
+
captureConfigGeneration,
|
|
7
|
+
type GenerationContext,
|
|
8
|
+
} from "../lib/state-store-sweeper";
|
|
5
9
|
|
|
6
10
|
export interface ComboPick {
|
|
7
11
|
comboId: string;
|
|
8
12
|
target: Required<OcxComboTarget>;
|
|
9
13
|
targetIndex: number;
|
|
10
14
|
attempted: string[];
|
|
15
|
+
writerGeneration: number;
|
|
11
16
|
}
|
|
12
17
|
|
|
13
18
|
interface SelectionState {
|
|
@@ -17,6 +22,17 @@ interface SelectionState {
|
|
|
17
22
|
}
|
|
18
23
|
|
|
19
24
|
const selectionState = new Map<string, SelectionState>();
|
|
25
|
+
let lastReconciledGeneration = 0;
|
|
26
|
+
let liveComboTargets = new Set<string>();
|
|
27
|
+
|
|
28
|
+
function comboTargetOwnerKey(comboId: string, key: string): string {
|
|
29
|
+
return `${comboId}::${key}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function mayCommitComboState(comboId: string, key: string, writerGeneration: number): boolean {
|
|
33
|
+
return writerGeneration >= lastReconciledGeneration
|
|
34
|
+
|| liveComboTargets.has(comboTargetOwnerKey(comboId, key));
|
|
35
|
+
}
|
|
20
36
|
|
|
21
37
|
export class UnknownComboError extends Error {
|
|
22
38
|
constructor(readonly comboId: string) {
|
|
@@ -74,6 +90,7 @@ export function pickComboTarget(
|
|
|
74
90
|
eligible?: (target: Required<OcxComboTarget>) => boolean;
|
|
75
91
|
} = {},
|
|
76
92
|
): ComboPick | null {
|
|
93
|
+
const writerGeneration = captureConfigGeneration();
|
|
77
94
|
const combo = getCombo(config, comboId);
|
|
78
95
|
if (!combo) throw new UnknownComboError(comboId);
|
|
79
96
|
const excluded = new Set(options.exclude ?? []);
|
|
@@ -114,6 +131,7 @@ export function pickComboTarget(
|
|
|
114
131
|
target,
|
|
115
132
|
targetIndex,
|
|
116
133
|
attempted: [...excluded, targetKey(target)],
|
|
134
|
+
writerGeneration,
|
|
117
135
|
};
|
|
118
136
|
}
|
|
119
137
|
|
|
@@ -121,10 +139,13 @@ export function noteComboSuccess(
|
|
|
121
139
|
comboId: string,
|
|
122
140
|
combo: NormalizedComboConfig,
|
|
123
141
|
target: Required<OcxComboTarget>,
|
|
142
|
+
writerGeneration = captureConfigGeneration(),
|
|
124
143
|
): void {
|
|
125
144
|
if (combo.strategy !== "round-robin") return;
|
|
145
|
+
const key = targetKey(target);
|
|
146
|
+
if (!mayCommitComboState(comboId, key, writerGeneration)) return;
|
|
126
147
|
const state = selectionState.get(comboId);
|
|
127
|
-
if (!state || state.activeKey !==
|
|
148
|
+
if (!state || state.activeKey !== key) return;
|
|
128
149
|
state.successes += 1;
|
|
129
150
|
if (state.successes >= combo.stickyLimit) {
|
|
130
151
|
delete state.activeKey;
|
|
@@ -132,7 +153,12 @@ export function noteComboSuccess(
|
|
|
132
153
|
}
|
|
133
154
|
}
|
|
134
155
|
|
|
135
|
-
export function noteComboFailure(
|
|
156
|
+
export function noteComboFailure(
|
|
157
|
+
comboId: string,
|
|
158
|
+
target: OcxComboTarget,
|
|
159
|
+
writerGeneration = captureConfigGeneration(),
|
|
160
|
+
): void {
|
|
161
|
+
if (!mayCommitComboState(comboId, targetKey(target), writerGeneration)) return;
|
|
136
162
|
const state = selectionState.get(comboId);
|
|
137
163
|
if (state?.activeKey === targetKey(target)) {
|
|
138
164
|
delete state.activeKey;
|
|
@@ -149,8 +175,11 @@ export function advanceComboAfterFailure(
|
|
|
149
175
|
eligible?: (target: Required<OcxComboTarget>) => boolean;
|
|
150
176
|
} = {},
|
|
151
177
|
): ComboPick | null {
|
|
152
|
-
noteComboFailure(pick.comboId, pick.target);
|
|
153
|
-
coolComboTarget(pick.comboId, pick.target,
|
|
178
|
+
noteComboFailure(pick.comboId, pick.target, pick.writerGeneration);
|
|
179
|
+
coolComboTarget(pick.comboId, pick.target, {
|
|
180
|
+
...options,
|
|
181
|
+
writerGeneration: pick.writerGeneration,
|
|
182
|
+
});
|
|
154
183
|
return pickComboTarget(config, pick.comboId, {
|
|
155
184
|
exclude: pick.attempted,
|
|
156
185
|
eligible: target => !isComboTargetInCooldown(pick.comboId, target, options.now)
|
|
@@ -158,9 +187,36 @@ export function advanceComboAfterFailure(
|
|
|
158
187
|
});
|
|
159
188
|
}
|
|
160
189
|
|
|
190
|
+
export function reconcileComboRotationState(context: GenerationContext): number {
|
|
191
|
+
if (context.generation <= lastReconciledGeneration) return 0;
|
|
192
|
+
let removed = 0;
|
|
193
|
+
for (const [comboId, state] of selectionState) {
|
|
194
|
+
if (!context.comboIds.has(comboId)) {
|
|
195
|
+
selectionState.delete(comboId);
|
|
196
|
+
removed += 1;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (state.activeKey && !context.comboTargets.has(comboTargetOwnerKey(comboId, state.activeKey))) {
|
|
200
|
+
delete state.activeKey;
|
|
201
|
+
state.successes = 0;
|
|
202
|
+
removed += 1;
|
|
203
|
+
}
|
|
204
|
+
for (const key of state.currentWeights.keys()) {
|
|
205
|
+
if (context.comboTargets.has(comboTargetOwnerKey(comboId, key))) continue;
|
|
206
|
+
state.currentWeights.delete(key);
|
|
207
|
+
removed += 1;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
liveComboTargets = new Set(context.comboTargets);
|
|
211
|
+
lastReconciledGeneration = context.generation;
|
|
212
|
+
return removed;
|
|
213
|
+
}
|
|
214
|
+
|
|
161
215
|
export function clearComboSelectionState(comboId?: string): void {
|
|
162
216
|
if (comboId === undefined) {
|
|
163
217
|
selectionState.clear();
|
|
218
|
+
liveComboTargets.clear();
|
|
219
|
+
lastReconciledGeneration = 0;
|
|
164
220
|
return;
|
|
165
221
|
}
|
|
166
222
|
selectionState.delete(comboId);
|
package/src/combos/types.ts
CHANGED
|
@@ -304,6 +304,18 @@ export function listComboIds(config: { combos?: Record<string, OcxComboConfig> }
|
|
|
304
304
|
return Object.keys(config.combos ?? {}).sort((a, b) => a.localeCompare(b));
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
+
export function listLiveComboTargetKeys(
|
|
308
|
+
config: { combos?: Record<string, OcxComboConfig> },
|
|
309
|
+
): ReadonlySet<string> {
|
|
310
|
+
const keys = new Set<string>();
|
|
311
|
+
for (const id of listComboIds(config)) {
|
|
312
|
+
const combo = getCombo(config, id);
|
|
313
|
+
if (!combo) continue;
|
|
314
|
+
for (const target of combo.targets) keys.add(`${id}::${targetKey(target)}`);
|
|
315
|
+
}
|
|
316
|
+
return keys;
|
|
317
|
+
}
|
|
318
|
+
|
|
307
319
|
export function getCombo(
|
|
308
320
|
config: { combos?: Record<string, OcxComboConfig> },
|
|
309
321
|
id: string,
|