@bitkyc08/opencodex 2.48.0 → 2.50.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 +11 -5
  3. package/SPONSORS.md +1 -1
  4. package/assets/sponsors/orcarouter.png +0 -0
  5. package/assets/sponsors/packycode.png +0 -0
  6. package/gui/dist/assets/index-BoBRSehJ.css +1 -0
  7. package/gui/dist/assets/index-C39tnjXO.js +115 -0
  8. package/gui/dist/index.html +2 -2
  9. package/gui/dist/provider-icons/packycode.svg +19 -0
  10. package/gui/dist/provider-icons/qoder.svg +5 -0
  11. package/package.json +5 -3
  12. package/src/adapters/anthropic.ts +31 -16
  13. package/src/adapters/codebuddy/adapter.ts +85 -0
  14. package/src/adapters/codebuddy/profiles.ts +52 -0
  15. package/src/adapters/coding-agent/profile.ts +100 -0
  16. package/src/adapters/coding-agent/protocol.ts +463 -0
  17. package/src/adapters/coding-agent/turn.ts +353 -0
  18. package/src/adapters/google.ts +15 -11
  19. package/src/adapters/mimo-free.ts +3 -0
  20. package/src/adapters/openai-chat.ts +2 -2
  21. package/src/adapters/openai-responses.ts +18 -11
  22. package/src/adapters/qoder/adapter.ts +70 -0
  23. package/src/adapters/qoder/live-models.ts +89 -0
  24. package/src/adapters/qoder/profiles.ts +36 -0
  25. package/src/adapters/registry.ts +12 -0
  26. package/src/adapters/responses-tool-schema.ts +113 -8
  27. package/src/claude/inbound.ts +17 -5
  28. package/src/cli/account-api.ts +18 -3
  29. package/src/cli/account-auth.ts +8 -1
  30. package/src/cli/account-extended.ts +2 -1
  31. package/src/cli/account.ts +1 -0
  32. package/src/cli/capabilities.ts +15 -1
  33. package/src/cli/dispatch.ts +2 -0
  34. package/src/cli/doctor.ts +40 -0
  35. package/src/cli/effort.ts +24 -8
  36. package/src/cli/help.ts +2 -0
  37. package/src/cli/index.ts +29 -2
  38. package/src/cli/models-runtime.ts +8 -3
  39. package/src/cli/observe.ts +13 -3
  40. package/src/cli/provider-runtime.ts +2 -1
  41. package/src/cli/registry.ts +2 -2
  42. package/src/cli/system-command.ts +10 -3
  43. package/src/cli/usage-report.ts +9 -5
  44. package/src/clients/config-export/zcode.ts +24 -0
  45. package/src/codex/account-lifecycle.ts +35 -2
  46. package/src/codex/account-runtime-state.ts +6 -1
  47. package/src/codex/account-store.ts +72 -9
  48. package/src/codex/account-usability.ts +3 -2
  49. package/src/codex/auth-api.ts +113 -26
  50. package/src/codex/auth-collision.ts +12 -2
  51. package/src/codex/auth-context.ts +96 -7
  52. package/src/codex/catalog/parsing.ts +23 -0
  53. package/src/codex/catalog/provider-fetch.ts +144 -11
  54. package/src/codex/catalog/sync.ts +14 -0
  55. package/src/codex/inject.ts +128 -30
  56. package/src/codex/internal/catalog-writer.ts +3 -0
  57. package/src/codex/journal.ts +61 -12
  58. package/src/codex/model-cache.ts +11 -4
  59. package/src/codex/native-profile-startup.ts +72 -5
  60. package/src/codex/native-profile-store.ts +2 -2
  61. package/src/codex/ocx-compaction-history.ts +226 -0
  62. package/src/codex/project-config-warnings.ts +3 -1
  63. package/src/codex/quota-auto-refresh.ts +6 -1
  64. package/src/codex/quota.ts +71 -15
  65. package/src/codex/reserve-availability.ts +21 -5
  66. package/src/codex/runtime.ts +45 -1
  67. package/src/codex/sync.ts +5 -0
  68. package/src/combos/index.ts +2 -0
  69. package/src/combos/resolve.ts +52 -0
  70. package/src/config.ts +59 -0
  71. package/src/generated/compatibility-version.json +178 -114
  72. package/src/images/loop.ts +1 -0
  73. package/src/images/xai-video-client.ts +2 -0
  74. package/src/integrations/registry.ts +1 -0
  75. package/src/lib/errors.ts +8 -0
  76. package/src/lib/privacy.ts +25 -0
  77. package/src/lib/process-control.ts +52 -8
  78. package/src/lib/upstream-retry.ts +1 -0
  79. package/src/oauth/chatgpt.ts +83 -0
  80. package/src/oauth/health.ts +47 -12
  81. package/src/oauth/index.ts +46 -8
  82. package/src/oauth/token-guardian.ts +32 -6
  83. package/src/oauth/xai.ts +151 -8
  84. package/src/providers/api-key-selection-capture.ts +10 -0
  85. package/src/providers/api-key-selection.ts +2 -7
  86. package/src/providers/caller-authorization.ts +36 -0
  87. package/src/providers/codebuddy-models.ts +184 -0
  88. package/src/providers/derive.ts +5 -0
  89. package/src/providers/free-directory.ts +26 -2
  90. package/src/providers/google-ai-studio-model-discovery.ts +74 -0
  91. package/src/providers/openai-sidecar.ts +35 -11
  92. package/src/providers/opencode-zen-rate-limit.ts +75 -0
  93. package/src/providers/qoder-models.ts +25 -0
  94. package/src/providers/quota.ts +15 -0
  95. package/src/providers/registry.ts +140 -1
  96. package/src/responses/compaction.ts +4 -0
  97. package/src/responses/task-input.ts +21 -1
  98. package/src/router.ts +1 -1
  99. package/src/server/auth-cors.ts +6 -0
  100. package/src/server/chat-completions.ts +30 -13
  101. package/src/server/chat-native.ts +10 -1
  102. package/src/server/claude-messages.ts +17 -7
  103. package/src/server/images.ts +3 -2
  104. package/src/server/index.ts +25 -2
  105. package/src/server/management/account-selection-stream.ts +13 -4
  106. package/src/server/management/config-routes.ts +24 -5
  107. package/src/server/management/logs-usage-routes.ts +5 -1
  108. package/src/server/management/model-rows.ts +16 -1
  109. package/src/server/management/native-integration-routes.ts +2 -1
  110. package/src/server/management/oauth-account-routes.ts +6 -2
  111. package/src/server/management/provider-routes.ts +33 -2
  112. package/src/server/management/request-history-routes.ts +4 -2
  113. package/src/server/management/route-registry.ts +5 -4
  114. package/src/server/management/shared.ts +66 -3
  115. package/src/server/management-api.ts +15 -1
  116. package/src/server/port-reclaim.ts +11 -26
  117. package/src/server/request-decompress.ts +91 -3
  118. package/src/server/request-log.ts +16 -0
  119. package/src/server/responses/codex-ws-wire.ts +1 -1
  120. package/src/server/responses/collaboration.ts +4 -9
  121. package/src/server/responses/compact.ts +8 -2
  122. package/src/server/responses/context-overflow.ts +11 -0
  123. package/src/server/responses/core.ts +285 -57
  124. package/src/server/responses/fetch-helpers.ts +18 -7
  125. package/src/server/responses/policy-fallback.ts +18 -2
  126. package/src/server/search.ts +2 -2
  127. package/src/service.ts +128 -9
  128. package/src/storage/cleanup.ts +77 -45
  129. package/src/types/accounts.ts +18 -0
  130. package/src/types/config.ts +43 -1
  131. package/src/types/provider.ts +56 -0
  132. package/src/types.ts +4 -0
  133. package/src/usage/log.ts +24 -0
  134. package/src/vision/anthropic-describe.ts +1 -0
  135. package/src/web-search/anthropic-executor.ts +1 -0
  136. package/src/web-search/loop.ts +1 -0
  137. package/src/web-search/ollama-executor.ts +127 -0
  138. package/src/web-search/passthrough-bridge.ts +761 -0
  139. package/src/web-search/progress-stream.ts +4 -0
  140. package/gui/dist/assets/index-B5r7LNHN.js +0 -115
  141. package/gui/dist/assets/index-D5SiRo8X.css +0 -1
