@bitkyc08/opencodex 2.75.0 → 2.76.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 (217) hide show
  1. package/README.md +6 -1
  2. package/bin/ocx.mjs +12 -1
  3. package/gui/dist/assets/App-CddorHdA.js +51 -0
  4. package/gui/dist/assets/App-DMudtISW.css +1 -0
  5. package/gui/dist/assets/{Tray-CGAD2AGk.js → Tray-DjNwwrWS.js} +1 -1
  6. package/gui/dist/assets/index-Btze1I3T.css +1 -0
  7. package/gui/dist/assets/index-CvnY7E6i.js +86 -0
  8. package/gui/dist/assets/quota-summary-B_AWpHfP.js +1 -0
  9. package/gui/dist/index.html +2 -2
  10. package/gui/dist/provider-icons/opengateway.svg +12 -0
  11. package/package.json +1 -1
  12. package/src/adapters/anthropic/account-metadata.ts +17 -0
  13. package/src/adapters/anthropic/client-identity.ts +69 -0
  14. package/src/adapters/anthropic/passthrough.ts +23 -4
  15. package/src/adapters/anthropic.ts +18 -1
  16. package/src/adapters/command-code-project-context.ts +7 -11
  17. package/src/adapters/devin/cloud-direct/tool-schema.ts +12 -48
  18. package/src/adapters/kiro-retry.ts +23 -8
  19. package/src/adapters/openai-chat/messages.ts +1 -1
  20. package/src/adapters/registry.ts +8 -1
  21. package/src/adapters/zed.ts +377 -0
  22. package/src/bridge/sse.ts +4 -2
  23. package/src/chatgpt/app-server-shim/app-server-rewrite.ts +45 -0
  24. package/src/chatgpt/app-server-shim/bundle-trust.ts +77 -0
  25. package/src/chatgpt/app-server-shim/filter.ts +157 -0
  26. package/src/chatgpt/app-server-shim/gate-rewrite.ts +99 -0
  27. package/src/chatgpt/app-server-shim/launcher.ts +91 -0
  28. package/src/claude/desktop-3p-startup.ts +62 -0
  29. package/src/claude/desktop-3p.ts +5 -0
  30. package/src/claude/first-party-settings.ts +14 -0
  31. package/src/claude/intercept/cli-catalog.ts +150 -0
  32. package/src/claude/intercept/cli-picker.ts +82 -0
  33. package/src/claude/intercept/listener.ts +21 -6
  34. package/src/claude/intercept/picker-bootstrap.ts +23 -5
  35. package/src/claude/intercept/picker-models.ts +57 -13
  36. package/src/claude/intercept/runtime.ts +30 -2
  37. package/src/claude/intercept/settings.ts +3 -0
  38. package/src/cli/agent.ts +172 -0
  39. package/src/cli/alias.ts +2 -1
  40. package/src/cli/capabilities-command.ts +3 -2
  41. package/src/cli/capabilities.ts +87 -0
  42. package/src/cli/chatgpt-command.ts +140 -0
  43. package/src/cli/combo.ts +144 -19
  44. package/src/cli/config-command.ts +3 -1
  45. package/src/cli/dispatch.ts +10 -1
  46. package/src/cli/help.ts +1 -0
  47. package/src/cli/index.ts +5 -24
  48. package/src/cli/init.ts +16 -2
  49. package/src/cli/integrations.ts +13 -0
  50. package/src/cli/internal-command.ts +12 -5
  51. package/src/cli/observe.ts +6 -1
  52. package/src/cli/registry.ts +6 -0
  53. package/src/cli/route-policy.ts +3 -1
  54. package/src/cli/runtime-api.ts +5 -2
  55. package/src/cli/status-oauth.ts +5 -0
  56. package/src/cli/status.ts +8 -1
  57. package/src/cli/stop-approval.ts +55 -16
  58. package/src/cli/stop-report.ts +5 -0
  59. package/src/clients/lazycodex.ts +48 -0
  60. package/src/clients/omo-role-models.ts +111 -0
  61. package/src/codex/account-credit-use.ts +99 -0
  62. package/src/codex/account-lifecycle.ts +2 -0
  63. package/src/codex/agent-role-models.ts +171 -0
  64. package/src/codex/auth-api/account-list.ts +10 -3
  65. package/src/codex/auth-api/routes.ts +34 -0
  66. package/src/codex/auth-context.ts +52 -11
  67. package/src/codex/catalog/discovered-natives.ts +11 -1
  68. package/src/codex/catalog/gather-capture.ts +6 -1
  69. package/src/codex/catalog/model-hints.ts +9 -1
  70. package/src/codex/catalog/provider-models.ts +60 -0
  71. package/src/codex/catalog/routed-gather.ts +30 -5
  72. package/src/codex/convergence-types.ts +2 -1
  73. package/src/codex/main-account-external-usage.ts +62 -0
  74. package/src/codex/main-account-hard-lock.ts +24 -13
  75. package/src/codex/native-profile-processes.ts +53 -16
  76. package/src/codex/prompt-layers/toml-edit.ts +1 -1
  77. package/src/codex/quota-types.ts +3 -7
  78. package/src/codex/quota.ts +19 -4
  79. package/src/codex/role-auto-assign.ts +159 -0
  80. package/src/codex/role-sizing-limits.ts +2 -0
  81. package/src/codex/role-sizing.ts +216 -0
  82. package/src/codex/routing/selection.ts +36 -0
  83. package/src/codex/routing/thread-affinity.ts +2 -0
  84. package/src/codex/routing.ts +4 -4
  85. package/src/codex/subagent-model-fallback.ts +82 -26
  86. package/src/codex/subagent-selectable-models.ts +32 -0
  87. package/src/combos/index.ts +18 -0
  88. package/src/combos/jev-decision-contract.ts +20 -0
  89. package/src/combos/jev-dispatch.ts +18 -0
  90. package/src/combos/jev-model-backend.ts +127 -0
  91. package/src/combos/jev.ts +249 -33
  92. package/src/combos/types.ts +125 -2
  93. package/src/config/diagnostics.ts +18 -0
  94. package/src/config/macos-system-proxy.ts +72 -15
  95. package/src/config/owner-registry.ts +13 -4
  96. package/src/config/process-state.ts +17 -8
  97. package/src/config/proxy-env.ts +35 -5
  98. package/src/config/schema/config-schema.ts +30 -1
  99. package/src/config/schema/leaf-validators.ts +4 -0
  100. package/src/config/serving-runtimes.ts +95 -1
  101. package/src/generated/compatibility-version.json +336 -156
  102. package/src/lib/errors.ts +7 -7
  103. package/src/lib/jsonc.ts +13 -0
  104. package/src/lib/local-chat-completion.ts +144 -0
  105. package/src/lib/provider-outbound.ts +87 -1
  106. package/src/lib/provider-runtime-fetch.ts +23 -0
  107. package/src/lib/provider-tls-profile.ts +221 -0
  108. package/src/lib/request-execution-budget.ts +16 -6
  109. package/src/lib/spend-reservation-ledger.ts +54 -20
  110. package/src/lib/sse-decoder.ts +20 -17
  111. package/src/lib/synced-state-location.ts +96 -0
  112. package/src/lib/winsw.ts +14 -5
  113. package/src/oauth/anthropic-account-refusal.ts +53 -4
  114. package/src/oauth/anthropic-model-quota.ts +83 -0
  115. package/src/oauth/anthropic-rate-limit-policy.ts +37 -0
  116. package/src/oauth/anthropic-routing.ts +112 -58
  117. package/src/oauth/health.ts +39 -3
  118. package/src/oauth/index.ts +14 -0
  119. package/src/oauth/kiro-account-load.ts +10 -0
  120. package/src/oauth/zed.ts +134 -0
  121. package/src/providers/model-discovery.ts +19 -8
  122. package/src/providers/model-rename-fields.ts +1 -0
  123. package/src/providers/provider-id-rewrite.ts +9 -2
  124. package/src/providers/quota/account-cache.ts +29 -7
  125. package/src/providers/quota/anthropic-cooldown-recovery.ts +18 -0
  126. package/src/providers/quota/anthropic-family-headers.ts +25 -0
  127. package/src/providers/quota/vendor-probes-oauth.ts +5 -1
  128. package/src/providers/quota-types.ts +4 -0
  129. package/src/providers/registry/entries-core.ts +18 -0
  130. package/src/providers/registry/entries-extended.ts +55 -0
  131. package/src/providers/registry/types.ts +3 -0
  132. package/src/providers/xai-fast-model.ts +12 -4
  133. package/src/providers/zed.ts +598 -0
  134. package/src/responses/state/snapshot-select.ts +22 -9
  135. package/src/responses/state.ts +2 -3
  136. package/src/router.ts +9 -0
  137. package/src/server/anthropic-rate-limit-headers.ts +7 -0
  138. package/src/server/auth-cors.ts +7 -0
  139. package/src/server/chat-completions.ts +9 -4
  140. package/src/server/claude-messages.ts +59 -21
  141. package/src/server/index/claude-intercept-lifecycle.ts +71 -25
  142. package/src/server/index/optional-listeners.ts +4 -0
  143. package/src/server/index/spend-ledger-lifecycle.ts +4 -0
  144. package/src/server/management/agent-settings-routes.ts +74 -37
  145. package/src/server/management/claude-desktop-picker-routes.ts +7 -0
  146. package/src/server/management/claude-intercept-routes.ts +35 -0
  147. package/src/server/management/codex-agent-role-routes.ts +122 -0
  148. package/src/server/management/codex-role-auto-assign.ts +208 -0
  149. package/src/server/management/combo-routes.ts +62 -5
  150. package/src/server/management/config-routes.ts +35 -3
  151. package/src/server/management/context.ts +4 -1
  152. package/src/server/management/decision-discovery.ts +74 -0
  153. package/src/server/management/decision-model-validation.ts +80 -0
  154. package/src/server/management/decision-routes.ts +160 -0
  155. package/src/server/management/native-integration-routes.ts +16 -0
  156. package/src/server/management/provider-routes.ts +30 -34
  157. package/src/server/management/route-registry.ts +12 -0
  158. package/src/server/management/sibling-guard.ts +1 -0
  159. package/src/server/management/zed-provider-probe.ts +38 -0
  160. package/src/server/management-api.ts +12 -1
  161. package/src/server/messages-native-oauth.ts +7 -2
  162. package/src/server/messages-native.ts +7 -3
  163. package/src/server/relay.ts +3 -1
  164. package/src/server/request-log.ts +2 -0
  165. package/src/server/responses/adapter-continuation.ts +3 -3
  166. package/src/server/responses/adapter-dispatch.ts +18 -3
  167. package/src/server/responses/compact.ts +14 -1
  168. package/src/server/responses/core-auth.ts +2 -0
  169. package/src/server/responses/core-combo.ts +67 -11
  170. package/src/server/responses/core-normalize.ts +21 -1
  171. package/src/server/responses/core-options.ts +11 -1
  172. package/src/server/responses/core.ts +5 -3
  173. package/src/server/responses/fetch-helpers.ts +6 -3
  174. package/src/server/responses/jev-model-invoke.ts +190 -0
  175. package/src/server/responses/passthrough-delivery.ts +16 -0
  176. package/src/server/responses/policy-fallback.ts +55 -3
  177. package/src/server/responses/policy-request-scope.ts +80 -0
  178. package/src/server/responses/request-prepare.ts +70 -18
  179. package/src/server/responses/request-transport.ts +54 -23
  180. package/src/server/responses/run-turn-execution.ts +1 -0
  181. package/src/server/responses/sidecar-execution.ts +9 -3
  182. package/src/server/responses-hosted-image-display.ts +225 -0
  183. package/src/service/managing-cli.ts +34 -4
  184. package/src/service/systemd-env.ts +75 -0
  185. package/src/service/windows-guarded-manager.ts +60 -19
  186. package/src/service/windows-scheduler.ts +22 -3
  187. package/src/service/windows-taskxml.ts +32 -10
  188. package/src/service-manager-probe.ts +72 -19
  189. package/src/types/config.ts +31 -1
  190. package/src/types/provider.ts +2 -0
  191. package/src/types/request.ts +2 -0
  192. package/src/update/index.ts +39 -6
  193. package/src/update/job.ts +63 -63
  194. package/src/update/npm-cache-preflight.d.mts +33 -1
  195. package/src/update/npm-cache-preflight.mjs +131 -18
  196. package/src/update/ownership-transaction.ts +19 -1
  197. package/src/update/restart-ownership.ts +80 -6
  198. package/src/update/transactional-install.d.mts +2 -0
  199. package/src/update/transactional-install.mjs +6 -1
  200. package/src/usage/jev-stats.ts +28 -1
  201. package/src/usage/log.ts +1 -1
  202. package/src/vision/anthropic-describe.ts +5 -3
  203. package/src/vision/index.ts +1 -0
  204. package/src/vision/plan.ts +2 -1
  205. package/src/vision/routed-describe.ts +29 -121
  206. package/src/web-search/alpha-search.ts +3 -3
  207. package/src/web-search/anthropic-executor.ts +6 -4
  208. package/src/web-search/index.ts +1 -1
  209. package/src/web-search/loop.ts +3 -3
  210. package/src/web-search/passthrough-bridge.ts +2 -1
  211. package/src/web-search/run-turn-loop.ts +1 -1
  212. package/src/web-search/sidecar-providers.ts +2 -1
  213. package/gui/dist/assets/App-ia4U0ufY.css +0 -1
  214. package/gui/dist/assets/App-xm0zGNOe.js +0 -51
  215. package/gui/dist/assets/index-DBT3Q31Y.css +0 -1
  216. package/gui/dist/assets/index-D_RGAT6Q.js +0 -86
  217. package/gui/dist/assets/quota-summary-CUFq48t-.js +0 -1
