@bitkyc08/opencodex 2.87.0 → 2.89.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 (246) hide show
  1. package/README.md +11 -1
  2. package/bin/ocx.mjs +15 -4
  3. package/gui/dist/assets/App-BnRSujOq.js +58 -0
  4. package/gui/dist/assets/App-dnkJ85te.css +1 -0
  5. package/gui/dist/assets/{Tray-DCd-A1az.js → Tray-7z1COnmz.js} +1 -1
  6. package/gui/dist/assets/index-uchD-ngf.js +94 -0
  7. package/gui/dist/assets/{quota-summary-ChvmKGOk.js → quota-summary-BY_10IEz.js} +1 -1
  8. package/gui/dist/index.html +1 -1
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic/cache-retention.ts +117 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -4
  12. package/src/adapters/anthropic-rejection-memo.ts +71 -0
  13. package/src/adapters/anthropic-replay.ts +135 -0
  14. package/src/adapters/anthropic-tool-catalog.ts +11 -0
  15. package/src/adapters/anthropic.ts +86 -48
  16. package/src/adapters/coding-agent/protocol.ts +6 -0
  17. package/src/adapters/google-http.ts +3 -2
  18. package/src/adapters/google-progress-history.ts +53 -0
  19. package/src/adapters/google-vertex-endpoint.ts +67 -0
  20. package/src/adapters/google.ts +52 -30
  21. package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
  22. package/src/adapters/openai-responses/passthrough.ts +17 -12
  23. package/src/adapters/openai-responses/reasoning.ts +68 -1
  24. package/src/adapters/registry.ts +1 -1
  25. package/src/adapters/upstream-http-error.ts +3 -2
  26. package/src/adapters/zed.ts +7 -4
  27. package/src/bridge/internal.ts +17 -3
  28. package/src/bridge/response-json.ts +4 -4
  29. package/src/bridge/sse.ts +17 -10
  30. package/src/claude/inbound-content-options.ts +66 -0
  31. package/src/claude/inbound.ts +12 -26
  32. package/src/claude/outbound.ts +18 -5
  33. package/src/cli/account-api.ts +13 -1
  34. package/src/cli/account-extended.ts +17 -5
  35. package/src/cli/account-key-quota.ts +4 -1
  36. package/src/cli/account.ts +1 -1
  37. package/src/cli/capabilities-agents-routing.ts +45 -8
  38. package/src/cli/capabilities-base.ts +5 -5
  39. package/src/cli/capabilities-provider-models.ts +5 -4
  40. package/src/cli/combo-result.ts +8 -2
  41. package/src/cli/combo.ts +17 -0
  42. package/src/cli/cross-home-owner.ts +16 -2
  43. package/src/cli/help.ts +1 -1
  44. package/src/cli/loopback-family.ts +31 -0
  45. package/src/cli/message-command.ts +4 -1
  46. package/src/cli/message-remote-args.ts +69 -0
  47. package/src/cli/message-remote-command.ts +77 -0
  48. package/src/cli/provider-runtime.ts +69 -1
  49. package/src/cli/registry.ts +3 -3
  50. package/src/codex/account-pause.ts +6 -1
  51. package/src/codex/account-runtime-state.ts +5 -0
  52. package/src/codex/account-store.ts +30 -3
  53. package/src/codex/auth-api/account-list.ts +3 -0
  54. package/src/codex/auth-api/main-account-probe.ts +3 -2
  55. package/src/codex/auth-context.ts +9 -4
  56. package/src/codex/catalog/aggregation.ts +15 -0
  57. package/src/codex/catalog/auto-review.ts +2 -1
  58. package/src/codex/catalog/build-entries.ts +17 -7
  59. package/src/codex/catalog/derive-entry.ts +3 -1
  60. package/src/codex/catalog/metadata.ts +2 -0
  61. package/src/codex/catalog/model-visibility.ts +0 -2
  62. package/src/codex/catalog/ownership.ts +71 -0
  63. package/src/codex/catalog/parsing.ts +12 -3
  64. package/src/codex/catalog/retained-sync.ts +2 -1
  65. package/src/codex/catalog/routed-gather.ts +3 -1
  66. package/src/codex/catalog/routed-removal.ts +4 -4
  67. package/src/codex/convergence.ts +2 -1
  68. package/src/codex/low-quota-desktop-notifier.ts +125 -0
  69. package/src/codex/low-quota-protection.ts +57 -31
  70. package/src/codex/main-account-cache.ts +77 -3
  71. package/src/codex/main-account.ts +6 -2
  72. package/src/codex/native-residue.ts +2 -3
  73. package/src/codex/quota.ts +26 -2
  74. package/src/codex/websocket-activity.ts +161 -0
  75. package/src/codex/websocket-registry.ts +25 -1
  76. package/src/combos/failover.ts +2 -1
  77. package/src/combos/jev-model-backend.ts +30 -7
  78. package/src/combos/jev-model-request.ts +20 -0
  79. package/src/combos/jev-quota-activation.ts +153 -0
  80. package/src/combos/jev-quota-config.ts +62 -0
  81. package/src/combos/jev-quota-route.ts +26 -0
  82. package/src/combos/jev-quota.ts +88 -0
  83. package/src/combos/jev.ts +25 -12
  84. package/src/combos/types.ts +27 -0
  85. package/src/compatibility/index.ts +3 -0
  86. package/src/compatibility/openai-responses.ts +8 -1
  87. package/src/compatibility/opengateway-responses.ts +61 -0
  88. package/src/config/account-auto-pause-revisions.ts +136 -0
  89. package/src/config/diagnostics.ts +13 -0
  90. package/src/config/live-reconcile.ts +9 -0
  91. package/src/config/opencode-zen-upgrade.ts +9 -8
  92. package/src/config/persist-unlocked.ts +10 -1
  93. package/src/config/removed-opencode-free.ts +135 -0
  94. package/src/config/schema/account-auto-pause.ts +14 -0
  95. package/src/config/schema/config-schema.ts +15 -0
  96. package/src/config/schema/leaf-validators.ts +7 -0
  97. package/src/config.ts +10 -3
  98. package/src/generated/compatibility-version.json +411 -175
  99. package/src/images/loop.ts +101 -27
  100. package/src/lib/account-auto-pause-response.ts +16 -0
  101. package/src/lib/bun-runtime.ts +18 -1
  102. package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
  103. package/src/lib/bun-transpiler-cache-scope.mjs +68 -0
  104. package/src/lib/decision-quota-sink.ts +76 -0
  105. package/src/lib/request-failure-model.ts +2 -0
  106. package/src/lib/state-store-registrations.ts +25 -0
  107. package/src/link/ssh-argv.ts +3 -0
  108. package/src/messaging/envelope.ts +6 -3
  109. package/src/messaging/remote-auth.ts +83 -0
  110. package/src/messaging/remote-bridge.ts +270 -0
  111. package/src/messaging/remote-contract.ts +108 -0
  112. package/src/messaging/remote-enrollment-recovery.ts +59 -0
  113. package/src/messaging/remote-enrollment.ts +244 -0
  114. package/src/messaging/remote-envelope.ts +40 -0
  115. package/src/messaging/remote-files.ts +117 -0
  116. package/src/messaging/remote-owner.ts +169 -0
  117. package/src/messaging/remote-ports.ts +56 -0
  118. package/src/messaging/remote-process.ts +131 -0
  119. package/src/messaging/remote-replies.ts +98 -0
  120. package/src/messaging/remote-rpc-admission.ts +32 -0
  121. package/src/messaging/remote-send.ts +72 -0
  122. package/src/messaging/remote-store.ts +111 -0
  123. package/src/messaging/remote-tunnels.ts +65 -0
  124. package/src/messaging/rpc.ts +13 -1
  125. package/src/oauth/account-auto-pause-policy.ts +66 -0
  126. package/src/oauth/account-auto-pause.ts +171 -0
  127. package/src/oauth/account-quota-rank.ts +2 -1
  128. package/src/oauth/anthropic-account-refusal.ts +6 -0
  129. package/src/oauth/anthropic-model-family.ts +8 -0
  130. package/src/oauth/anthropic-model-quota.ts +2 -6
  131. package/src/oauth/api-key-pool-preserve.ts +37 -0
  132. package/src/oauth/generic-account-failover.ts +7 -4
  133. package/src/oauth/index.ts +5 -35
  134. package/src/oauth/store.ts +35 -8
  135. package/src/oauth/types.ts +2 -0
  136. package/src/plugins/upstream-hooks.ts +14 -1
  137. package/src/protocols/encoders/adapter-events.ts +5 -5
  138. package/src/protocols/encoders/messages.ts +2 -2
  139. package/src/providers/api-key-pause.ts +32 -0
  140. package/src/providers/api-key-resolve.ts +10 -4
  141. package/src/providers/api-key-selection.ts +6 -1
  142. package/src/providers/api-keys.ts +8 -4
  143. package/src/providers/claude-cli-usage.ts +5 -0
  144. package/src/providers/key-failover.ts +11 -11
  145. package/src/providers/model-rename-fields.ts +1 -0
  146. package/src/providers/opencode-zen-rate-limit.ts +12 -73
  147. package/src/providers/quota/account-cache.ts +15 -1
  148. package/src/providers/quota/anthropic-account-quota.ts +2 -1
  149. package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
  150. package/src/providers/quota/anthropic-decision-windows.ts +47 -0
  151. package/src/providers/quota/antigravity.ts +39 -6
  152. package/src/providers/quota/report-cache.ts +14 -0
  153. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  154. package/src/providers/quota-decision-publication.ts +77 -0
  155. package/src/providers/quota-decision-snapshot.ts +145 -0
  156. package/src/providers/quota-routing-cache.ts +3 -0
  157. package/src/providers/quota-types.ts +6 -1
  158. package/src/providers/quota.ts +3 -2
  159. package/src/providers/registry/entries-extended.ts +8 -52
  160. package/src/providers/registry/model-seeds.ts +1 -1
  161. package/src/providers/registry.ts +1 -2
  162. package/src/providers/stale-vision-classification-migration.ts +1 -1
  163. package/src/responses/parser.ts +18 -10
  164. package/src/responses/reasoning-replay-cache.ts +50 -4
  165. package/src/responses/web-search-replay.ts +138 -0
  166. package/src/routing/analytics.ts +7 -15
  167. package/src/routing/compatibility/behavior.ts +4 -1
  168. package/src/server/auth-cors.ts +7 -0
  169. package/src/server/background-lifecycle.ts +2 -1
  170. package/src/server/chat-native.ts +28 -13
  171. package/src/server/claude-messages.ts +4 -2
  172. package/src/server/index/live-sideband.ts +3 -2
  173. package/src/server/index/optional-listeners.ts +6 -0
  174. package/src/server/index/serve-options.ts +11 -5
  175. package/src/server/index/websocket-handler.ts +18 -4
  176. package/src/server/index.ts +7 -7
  177. package/src/server/lifecycle.ts +13 -1
  178. package/src/server/management/combo-routes.ts +17 -2
  179. package/src/server/management/oauth-account-routes.ts +10 -1
  180. package/src/server/management/provider-completion-test.ts +289 -0
  181. package/src/server/management/provider-routes.ts +9 -1
  182. package/src/server/management/route-registry.ts +2 -0
  183. package/src/server/messages-native.ts +34 -7
  184. package/src/server/messages-shared.ts +4 -1
  185. package/src/server/request-log.ts +7 -2
  186. package/src/server/responses/account-auto-pause-recovery.ts +26 -0
  187. package/src/server/responses/adapter-continuation.ts +34 -18
  188. package/src/server/responses/adapter-delivery.ts +53 -6
  189. package/src/server/responses/adapter-dispatch.ts +104 -29
  190. package/src/server/responses/anthropic-thinking-recovery.ts +153 -0
  191. package/src/server/responses/combo-session-recall.ts +25 -0
  192. package/src/server/responses/combo-stream-preflight.ts +6 -3
  193. package/src/server/responses/combo-tool-loop-pin.ts +70 -0
  194. package/src/server/responses/compact.ts +8 -5
  195. package/src/server/responses/compaction-recovery.ts +2 -0
  196. package/src/server/responses/context-overflow.ts +141 -2
  197. package/src/server/responses/core-codex-account.ts +9 -3
  198. package/src/server/responses/core-combo.ts +34 -7
  199. package/src/server/responses/core-lifetime.ts +3 -2
  200. package/src/server/responses/core-options.ts +2 -0
  201. package/src/server/responses/core-replay.ts +5 -1
  202. package/src/server/responses/core.ts +12 -9
  203. package/src/server/responses/empty-completion-guard.ts +2 -0
  204. package/src/server/responses/fetch-helpers.ts +35 -4
  205. package/src/server/responses/jev-model-invoke.ts +13 -14
  206. package/src/server/responses/native-steering-log.ts +2 -2
  207. package/src/server/responses/passthrough-delivery.ts +3 -2
  208. package/src/server/responses/passthrough-dispatch.ts +30 -33
  209. package/src/server/responses/passthrough-execution.ts +3 -2
  210. package/src/server/responses/passthrough-observations.ts +38 -0
  211. package/src/server/responses/request-compression.ts +98 -0
  212. package/src/server/responses/request-prepare.ts +1 -0
  213. package/src/server/responses/request-transport.ts +32 -3
  214. package/src/server/responses/response-effects.ts +1 -0
  215. package/src/server/responses/response-tool-ownership.ts +21 -0
  216. package/src/server/responses/run-turn-execution.ts +2 -0
  217. package/src/server/responses/sidecar-execution.ts +59 -10
  218. package/src/server/responses/sidecar-send-budget.ts +2 -2
  219. package/src/server/responses/terminal-guard.ts +2 -0
  220. package/src/server/responses-undeclared-tool-guard.ts +1 -1
  221. package/src/server/startup-action-control.ts +2 -2
  222. package/src/server/startup-health-cache.ts +2 -2
  223. package/src/server/windows-tray-control.ts +2 -2
  224. package/src/server/ws-bridge.ts +2 -0
  225. package/src/service/definition.ts +9 -1
  226. package/src/types/account-auto-pause.ts +15 -0
  227. package/src/types/config.ts +23 -1
  228. package/src/types/opencode-zen.ts +9 -3
  229. package/src/types/provider.ts +9 -5
  230. package/src/types/request.ts +30 -4
  231. package/src/types/wire.ts +4 -2
  232. package/src/types.ts +2 -0
  233. package/src/usage/cost.ts +17 -1
  234. package/src/usage/expected-prices.ts +3 -1
  235. package/src/usage/jev-stats.ts +4 -0
  236. package/src/usage/log.ts +2 -0
  237. package/src/usage/summary.ts +16 -3
  238. package/src/usage/telemetry-contract.ts +1 -0
  239. package/src/web-search/anthropic-history.ts +59 -0
  240. package/src/web-search/loop.ts +212 -73
  241. package/src/web-search/progress-stream.ts +14 -1
  242. package/src/web-search/run-turn-loop.ts +9 -3
  243. package/src/web-search/sidecar-search-replay.ts +96 -0
  244. package/gui/dist/assets/App-ZfYbLHkB.js +0 -58
  245. package/gui/dist/assets/App-wT3mXqFz.css +0 -1
  246. package/gui/dist/assets/index-CfYDMj34.js +0 -94
