@bitkyc08/opencodex 2.23.0-preview.20260816 → 2.24.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/gui/dist/assets/index-CFqJKF2L.js +102 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/anthropic.ts +39 -7
- package/src/adapters/cursor/tool-definitions.ts +48 -0
- package/src/adapters/google.ts +18 -12
- package/src/adapters/openai-chat.ts +106 -13
- package/src/adapters/tool-call-id.ts +119 -0
- package/src/adapters/tool-catalog-nudge.ts +3 -0
- package/src/bridge.ts +16 -5
- package/src/chat/inbound.ts +5 -11
- package/src/claude/context-windows.ts +20 -4
- package/src/claude/desktop-3p.ts +11 -6
- package/src/claude/inbound.ts +39 -1
- package/src/claude/model-info.ts +28 -8
- package/src/cli/account-api.ts +5 -1
- package/src/cli/claude-desktop.ts +3 -0
- package/src/cli/config-command.ts +37 -14
- package/src/codex/app-server-restart-service.ts +1 -1
- package/src/codex/auth-api.ts +5 -0
- package/src/codex/auth-context.ts +43 -2
- package/src/codex/catalog/metadata.ts +135 -12
- package/src/codex/catalog/native-models.ts +32 -2
- package/src/codex/catalog/parsing.ts +61 -13
- package/src/codex/catalog/provider-fetch.ts +37 -7
- package/src/codex/catalog/sync.ts +49 -25
- package/src/codex/catalog-refresh-status.ts +21 -3
- package/src/codex/catalog.ts +1 -1
- package/src/codex/convergence-types.ts +23 -2
- package/src/codex/desired-state.ts +1 -1
- package/src/codex/inject.ts +38 -7
- package/src/codex/injected-marker.ts +28 -0
- package/src/codex/journal.ts +40 -1
- package/src/codex/management-convergence.ts +55 -2
- package/src/codex/quota-rejection.ts +61 -1
- package/src/codex/quota.ts +60 -6
- package/src/codex/routing.ts +30 -3
- package/src/combos/failover.ts +20 -0
- package/src/config.ts +271 -4
- package/src/generated/compatibility-version.json +90 -78
- package/src/grok/sync.ts +3 -1
- package/src/lab/artifacts/sanitize.ts +1 -1
- package/src/lab/live/manifest.ts +1 -1
- package/src/lib/codex-restart-contract.ts +1 -1
- package/src/lib/config-ownership.ts +1 -0
- package/src/lib/errors.ts +9 -0
- package/src/lib/lab-activation.ts +1 -1
- package/src/lib/optional-shutdown-hooks.ts +1 -1
- package/src/lib/pinned-http.ts +7 -2
- package/src/lib/windows-elevation.ts +3 -3
- package/src/providers/quota.ts +10 -4
- package/src/providers/registry.ts +2 -2
- package/src/responses/parser.ts +42 -7
- package/src/responses/provider-opaque-metadata.ts +1 -1
- package/src/responses/thought-signature-replay.ts +261 -0
- package/src/router.ts +6 -1
- package/src/routing/compatibility/provider-slot.ts +1 -1
- package/src/routing/evaluator.ts +12 -2
- package/src/routing/health.ts +16 -5
- package/src/routing/history/schema.ts +1 -1
- package/src/routing/trace.ts +1 -1
- package/src/server/auth-cors.ts +96 -21
- package/src/server/chat-completions.ts +6 -2
- package/src/server/chat-native.ts +32 -6
- package/src/server/index.ts +5 -3
- package/src/server/management/agent-settings-routes.ts +26 -4
- package/src/server/management/config-routes.ts +79 -2
- package/src/server/management/context.ts +1 -1
- package/src/server/management/model-rows.ts +5 -0
- package/src/server/management/native-integration-routes.ts +4 -1
- package/src/server/management/provider-routes.ts +19 -0
- package/src/server/management/shared.ts +3 -3
- package/src/server/management-api.ts +13 -6
- package/src/server/passive-route-linker.ts +1 -1
- package/src/server/relay.ts +16 -0
- package/src/server/responses/compact.ts +10 -3
- package/src/server/responses/core.ts +160 -33
- package/src/server/responses/fetch-helpers.ts +34 -2
- package/src/server/responses/input-admission.ts +17 -9
- package/src/server/responses-undeclared-tool-guard.ts +153 -0
- package/src/server/system-env.ts +4 -2
- package/src/service.ts +22 -7
- package/src/types.ts +35 -1
- package/gui/dist/assets/index-Ch-YtWdA.js +0 -102
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CatalogDisposition, CatalogNotice } from "./convergence-types";
|
|
1
|
+
import type { CatalogDisposition, CatalogFailureCause, CatalogNotice } from "./convergence-types";
|
|
2
2
|
|
|
3
3
|
const INVALID_CATALOG_DISPOSITION_FIELD = Symbol("invalid-catalog-disposition-field");
|
|
4
4
|
|
|
@@ -69,11 +69,15 @@ export function normalizeCatalogDisposition(value: unknown): CatalogDisposition
|
|
|
69
69
|
const phase = ownDataProperty(value, "phase");
|
|
70
70
|
const retryable = ownDataProperty(value, "retryable");
|
|
71
71
|
const partialWrite = ownDataProperty(value, "partialWrite");
|
|
72
|
-
if ((reason !== "provider-auth" && reason !== "provider-network" && reason !== "disk"
|
|
72
|
+
if ((reason !== "provider-auth" && reason !== "provider-network" && reason !== "disk"
|
|
73
|
+
&& reason !== "request-invalid" && reason !== "admission" && reason !== "internal")
|
|
73
74
|
|| (phase !== "gather" && phase !== "commit")
|
|
74
75
|
|| typeof retryable !== "boolean"
|
|
75
76
|
|| typeof partialWrite !== "boolean") return null;
|
|
76
|
-
|
|
77
|
+
// The cause is rebuilt from closed vocabularies, never copied through: this is the
|
|
78
|
+
// boundary that keeps a message, path or account id from riding out on a failure.
|
|
79
|
+
const cause = normalizeCatalogFailureCause(ownDataProperty(value, "cause"));
|
|
80
|
+
return { status, reason, phase, retryable, partialWrite, ...(cause ? { cause } : {}) };
|
|
77
81
|
}
|
|
78
82
|
return null;
|
|
79
83
|
} catch {
|
|
@@ -81,6 +85,20 @@ export function normalizeCatalogDisposition(value: unknown): CatalogDisposition
|
|
|
81
85
|
}
|
|
82
86
|
}
|
|
83
87
|
|
|
88
|
+
const FAILURE_CAUSE_KINDS: ReadonlySet<string> = new Set(["invalid-request", "lock-busy", "io", "unknown"]);
|
|
89
|
+
const FAILURE_CAUSE_CODES: ReadonlySet<string> = new Set([
|
|
90
|
+
"ENOSPC", "EACCES", "EPERM", "EROFS", "ENOENT", "SQLITE_BUSY",
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
function normalizeCatalogFailureCause(value: unknown): CatalogFailureCause | undefined {
|
|
94
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
95
|
+
const kind = ownDataProperty(value, "kind");
|
|
96
|
+
if (typeof kind !== "string" || !FAILURE_CAUSE_KINDS.has(kind)) return undefined;
|
|
97
|
+
const code = ownDataProperty(value, "code");
|
|
98
|
+
const safeCode = typeof code === "string" && FAILURE_CAUSE_CODES.has(code) ? code : undefined;
|
|
99
|
+
return { kind, ...(safeCode ? { code: safeCode } : {}) } as CatalogFailureCause;
|
|
100
|
+
}
|
|
101
|
+
|
|
84
102
|
/** Whether a persisted mutation still needs a successful catalog commit. */
|
|
85
103
|
export function catalogRefreshIsPending(disposition: CatalogDisposition): boolean {
|
|
86
104
|
return disposition.status !== "committed";
|
package/src/codex/catalog.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Public surface preserved exactly; importers keep using "src/codex/catalog".
|
|
3
3
|
export { isMediaGenerationModelId, shouldExposeRoutedModel, readCodexCatalogPath, readCatalog, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing";
|
|
4
4
|
export type { CatalogModel, MultiAgentMode } from "./catalog/parsing";
|
|
5
|
-
export { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, CODEX_NATIVE_ALIAS_CATALOG_KIND, NATIVE_DAYBREAK_BLUE_MODEL, NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS, NATIVE_OPENAI_MODELS, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, isNativeAliasCatalogEntry, isNativeOpenAiCapabilityAliasModel, nativeOpenAiCapabilitySourceSlug, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, observedAccountBoundNativeEntries, observedAccountBoundNativeOpenAiSlugs, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeInputModalities, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi } from "./catalog/metadata";
|
|
5
|
+
export { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, CODEX_NATIVE_ALIAS_CATALOG_KIND, NATIVE_DAYBREAK_BLUE_MODEL, NATIVE_GPT56_MAX_INPUT_TOKENS, NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS, NATIVE_OPENAI_MODELS, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, isNativeAliasCatalogEntry, isNativeOpenAiCapabilityAliasModel, nativeContextLimits, nativeOpenAiCapabilitySourceSlug, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, observedAccountBoundNativeEntries, observedAccountBoundNativeOpenAiSlugs, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeInputModalities, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, type NativeContextLimits, type NativeContextLimitsInput } from "./catalog/metadata";
|
|
6
6
|
export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled";
|
|
7
7
|
export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort";
|
|
8
8
|
export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithMetadata, resolveComboCatalogMember, configuredComboTargetModelsByProvider } from "./catalog/provider-fetch";
|
|
@@ -161,8 +161,29 @@ export type CatalogDisposition =
|
|
|
161
161
|
| { status: "skipped";
|
|
162
162
|
reason: "not-requested" | "catalog-unavailable" | "busy" | "stale" | "refused";
|
|
163
163
|
retryable: boolean }
|
|
164
|
-
| { status: "failed";
|
|
165
|
-
|
|
164
|
+
| { status: "failed";
|
|
165
|
+
/**
|
|
166
|
+
* `disk` used to absorb every unclassified failure, so a malformed request and a
|
|
167
|
+
* genuine ENOSPC were indistinguishable and both reported non-retryable (#1784).
|
|
168
|
+
*/
|
|
169
|
+
reason: "provider-auth" | "provider-network" | "disk" | "request-invalid" | "admission" | "internal";
|
|
170
|
+
phase: "gather" | "commit"; retryable: boolean; partialWrite: boolean;
|
|
171
|
+
/** Allowlisted cause summary. Closed vocabularies only -- never message text. */
|
|
172
|
+
cause?: CatalogFailureCause };
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Why a catalog operation failed, in terms safe to return from the management plane.
|
|
176
|
+
*
|
|
177
|
+
* Both fields are closed sets on purpose. An `Error.constructor.name` is dependency- or
|
|
178
|
+
* input-influenced (any thrown custom class names itself) and an `Error.message` routinely
|
|
179
|
+
* carries paths, home directories and account identifiers, none of which may cross this
|
|
180
|
+
* boundary.
|
|
181
|
+
*/
|
|
182
|
+
export type CatalogFailureCause = {
|
|
183
|
+
kind: "invalid-request" | "lock-busy" | "io" | "unknown";
|
|
184
|
+
/** Recognized errno/code token, when the underlying error carried one. */
|
|
185
|
+
code?: "ENOSPC" | "EACCES" | "EPERM" | "EROFS" | "ENOENT" | "SQLITE_BUSY";
|
|
186
|
+
};
|
|
166
187
|
|
|
167
188
|
/**
|
|
168
189
|
* The ONLY way Codex-owned bytes are written. Startup, ensure, /api/sync, the
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* depending on the temp root. Convergence takes the write lock; this module only
|
|
19
19
|
* records intent through the config coordinator.
|
|
20
20
|
*
|
|
21
|
-
* Design record: devlog/
|
|
21
|
+
* Design record: devlog/_fin/260803_codex_desktop_toggle/030_desired_state.md.
|
|
22
22
|
*/
|
|
23
23
|
import { loadConfig, mutatePersistedConfig } from "../config";
|
|
24
24
|
import type { OcxClientIntegrationsConfig, OcxConfig } from "../types";
|
package/src/codex/inject.ts
CHANGED
|
@@ -30,6 +30,8 @@ import {
|
|
|
30
30
|
} from "./user-identity";
|
|
31
31
|
import {
|
|
32
32
|
markJournalInjectedState,
|
|
33
|
+
journaledInjectedOpenaiBaseUrl,
|
|
34
|
+
journaledInjectedCatalogPath,
|
|
33
35
|
removeJournal,
|
|
34
36
|
restoreJournalState,
|
|
35
37
|
writeJournal,
|
|
@@ -52,6 +54,7 @@ import {
|
|
|
52
54
|
providerTableStart,
|
|
53
55
|
providerTableString,
|
|
54
56
|
rootTomlString,
|
|
57
|
+
stripJournaledOpenaiBaseUrl,
|
|
55
58
|
tomlStringPattern,
|
|
56
59
|
} from "./injected-marker";
|
|
57
60
|
import {
|
|
@@ -1138,12 +1141,18 @@ interface StripOpencodexConfigResult {
|
|
|
1138
1141
|
*/
|
|
1139
1142
|
function stripOpencodexConfigResult(
|
|
1140
1143
|
content: string,
|
|
1144
|
+
journaledBaseUrl: string | null = null,
|
|
1141
1145
|
): StripOpencodexConfigResult {
|
|
1142
1146
|
let out = content;
|
|
1143
1147
|
const hadRootOcxProvider =
|
|
1144
1148
|
readRootTomlString(out, "model_provider") === "opencodex";
|
|
1145
|
-
|
|
1149
|
+
// #1798: marker adjacency is FORMATTING evidence, and a Codex app rewrite keeps values
|
|
1150
|
+
// while dropping comments. Fall back to VALUE evidence -- the exact URL we recorded
|
|
1151
|
+
// writing -- so an app-rewritten config is still recognized as ours.
|
|
1152
|
+
const hadInjectedBaseUrl = hasInjectedOpenaiBaseUrl(out)
|
|
1153
|
+
|| (journaledBaseUrl !== null && rootTomlString(out, "openai_base_url") === journaledBaseUrl);
|
|
1146
1154
|
out = stripInjectedOpenaiBaseUrl(out); // before removeOcxSection — it keys on the marker line too
|
|
1155
|
+
out = stripJournaledOpenaiBaseUrl(out, journaledBaseUrl);
|
|
1147
1156
|
if (out.includes("[model_providers.opencodex]")) {
|
|
1148
1157
|
out = removeOcxSection(out);
|
|
1149
1158
|
}
|
|
@@ -1195,8 +1204,12 @@ export function removeCodexConfig(
|
|
|
1195
1204
|
// The unchanged fast path compares in LF space so an untouched file is never rewritten.
|
|
1196
1205
|
const eol = dominantEol(rawContent);
|
|
1197
1206
|
const content = applyEol(rawContent, "\n");
|
|
1198
|
-
|
|
1199
|
-
|
|
1207
|
+
// Read the recorded injection once: the strip below consumes it, and so does the
|
|
1208
|
+
// ownership verdict, which must agree with what was actually removed.
|
|
1209
|
+
const journaledBaseUrl = journaledInjectedOpenaiBaseUrl();
|
|
1210
|
+
const had = hasOpencodexRouting(content)
|
|
1211
|
+
|| (journaledBaseUrl !== null && rootTomlString(content, "openai_base_url") === journaledBaseUrl);
|
|
1212
|
+
const stripped = stripOpencodexConfigResult(content, journaledBaseUrl);
|
|
1200
1213
|
if (had || stripped.content !== content) {
|
|
1201
1214
|
atomicWriteFile(CODEX_CONFIG_PATH, applyEol(stripped.content, eol));
|
|
1202
1215
|
}
|
|
@@ -1371,13 +1384,23 @@ function restoreCodexConfigInline(): CodexRestoreConfigResult {
|
|
|
1371
1384
|
}
|
|
1372
1385
|
|
|
1373
1386
|
/** The catalog half, always inside its own K acquisition. */
|
|
1374
|
-
|
|
1387
|
+
/**
|
|
1388
|
+
* The catalog half, always inside its own K acquisition.
|
|
1389
|
+
*
|
|
1390
|
+
* `journaledCatalogPath` must be captured by the CALLER, before the config half runs: a
|
|
1391
|
+
* successful journal restore deletes the journal, and a config restore can remove
|
|
1392
|
+
* `model_catalog_json`. Reading it here would be too late in both cases (#1798).
|
|
1393
|
+
*/
|
|
1394
|
+
function restoreCodexCatalogArtifact(
|
|
1395
|
+
revalidateDesiredState: boolean,
|
|
1396
|
+
journaledCatalogPath: string | null,
|
|
1397
|
+
): CodexRestoreCatalogResult {
|
|
1375
1398
|
const owningCodexHome = getCodexHome();
|
|
1376
1399
|
try {
|
|
1377
1400
|
const restored = withCatalogWriteSerialization(owningCodexHome, permit =>
|
|
1378
1401
|
revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())
|
|
1379
1402
|
? null
|
|
1380
|
-
: restoreCodexCatalogWithPermit(permit, owningCodexHome));
|
|
1403
|
+
: restoreCodexCatalogWithPermit(permit, owningCodexHome, journaledCatalogPath));
|
|
1381
1404
|
return restored.kind === "completed" && restored.value !== null
|
|
1382
1405
|
? { state: "ok", changed: restored.value.removed > 0, ...restored.value, message: "Codex catalog restored." }
|
|
1383
1406
|
: restored.kind === "completed"
|
|
@@ -1435,6 +1458,10 @@ export async function restoreNativeCodexAsync(
|
|
|
1435
1458
|
integrationRecord: () => readIntegrationRecord(),
|
|
1436
1459
|
});
|
|
1437
1460
|
|
|
1461
|
+
// Captured before the config half: a successful journal restore DELETES the journal, and
|
|
1462
|
+
// restoring the config can drop `model_catalog_json`. Either one would hide the routed
|
|
1463
|
+
// catalog we actually wrote (#1798).
|
|
1464
|
+
const journaledCatalogPath = journaledInjectedCatalogPath();
|
|
1438
1465
|
let config: CodexRestoreConfigResult;
|
|
1439
1466
|
let transitionReceipt: { nativeGeneration: number; currentTxId: string } | undefined;
|
|
1440
1467
|
|
|
@@ -1511,7 +1538,7 @@ export async function restoreNativeCodexAsync(
|
|
|
1511
1538
|
config = restoreCodexConfigInline();
|
|
1512
1539
|
}
|
|
1513
1540
|
|
|
1514
|
-
const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true);
|
|
1541
|
+
const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true, journaledCatalogPath);
|
|
1515
1542
|
const outcome = await runCodexHistoryJob({
|
|
1516
1543
|
...resolveCodexHistoryJobTarget(),
|
|
1517
1544
|
...(options.revalidateDesiredState ? { expectedDesiredEnabled: false } : {}),
|
|
@@ -1561,8 +1588,12 @@ export function restoreNativeCodex(options: { skipHistory?: boolean; revalidateD
|
|
|
1561
1588
|
if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) {
|
|
1562
1589
|
return desiredEnabledRestoreSkip();
|
|
1563
1590
|
}
|
|
1591
|
+
// Captured before the config half: a successful journal restore DELETES the journal, and
|
|
1592
|
+
// restoring the config can drop `model_catalog_json`. Either one would hide the routed
|
|
1593
|
+
// catalog we actually wrote (#1798).
|
|
1594
|
+
const journaledCatalogPath = journaledInjectedCatalogPath();
|
|
1564
1595
|
const config = restoreCodexConfigInline();
|
|
1565
|
-
const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true);
|
|
1596
|
+
const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true, journaledCatalogPath);
|
|
1566
1597
|
// Design B (loopback) steady state: threads are already tagged openai, so prove the
|
|
1567
1598
|
// no-op with a readonly probe instead of write-opening a DB the Codex app may hold
|
|
1568
1599
|
// (Windows: WAL writer lock -> seconds of stalling + a false warning on every stop).
|
|
@@ -50,6 +50,34 @@ export function providerTableString(content: string, provider: string, key: stri
|
|
|
50
50
|
return null;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Drop a root `openai_base_url` whose VALUE is the one a recorded injection wrote.
|
|
55
|
+
*
|
|
56
|
+
* #1798: the marker-adjacency rule below is formatting evidence, and the Codex app
|
|
57
|
+
* reserializes the file -- values kept, comments dropped. This rule is value evidence
|
|
58
|
+
* instead, so it still recognizes our URL after that rewrite. It is deliberately an
|
|
59
|
+
* EXACT value match against what we recorded writing: a user gateway we never wrote
|
|
60
|
+
* cannot match, so restore can never delete a URL that was not ours.
|
|
61
|
+
*/
|
|
62
|
+
export function stripJournaledOpenaiBaseUrl(content: string, injectedUrl: string | null): string {
|
|
63
|
+
if (!injectedUrl) return content;
|
|
64
|
+
const lines = content.split(String.fromCharCode(10));
|
|
65
|
+
const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
|
|
66
|
+
const rootEnd = firstTable === -1 ? lines.length : firstTable;
|
|
67
|
+
const drop = new Set<number>();
|
|
68
|
+
for (let i = 0; i < rootEnd; i++) {
|
|
69
|
+
const line = lines[i]!;
|
|
70
|
+
if (!isRootOpenaiBaseUrlLine(line)) continue;
|
|
71
|
+
if (rootTomlString(line, "openai_base_url") !== injectedUrl) continue;
|
|
72
|
+
drop.add(i);
|
|
73
|
+
// Take an ownership marker directly above it too, so repeated cycles cannot
|
|
74
|
+
// accumulate orphaned comments.
|
|
75
|
+
if (i > 0 && lines[i - 1]!.includes(OCX_SECTION_MARKER)) drop.add(i - 1);
|
|
76
|
+
}
|
|
77
|
+
if (drop.size === 0) return content;
|
|
78
|
+
return lines.filter((_, i) => !drop.has(i)).join(String.fromCharCode(10));
|
|
79
|
+
}
|
|
80
|
+
|
|
53
81
|
export function hasInjectedOpenaiBaseUrl(content: string): boolean {
|
|
54
82
|
const lines = content.split("\n");
|
|
55
83
|
const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
|
package/src/codex/journal.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { atomicWriteFile } from "../config";
|
|
5
|
-
import { hasInjectedCodexRouting } from "./injected-marker";
|
|
5
|
+
import { hasInjectedCodexRouting, rootTomlString } from "./injected-marker";
|
|
6
6
|
import { CODEX_HOME, CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths";
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -22,6 +22,24 @@ interface Journal {
|
|
|
22
22
|
originalProfile: string | null;
|
|
23
23
|
injectedConfigHash?: string;
|
|
24
24
|
injectedProfileHash?: string | null;
|
|
25
|
+
/**
|
|
26
|
+
* The exact root `openai_base_url` this injection wrote, when it wrote one.
|
|
27
|
+
*
|
|
28
|
+
* #1798: ownership used to be inferred from a marker COMMENT on the preceding line,
|
|
29
|
+
* which a reserializing Codex app deletes while keeping the value. Recording the value
|
|
30
|
+
* we actually wrote makes ownership provable from evidence rather than from formatting,
|
|
31
|
+
* and it is what lets restore tell OUR loopback URL apart from a gateway the user set.
|
|
32
|
+
*/
|
|
33
|
+
injectedOpenaiBaseUrl?: string | null;
|
|
34
|
+
/**
|
|
35
|
+
* The catalog path this injection actually wrote to.
|
|
36
|
+
*
|
|
37
|
+
* #1798: restore re-resolves the catalog from the CURRENT config, so a Codex app rewrite
|
|
38
|
+
* that dropped `model_catalog_json` sends restore to the default catalog while the
|
|
39
|
+
* proxy-written one is left routed. The injected path is the only durable record of which
|
|
40
|
+
* file we actually touched.
|
|
41
|
+
*/
|
|
42
|
+
injectedCatalogPath?: string | null;
|
|
25
43
|
pid: number;
|
|
26
44
|
timestamp: string;
|
|
27
45
|
}
|
|
@@ -96,9 +114,30 @@ export function markJournalInjectedState(config: string, profile: string | null)
|
|
|
96
114
|
if (journal.injectedConfigHash) return;
|
|
97
115
|
journal.injectedConfigHash = sha256(config) ?? undefined;
|
|
98
116
|
journal.injectedProfileHash = sha256(profile);
|
|
117
|
+
// Read from the bytes we are about to install, not from the file: another writer may
|
|
118
|
+
// already have rewritten it, and then the recorded value would describe their config.
|
|
119
|
+
journal.injectedOpenaiBaseUrl = rootTomlString(config, "openai_base_url");
|
|
120
|
+
journal.injectedCatalogPath = rootTomlString(config, "model_catalog_json");
|
|
99
121
|
atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal));
|
|
100
122
|
}
|
|
101
123
|
|
|
124
|
+
/**
|
|
125
|
+
* The root `openai_base_url` the last injection wrote, or null when it wrote none.
|
|
126
|
+
*
|
|
127
|
+
* #1798: the fallback strip recognizes an injected URL by the marker COMMENT above it,
|
|
128
|
+
* and a Codex app rewrite keeps values while dropping comments. This is the evidence that
|
|
129
|
+
* survives such a rewrite, so restore can still prove the URL is ours -- and, just as
|
|
130
|
+
* importantly, prove that a DIFFERENT URL is not.
|
|
131
|
+
*/
|
|
132
|
+
export function journaledInjectedOpenaiBaseUrl(): string | null {
|
|
133
|
+
return readJournal()?.injectedOpenaiBaseUrl ?? null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The catalog path the last injection wrote to, or null when none was recorded. */
|
|
137
|
+
export function journaledInjectedCatalogPath(): string | null {
|
|
138
|
+
return readJournal()?.injectedCatalogPath ?? null;
|
|
139
|
+
}
|
|
140
|
+
|
|
102
141
|
export function removeJournal(): void {
|
|
103
142
|
try { unlinkSync(JOURNAL_PATH); } catch { /* ignore */ }
|
|
104
143
|
}
|
|
@@ -3,6 +3,7 @@ import { captureCatalogAdmissionSnapshot } from "./catalog-admission";
|
|
|
3
3
|
import { convergeCodexCatalog } from "./convergence";
|
|
4
4
|
import type {
|
|
5
5
|
CatalogDisposition,
|
|
6
|
+
CatalogFailureCause,
|
|
6
7
|
CatalogOnlyOutcome,
|
|
7
8
|
CodexHistoryState,
|
|
8
9
|
CodexObservedState,
|
|
@@ -49,6 +50,58 @@ function notEvaluatedObserved(history: CodexHistoryState): CodexObservedState {
|
|
|
49
50
|
};
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
/** Recognized errno/code tokens. Anything else is dropped rather than echoed. */
|
|
54
|
+
const RECOGNIZED_FAILURE_CODES: ReadonlySet<string> = new Set([
|
|
55
|
+
"ENOSPC", "EACCES", "EPERM", "EROFS", "ENOENT", "SQLITE_BUSY",
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Reduce a caught error to an allowlisted cause (#1784).
|
|
60
|
+
*
|
|
61
|
+
* Nothing from the error text reaches the caller: `kind` is chosen from a closed set and
|
|
62
|
+
* `code` is only emitted when it is a recognized token. An `Error.message` routinely carries
|
|
63
|
+
* paths, home directories and account ids, and `redactSecretString` masks token shapes but
|
|
64
|
+
* none of those, so the message is never a safe thing to forward from here.
|
|
65
|
+
*/
|
|
66
|
+
function catalogFailureCause(error: unknown): CatalogFailureCause {
|
|
67
|
+
const raw = (error as { code?: unknown } | null)?.code;
|
|
68
|
+
const code = typeof raw === "string" && RECOGNIZED_FAILURE_CODES.has(raw)
|
|
69
|
+
? raw as CatalogFailureCause["code"]
|
|
70
|
+
: undefined;
|
|
71
|
+
if (error instanceof TypeError || error instanceof RangeError || error instanceof SyntaxError) {
|
|
72
|
+
return { kind: "invalid-request", ...(code ? { code } : {}) };
|
|
73
|
+
}
|
|
74
|
+
if (code === "SQLITE_BUSY") return { kind: "lock-busy", code };
|
|
75
|
+
if (code !== undefined) return { kind: "io", code };
|
|
76
|
+
return { kind: "unknown" };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Classify a failure that is NOT a filesystem problem.
|
|
81
|
+
*
|
|
82
|
+
* `disk` used to be the catch-all, so an operator saw "non-retryable disk failure" for a
|
|
83
|
+
* malformed request. Reserve `disk` for real IO and route the rest to honest reasons.
|
|
84
|
+
*/
|
|
85
|
+
function classifiedCatalogFailure(error: unknown, commitBegan: boolean): CatalogDisposition {
|
|
86
|
+
const cause = catalogFailureCause(error);
|
|
87
|
+
const reason = cause.kind === "invalid-request"
|
|
88
|
+
? "request-invalid" as const
|
|
89
|
+
: cause.kind === "lock-busy"
|
|
90
|
+
? "admission" as const
|
|
91
|
+
: cause.kind === "io"
|
|
92
|
+
? "disk" as const
|
|
93
|
+
: "internal" as const;
|
|
94
|
+
return {
|
|
95
|
+
status: "failed",
|
|
96
|
+
reason,
|
|
97
|
+
phase: commitBegan ? "commit" : "gather",
|
|
98
|
+
// Contention is the one class worth retrying unchanged.
|
|
99
|
+
retryable: reason === "admission",
|
|
100
|
+
partialWrite: commitBegan,
|
|
101
|
+
cause,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
52
105
|
function unexpectedCatalogFailure(commitBegan: boolean): CatalogDisposition {
|
|
53
106
|
return {
|
|
54
107
|
status: "failed",
|
|
@@ -64,7 +117,7 @@ function admissionFailure(error: unknown): CatalogDisposition {
|
|
|
64
117
|
if (message.includes("config generation is busy") || message.includes("config generation is database")) {
|
|
65
118
|
return { status: "skipped", reason: "busy", retryable: true };
|
|
66
119
|
}
|
|
67
|
-
return
|
|
120
|
+
return classifiedCatalogFailure(error, false);
|
|
68
121
|
}
|
|
69
122
|
|
|
70
123
|
/** Project catalog work into the shared no-change/not-evaluated outcome shape. */
|
|
@@ -107,7 +160,7 @@ export function createManagementConvergeCodex(
|
|
|
107
160
|
} catch (error) {
|
|
108
161
|
return projectCatalogOnlyOutcome({
|
|
109
162
|
changed: false,
|
|
110
|
-
catalogRefresh: commitBegan ?
|
|
163
|
+
catalogRefresh: commitBegan ? classifiedCatalogFailure(error, true) : admissionFailure(error),
|
|
111
164
|
});
|
|
112
165
|
}
|
|
113
166
|
};
|
|
@@ -23,6 +23,63 @@ export interface CodexPreStreamRejection {
|
|
|
23
23
|
alternateRetryEligible: boolean;
|
|
24
24
|
resetCreditEligible: boolean;
|
|
25
25
|
semanticCode?: CodexResetEligibleExhaustionCode;
|
|
26
|
+
/**
|
|
27
|
+
* Structured denial evidence for a 403. Present only when the upstream body names a
|
|
28
|
+
* workspace/entitlement denial, which proves the CREDENTIAL is valid and the account
|
|
29
|
+
* simply lacks access here (#1789). Status alone can never set this.
|
|
30
|
+
*/
|
|
31
|
+
denial?: "workspace" | "entitlement";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Upstream codes that identify a WORKSPACE denial rather than a bad credential.
|
|
36
|
+
*
|
|
37
|
+
* #1789: a K12 account whose credential validates and whose WHAM usage returns 200 still
|
|
38
|
+
* gets 403 `codex_workspace_access_denied` on a routed prompt. Treating that as a credential
|
|
39
|
+
* failure tells the user to re-authenticate a credential that is already valid, and the loop
|
|
40
|
+
* repeats forever.
|
|
41
|
+
*/
|
|
42
|
+
const WORKSPACE_DENIAL_CODES: ReadonlySet<string> = new Set([
|
|
43
|
+
"codex_workspace_access_denied",
|
|
44
|
+
"workspace_access_denied",
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
const ENTITLEMENT_DENIAL_CODES: ReadonlySet<string> = new Set([
|
|
48
|
+
"codex_entitlement_missing",
|
|
49
|
+
"entitlement_missing",
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
/** Read a structured denial code out of a 403 body. Fails closed to undefined. */
|
|
53
|
+
async function denialFromResponse(
|
|
54
|
+
response: Response,
|
|
55
|
+
signal?: AbortSignal,
|
|
56
|
+
): Promise<"workspace" | "entitlement" | undefined> {
|
|
57
|
+
try {
|
|
58
|
+
const body = await readBoundedResponseBody(response.clone(), { signal, fatalUtf8: true });
|
|
59
|
+
if (!body.displaySafe || body.truncated || !body.text.trim()) return undefined;
|
|
60
|
+
if (isUnsafeJsonDocument(body.text)) return undefined;
|
|
61
|
+
const payload = JSON.parse(body.text) as unknown;
|
|
62
|
+
const code = structuredDenialCode(payload);
|
|
63
|
+
if (code === undefined) return undefined;
|
|
64
|
+
if (WORKSPACE_DENIAL_CODES.has(code)) return "workspace";
|
|
65
|
+
if (ENTITLEMENT_DENIAL_CODES.has(code)) return "entitlement";
|
|
66
|
+
return undefined;
|
|
67
|
+
} catch {
|
|
68
|
+
// Same fail-closed rule as the exhaustion classifier: an unreadable body must not
|
|
69
|
+
// downgrade a credential failure into a workspace one.
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Own-property `code` lookup at the top level or under `error`. No coercion, no accessors. */
|
|
75
|
+
function structuredDenialCode(payload: unknown): string | undefined {
|
|
76
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) return undefined;
|
|
77
|
+
const direct = (payload as Record<string, unknown>).code;
|
|
78
|
+
if (typeof direct === "string") return direct;
|
|
79
|
+
const error = (payload as Record<string, unknown>).error;
|
|
80
|
+
if (error === null || typeof error !== "object" || Array.isArray(error)) return undefined;
|
|
81
|
+
const nested = (error as Record<string, unknown>).code;
|
|
82
|
+
return typeof nested === "string" ? nested : undefined;
|
|
26
83
|
}
|
|
27
84
|
|
|
28
85
|
const RESET_ELIGIBLE_CODES: ReadonlySet<string> = new Set(RESET_ELIGIBLE_CODE_VALUES);
|
|
@@ -205,7 +262,10 @@ export async function classifyCodexPreStreamRejection(
|
|
|
205
262
|
): Promise<CodexPreStreamRejection> {
|
|
206
263
|
const status = response.status;
|
|
207
264
|
if (status === 401) return rejection(status, "authentication-error");
|
|
208
|
-
if (status === 403)
|
|
265
|
+
if (status === 403) {
|
|
266
|
+
const denial = await denialFromResponse(response, options.signal);
|
|
267
|
+
return { ...rejection(status, "permission-error"), ...(denial ? { denial } : {}) };
|
|
268
|
+
}
|
|
209
269
|
if (TRANSIENT_SERVER_STATUSES.has(status)) return rejection(status, "transient-server-error");
|
|
210
270
|
if (status !== 429 && status !== 402) return rejection(status, "other");
|
|
211
271
|
|
package/src/codex/quota.ts
CHANGED
|
@@ -9,6 +9,20 @@ export type StoredAccountQuota = {
|
|
|
9
9
|
monthlyPercent?: number;
|
|
10
10
|
weeklyResetAt?: number;
|
|
11
11
|
monthlyResetAt?: number;
|
|
12
|
+
/**
|
|
13
|
+
* A sub-day burst window, when upstream declares one (#1791).
|
|
14
|
+
*
|
|
15
|
+
* K12 and similar plans enforce a rolling 5-hour limit ALONGSIDE the weekly one.
|
|
16
|
+
* Not folding it into `weeklyPercent` stopped the mislabeling, but dropping it
|
|
17
|
+
* entirely hides a limit that genuinely blocks the account: a 429 at 100% here is
|
|
18
|
+
* real even while the weekly quota is untouched.
|
|
19
|
+
*
|
|
20
|
+
* `shortWindowSeconds` is retained because the duration is the only thing that makes
|
|
21
|
+
* this window self-describing; the slot it arrived in is not stable across plans.
|
|
22
|
+
*/
|
|
23
|
+
shortPercent?: number;
|
|
24
|
+
shortResetAt?: number;
|
|
25
|
+
shortWindowSeconds?: number;
|
|
12
26
|
resetCredits?: number;
|
|
13
27
|
/**
|
|
14
28
|
* True when `monthlyPercent` came from an explicitly-monthly PRIMARY window —
|
|
@@ -56,6 +70,19 @@ type WhamUsageWindow = {
|
|
|
56
70
|
};
|
|
57
71
|
|
|
58
72
|
const MONTHLY_WINDOW_MIN_SECONDS = 28 * 24 * 60 * 60;
|
|
73
|
+
/**
|
|
74
|
+
* Shortest window still plausibly the WEEKLY quota (#1791).
|
|
75
|
+
*
|
|
76
|
+
* K12 and similar plans send a 5-hour primary window plus a 7-day secondary. Folding the
|
|
77
|
+
* primary into `weeklyPercent` reported the 5-hour bar as the weekly one and discarded the
|
|
78
|
+
* real weekly reading entirely, so the dashboard showed a window that reset every few hours
|
|
79
|
+
* and routing never saw the limit that actually gates the account.
|
|
80
|
+
*
|
|
81
|
+
* 24h is the discriminator: anything shorter is a burst window, not a weekly one. A window
|
|
82
|
+
* with no declared duration is unchanged, because older payloads omit `limit_window_seconds`
|
|
83
|
+
* and guessing there would break every legacy account.
|
|
84
|
+
*/
|
|
85
|
+
const WEEKLY_WINDOW_MIN_SECONDS = 24 * 60 * 60;
|
|
59
86
|
const MONTHLY_WINDOW_MIN_MINUTES = MONTHLY_WINDOW_MIN_SECONDS / 60;
|
|
60
87
|
|
|
61
88
|
const accountQuota = new Map<string, StoredAccountQuota>();
|
|
@@ -72,13 +99,16 @@ export const CODEX_UNKNOWN_USAGE_SCORE = 101;
|
|
|
72
99
|
export const CODEX_EXHAUSTED_USAGE_PERCENT = 100;
|
|
73
100
|
|
|
74
101
|
export function isCodexQuotaExhausted(
|
|
75
|
-
quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent"> | null,
|
|
102
|
+
quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "shortPercent"> | null,
|
|
76
103
|
plan?: unknown,
|
|
77
104
|
): boolean {
|
|
78
105
|
if (!quota) return false;
|
|
106
|
+
// The burst window counts on EVERY plan. It is upstream-enforced independently, so an
|
|
107
|
+
// account at 100% there is blocked regardless of which longer window governs its plan;
|
|
108
|
+
// omitting it would route traffic straight into a 429 (#1791).
|
|
79
109
|
const values = codexQuotaWindowForPlan(plan) === "monthly"
|
|
80
|
-
? [quota.monthlyPercent]
|
|
81
|
-
: [quota.weeklyPercent, quota.monthlyPercent];
|
|
110
|
+
? [quota.monthlyPercent, quota.shortPercent]
|
|
111
|
+
: [quota.weeklyPercent, quota.monthlyPercent, quota.shortPercent];
|
|
82
112
|
return values.some(value => typeof value === "number"
|
|
83
113
|
&& Number.isFinite(value)
|
|
84
114
|
&& value >= CODEX_EXHAUSTED_USAGE_PERCENT);
|
|
@@ -104,7 +134,7 @@ export function codexQuotaWindowForPlan(plan?: unknown): "monthly" | "weekly" {
|
|
|
104
134
|
}
|
|
105
135
|
|
|
106
136
|
export function isCompleteCodexQuotaRecoverySnapshot(
|
|
107
|
-
quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "monthlyIsPrimaryWindow"> | null,
|
|
137
|
+
quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "monthlyIsPrimaryWindow" | "shortPercent"> | null,
|
|
108
138
|
plan?: unknown,
|
|
109
139
|
): boolean {
|
|
110
140
|
if (!quota || isCodexQuotaExhausted(quota, plan)) return false;
|
|
@@ -160,6 +190,15 @@ function hasKnownQuotaValue(quota: Omit<StoredAccountQuota, "updatedAt">): boole
|
|
|
160
190
|
.some(value => typeof value === "number" && Number.isFinite(value));
|
|
161
191
|
}
|
|
162
192
|
|
|
193
|
+
/** True only for a window that DECLARES a duration shorter than a day. */
|
|
194
|
+
function isExplicitShortWindow(window: WhamUsageWindow | null | undefined): boolean {
|
|
195
|
+
const seconds = window?.limit_window_seconds;
|
|
196
|
+
return typeof seconds === "number"
|
|
197
|
+
&& Number.isFinite(seconds)
|
|
198
|
+
&& seconds > 0
|
|
199
|
+
&& seconds < WEEKLY_WINDOW_MIN_SECONDS;
|
|
200
|
+
}
|
|
201
|
+
|
|
163
202
|
function isExplicitMonthlyWindow(window: WhamUsageWindow | null | undefined): boolean {
|
|
164
203
|
const seconds = window?.limit_window_seconds;
|
|
165
204
|
return typeof seconds === "number"
|
|
@@ -465,10 +504,25 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
|
|
|
465
504
|
// - 선택한 방식: only an explicit primary duration of at least 28 days changes it to monthly.
|
|
466
505
|
// - 다른 대안 대신 이 방식을 선택한 이유: it accepts calendar-month variance and preserves legacy payloads.
|
|
467
506
|
// - 장점, 단점 및 영향: Team monthly quotas classify correctly; unknown durations remain weekly by design.
|
|
468
|
-
|
|
507
|
+
// #1791: a primary window that declares a sub-day duration is a burst window, not the
|
|
508
|
+
// weekly one. Skip it so the secondary (the real 7-day window) is what lands in
|
|
509
|
+
// `weeklyPercent`; without this the 5-hour bar was reported as weekly and the actual
|
|
510
|
+
// weekly reading was dropped on the floor.
|
|
511
|
+
const primaryIsShort = isExplicitShortWindow(primaryWindow);
|
|
512
|
+
const weeklyCandidatePercent = primaryIsShort ? undefined : primaryPercent;
|
|
513
|
+
const weeklyCandidateResetAt = primaryIsShort ? undefined : primaryResetAt;
|
|
514
|
+
// Keep the burst reading instead of dropping it on the floor: it is a real limit, and
|
|
515
|
+
// the account is blocked when it fills even though the weekly window is fine (#1791).
|
|
516
|
+
if (primaryIsShort && primaryPercent !== undefined) {
|
|
517
|
+
quota.shortPercent = primaryPercent;
|
|
518
|
+
if (primaryResetAt !== undefined) quota.shortResetAt = primaryResetAt;
|
|
519
|
+
const seconds = primaryWindow?.limit_window_seconds;
|
|
520
|
+
if (typeof seconds === "number" && Number.isFinite(seconds)) quota.shortWindowSeconds = seconds;
|
|
521
|
+
}
|
|
522
|
+
const weeklyPercent = primaryIsMonthly ? secondaryPercent : weeklyCandidatePercent ?? secondaryPercent;
|
|
469
523
|
const weeklyResetAt = primaryIsMonthly
|
|
470
524
|
? secondaryResetAt
|
|
471
|
-
:
|
|
525
|
+
: weeklyCandidatePercent !== undefined ? weeklyCandidateResetAt : secondaryResetAt;
|
|
472
526
|
const monthlyPercent = primaryIsMonthly ? primaryPercent ?? tertiaryPercent : tertiaryPercent;
|
|
473
527
|
const monthlyResetAt = primaryIsMonthly && primaryPercent !== undefined ? primaryResetAt : tertiaryResetAt;
|
|
474
528
|
if (thirtyDayOnly) {
|