@@ -0,0 +1,99 @@
1
+ /** Plain-quota gate rewriting; usage display and non-quota restrictions are preserved. */
2
+ const PLAIN_QUOTA_REACHED_TYPE = "rate_limit_reached";
3
+
4
+ function isRecord(value: unknown): value is Record<string, unknown> {
5
+ return typeof value === "object" && value !== null && !Array.isArray(value);
6
+ }
7
+
8
+ function hasNonQuotaBlock(node: unknown): boolean {
9
+ if (Array.isArray(node)) return node.some(hasNonQuotaBlock);
10
+ if (!isRecord(node)) return false;
11
+ if (node.spendControlReached !== undefined && node.spendControlReached !== null && node.spendControlReached !== false) return true;
12
+ if (isRecord(node.spend_control) && node.spend_control.reached === true) return true;
13
+ const reached = node.rate_limit_reached_type;
14
+ if (isRecord(reached) && typeof reached.type === "string" && reached.type !== PLAIN_QUOTA_REACHED_TYPE) return true;
15
+ const rpcReached = node.rateLimitReachedType;
16
+ const rpcType = typeof rpcReached === "string" ? rpcReached : isRecord(rpcReached) ? rpcReached.type : undefined;
17
+ if (typeof rpcType === "string" && rpcType !== PLAIN_QUOTA_REACHED_TYPE) return true;
18
+ return Object.values(node).some(hasNonQuotaBlock);
19
+ }
20
+
21
+ export function unlockRateLimitGate(value: unknown): boolean {
22
+ let changed = false;
23
+ const payloadBlocked = hasNonQuotaBlock(value);
24
+ // A subtree shows the plain quota as the reason when a plain-quota reached type was removed in
25
+ // it or a usage window reads 100%; it stays "blocked" when a non-quota reason (workspace or
26
+ // credit reached type, spend control) is still standing in it.
27
+ const visit = (node: unknown): { cleared: boolean; exhausted: boolean; blocked: boolean } => {
28
+ let cleared = false;
29
+ let exhausted = false;
30
+ let blocked = false;
31
+ if (Array.isArray(node)) {
32
+ for (const item of node) {
33
+ const r = visit(item);
34
+ cleared ||= r.cleared;
35
+ exhausted ||= r.exhausted;
36
+ blocked ||= r.blocked;
37
+ }
38
+ return { cleared, exhausted, blocked };
39
+ }
40
+ if (!isRecord(node)) return { cleared, exhausted, blocked };
41
+ if (typeof node.usedPercent === "number" && node.usedPercent >= 100) exhausted = true;
42
+ if (node.spendControlReached !== undefined && node.spendControlReached !== null && node.spendControlReached !== false) blocked = true;
43
+ if (isRecord(node.spend_control) && node.spend_control.reached === true) blocked = true;
44
+ const reachedType = node.rate_limit_reached_type;
45
+ if (isRecord(reachedType) && reachedType.type === PLAIN_QUOTA_REACHED_TYPE) {
46
+ delete node.rate_limit_reached_type;
47
+ changed = true;
48
+ cleared = true;
49
+ } else if (isRecord(reachedType) && typeof reachedType.type === "string") {
50
+ blocked = true;
51
+ }
52
+ // The app-server's JSON-RPC spelling of the same field: a nullable string.
53
+ const rpcReached = node.rateLimitReachedType;
54
+ const rpcType = typeof rpcReached === "string" ? rpcReached : isRecord(rpcReached) ? rpcReached.type : undefined;
55
+ if (rpcType === PLAIN_QUOTA_REACHED_TYPE) {
56
+ node.rateLimitReachedType = null;
57
+ changed = true;
58
+ cleared = true;
59
+ } else if (typeof rpcType === "string") {
60
+ blocked = true;
61
+ }
62
+ for (const child of Object.values(node)) {
63
+ const r = visit(child);
64
+ cleared ||= r.cleared;
65
+ exhausted ||= r.exhausted;
66
+ blocked ||= r.blocked;
67
+ }
68
+ // The rate-limit flags are opened only when the plain quota is the visible reason in this
69
+ // subtree and no workspace, credit or spend-control reason stands anywhere in the payload.
70
+ // A flag closed for a reason the payload does not show stays as the server sent it.
71
+ const plainQuotaShown = (cleared || exhausted) && !blocked && !payloadBlocked;
72
+ if (plainQuotaShown) {
73
+ for (const key of ["rate_limit", "rateLimit"] as const) {
74
+ const rateLimit = node[key];
75
+ if (!isRecord(rateLimit)) continue;
76
+ if (rateLimit.allowed === false) {
77
+ rateLimit.allowed = true;
78
+ changed = true;
79
+ }
80
+ for (const limitKey of ["limit_reached", "limitReached"] as const) {
81
+ if (rateLimit[limitKey] === true) {
82
+ rateLimit[limitKey] = false;
83
+ changed = true;
84
+ }
85
+ }
86
+ }
87
+ }
88
+ // `ordinaryUsageAllowed: false` is the same quota gate seen from the RPC side, and the only
89
+ // field the app reads for it. Open it only when the plain quota is the visible reason and
90
+ // nothing else still explains the block.
91
+ if (node.ordinaryUsageAllowed === false && plainQuotaShown) {
92
+ node.ordinaryUsageAllowed = true;
93
+ changed = true;
94
+ }
95
+ return { cleared, exhausted, blocked };
96
+ };
97
+ visit(value);
98
+ return changed;
99
+ }
@@ -0,0 +1,91 @@
1
+ import { accessSync, chmodSync, constants, mkdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { randomBytes } from "node:crypto";
3
+ import { join, posix } from "node:path";
4
+ import { getConfigDir } from "../../config/paths";
5
+ import { selfLaunchArgv } from "../../lib/self-launch-argv";
6
+
7
+ export const CHATGPT_APP_CODEX_BINARY = "/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex";
8
+
9
+ /**
10
+ * Where the app-server binary sits inside a ChatGPT bundle, newest layout first. The bundle root
11
+ * is discovered by identity (com.openai.codex), so an install in ~/Applications or on another
12
+ * volume resolves to its own binary instead of the conventional /Applications path.
13
+ */
14
+ const BUNDLED_CODEX_LAYOUTS: readonly (readonly string[])[] = [
15
+ ["Contents", "Resources", "codex-cli", "CodexCLI.app", "Contents", "MacOS", "codex"],
16
+ ["Contents", "Resources", "codex"],
17
+ ];
18
+
19
+ function isExecutableFile(path: string): boolean {
20
+ try {
21
+ if (!statSync(path).isFile()) return false;
22
+ accessSync(path, constants.X_OK);
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ /** The bundled app-server binary of a verified bundle root, or null when the bundle has none. */
30
+ export function resolveChatgptCodexBinary(
31
+ bundleRoot: string,
32
+ isExecutable: (path: string) => boolean = isExecutableFile,
33
+ ): string | null {
34
+ for (const layout of BUNDLED_CODEX_LAYOUTS) {
35
+ const candidate = posix.join(bundleRoot, ...layout); // macOS bundle paths are POSIX on every host
36
+ if (isExecutable(candidate)) return candidate;
37
+ }
38
+ return null;
39
+ }
40
+
41
+ export function chatgptShimLauncherPath(configDir = getConfigDir()): string {
42
+ return join(configDir, "chatgpt-codex-shim.sh");
43
+ }
44
+
45
+ function shellQuote(value: string): string {
46
+ return `'${value.replace(/'/g, `'\\''`)}'`;
47
+ }
48
+
49
+ /**
50
+ * A failed precondition or a failed self-test runs the original binary with untouched stdout.
51
+ * A filter that passes the self-test and then dies mid-session closes the pipe.
52
+ * Expected (not yet validated against the bundled app-server): the server gets SIGPIPE or a
53
+ * write error and Desktop respawns it through the same launcher.
54
+ */
55
+ export function buildChatgptShimLauncher(argv: readonly string[], real = CHATGPT_APP_CODEX_BINARY): string {
56
+ return `#!/bin/bash
57
+ # opencodex (experimental): ChatGPT app-server stdout passes through the quota-gate filter.
58
+ REAL=${shellQuote(real)}
59
+ FILTER=(${argv.map(shellQuote).join(" ")})
60
+ if [ ! -x "$REAL" ]; then
61
+ echo "opencodex (experimental): the bundled app-server is no longer at $REAL; run 'ocx chatgpt launch' again or 'ocx chatgpt restore'." >&2
62
+ exit 127
63
+ fi
64
+ if [ "$(uname -s)" = "Darwin" ] && [ -x "\${FILTER[0]}" ] \\
65
+ && "\${FILTER[@]}" --self-test >/dev/null 2>&1; then
66
+ exec "$REAL" "$@" > >(exec "\${FILTER[@]}")
67
+ fi
68
+ exec "$REAL" "$@"
69
+ `;
70
+ }
71
+
72
+ /**
73
+ * Written to a fresh temp file (exclusive create, never through a link) and renamed over the
74
+ * launcher, so an existing symbolic link is replaced rather than followed and a respawn during
75
+ * the write never runs a half-written script.
76
+ */
77
+ export function writeChatgptShimLauncher(configDir = getConfigDir(), real = CHATGPT_APP_CODEX_BINARY): string {
78
+ mkdirSync(configDir, { recursive: true });
79
+ const path = chatgptShimLauncherPath(configDir);
80
+ const argv = [process.execPath, ...selfLaunchArgv(["internal", "chatgpt-app-server-filter"])];
81
+ const temp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
82
+ try {
83
+ writeFileSync(temp, buildChatgptShimLauncher(argv, real), { mode: 0o755, flag: "wx" });
84
+ chmodSync(temp, 0o755);
85
+ renameSync(temp, path);
86
+ } catch (error) {
87
+ try { unlinkSync(temp); } catch { /* the temp file was never created */ }
88
+ throw error;
89
+ }
90
+ return path;
91
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * One build of the Desktop 3P alias registry from the startup discovery inputs.
3
+ *
4
+ * Startup builds the registry asynchronously so a slow provider cannot hold the proxy closed. A
5
+ * consumer that needs decodable aliases before that build finishes — the Claude Code CLI picker,
6
+ * whose catalog the CLI then caches for an hour — awaits the same in-flight build instead of
7
+ * starting a second one, or starts it when none ran. Inputs match `/v1/models` (entitlement-admitted
8
+ * native slugs plus catalog-visible routes), so both paths install the same registry.
9
+ */
10
+ import type { OcxConfig } from "../types";
11
+ import { buildDesktop3pRegistry, desktop3pRegistrySize } from "./desktop-3p";
12
+
13
+ let pending: Promise<boolean> | null = null;
14
+ let lastUnproductiveAt = -Infinity;
15
+ /** After a build fails or installs nothing, on-demand callers wait this long before retrying. */
16
+ export const DESKTOP_3P_REGISTRY_RETRY_MS = 30_000;
17
+
18
+ /** Build and install the registry; concurrent callers share one build. Resolves false on failure. */
19
+ export function initDesktop3pRegistry(config: OcxConfig): Promise<boolean> {
20
+ pending ??= (async () => {
21
+ try {
22
+ const { fetchAllModels } = await import("../server/management-api");
23
+ const { desktopVisibleNativeSlugs } = await import("../codex/catalog");
24
+ const { resolveAdmittedCodexModelEntitlements } = await import("../codex/model-entitlement-admission");
25
+ const { buildDesktopDiscoveryInputs } = await import("./desktop-discovery-inputs");
26
+ const [models, modelEntitlements] = await Promise.all([
27
+ fetchAllModels(config),
28
+ resolveAdmittedCodexModelEntitlements(config, { clientVersion: null }),
29
+ ]);
30
+ const inputs = buildDesktopDiscoveryInputs({
31
+ config, models, modelEntitlements,
32
+ desktopNativeCandidates: desktopVisibleNativeSlugs(config),
33
+ });
34
+ buildDesktop3pRegistry(inputs.nativeSlugs, inputs.routedModels, config.claudeCode?.desktopProfile, inputs.nativeContextCap);
35
+ if (desktop3pRegistrySize() === 0) lastUnproductiveAt = Date.now();
36
+ return true;
37
+ } catch {
38
+ lastUnproductiveAt = Date.now();
39
+ // Best-effort; model discovery can rebuild it. Never reflect credential or provider errors.
40
+ console.warn("[opencodex] Claude Desktop model registry could not be initialized.");
41
+ return false;
42
+ } finally {
43
+ pending = null;
44
+ }
45
+ })();
46
+ return pending;
47
+ }
48
+
49
+ /**
50
+ * Resolve once a build has installed the registry, starting one only when none ran or is running.
51
+ * A failed or empty build is not retried on demand until the cooldown passes, so a broken provider
52
+ * cannot turn every CLI catalog request into a fresh discovery and entitlement round.
53
+ */
54
+ export async function ensureDesktop3pRegistry(readConfig: () => OcxConfig, now: () => number = Date.now): Promise<void> {
55
+ if (pending) {
56
+ await pending;
57
+ return;
58
+ }
59
+ if (desktop3pRegistrySize() > 0) return;
60
+ if (now() - lastUnproductiveAt < DESKTOP_3P_REGISTRY_RETRY_MS) return;
61
+ await initDesktop3pRegistry(readConfig());
62
+ }
@@ -334,6 +334,11 @@ export function resolveDesktop3pAlias(alias: string): string | null {
334
334
  return desktop3pRegistry.get(alias) ?? null;
335
335
  }
336
336
 
337
+ /** Number of decodable aliases in the installed registry (0 before the first build). */
338
+ export function desktop3pRegistrySize(): number {
339
+ return desktop3pRegistry.size;
340
+ }
341
+
337
342
  /** Exact registered and identity-preserving catalog IDs precede synthetic syntax. */
338
343
  export function isKnownDesktop3pModelId(id: string): boolean {
339
344
  return desktop3pRegistry.has(id) || desktop3pRealAnthropicIds.has(id);
@@ -11,6 +11,7 @@ import {
11
11
  type DesktopFirstPartyOptions,
12
12
  } from "./desktop-first-party";
13
13
  import { claudeInterceptCaCertPath } from "./intercept/local-ca";
14
+ import { invalidateClaudeCodeServedCatalog } from "./intercept/cli-catalog";
14
15
  import { claudeInterceptEnabled } from "./intercept/runtime";
15
16
  import { isClaudeInterceptProxyUrl, removeClaudeInterceptSettings, type ClaudeInterceptSettingsState } from "./intercept/settings";
16
17
 
@@ -44,6 +45,19 @@ export function reconcileClaudeFirstPartySettings(
44
45
  config: Pick<OcxConfig, "claudeCode" | "port" | "runtimeRole" | "clientIntegrations">,
45
46
  desired: ClaudeFirstPartyDesired,
46
47
  options: DesktopFirstPartyOptions = {},
48
+ ): ClaudeFirstPartyReconcileResult {
49
+ const result = reconcileSettingsEnv(config, desired, options);
50
+ // CLI intent can change without the shared env changing (Desktop already holds it), and the
51
+ // proxy only adds opencodex rows to the CLI's /model catalog while that intent is on. Drop the
52
+ // CLI's cached catalog on every successful reconcile so its next launch refetches the list.
53
+ if (result.ok) invalidateClaudeCodeServedCatalog(options.claudeConfigDir ?? claudeConfigDir());
54
+ return result;
55
+ }
56
+
57
+ function reconcileSettingsEnv(
58
+ config: Pick<OcxConfig, "claudeCode" | "port" | "runtimeRole" | "clientIntegrations">,
59
+ desired: ClaudeFirstPartyDesired,
60
+ options: DesktopFirstPartyOptions,
47
61
  ): ClaudeFirstPartyReconcileResult {
48
62
  if (!desired.desktop && !desired.cli) {
49
63
  const ownedCa = claudeInterceptCaCertPath(options.opencodexConfigDir ?? getConfigDir());
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Claude Code CLI first-party picker: rewrite the catalogs the standalone CLI builds `/model` from.
3
+ *
4
+ * A first-party `claude` reads its picker from `GET /api/organizations/<org>/model_selector/cc` (or
5
+ * `/api/model_selector/cc` without an org scope): the same row shape as Desktop's bootstrap, on the
6
+ * `cc` surface. When that served catalog is off, the CLI falls back to its compiled list plus
7
+ * `additional_model_options` from `GET /api/claude_cli/bootstrap`. Both arrive on the intercepted
8
+ * api.anthropic.com tunnel; this module decides which may be rewritten and merges opencodex rows in.
9
+ * Every failure returns the upstream bytes unchanged.
10
+ *
11
+ * The CLI only offers Claude-shaped ids (an `ocx-claude-*` row renders "Update Claude Code"), so the
12
+ * rows carry Desktop 3P registry aliases; `buildCliPickerModels` in picker-models.ts chooses them.
13
+ * Import-light on purpose: the TLS listener loads this file.
14
+ */
15
+ import { readdirSync, unlinkSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import type { ClaudeFirstPartyDesired } from "../first-party-settings";
18
+ import { classifyInterceptClient } from "./client-class";
19
+ import { BOOTSTRAP_MAX_DECODED_BYTES, injectPickerModels, type PickerModelEntry } from "./picker-bootstrap";
20
+
21
+ export type CliCatalogKind = "model_selector" | "bootstrap";
22
+
23
+ /** The CLI's own picker surface. Desktop's `ccd`/`code` surfaces belong to picker mode. */
24
+ export const CLI_PICKER_SURFACE_IDS = ["cc"] as const;
25
+ /** Template notices describe the Anthropic model a row was cloned from, never the routed one. */
26
+ const CLI_EXTRA_STRIPPED_KEYS = ["notice", "selection_notice"] as const;
27
+ const MODEL_SELECTOR_PATH = /^\/api\/(?:organizations\/[^/]+\/)?model_selector\/cc\/?$/;
28
+ const BOOTSTRAP_PATH = /^\/api\/claude_cli\/bootstrap\/?$/;
29
+ const REWRITE_DROPPED_HEADERS = ["content-length", "content-encoding", "etag", "digest", "content-md5", "transfer-encoding"];
30
+
31
+ export function cliCatalogKind(method: string, pathname: string): CliCatalogKind | null {
32
+ if (method !== "GET") return null;
33
+ if (MODEL_SELECTOR_PATH.test(pathname)) return "model_selector";
34
+ if (BOOTSTRAP_PATH.test(pathname)) return "bootstrap";
35
+ return null;
36
+ }
37
+
38
+ /**
39
+ * Whether this request's catalog may carry opencodex rows. The cc catalog is cached on disk and
40
+ * shared by every Claude Code process, so only a CLI-classified client with CLI first-party on gets
41
+ * rows (a Desktop-spawned process would otherwise seed rows a plain `claude` cannot route). The
42
+ * bootstrap request carries a bare `claude-code/<v>` User-Agent with no entrypoint, so it follows
43
+ * CLI intent alone. Like the Messages split, the User-Agent is a routing hint, not a trust boundary.
44
+ */
45
+ export function cliCatalogEligible(kind: CliCatalogKind, userAgent: string | null, desired: ClaudeFirstPartyDesired): boolean {
46
+ if (!desired.cli) return false;
47
+ if (kind === "bootstrap") return true;
48
+ return classifyInterceptClient(userAgent) === "cli";
49
+ }
50
+
51
+ function record(value: unknown): Record<string, unknown> | null {
52
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
53
+ }
54
+
55
+ /** Append rows to `additional_model_options` (`{model, name, description}`), skipping ids already present. */
56
+ export function injectCliBootstrapOptions(bootstrap: unknown, models: readonly PickerModelEntry[]): number {
57
+ const body = record(bootstrap);
58
+ if (!body) return 0;
59
+ const current = body.additional_model_options;
60
+ if (current !== undefined && current !== null && !Array.isArray(current)) return 0;
61
+ const options = Array.isArray(current) ? current : [];
62
+ const existing = new Set(options.map(option => record(option)?.model));
63
+ let added = 0;
64
+ for (const model of models) {
65
+ if (existing.has(model.id)) continue;
66
+ options.push({ model: model.id, name: model.name, description: model.description ?? "" });
67
+ existing.add(model.id);
68
+ added++;
69
+ }
70
+ if (added > 0) body.additional_model_options = options;
71
+ return added;
72
+ }
73
+
74
+ /** Rewrite a decoded catalog body; `null` when nothing was added or the body is not the expected shape. */
75
+ export function rewriteCliCatalogBody(kind: CliCatalogKind, text: string, models: readonly PickerModelEntry[]): string | null {
76
+ if (models.length === 0) return null;
77
+ let parsed: unknown;
78
+ try { parsed = JSON.parse(text); } catch { return null; }
79
+ const added = kind === "bootstrap"
80
+ ? injectCliBootstrapOptions(parsed, models)
81
+ : injectPickerModels(parsed, models, undefined, { surfaces: CLI_PICKER_SURFACE_IDS, extraStrippedKeys: CLI_EXTRA_STRIPPED_KEYS });
82
+ return added > 0 ? JSON.stringify(parsed) : null;
83
+ }
84
+
85
+ /** Read at most `cap` bytes; null (and the stream cancelled) once the body would exceed it. */
86
+ async function readCapped(body: ReadableStream<Uint8Array>, cap: number): Promise<Uint8Array<ArrayBuffer> | null> {
87
+ const reader = body.getReader();
88
+ const chunks: Uint8Array[] = [];
89
+ let total = 0;
90
+ for (;;) {
91
+ const { done, value } = await reader.read();
92
+ if (done) break;
93
+ total += value.byteLength;
94
+ if (total > cap) {
95
+ await reader.cancel().catch(() => {});
96
+ return null;
97
+ }
98
+ chunks.push(value);
99
+ }
100
+ const bytes = new Uint8Array(total);
101
+ let offset = 0;
102
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
103
+ return bytes;
104
+ }
105
+
106
+ /**
107
+ * Rewrite a relayed catalog response. Only a 2xx JSON body under the decoded cap is touched; the
108
+ * relay already decoded any content-encoding (and dropped content-length), so the cap is enforced
109
+ * while streaming; an oversized catalog is answered with a gateway error rather than buffered.
110
+ */
111
+ export async function rewriteCliCatalogResponse(
112
+ response: Response,
113
+ kind: CliCatalogKind,
114
+ models: readonly PickerModelEntry[],
115
+ ): Promise<Response> {
116
+ if (response.status < 200 || response.status >= 300 || models.length === 0) return response;
117
+ const declared = Number(response.headers.get("content-length") ?? "0");
118
+ if (Number.isFinite(declared) && declared > BOOTSTRAP_MAX_DECODED_BYTES) return response;
119
+ if (!response.body) return response;
120
+ const bytes = await readCapped(response.body, BOOTSTRAP_MAX_DECODED_BYTES);
121
+ if (bytes === null) {
122
+ return Response.json({ type: "error", error: { type: "api_error", message: "model catalog exceeded the intercept size cap" } }, { status: 502 });
123
+ }
124
+ const original = (): Response => new Response(bytes, { status: response.status, statusText: response.statusText, headers: response.headers });
125
+ const rewritten = rewriteCliCatalogBody(kind, new TextDecoder().decode(bytes), models);
126
+ if (rewritten === null) return original();
127
+ const headers = new Headers(response.headers);
128
+ for (const name of REWRITE_DROPPED_HEADERS) headers.delete(name);
129
+ headers.set("content-type", "application/json");
130
+ return new Response(rewritten, { status: response.status, statusText: response.statusText, headers });
131
+ }
132
+
133
+ /**
134
+ * Drop the CLI's cached served catalogs (`<claudeConfigDir>/cache/model-catalog/*-cc.json`). The CLI
135
+ * trusts a fresh copy for about an hour without refetching, so after first-party turns on or off the
136
+ * next launch would otherwise keep the previous list — opencodex rows that now go to Anthropic, or
137
+ * no rows at all. Desktop's `*-ccd.json` and failure markers are left alone. Best effort; returns the
138
+ * number of files removed.
139
+ */
140
+ export function invalidateClaudeCodeServedCatalog(claudeDir: string): number {
141
+ const dir = join(claudeDir, "cache", "model-catalog");
142
+ let names: string[];
143
+ try { names = readdirSync(dir); } catch { return 0; } // no-excuse-ok: catch -- no cache directory means nothing to drop.
144
+ let removed = 0;
145
+ for (const name of names) {
146
+ if (!name.endsWith("-cc.json")) continue;
147
+ try { unlinkSync(join(dir, name)); removed++; } catch { /* A concurrent writer or removal wins; the next reconcile retries. */ }
148
+ }
149
+ return removed;
150
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The Claude Code CLI picker's model source for the intercept listener's `cliCatalog` hook.
3
+ *
4
+ * Independent of Desktop picker mode: it needs only the intercept pair and CLI first-party intent.
5
+ * Discovery runs lazily, on the first eligible catalog request, and the result is persisted so a
6
+ * restart answers immediately. A cold start waits briefly for the Desktop 3P registry (the aliases
7
+ * must decode before they are advertised) and for one discovery; past that bound the catalog relays
8
+ * unchanged rather than holding the CLI's own request open.
9
+ */
10
+ import { join } from "node:path";
11
+ import type { OcxConfig } from "../../types";
12
+ import { desktop3pRegistrySize } from "../desktop-3p";
13
+ import type { ClaudeFirstPartyDesired } from "../first-party-settings";
14
+ import { cliCatalogEligible, type CliCatalogKind } from "./cli-catalog";
15
+ import { claudeInterceptStateDir } from "./local-ca";
16
+ import type { PickerModelEntry } from "./picker-bootstrap";
17
+ import { buildCliPickerModels, createPickerModelSnapshot, routableCliPickerModels, type PickerRouteInput } from "./picker-models";
18
+
19
+ export const CLI_PICKER_MODELS_FILE = "cli-picker-models.json";
20
+ export const CLI_PICKER_MODELS_MAX_AGE_MS = 5 * 60_000;
21
+ /** The CLI times its catalog fetch out after a few seconds; a cold build must answer well inside it. */
22
+ export const CLI_PICKER_COLD_WAIT_MS = 2_500;
23
+
24
+ export interface CliCatalogProviderOptions {
25
+ configDir: string;
26
+ loadRoutes: () => Promise<PickerRouteInput>;
27
+ desiredClients: () => ClaudeFirstPartyDesired;
28
+ /** Resolves once Desktop 3P aliases decode; defaults to the shared startup build. */
29
+ ensureRegistry?: () => Promise<void>;
30
+ /** Whether the registry holds aliases; defaults to the installed Desktop 3P registry. */
31
+ registryReady?: () => boolean;
32
+ coldWaitMs?: number;
33
+ }
34
+
35
+ async function defaultEnsureRegistry(): Promise<void> {
36
+ const [{ ensureDesktop3pRegistry }, { loadConfig }] = await Promise.all([
37
+ import("../desktop-3p-startup"),
38
+ import("../../config"),
39
+ ]);
40
+ await ensureDesktop3pRegistry(loadConfig as () => OcxConfig);
41
+ }
42
+
43
+ export function createCliCatalogProvider(options: CliCatalogProviderOptions): (req: Request, kind: CliCatalogKind) => Promise<readonly PickerModelEntry[] | null> {
44
+ const ensureRegistry = options.ensureRegistry ?? defaultEnsureRegistry;
45
+ const registryReady = options.registryReady ?? (() => desktop3pRegistrySize() > 0);
46
+ const snapshot = createPickerModelSnapshot(async () => {
47
+ await ensureRegistry();
48
+ // A failed build resolves into its retry cooldown with an empty registry. Building rows then
49
+ // would persist an empty list over the last good snapshot, so fail the refresh instead and keep it.
50
+ if (!registryReady()) throw new Error("Desktop 3P registry unavailable");
51
+ return options.loadRoutes();
52
+ }, join(claudeInterceptStateDir(options.configDir), CLI_PICKER_MODELS_FILE), buildCliPickerModels);
53
+ const coldWaitMs = options.coldWaitMs ?? CLI_PICKER_COLD_WAIT_MS;
54
+ // Every wait in one request shares a single deadline, so the whole answer stays inside coldWaitMs.
55
+ const bounded = async (work: () => Promise<unknown>, deadline: number): Promise<void> => {
56
+ const remaining = deadline - Date.now();
57
+ if (remaining <= 0) return;
58
+ let timer: ReturnType<typeof setTimeout> | undefined;
59
+ await Promise.race([work().catch(() => {}), new Promise<void>(resolve => { timer = setTimeout(resolve, remaining); })]);
60
+ clearTimeout(timer);
61
+ };
62
+ return async (req, kind) => {
63
+ if (!cliCatalogEligible(kind, req.headers.get("user-agent"), options.desiredClients())) return null;
64
+ const deadline = Date.now() + coldWaitMs;
65
+ if (snapshot.current() === null) {
66
+ await bounded(() => snapshot.refresh(), deadline);
67
+ } else {
68
+ snapshot.refreshIfStale(CLI_PICKER_MODELS_MAX_AGE_MS);
69
+ }
70
+ const current = snapshot.current();
71
+ if (!current) return null;
72
+ // A persisted snapshot can outlive the registry it was built against: a restart starts with an
73
+ // empty registry, and a provider change retires routes the snapshot still lists. An answer
74
+ // missing those rows is what the CLI would cache for the next hour, so first wait for the shared
75
+ // registry build, and if rows still do not decode, rebuild the snapshot from the current routes
76
+ // before answering, all within the request's one deadline.
77
+ const stale = (models: readonly PickerModelEntry[]): boolean => routableCliPickerModels(models).length < models.length;
78
+ if (stale(current.models)) await bounded(ensureRegistry, deadline);
79
+ if (stale(snapshot.current()!.models)) await bounded(() => snapshot.refresh(), deadline);
80
+ return routableCliPickerModels(snapshot.current()!.models);
81
+ };
82
+ }
@@ -1,5 +1,7 @@
1
1
  import type { Server } from "bun";
2
+ import { cliCatalogKind, rewriteCliCatalogResponse, type CliCatalogKind } from "./cli-catalog";
2
3
  import type { PemKeyPair } from "./local-ca";
4
+ import type { PickerModelEntry } from "./picker-bootstrap";
3
5
 
4
6
  /**
5
7
  * TLS listener that terminates intercepted `api.anthropic.com` tunnels.
@@ -9,7 +11,8 @@ import type { PemKeyPair } from "./local-ca";
9
11
  * handed to the router's own request handler, which already knows how to route mapped models
10
12
  * to providers and pass genuine Claude models through with the caller's subscription
11
13
  * credential. Every other path (usage, feedback, model listings, …) is relayed verbatim to
12
- * the real upstream so the client keeps behaving like a first-party install.
14
+ * the real upstream so the client keeps behaving like a first-party install. The one exception
15
+ * is the Claude Code CLI's own picker catalog, which `cliCatalog` may extend (cli-catalog.ts).
13
16
  */
14
17
 
15
18
  export const CLAUDE_INTERCEPT_UPSTREAM = "https://api.anthropic.com";
@@ -93,6 +96,11 @@ export interface ClaudeInterceptListenerOptions<T = undefined> {
93
96
  dispatch: (req: Request, server: Server<T>) => Promise<Response>;
94
97
  /** Per-request decision for every path; absent preserves the existing path split. */
95
98
  route?: (req: Request) => "router" | "relay-native";
99
+ /**
100
+ * Rows to merge into a relayed Claude Code CLI picker catalog, or null to relay it unchanged.
101
+ * Consulted for every catalog GET, including ones `route` sends to the native upstream.
102
+ */
103
+ cliCatalog?: (req: Request, kind: CliCatalogKind) => Promise<readonly PickerModelEntry[] | null>;
96
104
  upstreamBase?: string;
97
105
  maxRequestBodySize?: number;
98
106
  idleTimeout?: number;
@@ -113,13 +121,20 @@ export function startClaudeInterceptListener<T = undefined>(options: ClaudeInter
113
121
  ...(options.maxRequestBodySize !== undefined ? { maxRequestBodySize: options.maxRequestBodySize } : {}),
114
122
  async fetch(req, requestServer) {
115
123
  const url = new URL(req.url);
116
- if (options.route?.(req) === "relay-native") {
117
- return relayToUpstream(req, CLAUDE_INTERCEPT_UPSTREAM, options.fetchImpl);
118
- }
119
- if (isClaudeInterceptedPath(url.pathname, req.method)) {
124
+ const native = options.route?.(req) === "relay-native";
125
+ if (!native && isClaudeInterceptedPath(url.pathname, req.method)) {
120
126
  return options.dispatch(rewriteInterceptedRequest(req, loopbackOrigin), requestServer);
121
127
  }
122
- return relayToUpstream(req, upstreamBase, options.fetchImpl);
128
+ const upstream = native ? CLAUDE_INTERCEPT_UPSTREAM : upstreamBase;
129
+ const catalog = options.cliCatalog ? cliCatalogKind(req.method, url.pathname) : null;
130
+ if (catalog) {
131
+ let models: readonly PickerModelEntry[] | null = null;
132
+ try { models = await options.cliCatalog!(req, catalog); } catch { /* Fail open: relay unchanged. */ }
133
+ if (models && models.length > 0) {
134
+ return rewriteCliCatalogResponse(await relayToUpstream(req, upstream, options.fetchImpl), catalog, models);
135
+ }
136
+ }
137
+ return relayToUpstream(req, upstream, options.fetchImpl);
123
138
  },
124
139
  });
125
140
  loopbackOrigin = `http://127.0.0.1:${server.port}`;
@@ -9,6 +9,10 @@ export interface PickerModelEntry {
9
9
  id: string;
10
10
  name: string;
11
11
  contextWindow?: number;
12
+ /** Row description; Desktop entries never carry one, so their rows keep the stripped template. */
13
+ description?: string;
14
+ /** The route an alias was minted for (CLI rows), so a later registry rebuild can be re-checked. */
15
+ route?: string;
12
16
  }
13
17
 
14
18
  export const BOOTSTRAP_MAX_ENCODED_BYTES = 4 * 1024 * 1024;
@@ -44,8 +48,19 @@ export type PickerInjectionOutcome =
44
48
  export const PICKER_SURFACE_IDS = ["ccd", "code"] as const;
45
49
  /** Surface names the log may print; anything else from the body is counted, never echoed. */
46
50
  const KNOWN_SURFACE_IDS = new Set(["ccd", "code", "cc", "ccr", "cowork", "chat", "design"]);
51
+ const TEMPLATE_STRIPPED_KEYS = ["disabled", "disabled_reason", "badge", "tooltip", "description", "fast_mode"];
47
52
 
48
- function injectIntoSurface(surface: Record<string, unknown>, models: readonly PickerModelEntry[]): number | string {
53
+ /** Which surfaces to rewrite and which extra template keys to drop; the defaults are Desktop's. */
54
+ export interface PickerInjectionOptions {
55
+ surfaces?: readonly string[];
56
+ extraStrippedKeys?: readonly string[];
57
+ }
58
+
59
+ function injectIntoSurface(
60
+ surface: Record<string, unknown>,
61
+ models: readonly PickerModelEntry[],
62
+ stripped: readonly string[],
63
+ ): number | string {
49
64
  if (!Array.isArray(surface.models)) return "no_models";
50
65
  const entries = surface.models as unknown[];
51
66
  const template = entries.map(record).find(entry =>
@@ -63,9 +78,9 @@ function injectIntoSurface(surface: Record<string, unknown>, models: readonly Pi
63
78
  if (model.contextWindow === undefined) delete copy.context_window;
64
79
  else copy.context_window = model.contextWindow;
65
80
  for (const key of Object.keys(copy)) {
66
- if (["disabled", "disabled_reason", "badge", "tooltip", "description", "fast_mode"].includes(key)
67
- || /version/i.test(key)) delete copy[key];
81
+ if (stripped.includes(key) || /version/i.test(key)) delete copy[key];
68
82
  }
83
+ if (model.description !== undefined) copy.description = model.description;
69
84
  entries.push(copy);
70
85
  existing.add(model.id);
71
86
  added++;
@@ -77,13 +92,16 @@ export function injectPickerModels(
77
92
  bootstrap: unknown,
78
93
  models: readonly PickerModelEntry[],
79
94
  explain?: (outcome: PickerInjectionOutcome) => void,
95
+ options: PickerInjectionOptions = {},
80
96
  ): number {
81
97
  const unchanged = (reason: string): number => { explain?.({ kind: "unchanged", reason }); return 0; };
82
98
  const surfaces = record(bootstrap)?.model_selector_config;
83
99
  if (!Array.isArray(surfaces)) return unchanged("no_model_selector_config");
84
100
  const rows = surfaces.map(record);
101
+ const surfaceIds: readonly unknown[] = options.surfaces ?? PICKER_SURFACE_IDS;
102
+ const stripped = [...TEMPLATE_STRIPPED_KEYS, ...(options.extraStrippedKeys ?? [])];
85
103
  const targets = rows.filter((entry): entry is Record<string, unknown> =>
86
- entry !== null && (PICKER_SURFACE_IDS as readonly unknown[]).includes(entry.id));
104
+ entry !== null && surfaceIds.includes(entry.id));
87
105
  if (targets.length === 0) {
88
106
  // Only known surface names reach the log; any other body-derived value is counted.
89
107
  const known = rows.flatMap(entry => typeof entry?.id === "string" && KNOWN_SURFACE_IDS.has(entry.id) ? [entry.id] : []);
@@ -93,7 +111,7 @@ export function injectPickerModels(
93
111
  let added = 0;
94
112
  const skipped: string[] = [];
95
113
  for (const surface of targets) {
96
- const result = injectIntoSurface(surface, models);
114
+ const result = injectIntoSurface(surface, models, stripped);
97
115
  if (typeof result === "number") added += result;
98
116
  else skipped.push(`${String(surface.id)}:${result}`);
99
117
  }