@bitkyc08/opencodex 2.49.0 → 2.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS_INSTALL.md +9 -1
- package/README.md +3 -0
- package/bin/ocx.mjs +222 -71
- package/gui/dist/assets/index-D7BdZpZm.js +115 -0
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/qoder/adapter.ts +69 -1
- package/src/adapters/qoder/scaffold-guard.ts +233 -0
- package/src/claude/agents-inject.ts +29 -5
- package/src/claude/desktop-3p.ts +31 -3
- package/src/claude/gateway-cache.ts +12 -21
- package/src/claude/inbound.ts +17 -5
- package/src/cli/account-api.ts +18 -3
- package/src/cli/account-auth.ts +8 -1
- package/src/cli/account-extended.ts +2 -1
- package/src/cli/account.ts +1 -0
- package/src/cli/capabilities.ts +43 -1
- package/src/cli/claude-agent-startup-sync.ts +26 -1
- package/src/cli/claude.ts +138 -20
- package/src/cli/config-command.ts +67 -1
- package/src/cli/connect.ts +181 -14
- package/src/cli/dispatch.ts +53 -9
- package/src/cli/doctor.ts +9 -2
- package/src/cli/ensure-desired-integrations.ts +10 -0
- package/src/cli/gui-pair-client.ts +1 -12
- package/src/cli/help.ts +4 -1
- package/src/cli/hub.ts +367 -0
- package/src/cli/index.ts +99 -31
- package/src/cli/launcher-context.ts +1 -1
- package/src/cli/models-runtime.ts +8 -3
- package/src/cli/observe.ts +13 -3
- package/src/cli/registry.ts +43 -3
- package/src/cli/status.ts +325 -5
- package/src/cli/version-skew.ts +4 -1
- package/src/cli.ts +2 -2
- package/src/client/catalog-compatibility.ts +192 -0
- package/src/client/connect.ts +31 -0
- package/src/client/hub-client.ts +52 -0
- package/src/client/hub-state.ts +214 -0
- package/src/clients/config-export/zcode.ts +24 -0
- package/src/codex/account-runtime-state.ts +6 -1
- package/src/codex/account-store.ts +72 -9
- package/src/codex/account-usability.ts +50 -13
- package/src/codex/auth-api.ts +156 -28
- package/src/codex/auth-context.ts +21 -0
- package/src/codex/catalog/effort.ts +67 -8
- package/src/codex/catalog/parsing.ts +23 -0
- package/src/codex/catalog/provider-fetch.ts +71 -2
- package/src/codex/catalog/sync.ts +99 -0
- package/src/codex/codex-write-lock.ts +11 -2
- package/src/codex/desired-state.ts +47 -1
- package/src/codex/inject-coordination.ts +10 -5
- package/src/codex/inject.ts +29 -12
- package/src/codex/loopback-target.ts +45 -0
- package/src/codex/quota-auto-refresh.ts +6 -1
- package/src/codex/quota.ts +54 -8
- package/src/codex/routing.ts +48 -1
- package/src/codex/runtime.ts +37 -3
- package/src/codex/sync.ts +29 -9
- package/src/codex/warmup.ts +21 -4
- package/src/combos/index.ts +2 -0
- package/src/combos/resolve.ts +52 -0
- package/src/config/pending-teardown.ts +1 -1
- package/src/config.ts +184 -12
- package/src/generated/compatibility-version.json +188 -116
- package/src/grok/status.ts +9 -1
- package/src/integrations/config-io.ts +54 -1
- package/src/lib/bun-runtime.ts +1 -1
- package/src/lib/errors.ts +8 -0
- package/src/lib/gui-pair-capability.ts +27 -0
- package/src/lib/local-destinations.ts +162 -0
- package/src/lib/package-tree-integrity.ts +1 -1
- package/src/lib/privacy.ts +25 -0
- package/src/lib/process-control.ts +130 -20
- package/src/lib/service-secrets.ts +28 -0
- package/src/lib/test-home-guard.ts +49 -0
- package/src/oauth/health.ts +47 -12
- package/src/oauth/index.ts +46 -8
- package/src/oauth/token-guardian.ts +32 -6
- package/src/providers/google-ai-studio-model-discovery.ts +74 -0
- package/src/providers/opencode-go-transport.ts +9 -1
- package/src/providers/opencode-zen-rate-limit.ts +75 -0
- package/src/providers/quota.ts +20 -1
- package/src/providers/registry.ts +35 -6
- package/src/remote/hub-state.ts +182 -0
- package/src/server/auth-cors.ts +11 -0
- package/src/server/chat-completions.ts +10 -7
- package/src/server/chat-native.ts +10 -1
- package/src/server/claude-messages.ts +12 -6
- package/src/server/hub-state.ts +98 -0
- package/src/server/images.ts +2 -2
- package/src/server/index.ts +149 -8
- package/src/server/management/api-access.ts +14 -3
- package/src/server/management/config-routes.ts +2 -2
- package/src/server/management/cursor-integration-routes.ts +13 -4
- package/src/server/management/logs-usage-routes.ts +4 -1
- package/src/server/management/model-rows.ts +16 -1
- package/src/server/management/oauth-account-routes.ts +6 -2
- package/src/server/management/provider-routes.ts +9 -2
- package/src/server/management/request-history-routes.ts +4 -2
- package/src/server/management/route-registry.ts +5 -4
- package/src/server/management/shared.ts +66 -3
- package/src/server/management-api.ts +1 -1
- package/src/server/proxy-liveness.ts +7 -1
- package/src/server/request-decompress.ts +91 -3
- package/src/server/request-log-conversation.ts +41 -1
- package/src/server/request-log.ts +10 -0
- package/src/server/responses/codex-auth-error.ts +18 -1
- package/src/server/responses/codex-ws-exchange.ts +36 -4
- package/src/server/responses/codex-ws-wire.ts +76 -5
- package/src/server/responses/compact.ts +28 -11
- package/src/server/responses/context-overflow.ts +11 -0
- package/src/server/responses/core.ts +201 -48
- package/src/server/responses/policy-fallback.ts +13 -3
- package/src/server/search.ts +2 -2
- package/src/server/system-env-shell.ts +14 -2
- package/src/server/system-env.ts +106 -14
- package/src/service.ts +965 -68
- package/src/types/accounts.ts +18 -0
- package/src/types/config.ts +93 -4
- package/src/types/provider.ts +56 -0
- package/src/types.ts +4 -0
- package/src/update/badge.ts +3 -2
- package/src/update/index.ts +317 -64
- package/src/update/install-detection.d.mts +6 -0
- package/src/update/install-detection.mjs +73 -0
- package/src/update/job.ts +101 -49
- package/src/update/pnpm-global-install.d.mts +144 -0
- package/src/update/pnpm-global-install.mjs +591 -0
- package/src/update/pnpm-invocation.d.mts +43 -0
- package/src/update/pnpm-invocation.mjs +141 -0
- package/src/update/registry-integrity.d.mts +16 -0
- package/src/update/registry-integrity.mjs +37 -0
- package/src/update/transactional-install.d.mts +1 -1
- package/src/update/transactional-install.mjs +101 -7
- package/src/update/tray-update-plan.mjs +1 -1
- package/src/vision/plan.ts +13 -3
- package/src/vision/routed-describe.ts +51 -20
- package/src/web-search/ollama-executor.ts +127 -0
- package/src/web-search/passthrough-bridge.ts +761 -0
- package/gui/dist/assets/index-BtyONQrZ.js +0 -115
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
getCodexAccountCredential,
|
|
8
8
|
getValidCodexToken,
|
|
9
9
|
isCodexAccountGenerationLive,
|
|
10
|
+
readCodexAccountRecord,
|
|
10
11
|
} from "./account-store";
|
|
11
12
|
import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
|
|
12
13
|
import { ConfigMutationLockError } from "../config";
|
|
@@ -200,6 +201,19 @@ export class CodexPoolAuthenticationError extends Error {
|
|
|
200
201
|
}
|
|
201
202
|
}
|
|
202
203
|
|
|
204
|
+
class CodexAccountValidationPendingError extends CodexPoolAuthenticationError {
|
|
205
|
+
constructor() {
|
|
206
|
+
super("Codex account validation is pending; refresh quota after recovery to validate it");
|
|
207
|
+
this.name = "CodexAccountValidationPendingError";
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function assertCodexAccountValidationReady(accountId: string): void {
|
|
212
|
+
if (accountId !== MAIN_CODEX_ACCOUNT_ID && readCodexAccountRecord(accountId)?.codexValidationPending) {
|
|
213
|
+
throw new CodexAccountValidationPendingError();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
203
217
|
export const CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE =
|
|
204
218
|
"OpenCodex local native-main profile maintenance is active; retry this request";
|
|
205
219
|
|
|
@@ -585,6 +599,7 @@ export class CodexThreadAffinityExpiredError extends Error {
|
|
|
585
599
|
|
|
586
600
|
export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown): boolean {
|
|
587
601
|
return !(cause instanceof CodexMainAccountHardLockError)
|
|
602
|
+
&& !(cause instanceof CodexAccountValidationPendingError)
|
|
588
603
|
&& !(cause instanceof CodexReserveUnavailableError)
|
|
589
604
|
&& !(cause instanceof CodexCredentialGenerationConflictError)
|
|
590
605
|
&& !(cause instanceof CodexCredentialRefreshLockTimeoutError)
|
|
@@ -911,6 +926,9 @@ export async function resolveCodexAuthContext(
|
|
|
911
926
|
} finally {
|
|
912
927
|
selectionAdmission?.release();
|
|
913
928
|
}
|
|
929
|
+
// Legacy selectors may retain an unusable account for actionable errors. A
|
|
930
|
+
// deferred credential must never become request auth through that fallback.
|
|
931
|
+
assertCodexAccountValidationReady(accountId);
|
|
914
932
|
// Lazy prime: if the selected account has no quota yet, the pool is likely
|
|
915
933
|
// unprimed (dashboard never opened, or startup prime was blocked). Kick a
|
|
916
934
|
// best-effort prime so the NEXT routing decision has real scores. This never
|
|
@@ -1006,6 +1024,7 @@ export async function resolveCodexAuthContext(
|
|
|
1006
1024
|
|
|
1007
1025
|
try {
|
|
1008
1026
|
const token = await getValidCodexToken(accountId);
|
|
1027
|
+
assertCodexAccountValidationReady(accountId);
|
|
1009
1028
|
return {
|
|
1010
1029
|
kind: "pool",
|
|
1011
1030
|
accountId,
|
|
@@ -1045,6 +1064,7 @@ export function applyCodexAuthContextToProvider(
|
|
|
1045
1064
|
mode: CodexAccountMode | undefined,
|
|
1046
1065
|
): OcxRuntimeProviderConfig {
|
|
1047
1066
|
if (mode !== "pool" || (ctx.kind !== "pool" && ctx.kind !== "main-pool") || provider.authMode !== "forward") return provider;
|
|
1067
|
+
assertCodexAccountValidationReady(ctx.accountId);
|
|
1048
1068
|
return {
|
|
1049
1069
|
...provider,
|
|
1050
1070
|
_codexAccountOverride: {
|
|
@@ -1087,6 +1107,7 @@ export function materializeCodexUpstreamAuth(
|
|
|
1087
1107
|
if (value) selected.set(name, value);
|
|
1088
1108
|
}
|
|
1089
1109
|
if (ctx.kind === "pool" || ctx.kind === "main-pool") {
|
|
1110
|
+
assertCodexAccountValidationReady(ctx.accountId);
|
|
1090
1111
|
selected.set("authorization", `Bearer ${ctx.accessToken}`);
|
|
1091
1112
|
selected.set("chatgpt-account-id", ctx.chatgptAccountId);
|
|
1092
1113
|
if (ctx.kind === "main-pool") {
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
displayCodexRuntimePath,
|
|
47
47
|
persistEffortClamp,
|
|
48
48
|
resolveAndPersistCodexRuntime,
|
|
49
|
+
UNCLAMPABLE_REASONING_EFFORTS,
|
|
49
50
|
type EffortClampDiagnostic,
|
|
50
51
|
} from "../runtime";
|
|
51
52
|
|
|
@@ -354,7 +355,13 @@ export function clampEntryToCodexSupportedEfforts(
|
|
|
354
355
|
? entry.supported_reasoning_levels as Array<{ effort?: string }>
|
|
355
356
|
: null;
|
|
356
357
|
if (levels && levels.length > 0) {
|
|
357
|
-
|
|
358
|
+
// A rung survives when the observed runtime offers it OR when it is one of the rungs the
|
|
359
|
+
// clamp no longer removes (max/ultra, per the unconditional-emission ruling): CLI versions
|
|
360
|
+
// that genuinely lack them are out of support, and hiding them from current clients costs
|
|
361
|
+
// more than it buys. Hub admission is a different question and stays fail-closed in
|
|
362
|
+
// `catalogEffortCompatibility` below.
|
|
363
|
+
const kept = levels.filter(level => typeof level?.effort === "string"
|
|
364
|
+
&& (supported.has(level.effort) || UNCLAMPABLE_REASONING_EFFORTS.has(level.effort)));
|
|
358
365
|
if (requiresExactReserveEfforts(entry)) {
|
|
359
366
|
entry.supported_reasoning_levels = kept;
|
|
360
367
|
if (kept.length === 0) {
|
|
@@ -375,12 +382,19 @@ export function clampEntryToCodexSupportedEfforts(
|
|
|
375
382
|
.map(level => ({ ...level }));
|
|
376
383
|
}
|
|
377
384
|
const currentDefault = entry.default_reasoning_level;
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
385
|
+
const surviving = (Array.isArray(entry.supported_reasoning_levels) ? entry.supported_reasoning_levels : [])
|
|
386
|
+
.flatMap(level => typeof (level as { effort?: string })?.effort === "string"
|
|
387
|
+
? [(level as { effort: string }).effort]
|
|
388
|
+
: []);
|
|
389
|
+
// An exempt default survives only when the surviving ladder actually advertises it;
|
|
390
|
+
// otherwise the row would name a default the client cannot select (review: PR #4257).
|
|
391
|
+
if (typeof currentDefault === "string"
|
|
392
|
+
&& !supported.has(currentDefault)) {
|
|
393
|
+
const exemptAndAdvertised = UNCLAMPABLE_REASONING_EFFORTS.has(currentDefault)
|
|
394
|
+
&& surviving.includes(currentDefault);
|
|
395
|
+
if (!exemptAndAdvertised) {
|
|
396
|
+
entry.default_reasoning_level = clampedDefaultEffort(currentDefault, surviving);
|
|
397
|
+
}
|
|
384
398
|
}
|
|
385
399
|
}
|
|
386
400
|
|
|
@@ -389,6 +403,47 @@ export interface ObservedCatalogEffortClamp {
|
|
|
389
403
|
readonly affectedModels: readonly string[];
|
|
390
404
|
}
|
|
391
405
|
|
|
406
|
+
export interface CatalogEffortCompatibility {
|
|
407
|
+
readonly compatible: boolean;
|
|
408
|
+
readonly unsupportedEfforts: readonly string[];
|
|
409
|
+
readonly affectedModels: readonly string[];
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Report which reasoning efforts in a catalog the local Codex runtime would reject, without
|
|
414
|
+
* changing anything.
|
|
415
|
+
*
|
|
416
|
+
* The clamp above is mutate-and-continue, which is right when this process owns the file it
|
|
417
|
+
* is about to write. It is wrong for a catalog downloaded from a hub: rewriting it locally
|
|
418
|
+
* would make the client disagree with hub truth, and #4207 asks for the opposite — establish
|
|
419
|
+
* compatibility first, and refuse rather than materialise a catalog the local CLI cannot
|
|
420
|
+
* parse. `supported` of null means the runtime ladder could not be observed, which is not
|
|
421
|
+
* evidence of incompatibility, so nothing is reported.
|
|
422
|
+
*/
|
|
423
|
+
export function catalogEffortCompatibility(
|
|
424
|
+
models: readonly RawEntry[],
|
|
425
|
+
supported: ReadonlySet<string> | null,
|
|
426
|
+
): CatalogEffortCompatibility {
|
|
427
|
+
if (!supported) return { compatible: true, unsupportedEfforts: [], affectedModels: [] };
|
|
428
|
+
const unsupported = new Set<string>();
|
|
429
|
+
const affected: string[] = [];
|
|
430
|
+
for (const entry of models) {
|
|
431
|
+
const rejected = catalogEntryEfforts(entry).filter(effort => !supported.has(effort));
|
|
432
|
+
const fallback = typeof entry.default_reasoning_level === "string"
|
|
433
|
+
&& !supported.has(entry.default_reasoning_level)
|
|
434
|
+
? [entry.default_reasoning_level]
|
|
435
|
+
: [];
|
|
436
|
+
if (rejected.length === 0 && fallback.length === 0) continue;
|
|
437
|
+
for (const effort of [...rejected, ...fallback]) unsupported.add(effort);
|
|
438
|
+
if (typeof entry.slug === "string") affected.push(entry.slug);
|
|
439
|
+
}
|
|
440
|
+
return {
|
|
441
|
+
compatible: unsupported.size === 0,
|
|
442
|
+
unsupportedEfforts: [...unsupported].sort(),
|
|
443
|
+
affectedModels: affected,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
392
447
|
/** Apply an already-observed runtime ladder without probing, logging, or writing diagnostics. */
|
|
393
448
|
export function clampCatalogModelsToObservedCodexSupport(
|
|
394
449
|
models: RawEntry[],
|
|
@@ -425,7 +480,11 @@ export function clampCatalogModelsToObservedCodexSupport(
|
|
|
425
480
|
const omitted = requiresExactReserveEfforts(entry) && hadLadder && after.size === 0;
|
|
426
481
|
if (lost.length > 0 || defaultClamped || omitted) {
|
|
427
482
|
for (const effort of lost) removed.add(effort);
|
|
428
|
-
|
|
483
|
+
// An orphaned exempt default (ultra with no ultra rung in the ladder) is repaired for
|
|
484
|
+
// coherence, but nothing was removed from the offering — do not name it in the diagnostic.
|
|
485
|
+
if (defaultClamped && beforeDefault && !UNCLAMPABLE_REASONING_EFFORTS.has(beforeDefault)) {
|
|
486
|
+
removed.add(beforeDefault);
|
|
487
|
+
}
|
|
429
488
|
if (typeof entry.slug === "string") affected.push(entry.slug);
|
|
430
489
|
}
|
|
431
490
|
if (omitted) models.splice(index, 1);
|
|
@@ -144,6 +144,29 @@ export interface CatalogModel {
|
|
|
144
144
|
codexToolMode?: "code_mode_only" | "shell";
|
|
145
145
|
/** Normalized upstream capability names retained for management/API consumers (#485 follow-up). */
|
|
146
146
|
capabilities?: string[];
|
|
147
|
+
/**
|
|
148
|
+
* This row is listed but cannot currently serve a request (#1711). Today the only value is
|
|
149
|
+
* "no_credit", set when every usable target has positive quota-exhaustion evidence.
|
|
150
|
+
*
|
|
151
|
+
* It is NOT visibility. The row stays `visibility: "list"` on purpose: the issue explicitly
|
|
152
|
+
* rejects hiding, and Codex Desktop only understands "list" and "hide" anyway, so hiding would
|
|
153
|
+
* be the one outcome the reporter asked not to have. An OpenCodex-aware consumer greys the
|
|
154
|
+
* entry; the native picker ignores the field, which is the honest limit of what a custom
|
|
155
|
+
* catalog field can do.
|
|
156
|
+
*/
|
|
157
|
+
quotaInactiveReason?: "no_credit";
|
|
158
|
+
/**
|
|
159
|
+
* Discovered per-token cost class for this routed model (#3666). "free" means the provider's
|
|
160
|
+
* own /models row reported a numeric zero for BOTH the prompt and the completion rate;
|
|
161
|
+
* "paid" means at least one rate is above zero. ABSENT means unknown — the provider published
|
|
162
|
+
* no usable pair, or this row never came from a models API at all.
|
|
163
|
+
*
|
|
164
|
+
* Fail closed: a partial, non-numeric, or negative rate leaves the field absent, never "free",
|
|
165
|
+
* because showing a paid model under a Free filter costs the user money while hiding a free
|
|
166
|
+
* one costs a click. This is a management/Dashboard projection only — deriveEntry never
|
|
167
|
+
* serializes it into the Codex catalog, and it never affects routing or visibility.
|
|
168
|
+
*/
|
|
169
|
+
pricingStatus?: "free" | "paid";
|
|
147
170
|
/** OpenCodex-only catalog ownership marker; Codex ignores the serialized extension field. */
|
|
148
171
|
catalogKind?: typeof CODEX_CUSTOM_MODEL_CATALOG_KIND | typeof CODEX_PROVIDER_MODEL_CATALOG_KIND;
|
|
149
172
|
}
|
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
comboModelId,
|
|
61
61
|
getCombo,
|
|
62
62
|
listComboIds,
|
|
63
|
+
quotaInactiveReason,
|
|
63
64
|
targetKey,
|
|
64
65
|
} from "../../combos";
|
|
65
66
|
import type { NormalizedComboConfig } from "../../combos/types";
|
|
@@ -79,6 +80,7 @@ import {
|
|
|
79
80
|
type ProviderModelsApiItem,
|
|
80
81
|
type ResolvedProviderModelDiscovery,
|
|
81
82
|
} from "../../providers/model-discovery";
|
|
83
|
+
import { extractGoogleAiStudioModelItems } from "../../providers/google-ai-studio-model-discovery";
|
|
82
84
|
import { applyConfiguredHeadersLast, fetchOllamaShowEnrichment, ollamaShowEnrichable } from "../../providers/ollama-show";
|
|
83
85
|
import upstreamModelsSnapshot from "../data/upstream-models.json";
|
|
84
86
|
import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } from "../../lib/admission";
|
|
@@ -1400,6 +1402,51 @@ function modelInputModalities(
|
|
|
1400
1402
|
return undefined;
|
|
1401
1403
|
}
|
|
1402
1404
|
|
|
1405
|
+
/**
|
|
1406
|
+
* A per-token rate exactly as a /models row publishes it, or undefined when the value is not a
|
|
1407
|
+
* usable non-negative number. Providers ship these both as JSON numbers and as decimal strings —
|
|
1408
|
+
* OpenRouter encodes free as the string `"0.00000000"` — so both shapes are accepted and nothing
|
|
1409
|
+
* else is. The explicit numeric-shape test has to run BEFORE any coercion: `Number("")` and
|
|
1410
|
+
* `Number(" ")` are both 0 and `Number(true)` is 1, so a bare `Number(value)` would classify a
|
|
1411
|
+
* row with an empty price string as free.
|
|
1412
|
+
*/
|
|
1413
|
+
const DISCOVERED_PRICING_RATE_PATTERN = /^-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?$/;
|
|
1414
|
+
|
|
1415
|
+
function discoveredPricingRate(value: unknown): number | undefined {
|
|
1416
|
+
const numeric = typeof value === "number"
|
|
1417
|
+
? value
|
|
1418
|
+
: typeof value === "string" && DISCOVERED_PRICING_RATE_PATTERN.test(value.trim())
|
|
1419
|
+
? Number(value.trim())
|
|
1420
|
+
: undefined;
|
|
1421
|
+
if (numeric === undefined || !Number.isFinite(numeric) || numeric < 0) return undefined;
|
|
1422
|
+
return numeric;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/**
|
|
1426
|
+
* Cost class for one discovered row, read from the provider's own `pricing` object (#3666).
|
|
1427
|
+
*
|
|
1428
|
+
* Fail closed. Only a complete pair of non-negative numeric rates classifies at all; a missing,
|
|
1429
|
+
* one-sided, non-numeric, or negative rate is "unknown" and therefore excluded from a free-only
|
|
1430
|
+
* filter. Showing a paid model under a Free filter spends the user's money, while hiding a free
|
|
1431
|
+
* one costs a click.
|
|
1432
|
+
*
|
|
1433
|
+
* Two things that look like evidence and are not. A `:free` id suffix is an OpenRouter naming
|
|
1434
|
+
* convention, not a price — Nous ships `:free` slugs on a provider whose `freeTier` is false on
|
|
1435
|
+
* purpose. And the operator's own `modelCosts` overlay is an estimate they typed, not something
|
|
1436
|
+
* the provider published, so a zeroed overlay never reaches this field either.
|
|
1437
|
+
*
|
|
1438
|
+
* Classification is on numeric zero and never on a unit conversion: OpenRouter quotes USD per
|
|
1439
|
+
* token while the cost overlays and the jawcode bundle quote per 1M, and zero is zero in both.
|
|
1440
|
+
*/
|
|
1441
|
+
export function discoveredPricingStatus(item: ProviderModelsApiItem): "free" | "paid" | "unknown" {
|
|
1442
|
+
const pricing = plainRecord(item.pricing) ?? plainRecord(plainRecord(item.metadata)?.pricing);
|
|
1443
|
+
if (!pricing) return "unknown";
|
|
1444
|
+
const prompt = discoveredPricingRate(pricing.prompt ?? pricing.input);
|
|
1445
|
+
const completion = discoveredPricingRate(pricing.completion ?? pricing.output);
|
|
1446
|
+
if (prompt === undefined || completion === undefined) return "unknown";
|
|
1447
|
+
return prompt === 0 && completion === 0 ? "free" : "paid";
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1403
1450
|
export function catalogHintsFromModelsApiItem(providerName: string, item: ProviderModelsApiItem): Partial<CatalogModel> {
|
|
1404
1451
|
const metadata = plainRecord(item.metadata);
|
|
1405
1452
|
const capabilityRecord = plainRecord(metadata?.capabilities) ?? plainRecord(item.capabilities);
|
|
@@ -1464,6 +1511,7 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid
|
|
|
1464
1511
|
: undefined;
|
|
1465
1512
|
const capabilities = modelCapabilities(item);
|
|
1466
1513
|
const inputModalities = modelInputModalities(item, capabilities);
|
|
1514
|
+
const pricingStatus = discoveredPricingStatus(item);
|
|
1467
1515
|
return {
|
|
1468
1516
|
...(contextWindow && contextWindow > 0 ? { contextWindow } : {}),
|
|
1469
1517
|
...(maxInputTokens && maxInputTokens > 0 ? { maxInputTokens } : {}),
|
|
@@ -1471,6 +1519,11 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid
|
|
|
1471
1519
|
...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
|
|
1472
1520
|
...(inputModalities ? { inputModalities } : {}),
|
|
1473
1521
|
...(capabilities ? { capabilities } : {}),
|
|
1522
|
+
// Omitted when the classification is "unknown", following this function's existing
|
|
1523
|
+
// contract that an unknown property is absent rather than present-and-empty. Callers
|
|
1524
|
+
// that need to tell "provider published no prices" from "this build does not classify"
|
|
1525
|
+
// call discoveredPricingStatus directly.
|
|
1526
|
+
...(pricingStatus !== "unknown" ? { pricingStatus } : {}),
|
|
1474
1527
|
};
|
|
1475
1528
|
}
|
|
1476
1529
|
|
|
@@ -1871,7 +1924,14 @@ async function fetchProviderModelsWithAuth(
|
|
|
1871
1924
|
markProviderDiscoveryOk(name, live.length);
|
|
1872
1925
|
return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative");
|
|
1873
1926
|
}
|
|
1874
|
-
const
|
|
1927
|
+
const googleAiStudio = effectiveGoogleMode(name, prov) === "ai-studio"
|
|
1928
|
+
? extractGoogleAiStudioModelItems(bounded.value, discovery.maxModels)
|
|
1929
|
+
: undefined;
|
|
1930
|
+
// Native /v1beta/models wins; a google row served by an OpenAI-compatible
|
|
1931
|
+
// gateway keeps the generic data[] / top-level-array contract.
|
|
1932
|
+
const extracted = googleAiStudio?.ok
|
|
1933
|
+
? googleAiStudio
|
|
1934
|
+
: extractProviderModelItems(bounded.value, discovery);
|
|
1875
1935
|
if (!extracted.ok) {
|
|
1876
1936
|
const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" });
|
|
1877
1937
|
const diagnostic: Record<ModelDiscoveryResponseFailure, string> = {
|
|
@@ -2622,7 +2682,16 @@ async function gatherRoutedModelsUncached(
|
|
|
2622
2682
|
return {
|
|
2623
2683
|
models: models.map(model => {
|
|
2624
2684
|
const displayName = aliasDisplayNames.get(`${model.provider}/${model.id}`);
|
|
2625
|
-
|
|
2685
|
+
// #1711: one stamping point for every row this gather produces — routed, combo, and custom
|
|
2686
|
+
// alike — because it is the only place that has both the finished list and the config the
|
|
2687
|
+
// quota rules need. A combo votes over its own targets; anything else votes over the single
|
|
2688
|
+
// provider that would serve it.
|
|
2689
|
+
const targets = model.provider === COMBO_NAMESPACE
|
|
2690
|
+
? config.combos?.[model.id]?.targets ?? []
|
|
2691
|
+
: [{ provider: model.provider }];
|
|
2692
|
+
const inactive = quotaInactiveReason(config, targets);
|
|
2693
|
+
const named = displayName && !model.displayName ? { ...model, displayName } : model;
|
|
2694
|
+
return inactive ? { ...named, quotaInactiveReason: inactive } : named;
|
|
2626
2695
|
}),
|
|
2627
2696
|
comboOmissions: localOmissions,
|
|
2628
2697
|
providerAuthOutcomes: localProviderAuthOutcomes,
|
|
@@ -42,6 +42,8 @@ import {
|
|
|
42
42
|
resolveCodexModelEntitlements,
|
|
43
43
|
type CodexModelEntitlementSnapshot,
|
|
44
44
|
} from "../model-entitlements";
|
|
45
|
+
import { isAccountNeedsReauth } from "../account-runtime-state";
|
|
46
|
+
import { codexAccountLogLabel, fallbackCodexAccountLogLabel } from "../account-label";
|
|
45
47
|
|
|
46
48
|
|
|
47
49
|
import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, findSupportedNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readCodexCatalogPathForHome, readConfiguredAutoReviewModel, readNativeBaseline } from "./parsing";
|
|
@@ -99,6 +101,12 @@ export const PICKER_ORDER_PRIORITY_BASE = 1_000;
|
|
|
99
101
|
// independent of display order. It does not freeze native advertisements. Absent on unmoved rows.
|
|
100
102
|
export const SPAWN_PRIORITY_FIELD = "opencodex_spawn_priority";
|
|
101
103
|
|
|
104
|
+
// OpenCodex-private catalog field: this row is listed but currently unable to serve (#1711).
|
|
105
|
+
// Codex ignores unknown catalog fields (same as opencodex_catalog_kind and the spawn priority
|
|
106
|
+
// above) and ensureStrictCatalogFields does not strip extras, so this is invisible to the native
|
|
107
|
+
// picker and cannot change what Codex offers. It never touches `visibility`.
|
|
108
|
+
export const CATALOG_INACTIVE_REASON_FIELD = "opencodex_inactive_reason";
|
|
109
|
+
|
|
102
110
|
export type SpawnAgentSurface = "v1" | "v2";
|
|
103
111
|
|
|
104
112
|
export type SubagentRosterExclusionReason =
|
|
@@ -383,6 +391,10 @@ export function deriveEntry(
|
|
|
383
391
|
if (model) applyCatalogMetadata(e, model.provider, model.id, model.contextCap);
|
|
384
392
|
applyCatalogModelMetadata(e, model);
|
|
385
393
|
if (model?.catalogKind) e.opencodex_catalog_kind = model.catalogKind;
|
|
394
|
+
// Additive only. `visibility` is untouched: an inactive row must still be OFFERED, which is
|
|
395
|
+
// the whole point of #1711 — operator disable is what removes rows, and it stays a separate
|
|
396
|
+
// path from this one.
|
|
397
|
+
if (model?.quotaInactiveReason) e[CATALOG_INACTIVE_REASON_FIELD] = model.quotaInactiveReason;
|
|
386
398
|
} else {
|
|
387
399
|
applyNativeOpenAiContextOverride(e, contextCap);
|
|
388
400
|
if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(e);
|
|
@@ -430,6 +442,10 @@ export function deriveEntry(
|
|
|
430
442
|
if (model && isRouted) applyCatalogMetadata(entry, model.provider, model.id, model.contextCap);
|
|
431
443
|
applyCatalogModelMetadata(entry, model);
|
|
432
444
|
if (model?.catalogKind) entry.opencodex_catalog_kind = model.catalogKind;
|
|
445
|
+
// Same additive stamp as the templated path above. A routed row that reaches the no-template
|
|
446
|
+
// fallback is still a served row, so omitting it here would make the field depend on whether a
|
|
447
|
+
// template happened to be cached — which is exactly what the regression test caught.
|
|
448
|
+
if (model?.quotaInactiveReason) entry[CATALOG_INACTIVE_REASON_FIELD] = model.quotaInactiveReason;
|
|
433
449
|
if (!isRouted) applyNativeOpenAiContextOverride(entry, contextCap);
|
|
434
450
|
return ensureStrictCatalogFields(normalizeServiceTiers(entry), {
|
|
435
451
|
preserveExactInputModalities: preserveExact,
|
|
@@ -1694,6 +1710,75 @@ export function finalizeAutoReviewModelOverride(
|
|
|
1694
1710
|
return applyAutoReviewModelOverride(models, readConfiguredAutoReviewModel(), sourceModels);
|
|
1695
1711
|
}
|
|
1696
1712
|
|
|
1713
|
+
/**
|
|
1714
|
+
* Why an account-gated native model stopped being offered, but only when the answer is one the
|
|
1715
|
+
* operator can act on.
|
|
1716
|
+
*
|
|
1717
|
+
* Suppression is an omission: the row is never built, so there is no catalog entry for a reason
|
|
1718
|
+
* to ride on and no downstream consumer that could explain it later. #4212's reporter watched
|
|
1719
|
+
* their models disappear and reasonably concluded the proxy was broken, because every surface
|
|
1720
|
+
* that changed said nothing about the account that caused it.
|
|
1721
|
+
*
|
|
1722
|
+
* Returns `undefined` for the ordinary case — an account that is simply not entitled to a gated
|
|
1723
|
+
* model. That is the default state for most installations, it is not news, and warning about it
|
|
1724
|
+
* on every sync would bury the one case that matters. A credential the operator must repair is
|
|
1725
|
+
* the case that matters, so that is the only one this speaks up about.
|
|
1726
|
+
*
|
|
1727
|
+
* Accounts are named with the durable `p`-prefixed log label, the same identifier the dashboard
|
|
1728
|
+
* shows, never the raw pool id or the email.
|
|
1729
|
+
*/
|
|
1730
|
+
export function gatedNativeReauthSuppressionReason(args: {
|
|
1731
|
+
snapshot: CodexModelEntitlementSnapshot;
|
|
1732
|
+
slug: string;
|
|
1733
|
+
eligibleAccountIds?: ReadonlySet<string>;
|
|
1734
|
+
needsReauth: (accountId: string) => boolean;
|
|
1735
|
+
label: (accountId: string) => string;
|
|
1736
|
+
}): string | undefined {
|
|
1737
|
+
const observed = [...args.snapshot.modelsByAccount.keys()]
|
|
1738
|
+
.filter(accountId => !args.eligibleAccountIds || args.eligibleAccountIds.has(accountId))
|
|
1739
|
+
// Only accounts that could actually have served THIS model. An account upstream positively
|
|
1740
|
+
// denied is not why the model is missing, and blaming it would send the operator to repair a
|
|
1741
|
+
// credential that was never going to help. `unknown` has to stay in: an account whose roster
|
|
1742
|
+
// could not be confirmed reports `unknown` rather than `granted`, and a credential stuck on
|
|
1743
|
+
// a failed refresh is exactly that account.
|
|
1744
|
+
.filter(accountId => (
|
|
1745
|
+
codexModelEntitlementStateForAccount(args.snapshot, accountId, args.slug) !== "denied"
|
|
1746
|
+
));
|
|
1747
|
+
const stuck = observed.filter(accountId => args.needsReauth(accountId));
|
|
1748
|
+
if (stuck.length === 0) return undefined;
|
|
1749
|
+
const names = stuck.map(accountId => args.label(accountId)).sort().join(", ");
|
|
1750
|
+
return stuck.length === observed.length
|
|
1751
|
+
? `every Codex account that could serve it needs reauthentication (${names})`
|
|
1752
|
+
: `${stuck.length} of ${observed.length} Codex accounts that could serve it need reauthentication (${names})`;
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
/** Durable, operator-facing label for a pool account id; never the raw id or the email. */
|
|
1756
|
+
function gatedNativeAccountLabel(config: OcxConfig, accountId: string): string {
|
|
1757
|
+
// Direct mode narrows eligibility to the native main credential, so this is the account most
|
|
1758
|
+
// likely to be named here. `codexAuthContextLogLabel` calls it "main" everywhere else; hashing
|
|
1759
|
+
// it into a `p`-prefixed digest would name the one account the operator cannot look up.
|
|
1760
|
+
if (accountId === MAIN_CODEX_ACCOUNT_ID) return "main";
|
|
1761
|
+
const account = (config.codexAccounts ?? []).find(candidate => candidate.id === accountId);
|
|
1762
|
+
return account ? codexAccountLogLabel(account) : fallbackCodexAccountLogLabel(accountId);
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
const warnedGatedNativeSuppression = new Set<string>();
|
|
1766
|
+
|
|
1767
|
+
/** Test seam: the warn-once memory is process-global, so a case needs to be able to clear it. */
|
|
1768
|
+
export function resetGatedNativeSuppressionWarningsForTests(): void {
|
|
1769
|
+
warnedGatedNativeSuppression.clear();
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
function warnGatedNativeSuppressedOnce(slug: string, reason: string): void {
|
|
1773
|
+
const signature = `${slug}\u0000${reason}`;
|
|
1774
|
+
if (warnedGatedNativeSuppression.has(signature)) return;
|
|
1775
|
+
warnedGatedNativeSuppression.add(signature);
|
|
1776
|
+
console.warn(
|
|
1777
|
+
`[opencodex] catalog sync: ${slug} is not being offered because ${reason}. `
|
|
1778
|
+
+ "Sign in again to restore it.",
|
|
1779
|
+
);
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1697
1782
|
/**
|
|
1698
1783
|
* Mescla o catálogo retido com os modelos visíveis e as configurações atuais,
|
|
1699
1784
|
* incluindo os nomes nativos. Tenta preservar o backup original e usa a permissão
|
|
@@ -1763,6 +1848,20 @@ function writeRetainedCatalogSync({
|
|
|
1763
1848
|
const unavailableGatedNativeSlugs = new Set([...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(slug => (
|
|
1764
1849
|
!availableBareGatedNativeSlugs.has(slug)
|
|
1765
1850
|
)));
|
|
1851
|
+
// #4212: this set is the whole record of a model vanishing, and it is a set of strings that
|
|
1852
|
+
// nothing downstream ever asks a question of. Explain it here, while the entitlement snapshot
|
|
1853
|
+
// that produced it is still in scope, because after this point the model is simply absent and
|
|
1854
|
+
// no later surface can tell "never entitled" apart from "the account broke this morning".
|
|
1855
|
+
for (const slug of unavailableGatedNativeSlugs) {
|
|
1856
|
+
const reason = gatedNativeReauthSuppressionReason({
|
|
1857
|
+
snapshot: modelEntitlements,
|
|
1858
|
+
slug,
|
|
1859
|
+
eligibleAccountIds: bareEligibleAccountIds,
|
|
1860
|
+
needsReauth: isAccountNeedsReauth,
|
|
1861
|
+
label: accountId => gatedNativeAccountLabel(config, accountId),
|
|
1862
|
+
});
|
|
1863
|
+
if (reason) warnGatedNativeSuppressedOnce(slug, reason);
|
|
1864
|
+
}
|
|
1766
1865
|
const suppressedBareNativeSlugs = new Set([
|
|
1767
1866
|
...desktopAllowlistSuppressedNativeSlugs(config),
|
|
1768
1867
|
...unavailableGatedNativeSlugs,
|
|
@@ -66,7 +66,7 @@ export type CodexWriteLockRefusalReason =
|
|
|
66
66
|
|
|
67
67
|
export type CodexWriteLockResult<T> =
|
|
68
68
|
| { status: "acquired"; value: T; waitedMs: number; lockId: string }
|
|
69
|
-
| { status: "skipped"; reason:
|
|
69
|
+
| { status: "skipped"; reason: CodexWriteLockSkipReason; waitedMs: number }
|
|
70
70
|
| { status: "busy"; reason: "deadline" | "cancelled"; retryable: true; waitedMs: number; lockId: string }
|
|
71
71
|
| {
|
|
72
72
|
status: "refused";
|
|
@@ -127,9 +127,18 @@ export interface CodexWriteCommitContext {
|
|
|
127
127
|
readonly coordinator: CodexCoordinatorTransaction;
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Why an under-lock policy re-read refused the write.
|
|
132
|
+
*
|
|
133
|
+
* `hub-gated` is not the user's switch: a hub declines to rewrite its own local clients, and
|
|
134
|
+
* reporting that as "integration is OFF" sent operators hunting for a toggle they never set
|
|
135
|
+
* (#4236).
|
|
136
|
+
*/
|
|
137
|
+
export type CodexWriteLockSkipReason = "desired_disabled" | "desired_enabled" | "hub-gated";
|
|
138
|
+
|
|
130
139
|
/** A synchronous under-lock policy re-read proved the requested apply stale. */
|
|
131
140
|
export class CodexWriteLockSkipped extends Error {
|
|
132
|
-
constructor(readonly reason:
|
|
141
|
+
constructor(readonly reason: CodexWriteLockSkipReason) {
|
|
133
142
|
super(reason);
|
|
134
143
|
this.name = "CodexWriteLockSkipped";
|
|
135
144
|
}
|
|
@@ -76,11 +76,57 @@ type LocalClientSyncConfig = Pick<
|
|
|
76
76
|
"clientIntegrations" | "runtimeRole" | "unauthenticatedLoopbackListener"
|
|
77
77
|
>;
|
|
78
78
|
|
|
79
|
-
function localClientSyncAllowed(config: LocalClientSyncConfig): boolean {
|
|
79
|
+
export function localClientSyncAllowed(config: LocalClientSyncConfig): boolean {
|
|
80
80
|
return config.runtimeRole !== "hub"
|
|
81
81
|
|| config.unauthenticatedLoopbackListener?.enabled === true;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The one sentence every hub-gated skip says (#4236).
|
|
86
|
+
*
|
|
87
|
+
* The gate is a reasonable decision; reporting it as "Codex integration is OFF" was not. An
|
|
88
|
+
* operator whose `clientIntegrations` says nothing — or says `true` — was told their own switch
|
|
89
|
+
* was off, and `ocx restore back` went further and blamed a competing writer that did not
|
|
90
|
+
* exist. Name the gate and name the key that opens it.
|
|
91
|
+
*/
|
|
92
|
+
export const HUB_GATED_SKIP_MESSAGE =
|
|
93
|
+
"This machine is a hub; it does not rewrite its own Codex/Grok/Claude configs unless "
|
|
94
|
+
+ "unauthenticatedLoopbackListener is enabled.";
|
|
95
|
+
|
|
96
|
+
/** Why a local-client write was skipped. The gate outranks the toggle: it is the surprising one. */
|
|
97
|
+
export type LocalClientSkipReason = "desired_disabled" | "hub-gated";
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* "hub-gated" is claimed only when the toggle is ON and the gate is what stopped the write.
|
|
101
|
+
* With the toggle OFF the gate is moot: telling that operator to enable the loopback listener
|
|
102
|
+
* would send them to a key that cannot make the sync happen — the mirror image of the defect
|
|
103
|
+
* this reason exists to fix.
|
|
104
|
+
*/
|
|
105
|
+
export function localClientSkipReason(
|
|
106
|
+
config: LocalClientSyncConfig,
|
|
107
|
+
client: DurableIntentClientId = "codex",
|
|
108
|
+
): LocalClientSkipReason {
|
|
109
|
+
return integrationEnabled(config, client) && !localClientSyncAllowed(config)
|
|
110
|
+
? "hub-gated"
|
|
111
|
+
: "desired_disabled";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Pick the skip message for a snapshot: today's toggle text, or the hub-gate sentence.
|
|
116
|
+
*
|
|
117
|
+
* `hubSuffix` states what the hub-gated path still did (a catalog refresh, say) so the
|
|
118
|
+
* composed line stays as informative as the toggle one it replaces.
|
|
119
|
+
*/
|
|
120
|
+
export function localClientSkipMessage(
|
|
121
|
+
config: LocalClientSyncConfig,
|
|
122
|
+
integrationOffMessage: string,
|
|
123
|
+
hubSuffix?: string,
|
|
124
|
+
client: DurableIntentClientId = "codex",
|
|
125
|
+
): string {
|
|
126
|
+
if (localClientSkipReason(config, client) !== "hub-gated") return integrationOffMessage;
|
|
127
|
+
return hubSuffix ? `${HUB_GATED_SKIP_MESSAGE} ${hubSuffix}` : HUB_GATED_SKIP_MESSAGE;
|
|
128
|
+
}
|
|
129
|
+
|
|
84
130
|
export function shouldSyncCodexOnStart(config: LocalClientSyncConfig): boolean {
|
|
85
131
|
// A hub is a server for OTHER machines: it must not rewrite its own host's
|
|
86
132
|
// Codex/Claude/Grok client configs on startup (interview decision Q6, and the
|
|
@@ -8,7 +8,8 @@ import { createHash } from "node:crypto";
|
|
|
8
8
|
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
9
9
|
|
|
10
10
|
import { atomicWriteFile } from "../config";
|
|
11
|
-
import type { CodexWriteLockResult } from "./codex-write-lock";
|
|
11
|
+
import type { CodexWriteLockResult, CodexWriteLockSkipReason } from "./codex-write-lock";
|
|
12
|
+
import { HUB_GATED_SKIP_MESSAGE } from "./desired-state";
|
|
12
13
|
import { inspectCodexCoordinatorPath } from "./coordinator-doctor";
|
|
13
14
|
import { JOURNAL_PATH } from "./journal";
|
|
14
15
|
import { updateIntegrationRecord } from "./integration-record";
|
|
@@ -443,16 +444,20 @@ export function recomputeInjectWitness(options: {
|
|
|
443
444
|
export function codexInjectLockOutcome(
|
|
444
445
|
result: Exclude<CodexWriteLockResult<unknown>, { status: "acquired" }>,
|
|
445
446
|
): { success: false; message: string; retryable: boolean } | {
|
|
446
|
-
success: true; status: "skipped"; skippedReason:
|
|
447
|
+
success: true; status: "skipped"; skippedReason: CodexWriteLockSkipReason; message: string;
|
|
447
448
|
} {
|
|
448
449
|
if (result.status === "skipped") {
|
|
449
450
|
return {
|
|
450
451
|
success: true,
|
|
451
452
|
status: "skipped",
|
|
452
453
|
skippedReason: result.reason,
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
454
|
+
// Three distinct facts, three sentences. The hub gate in particular must not borrow the
|
|
455
|
+
// toggle's wording — that is the phantom "integration is OFF" report from #4236.
|
|
456
|
+
message: result.reason === "hub-gated"
|
|
457
|
+
? `${HUB_GATED_SKIP_MESSAGE} No Codex config, catalog, cache, or history was changed.`
|
|
458
|
+
: result.reason === "desired_disabled"
|
|
459
|
+
? "Codex integration is OFF; no Codex config, catalog, cache, or history was changed."
|
|
460
|
+
: "Codex integration was re-enabled; native restore was skipped.",
|
|
456
461
|
};
|
|
457
462
|
}
|
|
458
463
|
if (result.status === "busy") {
|