@@ -52,12 +52,15 @@ import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity";
52
52
  import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery";
53
53
  import { fetchCursorUsableModels } from "../../adapters/cursor/live-models";
54
54
  import { recordLiveCursorClaudeModels, recordLiveCursorMaxModeModels } from "../../adapters/cursor/catalog";
55
+ import { fetchQoderModels } from "../../adapters/qoder/live-models";
56
+ import { resolveQoderProfile } from "../../adapters/qoder/profiles";
55
57
  import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
56
58
  import {
57
59
  COMBO_NAMESPACE,
58
60
  comboModelId,
59
61
  getCombo,
60
62
  listComboIds,
63
+ quotaInactiveReason,
61
64
  targetKey,
62
65
  } from "../../combos";
63
66
  import type { NormalizedComboConfig } from "../../combos/types";
@@ -77,6 +80,7 @@ import {
77
80
  type ProviderModelsApiItem,
78
81
  type ResolvedProviderModelDiscovery,
79
82
  } from "../../providers/model-discovery";
83
+ import { extractGoogleAiStudioModelItems } from "../../providers/google-ai-studio-model-discovery";
80
84
  import { applyConfiguredHeadersLast, fetchOllamaShowEnrichment, ollamaShowEnrichable } from "../../providers/ollama-show";
81
85
  import upstreamModelsSnapshot from "../data/upstream-models.json";
82
86
  import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } from "../../lib/admission";
@@ -952,16 +956,21 @@ function comboMemberVendorMetadata(provider: string, modelId: string): ModelMeta
952
956
  */
