@bitkyc08/opencodex 2.8.2-preview.20260731 → 2.9.1
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-CuVjugeE.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 +86 -5
- 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 +89 -14
- 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/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/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/provider-fetch.ts +29 -0
- package/src/codex/catalog/sync.ts +7 -1
- 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/errors.ts +3 -0
- package/src/lib/gcp-adc.ts +40 -2
- package/src/lib/injection-debug-log.ts +26 -2
- 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 +8 -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.ts +10 -2
- 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/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 +239 -97
- package/src/server/lifecycle.ts +155 -25
- package/src/server/management/agent-settings-routes.ts +75 -32
- 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 +37 -12
- package/src/server/management/logs-usage-routes.ts +41 -21
- package/src/server/management/model-routes.ts +173 -53
- package/src/server/management/oauth-account-routes.ts +115 -26
- package/src/server/management/provider-routes.ts +56 -6
- 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 +51 -12
- 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 +23 -3
- 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 +45 -18
- package/src/types.ts +34 -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-BHsKRFh9.css +0 -1
- package/gui/dist/assets/index-GC0Vlu1Z.js +0 -67
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CodexCredentialGenerationConflictError,
|
|
3
3
|
CodexCredentialRefreshLockTimeoutError,
|
|
4
|
+
CodexCredentialRefreshBusyError,
|
|
5
|
+
CodexCredentialRefreshStaleError,
|
|
4
6
|
getValidCodexToken,
|
|
5
7
|
isCodexAccountGenerationLive,
|
|
6
8
|
} from "./account-store";
|
|
9
|
+
import { ConfigMutationLockError } from "../config";
|
|
7
10
|
import { markAccountNeedsReauth } from "./account-runtime-state";
|
|
8
11
|
import { isCodexAccountUsable } from "./account-usability";
|
|
9
12
|
import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
|
|
@@ -24,12 +27,14 @@ import { formatErrorResponse } from "../bridge";
|
|
|
24
27
|
import { getAccountQuota } from "./quota";
|
|
25
28
|
import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
|
|
26
29
|
import { FORWARD_HEADERS } from "../adapters/openai-responses";
|
|
30
|
+
import { captureConfigGeneration } from "../lib/state-store-sweeper";
|
|
27
31
|
|
|
28
32
|
export type CodexAuthContext =
|
|
29
33
|
| { kind: "main"; accountId: null }
|
|
30
34
|
| {
|
|
31
35
|
kind: "pool";
|
|
32
36
|
accountId: string;
|
|
37
|
+
writerGeneration: number;
|
|
33
38
|
generation: number;
|
|
34
39
|
accessToken: string;
|
|
35
40
|
chatgptAccountId: string;
|
|
@@ -49,6 +54,7 @@ export type CodexAuthContext =
|
|
|
49
54
|
// (Option A). Distinct from "main" (passthrough fallback that forwards the client token).
|
|
50
55
|
kind: "main-pool";
|
|
51
56
|
accountId: string;
|
|
57
|
+
writerGeneration: number;
|
|
52
58
|
accessToken: string;
|
|
53
59
|
chatgptAccountId: string;
|
|
54
60
|
/** See `pool.probeLeaseId`. */
|
|
@@ -180,7 +186,11 @@ export class CodexThreadAffinityExpiredError extends Error {
|
|
|
180
186
|
}
|
|
181
187
|
|
|
182
188
|
export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown): boolean {
|
|
183
|
-
return !(cause instanceof CodexCredentialGenerationConflictError)
|
|
189
|
+
return !(cause instanceof CodexCredentialGenerationConflictError)
|
|
190
|
+
&& !(cause instanceof CodexCredentialRefreshLockTimeoutError)
|
|
191
|
+
&& !(cause instanceof CodexCredentialRefreshBusyError)
|
|
192
|
+
&& !(cause instanceof CodexCredentialRefreshStaleError)
|
|
193
|
+
&& !(cause instanceof ConfigMutationLockError);
|
|
184
194
|
}
|
|
185
195
|
|
|
186
196
|
export interface ResolveCodexAuthContextOptions {
|
|
@@ -195,6 +205,7 @@ export async function resolveCodexAuthContext(
|
|
|
195
205
|
mode: CodexAccountMode,
|
|
196
206
|
options: ResolveCodexAuthContextOptions = {},
|
|
197
207
|
): Promise<CodexAuthContext> {
|
|
208
|
+
const writerGeneration = captureConfigGeneration();
|
|
198
209
|
if (mode === "direct") {
|
|
199
210
|
if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError();
|
|
200
211
|
return { kind: "main", accountId: null };
|
|
@@ -254,6 +265,7 @@ export async function resolveCodexAuthContext(
|
|
|
254
265
|
return {
|
|
255
266
|
kind: "main-pool",
|
|
256
267
|
accountId,
|
|
268
|
+
writerGeneration,
|
|
257
269
|
accessToken: token.accessToken,
|
|
258
270
|
chatgptAccountId: token.chatgptAccountId,
|
|
259
271
|
...(quotaScope ? { quotaScope } : {}),
|
|
@@ -267,6 +279,7 @@ export async function resolveCodexAuthContext(
|
|
|
267
279
|
return {
|
|
268
280
|
kind: "pool",
|
|
269
281
|
accountId,
|
|
282
|
+
writerGeneration,
|
|
270
283
|
generation: token.generation,
|
|
271
284
|
accessToken: token.accessToken,
|
|
272
285
|
chatgptAccountId: token.chatgptAccountId,
|
|
@@ -278,7 +291,7 @@ export async function resolveCodexAuthContext(
|
|
|
278
291
|
if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
|
|
279
292
|
else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
|
|
280
293
|
if (shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
|
|
281
|
-
markAccountNeedsReauth(accountId);
|
|
294
|
+
markAccountNeedsReauth(accountId, writerGeneration);
|
|
282
295
|
}
|
|
283
296
|
throw new CodexAuthContextError(accountId, cause);
|
|
284
297
|
}
|
|
@@ -281,6 +281,21 @@ export function resetOpenAiApiCatalogWarningStateForTests(): void {
|
|
|
281
281
|
export const slugAliasCollisionWarnings = new Set<string>();
|
|
282
282
|
|
|
283
283
|
export const comboMasqueradeCollisionWarnings = new Set<string>();
|
|
284
|
+
let lastWarningReconciledGeneration = 0;
|
|
285
|
+
|
|
286
|
+
export function reconcileCatalogWarningMemos(generation: number): number {
|
|
287
|
+
if (generation <= lastWarningReconciledGeneration) return 0;
|
|
288
|
+
const removed = openAiApiCollisionWarnings.size
|
|
289
|
+
+ comboCatalogWarningSignatures.size
|
|
290
|
+
+ slugAliasCollisionWarnings.size
|
|
291
|
+
+ comboMasqueradeCollisionWarnings.size;
|
|
292
|
+
openAiApiCollisionWarnings.clear();
|
|
293
|
+
comboCatalogWarningSignatures.clear();
|
|
294
|
+
slugAliasCollisionWarnings.clear();
|
|
295
|
+
comboMasqueradeCollisionWarnings.clear();
|
|
296
|
+
lastWarningReconciledGeneration = generation;
|
|
297
|
+
return removed;
|
|
298
|
+
}
|
|
284
299
|
|
|
285
300
|
export function warnComboMasqueradeCollisionOnce(slug: string): void {
|
|
286
301
|
if (comboMasqueradeCollisionWarnings.has(slug)) return;
|
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
type ProviderModelsApiItem,
|
|
54
54
|
} from "../../providers/model-discovery";
|
|
55
55
|
import upstreamModelsSnapshot from "../data/upstream-models.json";
|
|
56
|
+
import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } from "../../lib/admission";
|
|
56
57
|
|
|
57
58
|
|
|
58
59
|
import { JAWCODE_CATALOG_AUGMENT_PROVIDERS, catalogModelSlug, shouldExposeRoutedModel } from "./parsing";
|
|
@@ -69,6 +70,21 @@ interface GatherFlightResult {
|
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
const gatherInflight = new Map<string, Promise<GatherFlightResult>>();
|
|
73
|
+
const MAX_CONCURRENT_CATALOG_GATHERS = 8;
|
|
74
|
+
const gatherGate = createAdmissionGate("catalog_gathers", MAX_CONCURRENT_CATALOG_GATHERS);
|
|
75
|
+
|
|
76
|
+
export class CatalogGatherBusyError extends ResourceAdmissionError {
|
|
77
|
+
override readonly code = "catalog_busy";
|
|
78
|
+
readonly retryAfterSeconds = 1;
|
|
79
|
+
constructor() {
|
|
80
|
+
super("catalog_gathers", MAX_CONCURRENT_CATALOG_GATHERS);
|
|
81
|
+
this.name = "CatalogGatherBusyError";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function catalogGatherAdmissionMetrics(): AdmissionMetrics {
|
|
86
|
+
return gatherGate.metrics();
|
|
87
|
+
}
|
|
72
88
|
|
|
73
89
|
function stableJson(value: unknown): string {
|
|
74
90
|
return JSON.stringify(value, (_key, nested) => {
|
|
@@ -217,6 +233,15 @@ export function isDatedVariantId(liveId: string, configuredId: string): boolean
|
|
|
217
233
|
}
|
|
218
234
|
|
|
219
235
|
export const lastDropWarnSignature = new Map<string, string>();
|
|
236
|
+
let lastWarningReconciledGeneration = 0;
|
|
237
|
+
|
|
238
|
+
export function reconcileProviderFetchWarnings(generation: number): number {
|
|
239
|
+
if (generation <= lastWarningReconciledGeneration) return 0;
|
|
240
|
+
const removed = lastDropWarnSignature.size;
|
|
241
|
+
lastDropWarnSignature.clear();
|
|
242
|
+
lastWarningReconciledGeneration = generation;
|
|
243
|
+
return removed;
|
|
244
|
+
}
|
|
220
245
|
|
|
221
246
|
export const QUIET_AUTHORITATIVE_CATALOG_PROVIDERS = new Set(["kimi", "xai"]);
|
|
222
247
|
|
|
@@ -345,6 +370,7 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid
|
|
|
345
370
|
const contextWindow =
|
|
346
371
|
positiveSafeInteger(
|
|
347
372
|
limits?.max_context_length,
|
|
373
|
+
metadata?.context_length,
|
|
348
374
|
item.context_length,
|
|
349
375
|
item.context_size,
|
|
350
376
|
item.max_model_len,
|
|
@@ -648,10 +674,13 @@ export async function gatherRoutedModels(
|
|
|
648
674
|
const key = gatherFlightKey(config);
|
|
649
675
|
let promise = gatherInflight.get(key);
|
|
650
676
|
if (!promise) {
|
|
677
|
+
const lease = gatherGate.tryAcquire();
|
|
678
|
+
if (!lease) throw new CatalogGatherBusyError();
|
|
651
679
|
// Claim the slot synchronously before any await so same-key callers join this flight.
|
|
652
680
|
// Distinct keys keep their own entries — a second config must not evict the first.
|
|
653
681
|
const flight = gatherRoutedModelsUncached(config).finally(() => {
|
|
654
682
|
if (gatherInflight.get(key) === flight) gatherInflight.delete(key);
|
|
683
|
+
lease.release();
|
|
655
684
|
});
|
|
656
685
|
gatherInflight.set(key, flight);
|
|
657
686
|
promise = flight;
|
|
@@ -492,6 +492,12 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{
|
|
|
492
492
|
const catalog = loadCatalogForSync(catalogPath);
|
|
493
493
|
if (!catalog) return { added: 0, path: catalogPath, catalogWritten: false, comboOmissions: [] };
|
|
494
494
|
|
|
495
|
+
// The bundled catalog is a reliable native template on the default path, but it is not the
|
|
496
|
+
// merge source. Preservation must inspect the file that this sync is about to overwrite;
|
|
497
|
+
// otherwise an empty/partial provider gather cannot see routed or user-native rows on disk.
|
|
498
|
+
const onDiskCatalog = readCatalog(catalogPath);
|
|
499
|
+
const catalogModelsForMerge = onDiskCatalog?.models ?? catalog.models ?? [];
|
|
500
|
+
|
|
495
501
|
const template = findNativeTemplate(catalog);
|
|
496
502
|
|
|
497
503
|
const comboOmissions: ComboCatalogOmission[] = [];
|
|
@@ -534,7 +540,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{
|
|
|
534
540
|
// bare gpt-* rows that hard-404 via NoEnabledOpenAiProviderError. Keep natives when no
|
|
535
541
|
// providers are configured yet (fresh install / catalog bootstrap tests).
|
|
536
542
|
const includeNativeOpenAi = enabledProviders.length === 0 || hasCanonicalOpenai;
|
|
537
|
-
catalog.models = mergeCatalogEntriesForSync(
|
|
543
|
+
catalog.models = mergeCatalogEntriesForSync(catalogModelsForMerge, goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider, includeNativeOpenAi);
|
|
538
544
|
clampCatalogModelsToCodexSupport(catalog.models);
|
|
539
545
|
|
|
540
546
|
atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n");
|
package/src/codex/inject.ts
CHANGED
|
@@ -296,10 +296,10 @@ function stripExistingModelProvider(content: string): string {
|
|
|
296
296
|
}
|
|
297
297
|
|
|
298
298
|
/**
|
|
299
|
-
* Drop ROOT-level `model_context_window`
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
299
|
+
* Drop ROOT-level `model_context_window` overrides (keys before the first table header). Codex
|
|
300
|
+
* treats this root key as a global override that wins over the per-model catalog values, so a stale
|
|
301
|
+
* `model_context_window = 1000000` makes every model (e.g. gpt-5.5) report a 1M window. User-owned
|
|
302
|
+
* compaction limits do not alter the advertised context window and must survive reinjection.
|
|
303
303
|
*/
|
|
304
304
|
export function stripRootContextWindowOverrides(content: string): string {
|
|
305
305
|
const lines = content.split("\n");
|
|
@@ -307,7 +307,7 @@ export function stripRootContextWindowOverrides(content: string): string {
|
|
|
307
307
|
return lines
|
|
308
308
|
.filter((line, i) => {
|
|
309
309
|
const isRoot = firstTable === -1 || i < firstTable;
|
|
310
|
-
return !isRoot || !/^\s*
|
|
310
|
+
return !isRoot || !/^\s*model_context_window\s*=/.test(line);
|
|
311
311
|
})
|
|
312
312
|
.join("\n");
|
|
313
313
|
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { StoredAccountQuota } from "./quota";
|
|
2
|
+
import { truncateRetainedUtf8 } from "../lib/admission";
|
|
3
|
+
|
|
4
|
+
const MAX_DIAGNOSTIC_VALUE_BYTES = 8 * 1024;
|
|
2
5
|
|
|
3
6
|
export interface MainAccountInfo {
|
|
4
7
|
email: string | null;
|
|
@@ -17,7 +20,11 @@ export function getMainAccountInfoCache(): CachedMainAccountInfo | null {
|
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
export function setMainAccountInfoCache(value: CachedMainAccountInfo): void {
|
|
20
|
-
cachedMainAccountInfo =
|
|
23
|
+
cachedMainAccountInfo = {
|
|
24
|
+
...value,
|
|
25
|
+
email: value.email === null ? null : truncateRetainedUtf8(value.email, MAX_DIAGNOSTIC_VALUE_BYTES),
|
|
26
|
+
plan: value.plan === null ? null : truncateRetainedUtf8(value.plan, MAX_DIAGNOSTIC_VALUE_BYTES),
|
|
27
|
+
};
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
export function clearMainAccountInfoCache(): void {
|
package/src/codex/model-cache.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* restarts, so an in-memory cache is sufficient here (no SQLite layer needed).
|
|
9
9
|
*/
|
|
10
10
|
import type { CatalogModel } from "./catalog";
|
|
11
|
+
import type { GenerationContext } from "../lib/state-store-sweeper";
|
|
12
|
+
import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../lib/app-owned-memory";
|
|
11
13
|
|
|
12
14
|
/** Default freshness window. Matches Codex's own 5-min models cache so the two stay in step. */
|
|
13
15
|
export const DEFAULT_MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
@@ -15,6 +17,7 @@ export const DEFAULT_MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
|
15
17
|
interface CacheEntry {
|
|
16
18
|
models: CatalogModel[];
|
|
17
19
|
fetchedAt: number;
|
|
20
|
+
sizeBytes: number;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
export type ProviderModelDiscoveryFailureReason =
|
|
@@ -40,6 +43,29 @@ export type ProviderModelDiscoveryFailure = ProviderModelDiscoveryStatus extends
|
|
|
40
43
|
: never;
|
|
41
44
|
|
|
42
45
|
const cache = new Map<string, CacheEntry>();
|
|
46
|
+
let cacheBytes = 0;
|
|
47
|
+
let oldestCachedProvider: string | undefined;
|
|
48
|
+
let oldestCachedAt: number | null = null;
|
|
49
|
+
const modelCacheEncoder = new TextEncoder();
|
|
50
|
+
|
|
51
|
+
function recomputeOldestCachedProvider(): void {
|
|
52
|
+
oldestCachedProvider = undefined;
|
|
53
|
+
oldestCachedAt = null;
|
|
54
|
+
for (const [provider, entry] of cache) {
|
|
55
|
+
if (oldestCachedAt !== null && entry.fetchedAt >= oldestCachedAt) continue;
|
|
56
|
+
oldestCachedProvider = provider;
|
|
57
|
+
oldestCachedAt = entry.fetchedAt;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function deleteCachedProvider(provider: string): number {
|
|
62
|
+
const entry = cache.get(provider);
|
|
63
|
+
if (!entry) return 0;
|
|
64
|
+
cache.delete(provider);
|
|
65
|
+
cacheBytes = Math.max(0, cacheBytes - entry.sizeBytes);
|
|
66
|
+
if (oldestCachedProvider === provider) recomputeOldestCachedProvider();
|
|
67
|
+
return entry.sizeBytes;
|
|
68
|
+
}
|
|
43
69
|
|
|
44
70
|
/** Cooldown after a failed live `/models` fetch, so a dead/unreachable provider doesn't re-pay
|
|
45
71
|
* the full fetch timeout on every catalog poll (issue #54: UI stalls behind corporate proxies). */
|
|
@@ -54,6 +80,7 @@ const discoveryStatus = new Map<string, ProviderModelDiscoveryStatus>();
|
|
|
54
80
|
* custom-only, and the provider's configured fallback would wrongly stay authoritative.
|
|
55
81
|
*/
|
|
56
82
|
const liveModelCounts = new Map<string, number>();
|
|
83
|
+
let lastReconciledGeneration = 0;
|
|
57
84
|
|
|
58
85
|
export function markModelsFetchFailure(provider: string, now = Date.now()): void {
|
|
59
86
|
failureAt.set(provider, now);
|
|
@@ -129,20 +156,72 @@ export function getStaleCached(provider: string): CatalogModel[] | null {
|
|
|
129
156
|
}
|
|
130
157
|
|
|
131
158
|
export function setCached(provider: string, models: CatalogModel[], now = Date.now()): void {
|
|
132
|
-
|
|
159
|
+
deleteCachedProvider(provider);
|
|
160
|
+
const sizeBytes = modelCacheEncoder.encode(provider).byteLength
|
|
161
|
+
+ modelCacheEncoder.encode(JSON.stringify(models)).byteLength;
|
|
162
|
+
cache.set(provider, { models, fetchedAt: now, sizeBytes });
|
|
163
|
+
cacheBytes += sizeBytes;
|
|
164
|
+
if (oldestCachedAt === null || now < oldestCachedAt) {
|
|
165
|
+
oldestCachedProvider = provider;
|
|
166
|
+
oldestCachedAt = now;
|
|
167
|
+
}
|
|
168
|
+
enforceAppOwnedMemoryBudget();
|
|
133
169
|
}
|
|
134
170
|
|
|
135
171
|
/** Drop one provider's cache (or all) so the next resolve forces a live re-fetch. */
|
|
136
172
|
export function clearModelCache(provider?: string): void {
|
|
137
173
|
if (provider) {
|
|
138
|
-
|
|
174
|
+
deleteCachedProvider(provider);
|
|
139
175
|
failureAt.delete(provider);
|
|
140
176
|
discoveryStatus.delete(provider);
|
|
141
177
|
liveModelCounts.delete(provider);
|
|
142
178
|
} else {
|
|
143
179
|
cache.clear();
|
|
180
|
+
cacheBytes = 0;
|
|
181
|
+
oldestCachedProvider = undefined;
|
|
182
|
+
oldestCachedAt = null;
|
|
144
183
|
failureAt.clear();
|
|
145
184
|
discoveryStatus.clear();
|
|
146
185
|
liveModelCounts.clear();
|
|
147
186
|
}
|
|
148
187
|
}
|
|
188
|
+
|
|
189
|
+
export function reconcileModelCacheProviders(
|
|
190
|
+
validProviders: ReadonlySet<string>,
|
|
191
|
+
generation = lastReconciledGeneration + 1,
|
|
192
|
+
): number {
|
|
193
|
+
if (generation <= lastReconciledGeneration) return 0;
|
|
194
|
+
const removedProviders = new Set<string>();
|
|
195
|
+
for (const store of [failureAt, discoveryStatus, liveModelCounts]) {
|
|
196
|
+
for (const provider of store.keys()) {
|
|
197
|
+
if (validProviders.has(provider)) continue;
|
|
198
|
+
store.delete(provider);
|
|
199
|
+
removedProviders.add(provider);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
for (const provider of cache.keys()) {
|
|
203
|
+
if (validProviders.has(provider)) continue;
|
|
204
|
+
deleteCachedProvider(provider);
|
|
205
|
+
removedProviders.add(provider);
|
|
206
|
+
}
|
|
207
|
+
lastReconciledGeneration = generation;
|
|
208
|
+
return removedProviders.size;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function reconcileModelCacheGeneration(context: GenerationContext): number {
|
|
212
|
+
return reconcileModelCacheProviders(context.providerNames, context.generation);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function modelCacheRetainedStoreSnapshot(): RetainedStoreSnapshot {
|
|
216
|
+
return {
|
|
217
|
+
count: cache.size,
|
|
218
|
+
bytes: cacheBytes,
|
|
219
|
+
evictableBytes: cacheBytes,
|
|
220
|
+
pinnedBytes: 0,
|
|
221
|
+
oldestAt: oldestCachedAt,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function evictOldestModelCacheForBudget(): number {
|
|
226
|
+
return oldestCachedProvider === undefined ? 0 : deleteCachedProvider(oldestCachedProvider);
|
|
227
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { OcxAccountPoolRotationStrategy } from "../types";
|
|
2
|
+
import type { GenerationContext } from "../lib/state-store-sweeper";
|
|
2
3
|
|
|
3
4
|
export const POOL_KEY_CODEX = "codex";
|
|
4
5
|
export const POOL_KEY_ANTHROPIC = "anthropic";
|
|
@@ -10,6 +11,7 @@ interface SelectionState {
|
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
const selectionState = new Map<string, SelectionState>();
|
|
14
|
+
let lastReconciledGeneration = 0;
|
|
13
15
|
|
|
14
16
|
const DEFAULT_STICKY_LIMIT = 1;
|
|
15
17
|
const MIN_STICKY_LIMIT = 1;
|
|
@@ -184,3 +186,40 @@ export function clearPoolRotationState(poolKey?: string): void {
|
|
|
184
186
|
}
|
|
185
187
|
selectionState.delete(poolKey);
|
|
186
188
|
}
|
|
189
|
+
|
|
190
|
+
export function reconcilePoolRotationState(context: GenerationContext): number {
|
|
191
|
+
if (context.generation <= lastReconciledGeneration) return 0;
|
|
192
|
+
const anthropicIds = new Set<string>();
|
|
193
|
+
for (const key of context.oauthAccountKeys) {
|
|
194
|
+
const separator = key.indexOf("\0");
|
|
195
|
+
if (separator > 0 && key.slice(0, separator) === "anthropic") {
|
|
196
|
+
anthropicIds.add(key.slice(separator + 1));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
let removed = 0;
|
|
200
|
+
for (const [poolKey, state] of selectionState) {
|
|
201
|
+
const valid = poolKey === POOL_KEY_ANTHROPIC
|
|
202
|
+
? anthropicIds
|
|
203
|
+
: poolKey === POOL_KEY_CODEX || poolKey.startsWith(`${POOL_KEY_CODEX}:`)
|
|
204
|
+
? context.codexAccountIds
|
|
205
|
+
: null;
|
|
206
|
+
if (!valid) continue;
|
|
207
|
+
if (valid.size === 0) {
|
|
208
|
+
selectionState.delete(poolKey);
|
|
209
|
+
removed += 1;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (state.activeKey && !valid.has(state.activeKey)) {
|
|
213
|
+
delete state.activeKey;
|
|
214
|
+
state.successes = 0;
|
|
215
|
+
removed += 1;
|
|
216
|
+
}
|
|
217
|
+
for (const accountId of state.currentWeights.keys()) {
|
|
218
|
+
if (valid.has(accountId)) continue;
|
|
219
|
+
state.currentWeights.delete(accountId);
|
|
220
|
+
removed += 1;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
lastReconciledGeneration = context.generation;
|
|
224
|
+
return removed;
|
|
225
|
+
}
|
|
@@ -3,9 +3,11 @@ import path, { dirname, join, resolve } from "node:path";
|
|
|
3
3
|
import { expandUserPath } from "../config";
|
|
4
4
|
import { defaultCodexHome } from "./home";
|
|
5
5
|
import { readRootTomlString } from "./paths";
|
|
6
|
+
import { truncateRetainedUtf8 } from "../lib/admission";
|
|
6
7
|
|
|
7
8
|
const OCX_SECTION_MARKER = "# Auto-injected by opencodex";
|
|
8
9
|
const DIAGNOSTICS_CACHE_TTL_MS = 30_000;
|
|
10
|
+
const MAX_DIAGNOSTIC_VALUE_BYTES = 8 * 1024;
|
|
9
11
|
|
|
10
12
|
function resolveCodexConfigPath(): string {
|
|
11
13
|
const raw = process.env.CODEX_HOME?.trim();
|
|
@@ -309,7 +311,16 @@ export function getCachedProjectConfigDiagnostics(): {
|
|
|
309
311
|
} {
|
|
310
312
|
const now = Date.now();
|
|
311
313
|
if (!diagnosticsCache || now - diagnosticsCache.at > DIAGNOSTICS_CACHE_TTL_MS) {
|
|
312
|
-
diagnosticsCache = {
|
|
314
|
+
diagnosticsCache = {
|
|
315
|
+
at: now,
|
|
316
|
+
warnings: collectProjectCodexConfigWarnings().map(warning => ({
|
|
317
|
+
...warning,
|
|
318
|
+
path: truncateRetainedUtf8(warning.path, MAX_DIAGNOSTIC_VALUE_BYTES),
|
|
319
|
+
detail: truncateRetainedUtf8(warning.detail, MAX_DIAGNOSTIC_VALUE_BYTES),
|
|
320
|
+
...(warning.profileName === undefined ? {} : { profileName: truncateRetainedUtf8(warning.profileName, MAX_DIAGNOSTIC_VALUE_BYTES) }),
|
|
321
|
+
message: truncateRetainedUtf8(warning.message, MAX_DIAGNOSTIC_VALUE_BYTES),
|
|
322
|
+
})),
|
|
323
|
+
};
|
|
313
324
|
}
|
|
314
325
|
const warnings = diagnosticsCache.warnings;
|
|
315
326
|
return { warnings, grouped: groupProjectCodexConfigWarningsByPath(warnings) };
|
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
|
}
|