@bitkyc08/opencodex 2.52.0-preview.20260912 → 2.53.0-preview.20260913

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 (236) hide show
  1. package/gui/dist/assets/index-BBOZWGB6.css +1 -0
  2. package/gui/dist/assets/index-D7ynYo2K.js +128 -0
  3. package/gui/dist/index.html +2 -2
  4. package/native/remote-workspace-helper/Cargo.lock +130 -0
  5. package/native/remote-workspace-helper/Cargo.toml +24 -0
  6. package/native/remote-workspace-helper/src/main.rs +49 -0
  7. package/native/remote-workspace-helper/src/protocol.rs +246 -0
  8. package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
  9. package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
  10. package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
  11. package/package.json +6 -1
  12. package/src/adapters/anthropic-image-normalize.ts +30 -2
  13. package/src/adapters/anthropic.ts +1 -1
  14. package/src/adapters/base.ts +8 -2
  15. package/src/adapters/cursor/cursor-errors.ts +12 -0
  16. package/src/adapters/cursor/thread-continuity.ts +93 -0
  17. package/src/adapters/cursor.ts +104 -73
  18. package/src/adapters/devin/cloud-direct/chat.ts +312 -23
  19. package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
  20. package/src/adapters/devin/live-models.ts +70 -3
  21. package/src/adapters/devin.ts +281 -21
  22. package/src/adapters/google-wire-compiler.ts +14 -6
  23. package/src/adapters/google.ts +22 -8
  24. package/src/adapters/kiro/adapter.ts +316 -0
  25. package/src/adapters/kiro/conversation.ts +136 -0
  26. package/src/adapters/kiro/payload.ts +432 -0
  27. package/src/adapters/kiro/reasoning.ts +56 -0
  28. package/src/adapters/kiro/stream.ts +1153 -0
  29. package/src/adapters/kiro/usage.ts +223 -0
  30. package/src/adapters/kiro/wire.ts +76 -0
  31. package/src/adapters/kiro.ts +8 -2319
  32. package/src/adapters/mimo-free.ts +1 -1
  33. package/src/adapters/openai-chat-images.ts +101 -0
  34. package/src/adapters/openai-chat.ts +201 -181
  35. package/src/adapters/openai-responses.ts +92 -224
  36. package/src/adapters/registry.ts +0 -7
  37. package/src/adapters/run-turn-queue.ts +13 -6
  38. package/src/bridge.ts +14 -15
  39. package/src/chat/inbound.ts +29 -4
  40. package/src/chat/outbound.ts +145 -107
  41. package/src/claude/desktop-profile.ts +4 -6
  42. package/src/cli/account-api.ts +14 -0
  43. package/src/cli/account-extended.ts +1 -1
  44. package/src/cli/account-history.ts +60 -0
  45. package/src/cli/account-main.ts +80 -0
  46. package/src/cli/account.ts +11 -3
  47. package/src/cli/capabilities.ts +113 -0
  48. package/src/cli/catalog.ts +109 -0
  49. package/src/cli/dispatch.ts +9 -0
  50. package/src/cli/help.ts +2 -0
  51. package/src/cli/index.ts +2 -2
  52. package/src/cli/observe.ts +28 -1
  53. package/src/cli/opencode.ts +42 -8
  54. package/src/cli/provider-runtime.ts +11 -1
  55. package/src/cli/provider.ts +22 -2
  56. package/src/cli/registry.ts +21 -0
  57. package/src/cli/remote-workspace.ts +154 -0
  58. package/src/cli/status.ts +39 -7
  59. package/src/cli/usage-report.ts +14 -2
  60. package/src/client/hub-client.ts +34 -0
  61. package/src/client/hub-state.ts +9 -1
  62. package/src/codex/account-store.ts +78 -0
  63. package/src/codex/auth-api.ts +81 -54
  64. package/src/codex/auth-context.ts +45 -16
  65. package/src/codex/catalog/effort.ts +1 -1
  66. package/src/codex/catalog/metadata.ts +3 -6
  67. package/src/codex/catalog/native-models.ts +4 -4
  68. package/src/codex/catalog/parsing.ts +2 -20
  69. package/src/codex/catalog/provider-fetch.ts +10 -1
  70. package/src/codex/catalog/remote.ts +233 -0
  71. package/src/codex/catalog/sync.ts +403 -35
  72. package/src/codex/convergence.ts +1 -1
  73. package/src/codex/history-manifest.ts +36 -0
  74. package/src/codex/history-provider.ts +32 -5
  75. package/src/codex/inject.ts +9 -0
  76. package/src/codex/main-account.ts +113 -0
  77. package/src/codex/main-device-reauth-api.ts +89 -0
  78. package/src/codex/main-device-reauth.ts +217 -0
  79. package/src/codex/native-residue.ts +9 -2
  80. package/src/codex/quota-auto-refresh.ts +3 -2
  81. package/src/codex/quota-capacity.ts +98 -0
  82. package/src/codex/quota-history.ts +160 -0
  83. package/src/codex/quota-types.ts +8 -0
  84. package/src/codex/quota.ts +118 -91
  85. package/src/codex/refresh.ts +2 -1
  86. package/src/codex/routing.ts +90 -17
  87. package/src/codex/sync.ts +33 -4
  88. package/src/combos/request.ts +19 -1
  89. package/src/config/multi-agent-surface.ts +61 -0
  90. package/src/config/provider-validation.ts +176 -0
  91. package/src/config.ts +213 -11
  92. package/src/generated/compatibility-version.json +436 -168
  93. package/src/images/loop.ts +119 -36
  94. package/src/lib/admission.ts +12 -6
  95. package/src/lib/redact.ts +7 -0
  96. package/src/lib/translator-budget.ts +4 -3
  97. package/src/lib/windows-atomic-replace.ts +1 -0
  98. package/src/lib/windows-elevation.ts +1 -1
  99. package/src/oauth/chatgpt-device.ts +62 -5
  100. package/src/oauth/devin/cli-import.ts +130 -0
  101. package/src/oauth/devin.ts +63 -8
  102. package/src/oauth/index.ts +29 -14
  103. package/src/oauth/kiro.ts +18 -6
  104. package/src/oauth/login-cli.ts +9 -1
  105. package/src/oauth/meta-muse-device.ts +464 -0
  106. package/src/oauth/meta-muse.ts +123 -32
  107. package/src/oauth/pool-kernel.ts +9 -0
  108. package/src/oauth/pool-settings-capability.ts +2 -2
  109. package/src/oauth/store.ts +57 -0
  110. package/src/oauth/types.ts +31 -0
  111. package/src/providers/derive.ts +13 -3
  112. package/src/providers/devin-cli-authmode-migration.ts +57 -35
  113. package/src/providers/devin-provider-merge-migration.ts +240 -0
  114. package/src/providers/muse-key-quota.ts +117 -0
  115. package/src/providers/muse-subscription-usage.ts +14 -2
  116. package/src/providers/openai-sidecar.ts +25 -3
  117. package/src/providers/opencode-zen-rate-limit.ts +58 -0
  118. package/src/providers/provider-id-rewrite.ts +20 -5
  119. package/src/providers/quota-types.ts +12 -0
  120. package/src/providers/quota.ts +143 -102
  121. package/src/providers/reasoning-metadata.ts +543 -0
  122. package/src/providers/registry.ts +80 -49
  123. package/src/reasoning-effort.ts +26 -2
  124. package/src/remote/hub-usage.ts +32 -0
  125. package/src/remote-control/index.ts +192 -41
  126. package/src/remote-control/workspace-activation.ts +9 -0
  127. package/src/remote-control/workspace-agent-connection.ts +366 -0
  128. package/src/remote-control/workspace-claude-runtime.ts +243 -0
  129. package/src/remote-control/workspace-codex-runtime.ts +531 -0
  130. package/src/remote-control/workspace-codex-sandbox.ts +115 -0
  131. package/src/remote-control/workspace-command-runner.ts +748 -0
  132. package/src/remote-control/workspace-coordinator.ts +231 -0
  133. package/src/remote-control/workspace-device.ts +585 -0
  134. package/src/remote-control/workspace-executable.ts +43 -0
  135. package/src/remote-control/workspace-executor.ts +397 -0
  136. package/src/remote-control/workspace-hub.ts +519 -0
  137. package/src/remote-control/workspace-pi-runtime.ts +382 -0
  138. package/src/remote-control/workspace-process.ts +129 -0
  139. package/src/remote-control/workspace-rpc.ts +304 -0
  140. package/src/remote-control/workspace-runtime.ts +60 -0
  141. package/src/remote-control/workspace-secret-store.ts +39 -0
  142. package/src/remote-control/workspace-sessions.ts +799 -0
  143. package/src/remote-control/workspace-tool-bridge.ts +192 -0
  144. package/src/responses/code-mode-helper-compat.ts +22 -3
  145. package/src/responses/hosted-tool-policy.ts +0 -1
  146. package/src/responses/muse-tool-name-alias.ts +379 -0
  147. package/src/responses/plaintext-v2-agent-messages.ts +902 -0
  148. package/src/router.ts +7 -0
  149. package/src/routing/compatibility/behavior.ts +0 -1
  150. package/src/server/audio-client.ts +64 -0
  151. package/src/server/audio-dictation.ts +91 -0
  152. package/src/server/audio-live.ts +185 -0
  153. package/src/server/audio-transcriptions.ts +183 -0
  154. package/src/server/audio-upstream.ts +153 -0
  155. package/src/server/auth-cors.ts +61 -2
  156. package/src/server/chat-completions.ts +1 -1
  157. package/src/server/chat-native-sse.ts +92 -48
  158. package/src/server/chat-native.ts +37 -15
  159. package/src/server/hub-usage.ts +57 -0
  160. package/src/server/images.ts +4 -0
  161. package/src/server/index.ts +722 -57
  162. package/src/server/lifecycle.ts +5 -6
  163. package/src/server/live-call-bindings.ts +60 -0
  164. package/src/server/live.ts +12 -1
  165. package/src/server/management/agent-settings-routes.ts +25 -4
  166. package/src/server/management/api-access.ts +37 -0
  167. package/src/server/management/api-key-usage.ts +7 -2
  168. package/src/server/management/config-routes.ts +1 -18
  169. package/src/server/management/context.ts +15 -0
  170. package/src/server/management/logs-usage-routes.ts +2 -0
  171. package/src/server/management/oauth-account-routes.ts +39 -12
  172. package/src/server/management/provider-routes.ts +125 -2
  173. package/src/server/management/remote-workspace-routes.ts +140 -0
  174. package/src/server/management/route-registry.ts +15 -0
  175. package/src/server/management/usage-aggregate-cache.ts +14 -15
  176. package/src/server/management/usage-summary-cache.ts +2 -0
  177. package/src/server/management-api.ts +23 -0
  178. package/src/server/ports.ts +17 -0
  179. package/src/server/relay-eager.ts +4 -1
  180. package/src/server/relay.ts +70 -10
  181. package/src/server/request-decompress.ts +6 -3
  182. package/src/server/responses/agent-task-recovery.ts +25 -32
  183. package/src/server/responses/codex-auth-error.ts +11 -0
  184. package/src/server/responses/codex-ws-exchange.ts +52 -3
  185. package/src/server/responses/codex-ws-wire.ts +55 -0
  186. package/src/server/responses/compact.ts +9 -1
  187. package/src/server/responses/core.ts +337 -73
  188. package/src/server/responses/encrypted-payload.ts +45 -2
  189. package/src/server/responses/ws-upstream.ts +4 -1
  190. package/src/server/responses-self-named-namespace-scrub.ts +1 -3
  191. package/src/server/responses-undeclared-tool-guard.ts +1 -1
  192. package/src/server/search.ts +3 -0
  193. package/src/server/sse-payload-rewrite.ts +136 -51
  194. package/src/server/ws-bridge.ts +35 -1
  195. package/src/service/cli.ts +372 -0
  196. package/src/service/diagnostics.ts +340 -0
  197. package/src/service/guards.ts +303 -0
  198. package/src/service/health.ts +222 -0
  199. package/src/service/launchd.ts +853 -0
  200. package/src/service/orchestration.ts +617 -0
  201. package/src/service/repair.ts +334 -0
  202. package/src/service/state.ts +363 -0
  203. package/src/service/systemd.ts +229 -0
  204. package/src/service/windows-ops.ts +690 -0
  205. package/src/service/windows-scheduler.ts +769 -0
  206. package/src/service/windows-taskxml.ts +613 -0
  207. package/src/service.ts +22 -5550
  208. package/src/storage/cleanup/db.ts +258 -0
  209. package/src/storage/cleanup/execute.ts +358 -0
  210. package/src/storage/cleanup/paths.ts +189 -0
  211. package/src/storage/cleanup/pending.ts +140 -0
  212. package/src/storage/cleanup/preview.ts +292 -0
  213. package/src/storage/cleanup/reconcile.ts +347 -0
  214. package/src/storage/cleanup/restore.ts +932 -0
  215. package/src/storage/cleanup/satellite.ts +474 -0
  216. package/src/storage/cleanup/staging.ts +129 -0
  217. package/src/storage/cleanup/types.ts +98 -0
  218. package/src/storage/cleanup.ts +49 -3127
  219. package/src/types/accounts.ts +2 -0
  220. package/src/types/config.ts +13 -12
  221. package/src/types/provider.ts +37 -0
  222. package/src/types/request.ts +2 -0
  223. package/src/types/tools.ts +17 -5
  224. package/src/types.ts +1 -0
  225. package/src/usage/expected-prices.ts +127 -0
  226. package/src/usage/log.ts +58 -1
  227. package/src/vision/eligibility.ts +13 -2
  228. package/src/web-search/loop.ts +56 -3
  229. package/gui/dist/assets/index-D_t6sCWs.js +0 -115
  230. package/gui/dist/assets/index-EdoPnm9_.css +0 -1
  231. package/src/adapters/devin-cli/acp.ts +0 -204
  232. package/src/adapters/devin-cli/adapter.ts +0 -345
  233. package/src/adapters/devin-cli/binary.ts +0 -69
  234. package/src/adapters/devin-cli/models.ts +0 -57
  235. package/src/oauth/devin-cli.ts +0 -149
  236. package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