953
957
  function vendorMetadataComboFallback(target: { provider: string; model: string }): ComboCatalogMemberFallback | undefined {
954
958
  const metadataProvider = resolveMetadataProvider(target.provider);
955
- const metadata = metadataProvider ? comboMemberVendorMetadata(metadataProvider, target.model) : undefined;
959
+ // Custom OpenAI-compatible routes commonly retain the canonical OpenAI model id
960
+ // while using a provider name that has no metadata alias. Reuse only its effort
961
+ // ladder below; context/modality rows remain provider-owned.
962
+ const metadata = metadataProvider
963
+ ? comboMemberVendorMetadata(metadataProvider, target.model)
964
+ : comboMemberVendorMetadata("openai", target.model);
956
965
  if (!metadata) return undefined;
957
966
  return {
958
- ...(typeof metadata.contextWindow === "number" && metadata.contextWindow > 0
967
+ ...(metadataProvider && typeof metadata.contextWindow === "number" && metadata.contextWindow > 0
959
968
  ? { contextWindow: metadata.contextWindow }
960
969
  : {}),
961
- ...(typeof metadata.maxTokens === "number" && metadata.maxTokens > 0
970
+ ...(metadataProvider && typeof metadata.maxTokens === "number" && metadata.maxTokens > 0
962
971
  ? { maxOutputTokens: metadata.maxTokens }
963
972
  : {}),
964
- ...(Array.isArray(metadata.input) && metadata.input.length > 0
973
+ ...(metadataProvider && Array.isArray(metadata.input) && metadata.input.length > 0
965
974
  ? { inputModalities: [...metadata.input] }
966
975
  : {}),
967
976
  ...(metadata.reasoning === true ? { reasoningEfforts: [...ROUTED_COMBO_MEMBER_REASONING_EFFORTS] } : {}),
@@ -1038,15 +1047,21 @@ export function resolveComboCatalogMember(
1038
1047
  && typeof existing.contextWindow === "number"
1039
1048
  && existing.contextWindow > 0
1040
1049
  ) {
1041
- const capped = applyProviderContextCap(existing.contextWindow, contextCap);
1050
+ // Live discovery can explicitly say text-only even when configured routing
1051
+ // supplies a vision sidecar. Apply the same provider hints used for thin
1052
+ // rows before deriving a combo from this complete row.
1053
+ const hinted = prov && isModelVisionSidecarConsumer(prov, existing.id)
1054
+ ? applyProviderConfigHints(target.provider, prov, existing, contextCap, metadataModelIdCaseFold)
1055
+ : existing;
1056
+ const capped = applyProviderContextCap(hinted.contextWindow, contextCap);
1042
1057
  if (capped === undefined || capped === existing.contextWindow) {
1043
- return withFallbackMetadata(existing);
1058
+ return withFallbackMetadata(hinted);
1044
1059
  }
1045
- const maxInput = typeof existing.maxInputTokens === "number" && existing.maxInputTokens > 0
1046
- ? Math.min(existing.maxInputTokens, capped)
1060
+ const maxInput = typeof hinted.maxInputTokens === "number" && hinted.maxInputTokens > 0
1061
+ ? Math.min(hinted.maxInputTokens, capped)
1047
1062
  : Math.min(fallback?.maxInputTokens ?? capped, capped);
1048
1063
  return withFallbackMetadata({
1049
- ...existing,
1064
+ ...hinted,
1050
1065
  contextWindow: capped,
1051
1066
  maxInputTokens: maxInput,
1052
1067
  contextCap,
@@ -1387,6 +1402,51 @@ function modelInputModalities(
1387
1402
  return undefined;
1388
1403
  }
1389
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
+
1390
1450
  export function catalogHintsFromModelsApiItem(providerName: string, item: ProviderModelsApiItem): Partial<CatalogModel> {
1391
1451
  const metadata = plainRecord(item.metadata);
1392
1452
  const capabilityRecord = plainRecord(metadata?.capabilities) ?? plainRecord(item.capabilities);
@@ -1412,6 +1472,13 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid
1412
1472
  // supplying a recognized field changes behavior (#1797).
1413
1473
  plainRecord(item.meta)?.n_ctx,
1414
1474
  plainRecord(item.meta)?.n_ctx_train,
1475
+ // A chained OpenCodex hub (and other re-serving gateways) reports the per-model
1476
+ // window on the same capability record this function already reads for
1477
+ // `max_output_tokens` below (#4032). Without it every routed row fell through to
1478
+ // the 128k compatibility floor in parsing.ts while local forward rows kept their
1479
+ // real values. Appended after the recognized fields for the same reason as the
1480
+ // llama.cpp entries above: no provider that already resolves changes behavior.
1481
+ capabilityRecord?.context_length,
1415
1482
  );
1416
1483
  const maxInputTokens = positiveSafeInteger(limits?.max_input_tokens, item.max_input_tokens);
1417
1484
  const maxOutputTokens = positiveSafeInteger(
@@ -1444,6 +1511,7 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid
1444
1511
  : undefined;
1445
1512
  const capabilities = modelCapabilities(item);
1446
1513
  const inputModalities = modelInputModalities(item, capabilities);
1514
+ const pricingStatus = discoveredPricingStatus(item);
1447
1515
  return {
1448
1516
  ...(contextWindow && contextWindow > 0 ? { contextWindow } : {}),
1449
1517
  ...(maxInputTokens && maxInputTokens > 0 ? { maxInputTokens } : {}),
@@ -1451,6 +1519,11 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid
1451
1519
  ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
1452
1520
  ...(inputModalities ? { inputModalities } : {}),
1453
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 } : {}),
1454
1527
  };
1455
1528
  }
1456
1529
 
@@ -1582,6 +1655,50 @@ async function fetchProviderModelsWithAuth(
1582
1655
  ? [...models, vertexDefaultSeed]
1583
1656
  : models
1584
1657
  );
1658
+ if (prov.adapter === "qoder") {
1659
+ if (!apiKey) return observed(configured, "degraded");
1660
+ const profile = resolveQoderProfile(prov.baseUrl);
1661
+ if (!profile) return observed(configured, "degraded");
1662
+ // Qoder's model list is entitlement-specific. Bind cache reads/writes to an irreversible PAT
1663
+ // fingerprint so an account switch cannot observe another account's roster, even if a caller
1664
+ // bypasses the normal config mutation path that clears provider caches.
1665
+ const authorityIdentity = createHash("sha256").update(apiKey).digest("hex");
1666
+ const fresh = getFreshCached(name, ttlMs, Date.now(), authorityIdentity);
1667
+ if (fresh) {
1668
+ return observed(withConfiguredRetention(
1669
+ applyConfigHintsToCachedModels(name, prov, fresh, contextCap, metadataModelIdCaseFold, captured.effectiveAlias),
1670
+ ), "authoritative");
1671
+ }
1672
+ const scopedStale = getStaleCached(name, authorityIdentity);
1673
+ if (isModelsFetchCoolingDown(name) && scopedStale) {
1674
+ return observed(withConfiguredRetention(
1675
+ applyConfigHintsToCachedModels(name, prov, scopedStale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias),
1676
+ ), "degraded");
1677
+ }
1678
+ const live = await fetchQoderModels(profile, apiKey);
1679
+ if (live.ok) {
1680
+ const discovered = live.models.map(id => ({
1681
+ id,
1682
+ provider: name,
1683
+ ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias),
1684
+ }));
1685
+ const forCache = withConfiguredRetention(discovered, { retainComboTargets: false });
1686
+ if (!setCached(name, forCache, Date.now(), cacheGeneration, authorityIdentity)) {
1687
+ return observed(withConfiguredRetention(configured), "degraded");
1688
+ }
1689
+ markProviderDiscoveryOk(name, live.models.length);
1690
+ return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative");
1691
+ }
1692
+ if (isCurrentCacheGeneration()) {
1693
+ markModelsFetchFailure(name);
1694
+ markProviderDiscoveryFailed(name, { reason: "provider" });
1695
+ console.warn(`[opencodex] Qoder model discovery for "${name}" failed [${live.error}]${live.detail ? `: ${live.detail}` : ""}; using stale/static catalog degradation.`);
1696
+ }
1697
+ const stale = getStaleCached(name, authorityIdentity);
1698
+ return observed(withConfiguredRetention(
1699
+ stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias) : configured,
1700
+ ), "degraded");
1701
+ }
1585
1702
  if (prov.adapter === "cursor") {
1586
1703
  if (!apiKey) return observed(configured, "degraded");
1587
1704
  // Cursor uses a bespoke GetUsableModels RPC (not /models), returning the full effort-suffixed
@@ -1807,7 +1924,14 @@ async function fetchProviderModelsWithAuth(
1807
1924
  markProviderDiscoveryOk(name, live.length);
1808
1925
  return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative");
1809
1926
  }
1810
- 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);
1811
1935
  if (!extracted.ok) {
1812
1936
  const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" });
1813
1937
  const diagnostic: Record<ModelDiscoveryResponseFailure, string> = {
@@ -2558,7 +2682,16 @@ async function gatherRoutedModelsUncached(
2558
2682
  return {
2559
2683
  models: models.map(model => {
2560
2684
  const displayName = aliasDisplayNames.get(`${model.provider}/${model.id}`);
2561
- 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;
2562
2695
  }),
2563
2696
  comboOmissions: localOmissions,
2564
2697
  providerAuthOutcomes: localProviderAuthOutcomes,
@@ -99,6 +99,12 @@ export const PICKER_ORDER_PRIORITY_BASE = 1_000;
99
99
  // independent of display order. It does not freeze native advertisements. Absent on unmoved rows.
100
100
  export const SPAWN_PRIORITY_FIELD = "opencodex_spawn_priority";
101
101
 
102
+ // OpenCodex-private catalog field: this row is listed but currently unable to serve (#1711).
103
+ // Codex ignores unknown catalog fields (same as opencodex_catalog_kind and the spawn priority
104
+ // above) and ensureStrictCatalogFields does not strip extras, so this is invisible to the native
105
+ // picker and cannot change what Codex offers. It never touches `visibility`.
106
+ export const CATALOG_INACTIVE_REASON_FIELD = "opencodex_inactive_reason";
107
+
102
108
  export type SpawnAgentSurface = "v1" | "v2";
103
109
 
104
110
  export type SubagentRosterExclusionReason =
@@ -383,6 +389,10 @@ export function deriveEntry(
383
389
  if (model) applyCatalogMetadata(e, model.provider, model.id, model.contextCap);
384
390
  applyCatalogModelMetadata(e, model);
385
391
  if (model?.catalogKind) e.opencodex_catalog_kind = model.catalogKind;
392
+ // Additive only. `visibility` is untouched: an inactive row must still be OFFERED, which is
393
+ // the whole point of #1711 — operator disable is what removes rows, and it stays a separate
394
+ // path from this one.
395
+ if (model?.quotaInactiveReason) e[CATALOG_INACTIVE_REASON_FIELD] = model.quotaInactiveReason;
386
396
  } else {
387
397
  applyNativeOpenAiContextOverride(e, contextCap);
388
398
  if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(e);
@@ -430,6 +440,10 @@ export function deriveEntry(
430
440
  if (model && isRouted) applyCatalogMetadata(entry, model.provider, model.id, model.contextCap);
431
441
  applyCatalogModelMetadata(entry, model);
432
442
  if (model?.catalogKind) entry.opencodex_catalog_kind = model.catalogKind;
443
+ // Same additive stamp as the templated path above. A routed row that reaches the no-template
444
+ // fallback is still a served row, so omitting it here would make the field depend on whether a
445
+ // template happened to be cached — which is exactly what the regression test caught.
446
+ if (model?.quotaInactiveReason) entry[CATALOG_INACTIVE_REASON_FIELD] = model.quotaInactiveReason;
433
447
  if (!isRouted) applyNativeOpenAiContextOverride(entry, contextCap);
434
448
  return ensureStrictCatalogFields(normalizeServiceTiers(entry), {
435
449
  preserveExactInputModalities: preserveExact,
@@ -31,6 +31,7 @@ import {
31
31
  resolveEffectiveUserIdentity,
32
32
  } from "./user-identity";
33
33
  import {
34
+ hasUnverifiedJournalBaseline,
34
35
  markJournalInjectedState,
35
36
  journaledInjectedOpenaiBaseUrl,
36
37
  journaledInjectedRealtimeWsBaseUrl,
@@ -174,6 +175,8 @@ export interface CodexRoutingTarget {
174
175
  * and is never weakened by this flag.
175
176
  */
176
177
  desktopAuthless?: boolean;
178
+ /** Select the dedicated provider identity so Codex owns compaction locally. */
179
+ clientCompaction?: boolean;
177
180
  }
178
181
 
179
182
  function validateCodexRoutingTarget(target: CodexRoutingTarget): CodexRoutingTarget {
@@ -197,14 +200,19 @@ function validateCodexRoutingTarget(target: CodexRoutingTarget): CodexRoutingTar
197
200
  return { ...target, baseUrl: `${parsed.origin}/v1` };
198
201
  }
199
202
 
200
- /** Provider-table form is used for non-loopback admission and for the authless Desktop opt-in. */
203
+ /** Provider-table form is used when auth, admission, or compaction policy needs a dedicated provider. */
201
204
  function usesProviderTable(target: CodexRoutingTarget): boolean {
202
- return target.requiresAdmissionToken || target.desktopAuthless === true;
205
+ return target.requiresAdmissionToken
206
+ || target.desktopAuthless === true
207
+ || target.clientCompaction === true;
203
208
  }
204
209
 
205
210
  export function standaloneCodexRoutingTarget(
206
211
  port: number,
207
- config?: Pick<OcxConfig, "hostname" | "unauthenticatedLoopbackListener" | "codexDesktopAuthless">,
212
+ config?: Pick<
213
+ OcxConfig,
214
+ "hostname" | "unauthenticatedLoopbackListener" | "codexDesktopAuthless" | "codexClientCompaction"
215
+ >,
208
216
  ): CodexRoutingTarget {
209
217
  const loopback = config?.unauthenticatedLoopbackListener;
210
218
  const effectivePort = loopback?.enabled ? loopback.port : port;
@@ -217,6 +225,9 @@ export function standaloneCodexRoutingTarget(
217
225
  ...(config?.codexDesktopAuthless === true && !requiresAdmissionToken
218
226
  ? { desktopAuthless: true }
219
227
  : {}),
228
+ ...(config?.codexClientCompaction === true && !requiresAdmissionToken
229
+ ? { clientCompaction: true }
230
+ : {}),
220
231
  };
221
232
  }
222
233
 
@@ -833,7 +844,7 @@ function buildProfileFileForTarget(
833
844
  const host = new URL(origin).host;
834
845
  // Design B (loopback): the reference/fallback file documents the root override form.
835
846
  // Non-loopback keeps the legacy provider-table shape (built-in provider cannot carry
836
- // the x-opencodex-api-key env header); the authless Desktop opt-in shares that shape.
847
+ // the x-opencodex-api-key env header); explicit Desktop policies share that shape.
837
848
  if (!usesProviderTable(target)) {
838
849
  const lines = [
839
850
  "# OpenCodex proxy fallback config (Design B)",
@@ -1014,11 +1025,37 @@ export async function injectCodexConfig(
1014
1025
  ? setRootModelCatalogPath(content, catalogPath)
1015
1026
  : stripOpencodexCatalogPath(content);
1016
1027
 
1017
- // Provider-table form: non-loopback admission (legacy) or the authless Desktop opt-in (#1107).
1018
- const legacyMode = usesProviderTable(routingTarget);
1028
+ // Provider-table form: non-loopback admission or an explicit Desktop policy.
1029
+ const providerTableMode = usesProviderTable(routingTarget);
1030
+ // Client compaction is the one table form that must not orphan existing threads. It changes
1031
+ // the DEFAULT provider to `opencodex`, but a thread already tagged `openai` keeps resolving
1032
+ // to Codex's built-in entry, and without the root override that entry is api.openai.com —
1033
+ // the thread would resume outside this proxy and outside configured routing. Keeping the
1034
+ // marker-owned root override alongside the table fixes that at the source: codex builds its
1035
+ // provider map as merge_configured_model_providers(built_in_model_providers(openai_base_url),
1036
+ // model_providers), so the override lands on the built-in `openai` entry when the map is
1037
+ // built, independent of which id is the default, and the merge leaves that entry alone for
1038
+ // every id except the two Amazon Bedrock ones. With the managed override in place both
1039
+ // entries point at this proxy. That is a guarantee about the line we own: when the user owns
1040
+ // the root line we inject nothing, and the built-in entry keeps whatever destination they
1041
+ // chose, so an `openai`-tagged thread follows their configuration rather than this proxy.
1042
+ //
1043
+ // Re-tagging history was the alternative and it cannot be made durable: the length-preserving
1044
+ // first-line repair cannot grow "openai" into "opencodex" without pre-existing padding, and
1045
+ // codex re-appends that stale first line whenever it writes git or memory-mode metadata.
1046
+ //
1047
+ // Authless is excluded on purpose: its whole point is a provider that carries
1048
+ // requires_openai_auth = false, and admission-token forms cannot use the root key at all.
1049
+ // Those two forms therefore keep their existing behaviour, forward-tagging resume history with
1050
+ // originals backed up, and that includes the case where a user enables authless and client
1051
+ // compaction together. Only the compaction-only form skips the history unit.
1052
+ const keepRootOverrideAlongsideTable = providerTableMode
1053
+ && routingTarget.clientCompaction === true
1054
+ && routingTarget.desktopAuthless !== true
1055
+ && routingTarget.requiresAdmissionToken !== true;
1019
1056
  let keptUserBaseUrl = false;
1020
1057
  let keptUserRealtimeWsBaseUrl = false;
1021
- if (legacyMode) {
1058
+ if (providerTableMode) {
1022
1059
  // Legacy (non-loopback) injection: the built-in openai provider cannot carry the
1023
1060
  // x-opencodex-api-key env header, so keep the opencodex provider table + root re-tag.
1024
1061
  // The authless opt-in needs the same table because only a dedicated provider can carry
@@ -1030,6 +1067,14 @@ export async function injectCodexConfig(
1030
1067
  content.trimEnd() +
1031
1068
  "\n" +
1032
1069
  buildProviderTableBlockForTarget(routingTarget, websocketsEnabled(config ?? {}));
1070
+ // 3) Keep existing `openai`-tagged threads reaching the proxy (see above). Ownership rules
1071
+ // are the Design B ones: a user's own root line is never replaced.
1072
+ if (keepRootOverrideAlongsideTable) {
1073
+ content = stripInjectedOpenaiBaseUrl(content);
1074
+ const rootFallback = setRootOpenaiBaseUrlForTarget(content, routingTarget);
1075
+ content = rootFallback.content;
1076
+ keptUserBaseUrl = rootFallback.keptUserBaseUrl;
1077
+ }
1033
1078
  } else {
1034
1079
  // Design B (loopback): a single root override; codex keeps its native `openai` provider id
1035
1080
  // so thread history is never remapped. Any legacy form was already stripped above.
@@ -1149,6 +1194,24 @@ export async function injectCodexConfig(
1149
1194
  };
1150
1195
  }
1151
1196
 
1197
+ const journalBaselineIsNative = (): boolean => {
1198
+ // Value evidence survives an app rewrite that removes the ownership comments.
1199
+ const journaledBaseUrl = journaledInjectedOpenaiBaseUrl({ readOnly: true });
1200
+ const journaledRealtimeWsBaseUrl = journaledInjectedRealtimeWsBaseUrl({ readOnly: true });
1201
+ const looksInjectedByValue =
1202
+ (journaledBaseUrl !== null && rootTomlString(rawContent, "openai_base_url") === journaledBaseUrl)
1203
+ || (journaledRealtimeWsBaseUrl !== null
1204
+ && rootTomlString(rawContent, REALTIME_WS_BASE_URL_KEY) === journaledRealtimeWsBaseUrl);
1205
+ return !hasInjectedCodexRouting(rawContent) && !looksInjectedByValue;
1206
+ };
1207
+ const readCurrentProfile = (): string | null => existsSync(CODEX_PROFILE_PATH)
1208
+ ? readFileSync(CODEX_PROFILE_PATH, "utf-8")
1209
+ : null;
1210
+ const unverifiedJournalMessage = "Codex configuration was not written: the journal has no verified baseline for the current config/profile. Current files and the journal were preserved.";
1211
+ if (!journalBaselineIsNative() && hasUnverifiedJournalBaseline(baselineContent, readCurrentProfile())) {
1212
+ return { success: false, message: unverifiedJournalMessage };
1213
+ }
1214
+
1152
1215
  if (options.validateOnly) {
1153
1216
  return {
1154
1217
  success: true,
@@ -1157,31 +1220,29 @@ export async function injectCodexConfig(
1157
1220
  }
1158
1221
 
1159
1222
  const applyNativeArtifacts = (): void => {
1160
- // #1798 again: a Codex app rewrite keeps values and drops the ownership comments, so
1161
- // marker evidence alone would classify our own routed config as the user's native
1162
- // baseline and replace the real original snapshot. Value evidence from the journal
1163
- // (the URLs the last injection recorded writing) blocks that misclassification.
1164
- const journaledBaseUrl = journaledInjectedOpenaiBaseUrl();
1165
- const journaledRealtimeWsBaseUrl = journaledInjectedRealtimeWsBaseUrl();
1166
- const looksInjectedByValue =
1167
- (journaledBaseUrl !== null && rootTomlString(rawContent, "openai_base_url") === journaledBaseUrl)
1168
- || (journaledRealtimeWsBaseUrl !== null
1169
- && rootTomlString(rawContent, REALTIME_WS_BASE_URL_KEY) === journaledRealtimeWsBaseUrl);
1170
1223
  writeJournal({
1171
- currentStateIsNative: !hasInjectedCodexRouting(rawContent) && !looksInjectedByValue,
1224
+ currentStateIsNative: journalBaselineIsNative(),
1172
1225
  configContent: baselineContent,
1173
1226
  owner: options.journalOwner,
1174
1227
  });
1228
+ // A native snapshot may have been refreshed above. An older hashless routed snapshot
1229
+ // must not gain the new injection's hash and later overwrite preserved user edits.
1230
+ if (hasUnverifiedJournalBaseline(baselineContent, readCurrentProfile())) throw new Error(unverifiedJournalMessage);
1175
1231
  atomicWriteFile(CODEX_CONFIG_PATH, content);
1176
1232
  atomicWriteFile(CODEX_PROFILE_PATH, profileContent);
1177
1233
  markJournalInjectedState(content, profileContent, {
1178
- // A root override is ours only in loopback Design B when no user-owned value won.
1179
- injectedOpenaiBaseUrl: legacyMode || keptUserBaseUrl
1234
+ // A root override is ours whenever we wrote one and no user-owned value won. That is
1235
+ // loopback Design B, and now also the client-compaction form, which keeps the same
1236
+ // marker-owned root line beside its provider table. Journaling it matters because the
1237
+ // marker comment is not durable: the Codex app can reserialize config.toml and drop
1238
+ // comments, and restore then has only the journaled value to tell our line from a user's
1239
+ // (#1798). The other table forms never write the key, so they still record null.
1240
+ injectedOpenaiBaseUrl: (providerTableMode && !keepRootOverrideAlongsideTable) || keptUserBaseUrl
1180
1241
  ? null
1181
1242
  : rootTomlString(content, "openai_base_url"),
1182
1243
  // The sideband override is ours only when we wrote it this pass (never in legacy mode,
1183
1244
  // never when the user owns either key).
1184
- injectedRealtimeWsBaseUrl: legacyMode || keptUserBaseUrl || keptUserRealtimeWsBaseUrl
1245
+ injectedRealtimeWsBaseUrl: providerTableMode || keptUserBaseUrl || keptUserRealtimeWsBaseUrl
1185
1246
  ? null
1186
1247
  : rootTomlString(content, REALTIME_WS_BASE_URL_KEY),
1187
1248
  // This is the catalog artifact selected for this injection, even when config.toml
@@ -1318,7 +1379,11 @@ export async function injectCodexConfig(
1318
1379
  }
1319
1380
  // Legacy mode still forward-tags history so re-tagged threads stay listable. Design B needs
1320
1381
  // the opposite: a one-time migration of previously re-tagged threads BACK to openai (restore
1321
- // machinery; cheap no-op when there is nothing to migrate).
1382
+ // machinery; cheap no-op when there is nothing to migrate). The client-compaction opt-in keeps
1383
+ // the root override alongside its table precisely so it does NOT have to touch history: an
1384
+ // existing `openai`-tagged thread still reaches this proxy through the built-in entry. So it
1385
+ // skips this unit, and future-only means what it says — no provider metadata is rewritten and
1386
+ // no `ocx1:` payload is touched.
1322
1387
  // History runs in a Worker under H, not on this thread.
1323
1388
  //
1324
1389
  // The three surfaces it touches — the SQLite rows, the backup manifest, and the
@@ -1331,8 +1396,8 @@ export async function injectCodexConfig(
1331
1396
  expectedDesiredEnabled: true,
1332
1397
  operation: deriveCodexHistoryOperation({
1333
1398
  direction: "apply",
1334
- resumeHistory: config?.syncResumeHistory !== false,
1335
- legacyMode,
1399
+ resumeHistory: config?.syncResumeHistory !== false && !keepRootOverrideAlongsideTable,
1400
+ legacyMode: providerTableMode,
1336
1401
  }),
1337
1402
  });
1338
1403
  // A blocked or failed unit is reported, not silently counted as zero work:
@@ -1363,17 +1428,42 @@ export async function injectCodexConfig(
1363
1428
  const ejected = (history as { ejectedRows?: number }).ejectedRows ?? 0;
1364
1429
  const migratedRows = (history.rows ?? 0) + ejected;
1365
1430
  const historyMessage =
1366
- config?.syncResumeHistory === false
1431
+ keepRootOverrideAlongsideTable
1432
+ ? (keptUserBaseUrl
1433
+ ? ` Codex resume history: left unchanged; threads already tagged openai follow your configured root openai_base_url.\n`
1434
+ : ` Codex resume history: left unchanged; existing threads keep reaching the proxy through the retained openai_base_url override.\n`)
1435
+ : config?.syncResumeHistory === false
1367
1436
  ? ` Codex resume history: left unchanged (syncResumeHistory=false).\n`
1368
1437
  : history.failed
1369
- ? formatApplyHistoryFailure(historyOutcome, legacyMode)
1370
- : legacyMode
1438
+ ? formatApplyHistoryFailure(historyOutcome, providerTableMode)
1439
+ : providerTableMode
1371
1440
  ? ` Codex resume history: ${history.rows} thread(s) made visible for opencodex; originals backed up for restore.\n`
1372
1441
  : migratedRows > 0
1373
1442
  ? ` Codex resume history: restored original provider metadata for ${migratedRows} manifest-backed thread(s) (one-time).\n`
1374
1443
  : ` Codex resume history: no backed-up metadata pending; untracked routed history left unchanged.\n`;
1375
- // A user-owned root openai_base_url means we did NOT install routing — say so honestly
1444
+ // A user-owned root openai_base_url means we did NOT install root routing — say so honestly
1376
1445
  // instead of claiming the proxy route is active (catalog/fast_mode were still written).
1446
+ //
1447
+ // The client-compaction form writes a provider table as well, so "nothing was injected" would
1448
+ // misdescribe the file it just produced: new threads do use the injected table. Report that
1449
+ // mixed result on its own terms, and never tell the operator to delete a setting of theirs.
1450
+ // Ownership alone says nothing about destination: their line may already target this proxy.
1451
+ if (keptUserBaseUrl && keepRootOverrideAlongsideTable) {
1452
+ return {
1453
+ success: true,
1454
+ ...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}),
1455
+ message:
1456
+ `Injected opencodex as default provider into Codex config (client-side compaction mode; ChatGPT auth remains required).\n` +
1457
+ ` Your root openai_base_url was left exactly as you set it, so opencodex did not add its own.\n` +
1458
+ catalogMessage +
1459
+ historyMessage +
1460
+ managedDefaultsMessage +
1461
+ ` New threads use the injected opencodex provider and route through the proxy.\n` +
1462
+ ` Threads already tagged openai resolve through Codex's built-in provider, which your root openai_base_url points at.\n` +
1463
+ ` No root URL change is required to enable client-side compaction for new threads.\n` +
1464
+ ` Fallback: codex --profile opencodex (same behavior)`,
1465
+ };
1466
+ }
1377
1467
  if (keptUserBaseUrl) {
1378
1468
  return {
1379
1469
  success: true,
@@ -1391,7 +1481,9 @@ export async function injectCodexConfig(
1391
1481
  }
1392
1482
  const headline = routingTarget.desktopAuthless === true
1393
1483
  ? `Injected opencodex as default provider into Codex config (authless Desktop mode: requires_openai_auth = false).\n`
1394
- : legacyMode
1484
+ : routingTarget.clientCompaction === true
1485
+ ? `Injected opencodex as default provider into Codex config (client-side compaction mode; ChatGPT auth remains required).\n`
1486
+ : providerTableMode
1395
1487
  ? `Injected opencodex as default provider into Codex config.\n`
1396
1488
  : `Pointed Codex's built-in openai provider at the opencodex proxy (openai_base_url + realtime sideband override).\n`;
1397
1489
  return {
@@ -1405,7 +1497,7 @@ export async function injectCodexConfig(
1405
1497
  ` All models now route through opencodex proxy (like OpenRouter).\n` +
1406
1498
  ` OpenAI models (gpt-5.5, etc.) are passed through to OpenAI.\n` +
1407
1499
  ` Custom models route to their configured providers.\n` +
1408
- (legacyMode
1500
+ (providerTableMode
1409
1501
  ? ` Fallback: codex --profile opencodex (same behavior)`
1410
1502
  : ` Fallback reference: ${CODEX_PROFILE_PATH}`),
1411
1503
  };
@@ -1733,6 +1825,12 @@ export function skippedRestoreEnvelope(success: boolean, message: string): Codex
1733
1825
  function restoreCodexConfigInline(): CodexRestoreConfigResult {
1734
1826
  try {
1735
1827
  const journal = restoreJournalState();
1828
+ if (journal.unverified) {
1829
+ return {
1830
+ state: "failed", changed: false, action: "failed",
1831
+ message: "Codex journal recovery was not verified; current configuration files and the journal were preserved.",
1832
+ };
1833
+ }
1736
1834
  const restored = journal.configRestored
1737
1835
  ? { success: true, message: "Codex config restored from opencodex journal." }
1738
1836
  : removeCodexConfig({ preserveProfile: journal.profileRestored || journal.profileChanged });
@@ -16,6 +16,7 @@ import {
16
16
  forgetEphemeralSecretPath,
17
17
  hardenSecretPath,
18
18
  } from "../../lib/windows-secret-acl";
19
+ import { resetCodexAppServerCatalogStateCache } from "../app-server-processes";
19
20
 
20
21
  export interface PreparedCatalogFileWrite {
21
22
  readonly path: string;
@@ -167,6 +168,7 @@ export function replaceActiveCodexCatalog(
167
168
  ): void {
168
169
  assertCatalogWritePermit(permit, owningCodexHome);
169
170
  atomicWriteFile(prepared.path, prepared.content, io);
171
+ resetCodexAppServerCatalogStateCache();
170
172
  }
171
173
 
172
174
  /** Atomically publish the catalog-path-keyed immutable backup without clobbering. */
@@ -200,4 +202,5 @@ export function replaceCodexModelsCache(
200
202
  ): void {
201
203
  assertCatalogWritePermit(permit, owningCodexHome);
202
204
  atomicWriteFile(prepared.path, prepared.content, io);
205
+ resetCodexAppServerCatalogStateCache();
203
206
  }