@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.
Files changed (141) hide show
  1. package/AGENTS_INSTALL.md +9 -1
  2. package/README.md +3 -0
  3. package/bin/ocx.mjs +222 -71
  4. package/gui/dist/assets/index-D7BdZpZm.js +115 -0
  5. package/gui/dist/index.html +1 -1
  6. package/package.json +1 -1
  7. package/src/adapters/qoder/adapter.ts +69 -1
  8. package/src/adapters/qoder/scaffold-guard.ts +233 -0
  9. package/src/claude/agents-inject.ts +29 -5
  10. package/src/claude/desktop-3p.ts +31 -3
  11. package/src/claude/gateway-cache.ts +12 -21
  12. package/src/claude/inbound.ts +17 -5
  13. package/src/cli/account-api.ts +18 -3
  14. package/src/cli/account-auth.ts +8 -1
  15. package/src/cli/account-extended.ts +2 -1
  16. package/src/cli/account.ts +1 -0
  17. package/src/cli/capabilities.ts +43 -1
  18. package/src/cli/claude-agent-startup-sync.ts +26 -1
  19. package/src/cli/claude.ts +138 -20
  20. package/src/cli/config-command.ts +67 -1
  21. package/src/cli/connect.ts +181 -14
  22. package/src/cli/dispatch.ts +53 -9
  23. package/src/cli/doctor.ts +9 -2
  24. package/src/cli/ensure-desired-integrations.ts +10 -0
  25. package/src/cli/gui-pair-client.ts +1 -12
  26. package/src/cli/help.ts +4 -1
  27. package/src/cli/hub.ts +367 -0
  28. package/src/cli/index.ts +99 -31
  29. package/src/cli/launcher-context.ts +1 -1
  30. package/src/cli/models-runtime.ts +8 -3
  31. package/src/cli/observe.ts +13 -3
  32. package/src/cli/registry.ts +43 -3
  33. package/src/cli/status.ts +325 -5
  34. package/src/cli/version-skew.ts +4 -1
  35. package/src/cli.ts +2 -2
  36. package/src/client/catalog-compatibility.ts +192 -0
  37. package/src/client/connect.ts +31 -0
  38. package/src/client/hub-client.ts +52 -0
  39. package/src/client/hub-state.ts +214 -0
  40. package/src/clients/config-export/zcode.ts +24 -0
  41. package/src/codex/account-runtime-state.ts +6 -1
  42. package/src/codex/account-store.ts +72 -9
  43. package/src/codex/account-usability.ts +50 -13
  44. package/src/codex/auth-api.ts +156 -28
  45. package/src/codex/auth-context.ts +21 -0
  46. package/src/codex/catalog/effort.ts +67 -8
  47. package/src/codex/catalog/parsing.ts +23 -0
  48. package/src/codex/catalog/provider-fetch.ts +71 -2
  49. package/src/codex/catalog/sync.ts +99 -0
  50. package/src/codex/codex-write-lock.ts +11 -2
  51. package/src/codex/desired-state.ts +47 -1
  52. package/src/codex/inject-coordination.ts +10 -5
  53. package/src/codex/inject.ts +29 -12
  54. package/src/codex/loopback-target.ts +45 -0
  55. package/src/codex/quota-auto-refresh.ts +6 -1
  56. package/src/codex/quota.ts +54 -8
  57. package/src/codex/routing.ts +48 -1
  58. package/src/codex/runtime.ts +37 -3
  59. package/src/codex/sync.ts +29 -9
  60. package/src/codex/warmup.ts +21 -4
  61. package/src/combos/index.ts +2 -0
  62. package/src/combos/resolve.ts +52 -0
  63. package/src/config/pending-teardown.ts +1 -1
  64. package/src/config.ts +184 -12
  65. package/src/generated/compatibility-version.json +188 -116
  66. package/src/grok/status.ts +9 -1
  67. package/src/integrations/config-io.ts +54 -1
  68. package/src/lib/bun-runtime.ts +1 -1
  69. package/src/lib/errors.ts +8 -0
  70. package/src/lib/gui-pair-capability.ts +27 -0
  71. package/src/lib/local-destinations.ts +162 -0
  72. package/src/lib/package-tree-integrity.ts +1 -1
  73. package/src/lib/privacy.ts +25 -0
  74. package/src/lib/process-control.ts +130 -20
  75. package/src/lib/service-secrets.ts +28 -0
  76. package/src/lib/test-home-guard.ts +49 -0
  77. package/src/oauth/health.ts +47 -12
  78. package/src/oauth/index.ts +46 -8
  79. package/src/oauth/token-guardian.ts +32 -6
  80. package/src/providers/google-ai-studio-model-discovery.ts +74 -0
  81. package/src/providers/opencode-go-transport.ts +9 -1
  82. package/src/providers/opencode-zen-rate-limit.ts +75 -0
  83. package/src/providers/quota.ts +20 -1
  84. package/src/providers/registry.ts +35 -6
  85. package/src/remote/hub-state.ts +182 -0
  86. package/src/server/auth-cors.ts +11 -0
  87. package/src/server/chat-completions.ts +10 -7
  88. package/src/server/chat-native.ts +10 -1
  89. package/src/server/claude-messages.ts +12 -6
  90. package/src/server/hub-state.ts +98 -0
  91. package/src/server/images.ts +2 -2
  92. package/src/server/index.ts +149 -8
  93. package/src/server/management/api-access.ts +14 -3
  94. package/src/server/management/config-routes.ts +2 -2
  95. package/src/server/management/cursor-integration-routes.ts +13 -4
  96. package/src/server/management/logs-usage-routes.ts +4 -1
  97. package/src/server/management/model-rows.ts +16 -1
  98. package/src/server/management/oauth-account-routes.ts +6 -2
  99. package/src/server/management/provider-routes.ts +9 -2
  100. package/src/server/management/request-history-routes.ts +4 -2
  101. package/src/server/management/route-registry.ts +5 -4
  102. package/src/server/management/shared.ts +66 -3
  103. package/src/server/management-api.ts +1 -1
  104. package/src/server/proxy-liveness.ts +7 -1
  105. package/src/server/request-decompress.ts +91 -3
  106. package/src/server/request-log-conversation.ts +41 -1
  107. package/src/server/request-log.ts +10 -0
  108. package/src/server/responses/codex-auth-error.ts +18 -1
  109. package/src/server/responses/codex-ws-exchange.ts +36 -4
  110. package/src/server/responses/codex-ws-wire.ts +76 -5
  111. package/src/server/responses/compact.ts +28 -11
  112. package/src/server/responses/context-overflow.ts +11 -0
  113. package/src/server/responses/core.ts +201 -48
  114. package/src/server/responses/policy-fallback.ts +13 -3
  115. package/src/server/search.ts +2 -2
  116. package/src/server/system-env-shell.ts +14 -2
  117. package/src/server/system-env.ts +106 -14
  118. package/src/service.ts +965 -68
  119. package/src/types/accounts.ts +18 -0
  120. package/src/types/config.ts +93 -4
  121. package/src/types/provider.ts +56 -0
  122. package/src/types.ts +4 -0
  123. package/src/update/badge.ts +3 -2
  124. package/src/update/index.ts +317 -64
  125. package/src/update/install-detection.d.mts +6 -0
  126. package/src/update/install-detection.mjs +73 -0
  127. package/src/update/job.ts +101 -49
  128. package/src/update/pnpm-global-install.d.mts +144 -0
  129. package/src/update/pnpm-global-install.mjs +591 -0
  130. package/src/update/pnpm-invocation.d.mts +43 -0
  131. package/src/update/pnpm-invocation.mjs +141 -0
  132. package/src/update/registry-integrity.d.mts +16 -0
  133. package/src/update/registry-integrity.mjs +37 -0
  134. package/src/update/transactional-install.d.mts +1 -1
  135. package/src/update/transactional-install.mjs +101 -7
  136. package/src/update/tray-update-plan.mjs +1 -1
  137. package/src/vision/plan.ts +13 -3
  138. package/src/vision/routed-describe.ts +51 -20
  139. package/src/web-search/ollama-executor.ts +127 -0
  140. package/src/web-search/passthrough-bridge.ts +761 -0
  141. 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
- const kept = levels.filter(level => typeof level?.effort === "string" && supported.has(level.effort));
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
- if (typeof currentDefault === "string" && !supported.has(currentDefault)) {
379
- const surviving = (Array.isArray(entry.supported_reasoning_levels) ? entry.supported_reasoning_levels : [])
380
- .flatMap(level => typeof (level as { effort?: string })?.effort === "string"
381
- ? [(level as { effort: string }).effort]
382
- : []);
383
- entry.default_reasoning_level = clampedDefaultEffort(currentDefault, surviving);
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
- if (defaultClamped && beforeDefault) removed.add(beforeDefault);
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 extracted = extractProviderModelItems(bounded.value, discovery);
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
- return displayName && !model.displayName ? { ...model, displayName } : model;
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: "desired_disabled" | "desired_enabled"; waitedMs: number }
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: "desired_disabled" | "desired_enabled") {
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: "desired_disabled" | "desired_enabled"; message: string;
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
- message: result.reason === "desired_disabled"
454
- ? "Codex integration is OFF; no Codex config, catalog, cache, or history was changed."
455
- : "Codex integration was re-enabled; native restore was skipped.",
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") {