@@ -0,0 +1,543 @@
1
+ /**
2
+ * Data-driven reasoning ladders for routed providers.
3
+ *
4
+ * Routed providers rarely publish per-model effort ladders: OpenCode Zen Go answers /models
5
+ * with ids only (id/object/created/owned_by), so opencodex had to hardcode ladders in
6
+ * registry.ts and synthesise max/ultra for codex-rs catalog membership. The public models.dev
7
+ * catalogue DOES publish them per model:
8
+ * reasoning: true
9
+ * reasoning_options: [{type:"effort",values:["low","high","max"]}, {type:"toggle"},
10
+ * {type:"budget_tokens"}]
11
+ * This module snapshots that catalogue to disk and hands configuredReasoningEfforts() a
12
+ * fallback ladder, so the Codex catalog AND the wire clamp agree with the model instead of a
13
+ * hand-written guess.
14
+ *
15
+ * Failure policy: the network is never on the critical path. A missing, stale or corrupt
16
+ * snapshot yields undefined, which leaves every hand-written contract untouched. The second
17
+ * cache records rungs the upstream actually rejected (400/403 naming reasoning_effort), so an
18
+ * entitlement gap (muse-spark max needs an active Muse Code subscription) costs one rejected
19
+ * request instead of failing every turn that selects that rung.
20
+ */
21
+ import { existsSync, readFileSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ // Leaf modules on purpose: this file is imported from reasoning-effort.ts, which combos/types.ts
24
+ // already imports. Going through the ../config barrel closes a cycle back into account-namespaces.ts
25
+ // and leaves COMBO_NAMESPACE in its temporal dead zone for entry points that start at combos/types.ts.
26
+ import { atomicWriteFile } from "../config/atomic-write";
27
+ import { getConfigDir } from "../config/paths";
28
+ import type { OcxProviderConfig } from "../types";
29
+
30
+ const FILENAME = "reasoning-metadata-cache.json";
31
+ const SUPPORT_FILENAME = "reasoning-support-cache.json";
32
+ const SOURCE_URL = "https://models.dev/api.json";
33
+ const USER_AGENT = "opencodex-reasoning-metadata/1.0 (+https://github.com/lidge-jun/opencodex)";
34
+ /** Snapshot age that triggers a background refresh. Older snapshots still serve reads. */
35
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
36
+ /** A learned "this rung is refused" fact expires: entitlements change. */
37
+ const SUPPORT_TTL_MS = 30 * 24 * 60 * 60 * 1000;
38
+ const PERSIST_DEBOUNCE_MS = 250;
39
+
40
+ /** Canonical Codex ladder order; mirrors reasoning-effort.ts CODEX_REASONING_LEVELS. */
41
+ const LADDER_ORDER = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
42
+ /** Ranked rungs used for downgrade planning; ultra is client-only and folds to max. */
43
+ const RANKED = ["low", "medium", "high", "xhigh", "max"];
44
+ /** Mirror of registry.ts THINKING_TOGGLE_EFFORTS / THINKING_BUDGET_EFFORTS. */
45
+ const CLASSIFIED_STYLE_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
46
+
47
+ /**
48
+ * models.dev provider key for a provider config. OcxProviderConfig carries no id, so the
49
+ * destination URL is the stable handle. Only destinations this patch has evidence for are
50
+ * listed; an unlisted provider simply keeps its current behaviour.
51
+ */
52
+ const BASE_URL_TO_METADATA_PROVIDER: Record<string, string> = {
53
+ "https://opencode.ai/zen/go/v1": "opencode-go",
54
+ "https://opencode.ai/zen/v1": "opencode",
55
+ };
56
+
57
+ /**
58
+ * Both sides of the mapping are compared after this normalisation, so a trailing slash or a
59
+ * `/v1` suffix never decides whether a destination resolves. models.dev publishes each
60
+ * provider's own `api` URL; the snapshot keeps it (v2) so the mapping can be checked against
61
+ * published data instead of trusted blindly.
62
+ */
63
+ export function normalizeDestinationUrl(url: string | undefined): string | undefined {
64
+ if (typeof url !== "string" || url.trim() === "") return undefined;
65
+ try {
66
+ const parsed = new URL(url.trim());
67
+ const path = parsed.pathname.replace(/\/+$/, "").replace(/\/v1$/i, "");
68
+ return (parsed.protocol + "//" + parsed.host + path).toLowerCase();
69
+ } catch {
70
+ return undefined;
71
+ }
72
+ }
73
+
74
+ export type ReasoningMetadataOption = { type: string; values?: string[] };
75
+ export type ReasoningMetadataModel = { reasoning: boolean; options: ReasoningMetadataOption[] };
76
+
77
+ interface MetadataSnapshot {
78
+ version: 1 | 2;
79
+ fetchedAt: number;
80
+ source: string;
81
+ providers: Record<string, Record<string, ReasoningMetadataModel>>;
82
+ /**
83
+ * v2: models.dev provider key -> that provider's published api URL (normalised). v1 snapshots
84
+ * predate the field and keep working through BASE_URL_TO_METADATA_PROVIDER.
85
+ */
86
+ apis?: Record<string, string>;
87
+ }
88
+
89
+ interface SupportSnapshot {
90
+ version: 1;
91
+ rows: Record<string, { effort: string; at: number; evidence?: string }>;
92
+ }
93
+
94
+ let snapshotMemo: MetadataSnapshot | null | undefined;
95
+ let supportMemo: Map<string, number> | undefined;
96
+ let persistTimer: ReturnType<typeof setTimeout> | null = null;
97
+ let refreshInFlight: Promise<unknown> | null = null;
98
+
99
+ /** Test seam: drop the memoised snapshot/support caches so a suite can drive the load paths. */
100
+ export function resetReasoningMetadataCachesForTests(): void {
101
+ snapshotMemo = undefined;
102
+ supportMemo = undefined;
103
+ if (persistTimer) {
104
+ clearTimeout(persistTimer);
105
+ persistTimer = null;
106
+ }
107
+ refreshInFlight = null;
108
+ }
109
+
110
+ function readJsonFile<T>(filename: string): T | null {
111
+ try {
112
+ const path = join(getConfigDir(), filename);
113
+ if (!existsSync(path)) return null;
114
+ return JSON.parse(readFileSync(path, "utf8")) as T;
115
+ } catch {
116
+ // A corrupt cache must never break routing, the catalog, or the dashboard.
117
+ return null;
118
+ }
119
+ }
120
+
121
+ /** Canonical order + dedupe. Local mirror of sanitizeCodexReasoningEfforts (import cycle). */
122
+ function sanitizeLadder(values: readonly string[] | undefined): string[] | undefined {
123
+ if (!Array.isArray(values)) return undefined;
124
+ const seen = new Set(values.filter((value): value is string => typeof value === "string"));
125
+ const ordered = LADDER_ORDER.filter(effort => seen.has(effort));
126
+ return ordered.length > 0 ? ordered : undefined;
127
+ }
128
+
129
+ function metadataProviderKey(provider: OcxProviderConfig): string | undefined {
130
+ const normalized = normalizeDestinationUrl(typeof provider.baseUrl === "string" ? provider.baseUrl : undefined);
131
+ if (!normalized) return undefined;
132
+ for (const [destination, key] of Object.entries(BASE_URL_TO_METADATA_PROVIDER)) {
133
+ if (normalizeDestinationUrl(destination) === normalized) return key;
134
+ }
135
+ return undefined;
136
+ }
137
+
138
+ /**
139
+ * Local mirror of `modelRecordValue()` from `src/reasoning-effort.ts`, which imports this
140
+ * module and so cannot be imported back. Exact id, then the `family:` prefix, then a
141
+ * case-folded match — a configured ladder must resolve here exactly as it does there, or the
142
+ * downgrade rung is chosen off a different ladder than the catalog advertises.
143
+ */
144
+ function modelLadderValue(
145
+ record: Record<string, string[]> | undefined,
146
+ modelId: string,
147
+ ): readonly string[] | undefined {
148
+ if (!record) return undefined;
149
+ if (Object.prototype.hasOwnProperty.call(record, modelId)) return record[modelId];
150
+ const colon = modelId.indexOf(":");
151
+ if (colon > 0) {
152
+ const family = modelId.slice(0, colon);
153
+ if (Object.prototype.hasOwnProperty.call(record, family)) return record[family];
154
+ }
155
+ const folded = modelId.toLowerCase();
156
+ for (const [key, value] of Object.entries(record)) {
157
+ if (key.toLowerCase() === folded) return value;
158
+ }
159
+ return undefined;
160
+ }
161
+
162
+ /** Opaque row key: providerKey|modelId|effort. None of the three may contain a pipe. */
163
+ const KEY_SEP = "|";
164
+
165
+ function supportKey(providerKey: string, modelId: string, effort: string): string {
166
+ return providerKey + KEY_SEP + modelId + KEY_SEP + effort;
167
+ }
168
+
169
+ function loadSnapshot(): MetadataSnapshot | null {
170
+ if (snapshotMemo !== undefined) return snapshotMemo;
171
+ const parsed = readJsonFile<MetadataSnapshot>(FILENAME);
172
+ snapshotMemo = parsed && (parsed.version === 1 || parsed.version === 2) && parsed.providers && typeof parsed.providers === "object"
173
+ ? parsed
174
+ : null;
175
+ return snapshotMemo;
176
+ }
177
+
178
+ /**
179
+ * Mapping report for diagnostics and tests: every gated destination, the models.dev provider it
180
+ * resolves to, and whether the snapshot (v2) publishes an `api` URL that confirms it. The gate is
181
+ * deliberate -- 36 of the registry's 83 destinations match a models.dev provider, so resolving by
182
+ * URL alone would silently move ladders for providers this change has no evidence for.
183
+ */
184
+ export function reasoningMetadataMapping(): Array<{
185
+ destination: string;
186
+ provider: string;
187
+ publishedApi?: string;
188
+ confirmed?: boolean;
189
+ models: number;
190
+ }> {
191
+ const snapshot = loadSnapshot();
192
+ return Object.entries(BASE_URL_TO_METADATA_PROVIDER).map(([destination, provider]) => {
193
+ const normalized = normalizeDestinationUrl(destination);
194
+ const publishedApi = snapshot?.apis?.[provider];
195
+ const row = {
196
+ destination,
197
+ provider,
198
+ ...(publishedApi ? { publishedApi } : {}),
199
+ ...(publishedApi ? { confirmed: publishedApi === normalized } : {}),
200
+ models: Object.keys(snapshot?.providers?.[provider] ?? {}).length,
201
+ };
202
+ return row;
203
+ });
204
+ }
205
+
206
+ function loadSupport(): Map<string, number> {
207
+ const nowMs = Date.now();
208
+ if (supportMemo) {
209
+ // The memo lives for the process lifetime, so the TTL has to be re-applied on every read.
210
+ // Checking it only on the disk load meant a long-running proxy kept clamping on a refusal
211
+ // it recorded a month earlier, and `dropLearnedUnsupportedReasoningEfforts` inherited that
212
+ // through the same map.
213
+ for (const [key, at] of supportMemo) {
214
+ if (nowMs - at > SUPPORT_TTL_MS) {
215
+ supportMemo.delete(key);
216
+ supportEvidence.delete(key);
217
+ }
218
+ }
219
+ return supportMemo;
220
+ }
221
+ const rows = new Map<string, number>();
222
+ const parsed = readJsonFile<SupportSnapshot>(SUPPORT_FILENAME);
223
+ if (parsed && parsed.version === 1 && parsed.rows && typeof parsed.rows === "object") {
224
+ for (const [key, row] of Object.entries(parsed.rows)) {
225
+ if (!row || typeof row.at !== "number") continue;
226
+ if (nowMs - row.at > SUPPORT_TTL_MS) continue;
227
+ rows.set(key, row.at);
228
+ }
229
+ }
230
+ supportMemo = rows;
231
+ return rows;
232
+ }
233
+
234
+ /** Snapshot health for ocx status / diagnostics. */
235
+ export function reasoningMetadataStatus(): { fetchedAt?: number; ageMs?: number; stale: boolean; models: number } {
236
+ const snapshot = loadSnapshot();
237
+ if (!snapshot) return { stale: false, models: 0 };
238
+ const ageMs = Date.now() - snapshot.fetchedAt;
239
+ let models = 0;
240
+ for (const provider of Object.values(snapshot.providers)) models += Object.keys(provider).length;
241
+ return { fetchedAt: snapshot.fetchedAt, ageMs, stale: ageMs > CACHE_TTL_MS, models };
242
+ }
243
+
244
+ export function reasoningMetadataModel(provider: OcxProviderConfig, modelId: string): ReasoningMetadataModel | undefined {
245
+ const key = metadataProviderKey(provider);
246
+ if (!key) return undefined;
247
+ const models = loadSnapshot()?.providers?.[key];
248
+ if (!models) return undefined;
249
+ const model = models[modelId];
250
+ return model && typeof model === "object" ? model : undefined;
251
+ }
252
+
253
+ /** Raw models.dev effort values for a model, canonicalised; undefined when not published. */
254
+ export function metadataEffortValues(provider: OcxProviderConfig, modelId: string): string[] | undefined {
255
+ const model = reasoningMetadataModel(provider, modelId);
256
+ if (!model) return undefined;
257
+ const options = Array.isArray(model.options) ? model.options : [];
258
+ const effort = options.find(option => option && option.type === "effort");
259
+ const ladder = sanitizeLadder(effort?.values);
260
+ // none/minimal are sentinels, not picker rungs (mapReasoningEffort folds minimal to low), and
261
+ // advertising them would trip the Codex runtime clamp for no user-visible gain.
262
+ const rungs = ladder?.filter(value => value !== "none" && value !== "minimal");
263
+ return rungs && rungs.length > 0 ? rungs : undefined;
264
+ }
265
+
266
+ /** True when models.dev publishes the named option type (toggle / budget_tokens) for a model. */
267
+ export function metadataDeclaresType(provider: OcxProviderConfig, modelId: string, type: string): boolean {
268
+ const model = reasoningMetadataModel(provider, modelId);
269
+ if (!model) return false;
270
+ const options = Array.isArray(model.options) ? model.options : [];
271
+ return options.some(option => option?.type === type);
272
+ }
273
+
274
+ export function isReasoningEffortLearnedUnsupported(provider: OcxProviderConfig, modelId: string, effort: string): boolean {
275
+ const key = metadataProviderKey(provider);
276
+ if (!key) return false;
277
+ return loadSupport().has(supportKey(key, modelId, effort));
278
+ }
279
+
280
+ /**
281
+ * After the ladder is chosen (registry config or models.dev metadata), remove the rungs this
282
+ * account actually had refused. Applied at the configuredReasoningEfforts() exit so a
283
+ * registry-pinned ladder learns exactly like a metadata-derived one; without it a pinned rung
284
+ * the upstream rejects would replay-and-fail on every request. An all-refused ladder keeps the
285
+ * original list: turning "some rungs" into "no effort control" would silently drop the picker.
286
+ */
287
+ export function dropLearnedUnsupportedReasoningEfforts(
288
+ provider: OcxProviderConfig,
289
+ modelId: string,
290
+ efforts: readonly string[],
291
+ ): string[] {
292
+ if (efforts.length === 0) return [...efforts];
293
+ const key = metadataProviderKey(provider);
294
+ if (!key) return [...efforts];
295
+ const support = loadSupport();
296
+ if (support.size === 0) return [...efforts];
297
+ const kept = efforts.filter(effort => !support.has(supportKey(key, modelId, effort)));
298
+ return kept.length === 0 ? [...efforts] : kept;
299
+ }
300
+
301
+ /**
302
+ * Metadata fallback ladder for a provider/model.
303
+ *
304
+ * - Published effort values win.
305
+ * - A model the provider already classifies as thinking-toggle / thinking-budget keeps the
306
+ * provider's own effort list; a toggle-only entry never invents wire semantics here.
307
+ * - Rungs the upstream actually refused are removed; a ladder emptied by that learning
308
+ * returns undefined (status quo) rather than advertising "no effort control".
309
+ */
310
+ export function reasoningEffortsFromMetadata(provider: OcxProviderConfig, modelId: string): string[] | undefined {
311
+ const published = metadataEffortValues(provider, modelId);
312
+ let ladder = published;
313
+ if (!ladder) {
314
+ const classified = (provider.thinkingToggleModels ?? []).includes(modelId)
315
+ || (provider.thinkingBudgetModels ?? []).includes(modelId);
316
+ ladder = classified ? CLASSIFIED_STYLE_EFFORTS : undefined;
317
+ }
318
+ if (!ladder || ladder.length === 0) return undefined;
319
+ const kept = ladder.filter(effort => !isReasoningEffortLearnedUnsupported(provider, modelId, effort));
320
+ if (kept.length === 0) return undefined;
321
+ return kept;
322
+ }
323
+
324
+ const supportEvidence = new Map<string, string>();
325
+
326
+ /**
327
+ * Record that the upstream refused a rung. Persisted (debounced) so the next catalog sync and
328
+ * every later request clamp before dispatch. Returns true when this is new information.
329
+ */
330
+ export function recordUnsupportedReasoningEffort(
331
+ provider: OcxProviderConfig,
332
+ modelId: string,
333
+ effort: string,
334
+ evidence?: string,
335
+ ): boolean {
336
+ const key = metadataProviderKey(provider);
337
+ if (!key || !effort) return false;
338
+ const rowKey = supportKey(key, modelId, effort);
339
+ const rows = loadSupport();
340
+ if (rows.has(rowKey)) return false;
341
+ rows.set(rowKey, Date.now());
342
+ if (evidence) supportEvidence.set(rowKey, evidence.slice(0, 240));
343
+ if (persistTimer) clearTimeout(persistTimer);
344
+ persistTimer = setTimeout(() => {
345
+ persistTimer = null;
346
+ try {
347
+ const out: SupportSnapshot["rows"] = {};
348
+ for (const [rowKey, at] of rows) {
349
+ const parts = rowKey.split(KEY_SEP);
350
+ const evidenceText = supportEvidence.get(rowKey);
351
+ out[rowKey] = {
352
+ effort: parts[2] ?? "",
353
+ at,
354
+ ...(evidenceText ? { evidence: evidenceText } : {}),
355
+ };
356
+ }
357
+ atomicWriteFile(join(getConfigDir(), SUPPORT_FILENAME), JSON.stringify({ version: 1, rows: out }) + "\n");
358
+ } catch {
359
+ // Best-effort persistence only.
360
+ }
361
+ }, PERSIST_DEBOUNCE_MS);
362
+ return true;
363
+ }
364
+
365
+ /** Test seam: flush a pending support write so a script sees the snapshot immediately. */
366
+ export function flushReasoningSupportCache(): void {
367
+ if (!persistTimer) return;
368
+ clearTimeout(persistTimer);
369
+ persistTimer = null;
370
+ try {
371
+ const rows = loadSupport();
372
+ const out: SupportSnapshot["rows"] = {};
373
+ for (const [rowKey, at] of rows) {
374
+ const parts = rowKey.split(KEY_SEP);
375
+ const evidenceText = supportEvidence.get(rowKey);
376
+ out[rowKey] = { effort: parts[2] ?? "", at, ...(evidenceText ? { evidence: evidenceText } : {}) };
377
+ }
378
+ atomicWriteFile(join(getConfigDir(), SUPPORT_FILENAME), JSON.stringify({ version: 1, rows: out }) + "\n");
379
+ } catch {
380
+ // Best-effort persistence only.
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Words an upstream uses when it is refusing the parameter it just named. Requiring one of
386
+ * these beside the effort term is what separates "the gateway rejected reasoning effort" from
387
+ * "the gateway rejected something else and echoed the request back".
388
+ */
389
+ const REJECTION_LANGUAGE = /unsupported|not supported|does not support|invalid|unrecognized|unknown|not allowed|not permitted|must be|requires|required|cannot|can't|out of range/i;
390
+
391
+ /**
392
+ * How far from the effort term the rejection language may sit and still be about it. Kept
393
+ * deliberately short: an error body that echoes the request back puts unrelated field names and
394
+ * their complaints within a hundred characters of each other, so a generous window classifies
395
+ * every 400 that mentions effort as a refusal of it.
396
+ */
397
+ const REJECTION_WINDOW = 48;
398
+
399
+ /**
400
+ * The `invalid_request_error` type tag rides along on essentially every 400 an OpenAI-shaped
401
+ * gateway emits, so it is evidence of nothing. Blanked before the language scan rather than
402
+ * dropped from the pattern, because `invalid` is real evidence when it is the message.
403
+ */
404
+ const GENERIC_ERROR_TYPE = /invalid_request_error/gi;
405
+
406
+ /**
407
+ * Evidence test for a rejection body: does it blame reasoning effort?
408
+ *
409
+ * The parameter name on its own is not evidence. A 400 that refuses `max_tokens` may still
410
+ * echo the whole request body back, `reasoning_effort` included, and treating that as a
411
+ * refusal spends this request's one downgrade replay on a rung the upstream never objected to
412
+ * — and persists a false refusal that clamps every later turn for thirty days.
413
+ */
414
+ export function isReasoningEffortRejection(text: string | undefined): boolean {
415
+ if (!text) return false;
416
+ if (/unsupported.{0,24}effort/i.test(text)) return true;
417
+ // An upstream that names the offending parameter has already said which one it means.
418
+ if (/["']?param["']?\s*[:=]\s*["']?(?:reasoning[._ ]effort|reasoning)/i.test(text)) return true;
419
+ const scanned = text.replace(GENERIC_ERROR_TYPE, " ");
420
+ const term = /reasoning\.effort|reasoning_effort|reasoning effort|thinking budget|reasoning_parameters/gi;
421
+ for (let match = term.exec(scanned); match; match = term.exec(scanned)) {
422
+ const from = Math.max(0, match.index - REJECTION_WINDOW);
423
+ const to = Math.min(scanned.length, match.index + match[0].length + REJECTION_WINDOW);
424
+ if (REJECTION_LANGUAGE.test(scanned.slice(from, to))) return true;
425
+ }
426
+ return false;
427
+ }
428
+
429
+ /**
430
+ * Plan a single-rung downgrade for a rejected request: records the refusal (so later turns
431
+ * clamp before dispatch) and returns the next lower rung the model does publish.
432
+ */
433
+ export function planReasoningEffortDowngrade(args: {
434
+ provider: OcxProviderConfig;
435
+ modelId: string;
436
+ requested?: string;
437
+ rejectionText?: string;
438
+ }): { effort: string; recorded: boolean } | undefined {
439
+ const requested = args.requested === "ultra" ? "max" : args.requested;
440
+ if (!requested || !RANKED.includes(requested)) return undefined;
441
+ const recorded = recordUnsupportedReasoningEffort(args.provider, args.modelId, requested, args.rejectionText);
442
+ // Same precedence as configuredReasoningEfforts(): a hand-written ladder is a contract and
443
+ // models.dev is only consulted when nothing was configured for this model. Reading metadata
444
+ // first would have picked the downgrade rung off the published ladder even where a pinned
445
+ // one disagreed, so the replay could land on a rung the registry deliberately excludes.
446
+ const effective = sanitizeLadder(modelLadderValue(args.provider.modelReasoningEfforts, args.modelId))
447
+ ?? sanitizeLadder(args.provider.reasoningEfforts)
448
+ ?? metadataEffortValues(args.provider, args.modelId);
449
+ const ladder = (effective ?? []).filter(effort => RANKED.includes(effort));
450
+ if (ladder.length === 0) return undefined;
451
+ const candidates = ladder
452
+ .filter(effort => RANKED.indexOf(effort) < RANKED.indexOf(requested))
453
+ .filter(effort => !isReasoningEffortLearnedUnsupported(args.provider, args.modelId, effort));
454
+ if (candidates.length === 0) return undefined;
455
+ return { effort: candidates[candidates.length - 1], recorded };
456
+ }
457
+
458
+ /**
459
+ * Refresh the models.dev snapshot. Best-effort and idempotent: never throws, never blocks a
460
+ * request, keeps the previous snapshot on failure. Ladders are stored for the gated destinations
461
+ * only (OpenCode Zen + Zen Go: about 130 models), while every published provider `api` URL is
462
+ * kept so the gate can be checked against real data and widened without another format change.
463
+ * Non-reasoning models carry no ladder and are dropped.
464
+ */
465
+ export async function refreshReasoningMetadata(options: { force?: boolean } = {}): Promise<{
466
+ ok: boolean;
467
+ reason: string;
468
+ providers?: number;
469
+ models?: number;
470
+ }> {
471
+ const snapshot = loadSnapshot();
472
+ if (!options.force && snapshot && Date.now() - snapshot.fetchedAt <= CACHE_TTL_MS) {
473
+ return { ok: true, reason: "fresh" };
474
+ }
475
+ if (refreshInFlight) {
476
+ await refreshInFlight;
477
+ return { ok: true, reason: "coalesced" };
478
+ }
479
+ const job = (async () => {
480
+ const response = await fetch(SOURCE_URL, {
481
+ headers: { "user-agent": USER_AGENT, accept: "application/json" },
482
+ // A hanging connection must not pin refreshInFlight for the life of the process.
483
+ signal: AbortSignal.timeout(15_000),
484
+ });
485
+ if (!response.ok) throw new Error("models.dev HTTP " + response.status);
486
+ const raw = await response.json() as Record<string, { api?: unknown; models?: Record<string, unknown> }>;
487
+ const providers: MetadataSnapshot["providers"] = {};
488
+ const apis: Record<string, string> = {};
489
+ const gated = new Set(Object.values(BASE_URL_TO_METADATA_PROVIDER));
490
+ let models = 0;
491
+ for (const [providerKey, entry] of Object.entries(raw ?? {})) {
492
+ const api = normalizeDestinationUrl(typeof entry?.api === "string" ? entry.api : undefined);
493
+ if (api) apis[providerKey] = api;
494
+ if (!gated.has(providerKey)) continue;
495
+ const out: Record<string, ReasoningMetadataModel> = {};
496
+ for (const [modelId, value] of Object.entries(entry?.models ?? {})) {
497
+ const model = value as { reasoning?: unknown; reasoning_options?: unknown };
498
+ if (model?.reasoning !== true) continue;
499
+ const options: ReasoningMetadataOption[] = [];
500
+ if (Array.isArray(model?.reasoning_options)) {
501
+ for (const option of model.reasoning_options) {
502
+ if (!option || typeof option !== "object") continue;
503
+ const type = (option as { type?: unknown }).type;
504
+ if (typeof type !== "string") continue;
505
+ const values = (option as { values?: unknown }).values;
506
+ options.push({
507
+ type,
508
+ ...(Array.isArray(values)
509
+ ? { values: values.filter((v): v is string => typeof v === "string").slice(0, 12) }
510
+ : {}),
511
+ });
512
+ }
513
+ }
514
+ out[modelId] = { reasoning: model?.reasoning === true, options };
515
+ models += 1;
516
+ }
517
+ if (Object.keys(out).length === 0) continue;
518
+ providers[providerKey] = out;
519
+ }
520
+ const next: MetadataSnapshot = { version: 2, fetchedAt: Date.now(), source: SOURCE_URL, providers, apis };
521
+ atomicWriteFile(join(getConfigDir(), FILENAME), JSON.stringify(next) + "\n");
522
+ snapshotMemo = next;
523
+ return { ok: true, reason: "refreshed", providers: Object.keys(providers).length, models };
524
+ })();
525
+ refreshInFlight = job.catch(() => undefined).finally(() => { refreshInFlight = null; });
526
+ try {
527
+ return await job;
528
+ } catch (error) {
529
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Kick a background refresh when the snapshot is missing or stale. Called from the ladder read
535
+ * path so both the long-lived proxy and short-lived ocx sync self-heal without a new CLI
536
+ * surface. One refresh per process at a time; failures are ignored on purpose.
537
+ */
538
+ export function ensureReasoningMetadataSnapshot(): void {
539
+ const snapshot = loadSnapshot();
540
+ if (snapshot && Date.now() - snapshot.fetchedAt <= CACHE_TTL_MS) return;
541
+ if (refreshInFlight) return;
542
+ void refreshReasoningMetadata().catch(() => undefined);
543
+ }