@@ -26,6 +26,7 @@ import {
26
26
  isGpt56NativeSlug,
27
27
  } from "./effort";
28
28
  import { CATALOG_INACTIVE_REASON_FIELD, SPAWN_PRIORITY_FIELD } from "./subagent-roster";
29
+ import { applyRoutedDescription } from "./ownership";
29
30
 
30
31
  export function finishUpstreamNativeEntry(clone: RawEntry, priority: number, contextCap?: NativeContextLimitsInput): RawEntry {
31
32
  if (priority !== 9) clone.priority = priority;
@@ -128,7 +129,7 @@ export function deriveEntry(
128
129
  delete e[SPAWN_PRIORITY_FIELD];
129
130
  e.slug = slug;
130
131
  e.display_name = routedDisplayName(slug, model);
131
- e.description = desc;
132
+ applyRoutedDescription(e, desc, model);
132
133
  e.priority = priority;
133
134
  e.visibility = "list";
134
135
  if ("upgrade" in e) e.upgrade = null;
@@ -212,6 +213,7 @@ export function deriveEntry(
212
213
  : { web_search_tool_type: "text_and_image", supports_search_tool: true }
213
214
  : {}),
214
215
  };
216
+ applyRoutedDescription(entry, desc, model);
215
217
  if (isRouted) {
216
218
  applyRoutedCodexToolMode(entry, model?.codexToolMode);
217
219
  applyReasoningLevels(
@@ -38,6 +38,7 @@ import type { RawEntry } from "./parsing";
38
38
  import { readCurrentCatalogOrCache, readCurrentCodexCatalog, readCurrentCodexModelsCache, unique } from "./bundled";
39
39
  import { trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./account-models";
40
40
  import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds";
41
+ import { ROUTED_ORIGIN_FIELD } from "./ownership";
41
42
  import { RESERVE_METADATA_SOURCE_FIELD } from "./reserve";
42
43
  import {
43
44
  ACCOUNT_GATED_NATIVE_OPENAI_MODELS,
@@ -813,6 +814,7 @@ export function observedReserveCatalogSource(
813
814
  }
814
815
  source.slug = NATIVE_RESERVE_MODEL;
815
816
  delete source.opencodex_catalog_kind;
817
+ delete source[ROUTED_ORIGIN_FIELD];
816
818
  delete source[RESERVE_METADATA_SOURCE_FIELD];
817
819
  delete source[ACCOUNT_BOUND_OBSERVED_NATIVE_MARKER];
818
820
  delete source[ACCOUNT_BOUND_OBSERVED_SELECTORS_MARKER];
@@ -189,7 +189,6 @@ export function warnDroppedConfiguredIdsOnce(name: string, droppedConfiguredIds:
189
189
  );
190
190
  }
191
191
  export function shouldExposeProviderModel(providerName: string, modelId: string): boolean {
192
- if (providerName === "opencode-free") return modelId === "big-pickle" || modelId.endsWith("-free");
193
192
  // xAI /models advertises both the dated deployment and this floating alias.
194
193
  // Keep only grok-4.20-multi-agent-0309; the alias is the same server-side id.
195
194
  if (providerName === "xai" && modelId === "grok-4.20-multi-agent-beta-latest") return false;
@@ -205,7 +204,6 @@ export function shouldRetainConfiguredProviderModel(
205
204
  prov?: OcxProviderConfig,
206
205
  ): boolean {
207
206
  if (CALLABLE_CONFIGURED_COMPATIBILITY_MODELS[providerName]?.has(modelId)) return true;
208
- if (providerName === "opencode-free") return modelId === "big-pickle" || modelId.endsWith("-free");
209
207
  if (modelInList(prov?.retainModels, modelId)) return true;
210
208
  return false;
211
209
  }
@@ -0,0 +1,71 @@
1
+ import { COMBO_NAMESPACE } from "../../combos/identifiers";
2
+ import type { CatalogModel, RawEntry } from "./parsing";
3
+
4
+ /**
5
+ * Authorship of OpenCodex-generated routed catalog rows (#855, #7229).
6
+ *
7
+ * A generated routed row normally carries the description prefix below, and that prefix is what
8
+ * removal, template selection, auto-review and residue detection have always recognized. A combo
9
+ * may replace its row description (#7229), so such a row carries a stable marker instead. Rows
10
+ * with the generated description stay byte-identical and keep prefix recognition; only an exact
11
+ * known marker value confers authorship.
12
+ */
13
+ export const ROUTED_DESCRIPTION_PREFIX = "Routed via opencodex → ";
14
+ export const ROUTED_ORIGIN_FIELD = "opencodex_routed_origin";
15
+ export const COMBO_ROUTED_ORIGIN = "combo-v1";
16
+ const COMBO_DESCRIPTION_PREFIX = `${ROUTED_DESCRIPTION_PREFIX}${COMBO_NAMESPACE} (`;
17
+
18
+ function descriptionStartsWith(entry: RawEntry, prefix: string): boolean {
19
+ return typeof entry.description === "string" && entry.description.startsWith(prefix);
20
+ }
21
+
22
+ /** True for a row whose description was overridden by a combo and stamped by OpenCodex. */
23
+ export function hasComboRoutedOriginMarker(entry: RawEntry): boolean {
24
+ return entry[ROUTED_ORIGIN_FIELD] === COMBO_ROUTED_ORIGIN;
25
+ }
26
+
27
+ /** Generated by OpenCodex routing: the stable marker, or the legacy generated description. */
28
+ export function hasOcxRoutedAuthorship(entry: RawEntry): boolean {
29
+ return hasComboRoutedOriginMarker(entry) || descriptionStartsWith(entry, ROUTED_DESCRIPTION_PREFIX);
30
+ }
31
+
32
+ /** Generated for a combo: the stable marker, or the legacy generated combo description. */
33
+ export function hasOcxComboAuthorship(entry: RawEntry): boolean {
34
+ return hasComboRoutedOriginMarker(entry) || descriptionStartsWith(entry, COMBO_DESCRIPTION_PREFIX);
35
+ }
36
+
37
+ /** The operator's combo description override, if this routed model has one. */
38
+ export function comboDescriptionOverride(model: CatalogModel | undefined): string | undefined {
39
+ if (model?.provider !== COMBO_NAMESPACE) return undefined;
40
+ const description = typeof model.description === "string" ? model.description.trim() : "";
41
+ return description || undefined;
42
+ }
43
+
44
+ /**
45
+ * Set a routed row's description and its authorship marker together. A template clone may carry
46
+ * another row's marker, so the marker is always rewritten from this model's own override.
47
+ */
48
+ export function applyRoutedDescription(entry: RawEntry, generated: string, model: CatalogModel | undefined): void {
49
+ const override = comboDescriptionOverride(model);
50
+ entry.description = override ?? generated;
51
+ if (override !== undefined) entry[ROUTED_ORIGIN_FIELD] = COMBO_ROUTED_ORIGIN;
52
+ else delete entry[ROUTED_ORIGIN_FIELD];
53
+ }
54
+
55
+ /**
56
+ * A combo row retained from the previous catalog (its fresh derivation failed, e.g. a member
57
+ * provider is temporarily unavailable) bypasses deriveEntry. Re-project the combo's CURRENT
58
+ * description onto it so clearing or changing the description in config still reaches the
59
+ * picker. A cleared override returns the row to the generated combo text without the marker;
60
+ * an unmarked row without a current override is left exactly as it was.
61
+ */
62
+ export function reapplyRetainedComboDescription(entry: RawEntry, current: string | undefined): void {
63
+ const description = current?.trim();
64
+ if (description) {
65
+ entry.description = description;
66
+ entry[ROUTED_ORIGIN_FIELD] = COMBO_ROUTED_ORIGIN;
67
+ } else if (hasComboRoutedOriginMarker(entry)) {
68
+ entry.description = `${ROUTED_DESCRIPTION_PREFIX}${COMBO_NAMESPACE} (${COMBO_NAMESPACE}).`;
69
+ delete entry[ROUTED_ORIGIN_FIELD];
70
+ }
71
+ }
@@ -43,6 +43,7 @@ import { NATIVE_OPENAI_CONTEXT_OVERRIDES, SUPPORTED_NATIVE_OPENAI_SLUGS, UPSTREA
43
43
  import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget";
44
44
  import { trustedAccountBoundNativeCatalogSlug } from "./account-models";
45
45
  import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds";
46
+ import { hasComboRoutedOriginMarker, hasOcxRoutedAuthorship } from "./ownership";
46
47
  import { NATIVE_GPT6_ASTRA_MODEL } from "./native-models";
47
48
  import { recordOwnedConfigPath } from "../../lib/config-ownership";
48
49
 
@@ -95,6 +96,12 @@ export interface CatalogModel {
95
96
  * which carries no CatalogModel, so a configured displayName can never override a native name.
96
97
  */
97
98
  displayName?: string;
99
+ /**
100
+ * Display-only Codex catalog `description` override, honored for combo rows only (#7229).
101
+ * Unset keeps the generated "Routed via opencodex → …" text; a set value stamps the row with
102
+ * the ownership marker in ./ownership so removal and template selection still recognize it.
103
+ */
104
+ description?: string;
98
105
  owned_by?: string;
99
106
  reasoningEfforts?: string[];
100
107
  /** Exact CCA family evidence, retained through the in-memory discovery cache. */
@@ -297,7 +304,7 @@ export function findNativeTemplate(catalog: RawCatalog | null): RawEntry | null
297
304
  && "base_instructions" in m
298
305
  && m.opencodex_catalog_kind !== CODEX_NATIVE_ALIAS_CATALOG_KIND
299
306
  && m.owned_by !== COMBO_NAMESPACE
300
- && !(typeof m.description === "string" && m.description.startsWith("Routed via opencodex → ")),
307
+ && !hasOcxRoutedAuthorship(m),
301
308
  ) ?? null;
302
309
  }
303
310
 
@@ -325,7 +332,7 @@ export function findSupportedNativeTemplate(catalog: RawCatalog | null): RawEntr
325
332
  && "base_instructions" in m
326
333
  && m.opencodex_catalog_kind !== CODEX_NATIVE_ALIAS_CATALOG_KIND
327
334
  && m.owned_by !== COMBO_NAMESPACE
328
- && !(typeof m.description === "string" && m.description.startsWith("Routed via opencodex → ")),
335
+ && !hasOcxRoutedAuthorship(m),
329
336
  ) ?? null;
330
337
  }
331
338
 
@@ -925,7 +932,9 @@ export function readCatalogBackup(catalogPath: string): RawCatalog | null {
925
932
 
926
933
  export function catalogHasRoutedEntries(catalog: RawCatalog | null): boolean {
927
934
  return (catalog?.models ?? []).some(m => typeof m.slug === "string"
928
- && (m.slug.includes("/") || m.opencodex_catalog_kind === CODEX_NATIVE_ALIAS_CATALOG_KIND));
935
+ && (m.slug.includes("/")
936
+ || m.opencodex_catalog_kind === CODEX_NATIVE_ALIAS_CATALOG_KIND
937
+ || hasComboRoutedOriginMarker(m)));
929
938
  }
930
939
 
931
940
  export function writePristineCatalogBackup(backupPath: string, catalogPath: string, catalog: RawCatalog): void {
@@ -60,7 +60,7 @@ import { isMultiAgentV2Enabled } from "../features";
60
60
  import { clampCatalogModelsToCodexSupport } from "./effort";
61
61
  import { suppressedSyntheticMaxCatalogSlugs } from "./model-hints";
62
62
  import { filterCatalogVisibleModels, gatherRoutedModels, type CatalogGatherProviderModelOutcome } from "./provider-fetch";
63
- import { dedupeCatalogEntriesBySlug, enforceCatalogSlugUniqueness, exactComboCatalogSlugs, type ComboCatalogOmission } from "./aggregation";
63
+ import { comboCatalogDescriptions, dedupeCatalogEntriesBySlug, enforceCatalogSlugUniqueness, exactComboCatalogSlugs, type ComboCatalogOmission } from "./aggregation";
64
64
  import {
65
65
  withCatalogWriteSerialization,
66
66
  type CatalogWritePermit,
@@ -530,6 +530,7 @@ function writeRetainedCatalogSync({
530
530
  multiAgentV2Enabled,
531
531
  keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true,
532
532
  exactComboSlugs,
533
+ comboDescriptions: comboCatalogDescriptions(config),
533
534
  hasPhysicalComboProvider,
534
535
  includeNativeOpenAi,
535
536
  accountBoundEntries,
@@ -102,7 +102,7 @@ import type {
102
102
  CatalogTrustedOpenAiApiPolicySnapshot,
103
103
  } from "../convergence-types";
104
104
  import type { CatalogGatherProviderAuthOutcome, CatalogGatherProviderModelOutcome, GatherFlightCapture, ModelsAuthResolverFactory } from "./gather-capture";
105
- import { applyProviderConfigHints, configuredAutoCompactTokenLimit, configuredMaxInputTokens, configuredReasoningSummarySupport, modelInputModalities, routedMaxOutputTokens, withVisionSidecarInput } from "./model-hints";
105
+ import { applyProviderConfigHints, configuredAutoCompactTokenLimit, configuredMaxInputTokens, configuredModelDisplayName, configuredReasoningSummarySupport, modelInputModalities, routedMaxOutputTokens, withVisionSidecarInput } from "./model-hints";
106
106
  import { resolveComboCatalogMember } from "./combo-member";
107
107
  import { captureGatherFlight, captureTrustedOpenAiApiPolicy, gatherFlightKey, keyedGatherBytesIdentity, withCanonicalOpenAiForwardAuthDefault } from "./gather-capture";
108
108
  import { fetchProviderModelsWithAuth, observedModelsAuthResolver, refreshingModelsAuthResolver } from "./provider-models";
@@ -886,10 +886,12 @@ function augmentRoutedModelsWithCapturedOpenAiApiRows(
886
886
  undefined,
887
887
  metadataConfigDir,
888
888
  );
889
+ const displayName = configuredModelDisplayName(configured, id);
889
890
  return {
890
891
  provider: OPENAI_API_PROVIDER_ID,
891
892
  id,
892
893
  owned_by: OPENAI_API_PROVIDER_ID,
894
+ ...(displayName ? { displayName } : {}),
893
895
  ...(contextWindow ? { contextWindow } : {}),
894
896
  ...(maxInputTokens ? { maxInputTokens } : {}),
895
897
  ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
@@ -4,6 +4,7 @@ import type { OcxConfig } from "../../types";
4
4
  import { trustedAccountBoundNativeCatalogSlug } from "./account-models";
5
5
  import { isOcxAuthoredRoutedEntry } from "./build-entries";
6
6
  import { isNativeAliasCatalogEntry } from "./metadata";
7
+ import { hasOcxRoutedAuthorship } from "./ownership";
7
8
  import type { RawCatalog, RawEntry } from "./parsing";
8
9
 
9
10
  /**
@@ -25,10 +26,9 @@ function routedNamespace(entry: RawEntry): string | null {
25
26
  if (trustedAccountBoundNativeCatalogSlug(entry) !== undefined) return null;
26
27
  if (isNativeAliasCatalogEntry(entry)) return COMBO_NAMESPACE;
27
28
  // Combo aliases may be bare or use an unrelated provider namespace. Ownership selects the
28
- // combo namespace only with the same generated-description authorship signal as routed rows.
29
- if (entry.owned_by === COMBO_NAMESPACE
30
- && typeof entry.description === "string"
31
- && entry.description.startsWith("Routed via opencodex → ")) return COMBO_NAMESPACE;
29
+ // combo namespace only with the same authorship signal as routed rows (generated description or
30
+ // the #7229 marker a custom combo description carries).
31
+ if (entry.owned_by === COMBO_NAMESPACE && hasOcxRoutedAuthorship(entry)) return COMBO_NAMESPACE;
32
32
  // Foreign rows (Cursor, user tooling) are never removed by the provider rule.
33
33
  if (!isOcxAuthoredRoutedEntry(entry)) return null;
34
34
  return entry.slug.slice(0, entry.slug.indexOf("/"));
@@ -53,7 +53,7 @@ import {
53
53
  orderForSubagents,
54
54
  } from "./catalog/sync";
55
55
  import { multiAgentV2EnabledFromConfigText } from "./features";
56
- import { enforceCatalogSlugUniqueness, exactComboCatalogSlugs } from "./catalog/aggregation";
56
+ import { comboCatalogDescriptions, enforceCatalogSlugUniqueness, exactComboCatalogSlugs } from "./catalog/aggregation";
57
57
  import {
58
58
  isNativeAliasCatalogEntry,
59
59
  accountBoundNativeOpenAiSlugs,
@@ -392,6 +392,7 @@ function prepareCatalog(
392
392
  multiAgentV2Enabled,
393
393
  keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true,
394
394
  exactComboSlugs,
395
+ comboDescriptions: comboCatalogDescriptions(config),
395
396
  hasPhysicalComboProvider,
396
397
  includeNativeOpenAi,
397
398
  accountBoundEntries,
@@ -0,0 +1,125 @@
1
+ import { existsSync } from "node:fs";
2
+ import { isTestHomeGuardArmed } from "../lib/test-home-guard";
3
+
4
+ /** What a low-quota notice may tell the operating system. Never an email, raw id or credential. */
5
+ export type LowQuotaNotice = {
6
+ /** Pseudonymous `p<hex6>` log label of the pool account. */
7
+ accountLabel: string;
8
+ window: "short" | "weekly";
9
+ percentUsed: number;
10
+ threshold: number;
11
+ /** Epoch milliseconds of the window reset, when known. */
12
+ resetAt: number | null;
13
+ };
14
+
15
+ /** Resolves when the OS notifier accepted the notice; rejects on failure, timeout or abort. */
16
+ export type LowQuotaNoticeSink = (notice: LowQuotaNotice, signal: AbortSignal) => Promise<void>;
17
+
18
+ type SpawnedNotifier = { exited: Promise<number | null>; kill(): void };
19
+ export type LowQuotaNotifierIo = {
20
+ platform?: NodeJS.Platform;
21
+ /** Returns true when the absolute path is an existing executable candidate. */
22
+ exists?: (path: string) => boolean;
23
+ spawn?: (argv: string[]) => SpawnedNotifier;
24
+ timeoutMs?: number;
25
+ /** Formats the reset time for display; defaults to the host locale. */
26
+ formatTime?: (epochMs: number) => string;
27
+ };
28
+
29
+ const OSASCRIPT = "/usr/bin/osascript";
30
+ /** Literal system locations only: a PATH lookup would let any earlier PATH entry run as the proxy. */
31
+ const NOTIFY_SEND_CANDIDATES = ["/usr/bin/notify-send", "/usr/local/bin/notify-send", "/bin/notify-send"];
32
+ const DEFAULT_TIMEOUT_MS = 5_000;
33
+ const TITLE = "OpenCodex quota";
34
+
35
+ export function formatLowQuotaNotice(notice: LowQuotaNotice, formatTime: (epochMs: number) => string = defaultFormatTime): { title: string; body: string } {
36
+ const window = notice.window === "short" ? "5-hour" : "weekly";
37
+ const reset = notice.resetAt === null ? "reset time unknown" : `resets ${formatTime(notice.resetAt)}`;
38
+ return {
39
+ title: TITLE,
40
+ body: `Account ${notice.accountLabel}: ${window} quota at ${Math.round(notice.percentUsed)}% (threshold ${notice.threshold}%), ${reset}.`,
41
+ };
42
+ }
43
+
44
+ function defaultFormatTime(epochMs: number): string {
45
+ return new Date(epochMs).toLocaleString();
46
+ }
47
+
48
+ /** notify-send bodies may be parsed as markup by the notification server. */
49
+ function escapeMarkup(text: string): string {
50
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
51
+ }
52
+
53
+ /**
54
+ * Builds the fixed argv for the host notifier. The notice text is always a separate argv item:
55
+ * the AppleScript source is constant and reads its arguments, and notify-send stops option
56
+ * parsing at `--`, so no account or quota text is ever interpreted as script or flags.
57
+ */
58
+ export function lowQuotaNotifierArgv(
59
+ platform: NodeJS.Platform,
60
+ exists: (path: string) => boolean,
61
+ title: string,
62
+ body: string,
63
+ ): string[] | undefined {
64
+ if (platform === "darwin") {
65
+ if (!exists(OSASCRIPT)) return undefined;
66
+ return [OSASCRIPT, "-e", "on run argv", "-e", "display notification (item 2 of argv) with title (item 1 of argv)", "-e", "end run", title, body];
67
+ }
68
+ if (platform === "linux") {
69
+ const notifySend = NOTIFY_SEND_CANDIDATES.find(exists);
70
+ if (!notifySend) return undefined;
71
+ return [notifySend, "--app-name=OpenCodex", "--", title, escapeMarkup(body)];
72
+ }
73
+ return undefined;
74
+ }
75
+
76
+ function defaultSpawn(argv: string[]): SpawnedNotifier {
77
+ const proc = Bun.spawn(argv, { stdin: "ignore", stdout: "ignore", stderr: "ignore", windowsHide: true });
78
+ return { exited: proc.exited, kill: () => { try { proc.kill(); } catch { /* already gone */ } } };
79
+ }
80
+
81
+ /**
82
+ * Returns a sink that hands notices to this host's notification service, or undefined when the
83
+ * platform has none we support (Windows and others stay log-only). An armed test process never
84
+ * reaches a real notifier unless a spawn is injected.
85
+ */
86
+ export function resolveLowQuotaDesktopSink(io: LowQuotaNotifierIo = {}): LowQuotaNoticeSink | undefined {
87
+ if (!io.spawn && isTestHomeGuardArmed()) return undefined;
88
+ const platform = io.platform ?? process.platform;
89
+ const exists = io.exists ?? existsSync;
90
+ const spawn = io.spawn ?? defaultSpawn;
91
+ const timeoutMs = io.timeoutMs ?? DEFAULT_TIMEOUT_MS;
92
+ const probe = lowQuotaNotifierArgv(platform, exists, TITLE, "");
93
+ if (!probe) return undefined;
94
+ return (notice, signal) => {
95
+ if (signal.aborted) return Promise.reject(new Error("low-quota notice cancelled"));
96
+ const { title, body } = formatLowQuotaNotice(notice, io.formatTime);
97
+ const argv = lowQuotaNotifierArgv(platform, exists, title, body);
98
+ if (!argv) return Promise.reject(new Error("low-quota notifier unavailable"));
99
+ let child: SpawnedNotifier;
100
+ try {
101
+ child = spawn(argv);
102
+ } catch (error) {
103
+ return Promise.reject(error);
104
+ }
105
+ return new Promise<void>((resolve, reject) => {
106
+ let settled = false;
107
+ const finish = (error?: Error) => {
108
+ if (settled) return;
109
+ settled = true;
110
+ clearTimeout(timer);
111
+ signal.removeEventListener("abort", onAbort);
112
+ if (error) reject(error);
113
+ else resolve();
114
+ };
115
+ const onAbort = () => { child.kill(); finish(new Error("low-quota notice cancelled")); };
116
+ const timer = setTimeout(() => { child.kill(); finish(new Error("low-quota notifier timed out")); }, timeoutMs);
117
+ (timer as { unref?: () => void }).unref?.();
118
+ signal.addEventListener("abort", onAbort, { once: true });
119
+ child.exited.then(
120
+ code => finish(code === 0 ? undefined : new Error(`low-quota notifier exited ${code}`)),
121
+ error => finish(error instanceof Error ? error : new Error("low-quota notifier failed")),
122
+ );
123
+ });
124
+ };
125
+ }
@@ -4,15 +4,28 @@ import { isCodexAccountPaused, setCodexAccountPaused } from "./account-pause";
4
4
  import { createLowQuotaEventLedger, type LowQuotaEvent } from "./low-quota-events";
5
5
  import { registerLowQuotaObserver } from "./low-quota-observer";
6
6
  import { resetAtToMs } from "./quota-types";
7
+ import { codexAccountLogLabel, fallbackCodexAccountLogLabel } from "./account-label";
8
+ import type { LowQuotaNotice, LowQuotaNoticeSink } from "./low-quota-desktop-notifier";
7
9
 
8
- type Window = "short" | "weekly";
9
- type Notice = { window: Window; percentUsed: number; threshold: number };
10
10
  type Dependencies = {
11
11
  persist?: (config: OcxConfig) => void | Promise<void>;
12
- notify?: (notice: Notice) => void | Promise<void>;
12
+ /** Injected sink; takes precedence over desktop delivery. */
13
+ notify?: (notice: LowQuotaNotice, signal: AbortSignal) => void | Promise<void>;
14
+ /** Resolved per notice, and only while the live policy opts into actions.desktop. */
15
+ desktopSink?: () => LowQuotaNoticeSink | undefined;
13
16
  };
14
17
  type EventBase = Omit<LowQuotaEvent, "timestamp" | "status" | "delivery">;
15
- type Episode = { reset: string; notice: "in-flight" | "logged" | "delivered" | "failed" | undefined; pausedByUs: boolean; noticeBase?: EventBase };
18
+ type Episode = {
19
+ reset: string;
20
+ notice: "in-flight" | "logged" | "delivered" | "failed" | undefined;
21
+ pausedByUs: boolean;
22
+ noticeBase?: EventBase;
23
+ noticeAbort?: AbortController;
24
+ /** A failed notice may retry only after this time, so a broken notifier is not respawned per request. */
25
+ noticeRetryAt?: number;
26
+ /** The episode's log line was written; retries do not repeat it. */
27
+ warned?: boolean;
28
+ };
16
29
  export type LowQuotaRegistration = (() => void) & {
17
30
  hasPendingSave(): boolean;
18
31
  flush(): Promise<void>;
@@ -20,6 +33,7 @@ export type LowQuotaRegistration = (() => void) & {
20
33
  };
21
34
 
22
35
  const RETRY_DELAYS_MS = [100, 250];
36
+ const NOTICE_RETRY_MS = 5 * 60_000;
23
37
  const FLUSH_DEADLINE_MS = 500;
24
38
 
25
39
  /** Each server owns its own policy, episode state and deferred writer. */
@@ -44,6 +58,12 @@ export function registerCodexLowQuotaProtection(config: OcxConfig, deps: Depende
44
58
  if (delivery === "pause-save") pauseStatus.set(base, status);
45
59
  ledger.publish({ ...base, delivery, status, timestamp: Date.now() });
46
60
  }
61
+ /** Publishes one cancellation for an in-flight notice and stops its delivery. */
62
+ function cancelNotice(episode: Episode): void {
63
+ if (episode.notice === "in-flight" && episode.noticeBase) event(episode.noticeBase, "notice", "cancelled");
64
+ episode.noticeAbort?.abort();
65
+ episode.noticeAbort = undefined;
66
+ }
47
67
  function cancelTimer(): void {
48
68
  if (saveTimer) clearTimeout(saveTimer);
49
69
  saveTimer = null;
@@ -132,9 +152,7 @@ export function registerCodexLowQuotaProtection(config: OcxConfig, deps: Depende
132
152
  if (pauseStatus.get(base) === "pending") event(base, "pause-save", "cancelled");
133
153
  pendingPauseEvents.delete(key);
134
154
  }
135
- for (const episode of episodes.values()) {
136
- if (episode.notice === "in-flight" && episode.noticeBase) event(episode.noticeBase, "notice", "cancelled");
137
- }
155
+ for (const episode of episodes.values()) cancelNotice(episode);
138
156
  dirty = false;
139
157
  autoPausedAccounts.clear();
140
158
  unregister();
@@ -175,9 +193,7 @@ export function registerCodexLowQuotaProtection(config: OcxConfig, deps: Depende
175
193
  const policy = config.codexPool?.lowQuotaProtection;
176
194
  const nextKey = JSON.stringify(policy);
177
195
  if (policyKey !== nextKey) {
178
- for (const episode of episodes.values()) {
179
- if (episode.notice === "in-flight" && episode.noticeBase) event(episode.noticeBase, "notice", "cancelled");
180
- }
196
+ for (const episode of episodes.values()) cancelNotice(episode);
181
197
  episodes.clear();
182
198
  autoPausedAccounts.clear();
183
199
  policyKey = nextKey;
@@ -187,7 +203,7 @@ export function registerCodexLowQuotaProtection(config: OcxConfig, deps: Depende
187
203
  for (const id of autoPausedAccounts) if (!liveIds.has(id)) autoPausedAccounts.delete(id);
188
204
  for (const [key, episode] of episodes) {
189
205
  if (liveIds.has(key.split("\u0000")[0]!)) continue;
190
- if (episode.notice === "in-flight" && episode.noticeBase) event(episode.noticeBase, "notice", "cancelled");
206
+ cancelNotice(episode);
191
207
  episodes.delete(key);
192
208
  }
193
209
  if (!liveIds.has(accountId)) return;
@@ -203,7 +219,7 @@ export function registerCodexLowQuotaProtection(config: OcxConfig, deps: Depende
203
219
  const key = `${accountId}\u0000${window}`;
204
220
  if (percentUsed < policy.threshold) {
205
221
  const prior = episodes.get(key);
206
- if (prior?.notice === "in-flight" && prior.noticeBase) event(prior.noticeBase, "notice", "cancelled");
222
+ if (prior) cancelNotice(prior);
207
223
  episodes.delete(key);
208
224
  continue;
209
225
  }
@@ -211,6 +227,7 @@ export function registerCodexLowQuotaProtection(config: OcxConfig, deps: Depende
211
227
  const reset = resetAt === null ? "unknown" : String(resetAt);
212
228
  let episode = episodes.get(key);
213
229
  if (!episode || episode.reset !== reset) {
230
+ if (episode) cancelNotice(episode);
214
231
  episode = { reset, notice: undefined, pausedByUs: false };
215
232
  episodes.set(key, episode);
216
233
  }
@@ -234,32 +251,41 @@ export function registerCodexLowQuotaProtection(config: OcxConfig, deps: Depende
234
251
  scheduleSave();
235
252
  }
236
253
  // A manual resume leaves pausedByUs set until recovery or a new reset episode.
237
- if (policy.actions.notify && episode.notice !== "in-flight" && episode.notice !== "logged" && episode.notice !== "delivered") {
238
- console.warn(`[codex-low-quota] ${window === "short" ? "5-hour" : "weekly"} quota reached ${percentUsed}% used (threshold ${policy.threshold}%)`);
239
- if (!deps.notify) {
254
+ if (policy.actions.notify && episode.notice !== "in-flight" && episode.notice !== "logged" && episode.notice !== "delivered"
255
+ && !(episode.notice === "failed" && (episode.noticeRetryAt ?? 0) > Date.now())) {
256
+ if (!episode.warned) {
257
+ episode.warned = true;
258
+ console.warn(`[codex-low-quota] ${window === "short" ? "5-hour" : "weekly"} quota reached ${percentUsed}% used (threshold ${policy.threshold}%)`);
259
+ }
260
+ const sink = deps.notify ?? (policy.actions.desktop === true ? deps.desktopSink?.() : undefined);
261
+ if (!sink) {
240
262
  event(base, "notice", "logged");
241
263
  episode.notice = "logged";
242
264
  continue;
243
265
  }
244
- episode.notice = "in-flight";
245
- episode.noticeBase = base;
266
+ const account = config.codexAccounts?.find(candidate => candidate.id === accountId);
267
+ const notice: LowQuotaNotice = {
268
+ accountLabel: account ? codexAccountLogLabel(account) : fallbackCodexAccountLogLabel(accountId),
269
+ window, percentUsed, threshold: policy.threshold, resetAt,
270
+ };
271
+ const abort = new AbortController();
272
+ const current = episode;
273
+ current.notice = "in-flight";
274
+ current.noticeBase = base;
275
+ current.noticeAbort = abort;
246
276
  event(base, "notice", "pending");
277
+ const settle = (status: "delivered" | "failed") => {
278
+ if (closed || abort.signal.aborted || episodes.get(key) !== current) return;
279
+ event(base, "notice", status);
280
+ current.notice = status;
281
+ current.noticeRetryAt = status === "failed" ? Date.now() + NOTICE_RETRY_MS : undefined;
282
+ current.noticeBase = undefined;
283
+ current.noticeAbort = undefined;
284
+ };
247
285
  try {
248
- void Promise.resolve(deps.notify({ window, percentUsed, threshold: policy.threshold })).then(() => {
249
- if (closed || episodes.get(key) !== episode) return;
250
- event(base, "notice", "delivered");
251
- episode.notice = "delivered";
252
- episode.noticeBase = undefined;
253
- }, () => {
254
- if (closed || episodes.get(key) !== episode) return;
255
- event(base, "notice", "failed");
256
- episode.notice = "failed";
257
- episode.noticeBase = undefined;
258
- });
286
+ void Promise.resolve(sink(notice, abort.signal)).then(() => settle("delivered"), () => settle("failed"));
259
287
  } catch {
260
- event(base, "notice", "failed");
261
- episode.notice = "failed";
262
- episode.noticeBase = undefined;
288
+ settle("failed");
263
289
  }
264
290
  }
265
291
  }