@bitkyc08/opencodex 2.42.0 → 2.43.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/AGENTS_INSTALL.md +2 -2
  2. package/README.md +31 -0
  3. package/bin/ocx.mjs +10 -1
  4. package/gui/dist/assets/index-DS1NE4Jn.css +1 -0
  5. package/gui/dist/assets/index-Djowl68T.js +112 -0
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic-image-codec.ts +304 -0
  9. package/src/adapters/anthropic-image-normalize.ts +8 -298
  10. package/src/adapters/anthropic.ts +30 -7
  11. package/src/adapters/command-code.ts +7 -5
  12. package/src/adapters/cursor/desktop-executor-contract.ts +15 -0
  13. package/src/adapters/cursor/images.ts +36 -6
  14. package/src/adapters/cursor/live-transport.ts +7 -2
  15. package/src/adapters/cursor/native-exec-desktop.ts +2 -15
  16. package/src/adapters/cursor/protobuf-request.ts +13 -3
  17. package/src/adapters/cursor/tool-definitions.ts +5 -670
  18. package/src/adapters/cursor/tool-guidance.ts +236 -0
  19. package/src/adapters/cursor/tool-naming.ts +252 -0
  20. package/src/adapters/cursor/tool-schemas.ts +195 -0
  21. package/src/adapters/cursor/types.ts +6 -3
  22. package/src/adapters/exec-tool-result-normalize.ts +1 -1
  23. package/src/adapters/google-errors.ts +9 -1
  24. package/src/adapters/google.ts +1 -0
  25. package/src/adapters/kiro-calibration.ts +181 -0
  26. package/src/adapters/kiro.ts +135 -3
  27. package/src/adapters/openai-responses.ts +216 -26
  28. package/src/adapters/responses-code-mode.ts +59 -0
  29. package/src/adapters/tool-catalog-nudge.ts +1 -1
  30. package/src/adapters/xai-schema-analysis.ts +86 -0
  31. package/src/adapters/xai-tool-schema.ts +2 -87
  32. package/src/adapters/xai-web-search.ts +1 -1
  33. package/src/bridge.ts +22 -10
  34. package/src/chat/inbound.ts +11 -3
  35. package/src/claude/inbound-content-options.ts +60 -0
  36. package/src/claude/inbound-model-options.ts +142 -0
  37. package/src/claude/inbound-records.ts +7 -0
  38. package/src/claude/inbound.ts +10 -202
  39. package/src/claude/model-info.ts +45 -0
  40. package/src/cli/account-auth.ts +21 -6
  41. package/src/cli/capabilities.ts +13 -2
  42. package/src/cli/claude.ts +232 -39
  43. package/src/cli/config-command.ts +9 -1
  44. package/src/cli/dispatch.ts +5 -1
  45. package/src/cli/doctor.ts +10 -0
  46. package/src/cli/effort.ts +372 -0
  47. package/src/cli/export-command.ts +3 -9
  48. package/src/cli/help.ts +1 -0
  49. package/src/cli/index.ts +13 -0
  50. package/src/cli/init.ts +4 -0
  51. package/src/cli/model-selection-guidance.ts +30 -0
  52. package/src/cli/models-runtime.ts +3 -2
  53. package/src/cli/models.ts +8 -3
  54. package/src/cli/opencode.ts +4 -1
  55. package/src/cli/provider-runtime.ts +65 -0
  56. package/src/cli/provider.ts +8 -0
  57. package/src/cli/registry.ts +16 -2
  58. package/src/cli/runtime-api.ts +3 -1
  59. package/src/cli/star-prompt.ts +22 -6
  60. package/src/cli/status-probes.ts +168 -0
  61. package/src/cli/status.ts +5 -168
  62. package/src/clients/config-export/constants.ts +69 -0
  63. package/src/clients/config-export/contracts.ts +154 -0
  64. package/src/clients/config-export/dsh.ts +132 -0
  65. package/src/clients/config-export/fast-models.ts +29 -0
  66. package/src/clients/config-export/mcode.ts +83 -0
  67. package/src/clients/config-export/model-metadata.ts +108 -0
  68. package/src/clients/config-export/omp.ts +104 -0
  69. package/src/clients/config-export/zcode.ts +92 -0
  70. package/src/clients/config-export.ts +18 -710
  71. package/src/codex/account-lifecycle.ts +20 -3
  72. package/src/codex/account-usability.ts +2 -0
  73. package/src/codex/auth-api.ts +170 -23
  74. package/src/codex/auth-context.ts +328 -24
  75. package/src/codex/catalog/effort.ts +30 -4
  76. package/src/codex/catalog/metadata.ts +39 -9
  77. package/src/codex/catalog/native-models.ts +53 -12
  78. package/src/codex/catalog/parsing.ts +119 -5
  79. package/src/codex/catalog/provider-fetch.ts +150 -20
  80. package/src/codex/catalog/reserve.ts +52 -0
  81. package/src/codex/catalog/sync.ts +89 -16
  82. package/src/codex/convergence-types.ts +1 -0
  83. package/src/codex/convergence.ts +2 -0
  84. package/src/codex/data/upstream-models.json +1 -1
  85. package/src/codex/forward-transport-headers.ts +25 -0
  86. package/src/codex/inject.ts +3 -28
  87. package/src/codex/legacy-config-keys.ts +68 -0
  88. package/src/codex/log-guard/inspect-schema.ts +137 -0
  89. package/src/codex/log-guard/inspect.ts +2 -134
  90. package/src/codex/loopback-target.ts +54 -0
  91. package/src/codex/main-account-cache.ts +63 -1
  92. package/src/codex/main-account-hard-lock.ts +52 -0
  93. package/src/codex/main-account.ts +3 -1
  94. package/src/codex/management-convergence.ts +3 -0
  95. package/src/codex/model-entitlements.ts +54 -4
  96. package/src/codex/project-config-warnings.ts +92 -2
  97. package/src/codex/prompt-layers/encoding.ts +80 -0
  98. package/src/codex/prompt-layers/paths.ts +54 -0
  99. package/src/codex/prompt-layers/revision.ts +55 -0
  100. package/src/codex/prompt-layers/toml-edit.ts +163 -0
  101. package/src/codex/prompt-layers/toml-read.ts +181 -0
  102. package/src/codex/prompt-layers.ts +14 -520
  103. package/src/codex/quota-auto-refresh-state.ts +16 -0
  104. package/src/codex/quota-auto-refresh.ts +219 -0
  105. package/src/codex/quota-types.ts +51 -0
  106. package/src/codex/quota.ts +252 -93
  107. package/src/codex/reserve-availability.ts +177 -0
  108. package/src/codex/routing.ts +28 -9
  109. package/src/codex/shim.ts +53 -11
  110. package/src/codex/subagent-model-fallback.ts +23 -3
  111. package/src/combos/failover.ts +125 -7
  112. package/src/combos/identifiers.ts +89 -0
  113. package/src/combos/index.ts +4 -0
  114. package/src/combos/resolve.ts +80 -9
  115. package/src/combos/types.ts +20 -93
  116. package/src/config/subagent-models.ts +24 -0
  117. package/src/config.ts +156 -13
  118. package/src/generated/compatibility-version.json +454 -166
  119. package/src/generated/model-metadata.ts +1 -1
  120. package/src/integrations/journal.ts +65 -4
  121. package/src/integrations/store.ts +5 -0
  122. package/src/lab/events/limits.ts +4 -0
  123. package/src/lib/destination-policy.ts +31 -2
  124. package/src/lib/errors.ts +39 -0
  125. package/src/lib/provider-outbound.ts +69 -3
  126. package/src/lib/proxy-env.ts +22 -0
  127. package/src/lib/redact-folding.ts +176 -0
  128. package/src/lib/redact.ts +2 -175
  129. package/src/lib/state-store-sweeper.ts +20 -6
  130. package/src/lib/token-estimate.ts +94 -27
  131. package/src/lib/windows-user-principal.ts +53 -5
  132. package/src/oauth/anthropic-routing.ts +99 -3
  133. package/src/oauth/generic-account-failover.ts +36 -13
  134. package/src/oauth/index.ts +127 -26
  135. package/src/oauth/login-cli.ts +5 -0
  136. package/src/oauth/meta-muse.ts +117 -15
  137. package/src/oauth/pool-settings-capability.ts +15 -4
  138. package/src/providers/api-keys.ts +8 -10
  139. package/src/providers/default-aliases.ts +39 -0
  140. package/src/providers/derive.ts +10 -2
  141. package/src/providers/fastwire.ts +36 -7
  142. package/src/providers/initial-model-selection-runtime.ts +90 -0
  143. package/src/providers/initial-model-selection.ts +120 -0
  144. package/src/providers/key-failover.ts +134 -54
  145. package/src/providers/key-store.ts +11 -1
  146. package/src/providers/label.ts +1 -1
  147. package/src/providers/model-discovery.ts +76 -0
  148. package/src/providers/model-rename-startup.ts +72 -8
  149. package/src/providers/openai-sidecar.ts +17 -5
  150. package/src/providers/openai-tiers-destination.ts +102 -0
  151. package/src/providers/openai-tiers.ts +2 -99
  152. package/src/providers/opencode-go-transport.ts +41 -0
  153. package/src/providers/quota-key-accounts.ts +141 -0
  154. package/src/providers/quota-types.ts +9 -0
  155. package/src/providers/quota.ts +535 -104
  156. package/src/providers/registry.ts +60 -17
  157. package/src/providers/xai-responses-opt-in.ts +31 -5
  158. package/src/quota/reset-activation.ts +81 -0
  159. package/src/quota/reset-detector.ts +305 -0
  160. package/src/quota/reset-notify-config.ts +162 -0
  161. package/src/quota/reset-observer.ts +125 -0
  162. package/src/quota/reset-poller.ts +160 -0
  163. package/src/quota/reset-seen-store.ts +385 -0
  164. package/src/quota/reset-sinks.ts +199 -0
  165. package/src/quota/window-mapping.ts +106 -0
  166. package/src/responses/apply-patch-envelope.ts +46 -0
  167. package/src/responses/code-mode-helper-compat.ts +39 -1
  168. package/src/responses/custom-tool-compat.ts +10 -4
  169. package/src/responses/hosted-tool-policy.ts +12 -4
  170. package/src/responses/parser-content.ts +133 -0
  171. package/src/responses/parser-text-format.ts +24 -0
  172. package/src/responses/parser-tools.ts +188 -0
  173. package/src/responses/parser.ts +3 -326
  174. package/src/responses/state.ts +124 -28
  175. package/src/router.ts +48 -13
  176. package/src/routing/analytics.ts +1 -0
  177. package/src/routing/capability.ts +17 -4
  178. package/src/server/auth-cors.ts +7 -1
  179. package/src/server/background-lifecycle.ts +23 -1
  180. package/src/server/chat-completions.ts +25 -3
  181. package/src/server/claude-messages.ts +62 -5
  182. package/src/server/effort-row.ts +1 -1
  183. package/src/server/fast-row.ts +295 -0
  184. package/src/server/gui-static.ts +30 -4
  185. package/src/server/index.ts +107 -21
  186. package/src/server/management/agent-settings-routes.ts +2 -2
  187. package/src/server/management/combo-routes.ts +37 -9
  188. package/src/server/management/config-routes.ts +93 -2
  189. package/src/server/management/integration-routes.ts +108 -0
  190. package/src/server/management/model-routes.ts +13 -3
  191. package/src/server/management/model-rows.ts +20 -1
  192. package/src/server/management/native-integration-routes.ts +4 -1
  193. package/src/server/management/oauth-account-routes.ts +38 -10
  194. package/src/server/management/provider-routes.ts +34 -3
  195. package/src/server/management/quota-reset-routes.ts +57 -0
  196. package/src/server/management/route-registry.ts +7 -4
  197. package/src/server/management/shared.ts +19 -5
  198. package/src/server/management/system-routes.ts +3 -2
  199. package/src/server/management-api.ts +14 -2
  200. package/src/server/ports.ts +12 -2
  201. package/src/server/relay-eager.ts +38 -23
  202. package/src/server/relay.ts +4 -0
  203. package/src/server/request-log.ts +6 -0
  204. package/src/server/responses/codex-ws-correlation.ts +65 -0
  205. package/src/server/responses/codex-ws-exchange.ts +261 -0
  206. package/src/server/responses/codex-ws-metadata.ts +134 -0
  207. package/src/server/responses/codex-ws-pool.ts +162 -0
  208. package/src/server/responses/codex-ws-request.ts +87 -0
  209. package/src/server/responses/codex-ws-session.ts +93 -0
  210. package/src/server/responses/codex-ws-wire.ts +144 -0
  211. package/src/server/responses/collaboration.ts +41 -1
  212. package/src/server/responses/compact.ts +105 -12
  213. package/src/server/responses/core.ts +476 -57
  214. package/src/server/responses/fetch-helpers.ts +10 -3
  215. package/src/server/responses/input-admission.ts +16 -9
  216. package/src/server/responses/responses-field-backfill.ts +1 -1
  217. package/src/server/responses/ws-upstream.ts +34 -318
  218. package/src/server/responses-custom-tool-repair.ts +20 -4
  219. package/src/server/responses-undeclared-tool-guard.ts +100 -8
  220. package/src/server/safe-response-headers.ts +23 -0
  221. package/src/server/search.ts +9 -0
  222. package/src/server/subagent-models-startup.ts +27 -0
  223. package/src/server/system-env-shell.ts +238 -0
  224. package/src/server/system-env.ts +7 -234
  225. package/src/server/ws-bridge.ts +3 -25
  226. package/src/server/xai-responses-startup.ts +21 -0
  227. package/src/service-manager-probe.ts +1 -1
  228. package/src/service.ts +55 -16
  229. package/src/types/config.ts +108 -12
  230. package/src/types/provider.ts +36 -7
  231. package/src/types/tools.ts +26 -1
  232. package/src/types.ts +2 -0
  233. package/src/update/notify.ts +8 -2
  234. package/src/usage/cost.ts +38 -28
  235. package/src/usage/expected-prices.ts +34 -15
  236. package/src/usage/log.ts +2 -0
  237. package/src/usage/model-identity.ts +26 -0
  238. package/src/usage/summary.ts +15 -1
  239. package/src/vision/describe.ts +6 -0
  240. package/src/vision/image-rewrite.ts +108 -0
  241. package/src/vision/index.ts +19 -306
  242. package/src/vision/plan.ts +205 -0
  243. package/src/web-search/executor.ts +6 -0
  244. package/src/web-search/index.ts +8 -1
  245. package/gui/dist/assets/index-BU1tE0sr.js +0 -112
  246. package/gui/dist/assets/index-DL9-iS6J.css +0 -1
@@ -244,9 +244,22 @@ export function resolveFastPolicy(
244
244
  };
245
245
  }
246
246
 
247
- export function canonicalFastTierMarker(callerTier: string | undefined): "priority" | undefined {
247
+ /**
248
+ * Fold a caller's service tier onto a canonical fast marker.
249
+ *
250
+ * `ultrafast` is recognised as INTENT even though no shipped catalog advertises it and
251
+ * `DEFAULT_SERVICE_TIER_FAST_WIRE` has no wire mapping for it. That asymmetry is
252
+ * deliberate: a caller who sends `ultrafast` (which #3429's reporter did, via their own
253
+ * catalog edit) was previously folded to `undefined`, which made `fastIntent` false and
254
+ * recorded `fastOutcome: "not-requested"` — the log asserting the user asked for nothing.
255
+ * Recognising the intent without a wire mapping lands the attempt on `unknown` instead,
256
+ * which is the truth: the tier was requested, and we cannot confirm it was honored.
257
+ */
258
+ export function canonicalFastTierMarker(callerTier: string | undefined): "priority" | "ultrafast" | undefined {
248
259
  const folded = callerTier?.trim().toLowerCase();
249
- return folded === "priority" || folded === "fast" ? "priority" : undefined;
260
+ if (folded === "priority" || folded === "fast") return "priority";
261
+ if (folded === "ultrafast") return "ultrafast";
262
+ return undefined;
250
263
  }
251
264
 
252
265
  /** Capture Fast demand before the final A1 serialization action rewrites the parsed tier view. */
@@ -298,7 +311,18 @@ export function createAdapterTierMetadata(
298
311
  ): AdapterTierMetadata | undefined {
299
312
  if (!context || !decision) return undefined;
300
313
 
301
- const callerCanonicalFast = canonicalFastTierMarker(context.callerTier) === "priority";
314
+ const callerMarker = canonicalFastTierMarker(context.callerTier);
315
+ // Two different questions, and conflating them mislabels the record.
316
+ //
317
+ // "Did the caller ask for FAST?" governs the drop and suppression facts: the Fast
318
+ // toggle suppressing a request is only true of the 1.5x Fast tier, so an `ultrafast`
319
+ // caller turned away by `fastMode: false` was NOT a suppressed Fast request and must
320
+ // still read as `callerTierDropped`.
321
+ //
322
+ // "Did the caller ask for SOME fast-family tier?" is the wider question, and only
323
+ // `fastIntent` below is entitled to it.
324
+ const callerCanonicalFast = callerMarker === "priority";
325
+ const callerFastFamilyIntent = callerMarker !== undefined;
302
326
  const callerTierDropped = context.callerTier !== undefined
303
327
  && !callerCanonicalFast
304
328
  && wireValue === null;
@@ -338,7 +362,7 @@ export function createAdapterTierMetadata(
338
362
  // Known-unsupported routes still need a downgrade when the caller/config expressed Fast intent,
339
363
  // but they are deliberately outside the effective-demand calculation above.
340
364
  const fastIntent = context.demandDecision === "force-fast"
341
- || (context.demandDecision === "inherit" && callerCanonicalFast);
365
+ || (context.demandDecision === "inherit" && callerFastFamilyIntent);
342
366
 
343
367
  if (!fastIntent) {
344
368
  outcome.fastOutcome = "not-requested";
@@ -421,9 +445,14 @@ export function decideTier(
421
445
  const callerCanonicalFast = canonicalFastTierMarker(callerTier);
422
446
  if (callerCanonicalFast !== undefined) {
423
447
  const value = policy.fastWire.canonicalToWire[callerCanonicalFast];
424
- return typeof value === "string" && value.length > 0
425
- ? { kind: "set", value }
426
- : { kind: "drop" };
448
+ if (typeof value === "string" && value.length > 0) return { kind: "set", value };
449
+ // A canonical marker with NO wire mapping is not a reason to drop the tier.
450
+ //
451
+ // `ultrafast` is recognised as intent but deliberately unmapped, because no wire
452
+ // advertises it. Dropping here would have made recognition strictly worse than not
453
+ // recognising it at all: before, `ultrafast` was a foreign tier and
454
+ // `foreignCallerTiers: "verbatim"` forwarded it untouched. Falling through keeps that
455
+ // behavior, so an operator-supplied tier still reaches the provider.
427
456
  }
428
457
  if (callerTier !== undefined && !policy.forwardCallerTier) return { kind: "drop" };
429
458
  if (
@@ -0,0 +1,90 @@
1
+ import { mutatePersistedConfig, validateConfigCandidate } from "../config";
2
+ import { isDeepStrictEqual } from "node:util";
3
+ import type { OcxConfig, OcxProviderConfig } from "../types";
4
+ import type { CatalogModel } from "../codex/catalog";
5
+ import {
6
+ adoptInitialModelSelections,
7
+ initialModelSelection,
8
+ initialModelSelectionPending,
9
+ reconcileInitialModelSelections,
10
+ } from "./initial-model-selection";
11
+
12
+ interface InitialSelectionBaseline {
13
+ providers: string[];
14
+ inventory: unknown;
15
+ disabled: string;
16
+ }
17
+
18
+ function inventoryIdentity(config: OcxConfig): unknown {
19
+ const validated = validateConfigCandidate(config);
20
+ if (!validated.ok) return null;
21
+ // Compare all inventory-producing configuration, including custom rows and combos.
22
+ // Normalize schema defaults, ignoring completed-selection state and switch values.
23
+ // Listener binding intentionally differs between live and disk after a port/host edit;
24
+ // it cannot affect provider discovery and must not leave registration pending forever.
25
+ // The incarnation remains: identical delete/re-add is NOT the same registration.
26
+ const providers = Object.fromEntries(Object.entries(validated.config.providers).map(([name, provider]) => [name, {
27
+ ...provider,
28
+ initialModelSelection: initialModelSelection(provider)?.registrationId,
29
+ }]));
30
+ // Ephemeral only: never log this value, which may contain credentials.
31
+ return JSON.parse(JSON.stringify({ ...validated.config, providers, disabledModels: undefined, port: undefined, hostname: undefined }));
32
+ }
33
+
34
+ export function captureInitialSelectionBaseline(config: OcxConfig): InitialSelectionBaseline | null {
35
+ const providers = Object.entries(config.providers)
36
+ .filter(([, provider]) => initialModelSelectionPending(provider))
37
+ .map(([name]) => name);
38
+ if (!providers.length) return null;
39
+ const inventory = inventoryIdentity(config);
40
+ return inventory === null ? null : { providers, inventory, disabled: JSON.stringify(config.disabledModels ?? []) };
41
+ }
42
+
43
+ /** Commit only decisions whose provider and user-selection snapshot still match. */
44
+ export function finalizeInitialModelSelection(
45
+ config: OcxConfig,
46
+ baseline: InitialSelectionBaseline | null,
47
+ models: readonly CatalogModel[],
48
+ authoritativeProviders: readonly string[],
49
+ ): void {
50
+ if (!baseline || JSON.stringify(config.disabledModels ?? []) !== baseline.disabled) return;
51
+ if (!isDeepStrictEqual(inventoryIdentity(config), baseline.inventory)) return;
52
+ try {
53
+ const outcome = mutatePersistedConfig(fresh => {
54
+ if (!isDeepStrictEqual(inventoryIdentity(fresh), baseline.inventory)) return { changed: false, value: null };
55
+ const providers: Record<string, OcxProviderConfig> = {};
56
+ for (const name of baseline.providers) {
57
+ const provider = fresh.providers[name];
58
+ if (!provider || !initialModelSelection(provider)) continue;
59
+ // A concurrent successful initializer may already have committed its result.
60
+ // Adopt that result, including any later manual switch edits; never initialize twice.
61
+ if (initialModelSelectionPending(provider) && JSON.stringify(fresh.disabledModels ?? []) !== baseline.disabled) continue;
62
+ providers[name] = provider;
63
+ }
64
+ const projection = { ...fresh, providers };
65
+ const changed = reconcileInitialModelSelections(projection, models, authoritativeProviders);
66
+ if (changed) fresh.disabledModels = projection.disabledModels;
67
+ return { changed, value: { ...projection, disabledModels: fresh.disabledModels } };
68
+ });
69
+ if (outcome.status === "unavailable" || !outcome.value) return;
70
+ adoptInitialModelSelections(config, outcome.value);
71
+ if (Object.keys(outcome.value.providers).length) {
72
+ config.disabledModels = outcome.value.disabledModels === undefined ? undefined : [...outcome.value.disabledModels];
73
+ }
74
+ } catch {
75
+ // Keep pending publication fenced on contention or failed persistence. A later ordinary
76
+ // model refresh retries; no dedicated timer and no private exception/path output.
77
+ console.warn("[initial-model-selection] Could not save initial model choices; model exposure remains pending. Retry model discovery.");
78
+ }
79
+ }
80
+
81
+ /** Ordinary discovery, before retained catalog evidence is captured. */
82
+ export async function resolvePendingInitialModelSelection(config: OcxConfig): Promise<void> {
83
+ const baseline = captureInitialSelectionBaseline(config);
84
+ if (!baseline) return;
85
+ const { gatherRoutedModels, uniqueCatalogModelsForPublicList } = await import("../codex/catalog");
86
+ const outcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = [];
87
+ const models = await gatherRoutedModels(config, { providerModelOutcomes: outcomes });
88
+ finalizeInitialModelSelection(config, baseline, uniqueCatalogModelsForPublicList(models),
89
+ outcomes.filter(outcome => outcome.state === "authoritative").map(outcome => outcome.provider));
90
+ }
@@ -0,0 +1,120 @@
1
+ import type { OcxConfig, OcxProviderConfig } from "../types";
2
+ import { randomUUID } from "node:crypto";
3
+ import { getProviderRegistryEntry, providerMatchesRegistryTransport } from "./registry";
4
+ import { routedSlug, slugEquivalenceKey } from "./slug-codec";
5
+ import { comboDisabledModelSelectors } from "../combos/types";
6
+ import { providerUsesKeyAuthOverride, resolveProviderApiKey } from "./key-store";
7
+
8
+ export const INITIAL_MODEL_SELECTION_THRESHOLD = 20;
9
+ type Selection = NonNullable<OcxProviderConfig["initialModelSelection"]>;
10
+
11
+ /** Read only the public, non-secret shape; editor input never owns this state. */
12
+ export function initialModelSelection(provider: OcxProviderConfig | undefined): Selection | undefined {
13
+ const value = provider?.initialModelSelection;
14
+ if (!value || value.version !== 1 || typeof value.registrationId !== "string"
15
+ || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value.registrationId)
16
+ || !["pending", "ready", "all-off"].includes(value.status)) return undefined;
17
+ return {
18
+ version: 1,
19
+ registrationId: value.registrationId,
20
+ status: value.status,
21
+ ...(Number.isSafeInteger(value.modelCount) && value.modelCount! >= 0 ? { modelCount: value.modelCount } : {}),
22
+ };
23
+ }
24
+
25
+ export function initialModelSelectionPending(provider: OcxProviderConfig | undefined): boolean {
26
+ return initialModelSelection(provider)?.status === "pending";
27
+ }
28
+
29
+ function loginConnection(name: string, provider: OcxProviderConfig): boolean {
30
+ const entry = getProviderRegistryEntry(name);
31
+ if (entry && providerMatchesRegistryTransport(name, provider)) {
32
+ if (entry.authKind === "forward") return true;
33
+ if (entry.authKind === "oauth") {
34
+ return !providerUsesKeyAuthOverride(entry, provider, resolveProviderApiKey(provider.apiKey));
35
+ }
36
+ }
37
+ return provider.authMode === "oauth" || provider.authMode === "forward";
38
+ }
39
+
40
+ /** Registration only: absence on an existing row is legacy/exempt, never a migration trigger. */
41
+ export function initializeProviderModelSelection(
42
+ name: string,
43
+ next: OcxProviderConfig,
44
+ existing?: OcxProviderConfig,
45
+ config?: Pick<OcxConfig, "disabledModels" | "combos" | "modelDiscovery">,
46
+ ): void {
47
+ delete next.initialModelSelection;
48
+ if (existing) {
49
+ for (const key of ["selectedModels", "modelPreset", "newModelPolicy"] as const) {
50
+ if (next[key] === undefined && existing[key] !== undefined) {
51
+ Object.assign(next, { [key]: structuredClone(existing[key]) });
52
+ }
53
+ }
54
+ if (existing.initialModelSelection !== undefined) next.initialModelSelection = structuredClone(existing.initialModelSelection);
55
+ } else {
56
+ // A deleted provider's discovery history belongs to that old registration too.
57
+ if (config?.modelDiscovery?.knownModels) delete config.modelDiscovery.knownModels[name];
58
+ if (config?.modelDiscovery?.recentArrivals) delete config.modelDiscovery.recentArrivals[name];
59
+ if (config?.disabledModels) {
60
+ const comboSelectors = new Set(Object.entries(config.combos ?? {})
61
+ .flatMap(([id, combo]) => comboDisabledModelSelectors(id, combo)));
62
+ config.disabledModels = config.disabledModels.filter(selector =>
63
+ !selector.startsWith(`${name}/`) || comboSelectors.has(selector));
64
+ }
65
+ if (!loginConnection(name, next)) {
66
+ next.initialModelSelection = { version: 1, registrationId: randomUUID(), status: "pending" };
67
+ }
68
+ }
69
+ }
70
+
71
+ /** Count the canonical switch identities that the Models inventory displays. */
72
+ export function reconcileInitialModelSelections(
73
+ config: OcxConfig,
74
+ models: Iterable<{ provider: string; id: string }>,
75
+ authoritativeProviders: Iterable<string>,
76
+ ): boolean {
77
+ const selectors = new Map<string, Set<string>>();
78
+ for (const model of models) {
79
+ const ids = selectors.get(model.provider) ?? new Set<string>();
80
+ ids.add(routedSlug(model.provider, model.id));
81
+ selectors.set(model.provider, ids);
82
+ }
83
+ const authoritative = new Set(authoritativeProviders);
84
+ let changed = false;
85
+ for (const [name, provider] of Object.entries(config.providers)) {
86
+ const initial = initialModelSelection(provider);
87
+ if (initial?.status !== "pending") continue;
88
+ if (loginConnection(name, provider)) {
89
+ provider.initialModelSelection = { version: 1, registrationId: initial.registrationId, status: "ready" };
90
+ changed = true;
91
+ continue;
92
+ }
93
+ if (!authoritative.has(name)) continue;
94
+ const ids = selectors.get(name) ?? new Set<string>();
95
+ const allOff = ids.size >= INITIAL_MODEL_SELECTION_THRESHOLD;
96
+ if (allOff) {
97
+ const disabled = config.disabledModels ??= [];
98
+ const keys = new Set(disabled.map(slugEquivalenceKey));
99
+ for (const id of ids) {
100
+ const key = slugEquivalenceKey(id);
101
+ if (!keys.has(key)) { disabled.push(id); keys.add(key); }
102
+ }
103
+ }
104
+ provider.initialModelSelection = { version: 1, registrationId: initial.registrationId, status: allOff ? "all-off" : "ready", modelCount: ids.size };
105
+ changed = true;
106
+ }
107
+ return changed;
108
+ }
109
+
110
+ export function adoptInitialModelSelections(target: OcxConfig, source: OcxConfig): void {
111
+ for (const [name, provider] of Object.entries(source.providers)) {
112
+ if (target.providers[name] && provider.initialModelSelection !== undefined) {
113
+ target.providers[name].initialModelSelection = structuredClone(provider.initialModelSelection);
114
+ }
115
+ }
116
+ }
117
+
118
+ export function pendingModelSelectionProviders(config: Pick<OcxConfig, "providers">): Set<string> {
119
+ return new Set(Object.entries(config.providers).filter(([, provider]) => initialModelSelectionPending(provider)).map(([name]) => name));
120
+ }
@@ -8,8 +8,10 @@
8
8
  *
9
9
  * Modelled after src/codex/routing.ts cooldown logic but scoped to plain API-key pools.
10
10
  */
11
- import { saveConfigPreservingClaudeCode } from "../config";
11
+ import { mutatePersistedConfig } from "../config";
12
+ import { routedProviderConfig } from "../router";
12
13
  import type { OcxConfig, OcxProviderConfig, RateLimitRetryPolicy, TransientRetryPolicy } from "../types";
14
+ import { OPENCODE_GO_SESSION_HEADER } from "./opencode-go-transport";
13
15
  import { resolveProviderTransport, type OcxProviderTransport } from "./xai-transport";
14
16
  import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
15
17
 
@@ -174,11 +176,13 @@ export function rateLimitRetryDelayMs(
174
176
  * The returned object is a snapshot of the PERSISTED config — it carries none of the
175
177
  * registry backfills `routedProviderConfig` merges in at request time. Request paths must
176
178
  * not assign it to an active route wholesale; use `rotateProviderTransportOn429`, which
177
- * takes only the swapped key and keeps the routed provider intact.
179
+ * rebuilds from this committed row, reapplies registry metadata, and retains only explicit
180
+ * runtime transport state (`fetch` and generated OpenCode session affinity).
178
181
  */
179
- export function rotateKeyOn429(
182
+ function rotateKeyAfterFailure(
180
183
  config: OcxConfig,
181
184
  providerName: string,
185
+ failureStatus: 401 | 429,
182
186
  retryAfterHeader: string | null | undefined,
183
187
  now = Date.now(),
184
188
  attemptedKey?: string,
@@ -187,50 +191,101 @@ export function rotateKeyOn429(
187
191
  if (!provider) return null;
188
192
  if (provider.authMode === "oauth" || provider.authMode === "forward") return null;
189
193
 
190
- const pool = provider.apiKeyPool;
191
- if (!pool || pool.length < 2) return null;
192
-
193
- // Cool the key that ACTUALLY failed. Under concurrent 429s another request may already have
194
- // rotated provider.apiKey — cooling the live key would punish an innocent replacement and can
195
- // exhaust a 2-key pool from a single bad key. CAS semantics: callers pass the key they used.
196
194
  const failedKey = attemptedKey ?? provider.apiKey;
197
- const currentEntry = pool.find(e => e.key === failedKey);
198
- if (currentEntry) {
199
- const cooldownMs = parseRetryAfterMs(retryAfterHeader, now) ?? DEFAULT_COOLDOWN_MS;
200
- keyCooldowns.set(cooldownKey(providerName, currentEntry.id), {
201
- cooldownUntil: now + cooldownMs,
202
- });
203
- sweepExpiredOnWrite(now);
204
- }
195
+ type Rotation =
196
+ | { provider: OcxProviderConfig; failedId?: string; candidateId?: string }
197
+ | { exhaustedCount: number; failedId?: string };
198
+ const outcome = mutatePersistedConfig<Rotation | null>(fresh => {
199
+ const freshProvider = fresh.providers[providerName];
200
+ if (!freshProvider || freshProvider.authMode === "oauth" || freshProvider.authMode === "forward") {
201
+ return { changed: false, value: null };
202
+ }
203
+ const pool = freshProvider.apiKeyPool;
204
+ if (!pool || pool.length < 2) return { changed: false, value: null };
205
205
 
206
- // Lost the race: someone already rotated away from the failed key. If the live key is healthy,
207
- // retry with it as-is instead of rotating a second time.
208
- if (attemptedKey !== undefined && provider.apiKey !== attemptedKey) {
209
- const liveEntry = pool.find(e => e.key === provider.apiKey);
210
- if (liveEntry && !isKeyInCooldown(providerName, liveEntry.id, now)) {
211
- return { ...provider };
206
+ // The callback can be rerun after rebasing, so identify the failed key here but
207
+ // defer the in-memory cooldown side effect until persistence has succeeded.
208
+ const failedEntry = pool.find(entry => entry.key === failedKey);
209
+
210
+ if (freshProvider.apiKey !== failedKey) {
211
+ const activeEntry = pool.find(entry => entry.key === freshProvider.apiKey);
212
+ if (activeEntry && !isKeyInCooldown(providerName, activeEntry.id, now)) {
213
+ return {
214
+ changed: false,
215
+ value: { provider: structuredClone(freshProvider), failedId: failedEntry?.id },
216
+ };
217
+ }
212
218
  }
213
- }
214
219
 
215
- // Pick the next key that is NOT in cooldown
216
- const currentIndex = currentEntry ? pool.indexOf(currentEntry) : -1;
217
- for (let i = 1; i < pool.length; i++) {
218
- const candidate = pool[(currentIndex + i) % pool.length]!;
219
- if (!isKeyInCooldown(providerName, candidate.id, now)) {
220
- // Swap active key
221
- provider.apiKey = candidate.key;
222
- saveConfigPreservingClaudeCode(config);
223
- console.warn(
224
- // Log ids only — labels are user-supplied free text and could carry secret material.
225
- `[key-failover] ${providerName}: 429 on key ${currentEntry?.id ?? "?"}; rotating to key ${candidate.id}`,
226
- );
227
- return { ...provider };
220
+ const currentIndex = failedEntry ? pool.indexOf(failedEntry) : -1;
221
+ const candidateCount = failedEntry ? pool.length - 1 : pool.length;
222
+ for (let offset = 1; offset <= candidateCount; offset += 1) {
223
+ const candidate = pool[(currentIndex + offset) % pool.length]!;
224
+ if (isKeyInCooldown(providerName, candidate.id, now)) continue;
225
+ freshProvider.apiKey = candidate.key;
226
+ return {
227
+ changed: true,
228
+ value: {
229
+ provider: structuredClone(freshProvider),
230
+ failedId: failedEntry?.id,
231
+ candidateId: candidate.id,
232
+ },
233
+ };
228
234
  }
235
+ return { changed: false, value: { exhaustedCount: pool.length, failedId: failedEntry?.id } };
236
+ });
237
+ if (outcome.status === "unavailable" || outcome.value === null) return null;
238
+ if (outcome.value.failedId) {
239
+ // A 401 is a verdict about the credential itself, not a timing signal: the key is rejected
240
+ // until an operator replaces it, and upstreams send no Retry-After for it. Hold it for the
241
+ // full cap instead of the 429 default so a dead key is not re-tried once a minute.
242
+ const cooldownMs = failureStatus === 401
243
+ ? MAX_COOLDOWN_MS
244
+ : parseRetryAfterMs(retryAfterHeader, now) ?? DEFAULT_COOLDOWN_MS;
245
+ keyCooldowns.set(cooldownKey(providerName, outcome.value.failedId), { cooldownUntil: now + cooldownMs });
246
+ sweepExpiredOnWrite(now);
247
+ }
248
+ if ("exhaustedCount" in outcome.value) {
249
+ console.warn(`[key-failover] ${providerName}: all ${outcome.value.exhaustedCount} keys in cooldown after ${failureStatus}; returning the upstream status to the client`);
250
+ return null;
251
+ }
252
+
253
+ const committed = structuredClone(outcome.value.provider);
254
+ config.providers[providerName] = committed;
255
+ if (outcome.value.candidateId) {
256
+ console.warn(
257
+ // Log ids only — labels are user-supplied free text and could carry secret material.
258
+ `[key-failover] ${providerName}: ${failureStatus} on key ${outcome.value.failedId ?? "?"}; rotating to key ${outcome.value.candidateId}`,
259
+ );
229
260
  }
261
+ return structuredClone(committed);
262
+ }
263
+
264
+ export function rotateKeyOn429(
265
+ config: OcxConfig,
266
+ providerName: string,
267
+ retryAfterHeader: string | null | undefined,
268
+ now = Date.now(),
269
+ attemptedKey?: string,
270
+ ): OcxProviderConfig | null {
271
+ return rotateKeyAfterFailure(config, providerName, 429, retryAfterHeader, now, attemptedKey);
272
+ }
230
273
 
231
- // All keys in cooldown
232
- console.warn(`[key-failover] ${providerName}: all ${pool.length} keys in cooldown; returning 429 to client`);
233
- return null;
274
+ /**
275
+ * Record a 401 for the current key and attempt to switch to the next available one.
276
+ *
277
+ * A static key pool can recover a credential-scoped 401 without abandoning the provider: one
278
+ * revoked or mistyped key in a pool of several says nothing about its siblings. OAuth and
279
+ * forward providers never reach here — they refresh or re-authenticate instead, and
280
+ * `rotateKeyAfterFailure` rejects both auth modes outright.
281
+ */
282
+ export function rotateKeyOn401(
283
+ config: OcxConfig,
284
+ providerName: string,
285
+ now = Date.now(),
286
+ attemptedKey?: string,
287
+ ): OcxProviderConfig | null {
288
+ return rotateKeyAfterFailure(config, providerName, 401, null, now, attemptedKey);
234
289
  }
235
290
 
236
291
  export function sweepExpiredApiKeyCooldowns(now = Date.now()): number {
@@ -253,13 +308,8 @@ interface RotateProviderTransportOptions {
253
308
  /**
254
309
  * Rotate a failed key and re-apply provider-specific transport metadata to the replacement.
255
310
  *
256
- * `routedProvider` is the request's active provider (the `routedProviderConfig` output the
257
- * route was built with). The result inherits it and swaps ONLY the API key: the persisted
258
- * config that `rotateKeyOn429` snapshots predates registry backfill, so building the retry
259
- * provider from that snapshot would silently drop every field the registry merged in at
260
- * routing time (scalar flags like `promptCacheKey`/`parallelToolCalls`, merged model
261
- * metadata such as `noTemperatureModels`, a pinned baseUrl). Mirrors the OAuth-401 replay
262
- * path in src/server/responses/core.ts, which spreads `route.provider` for the same reason.
311
+ * Route the authoritative committed row again so concurrent provider edits take effect, then
312
+ * restore only transport-only state that can never come from persisted configuration.
263
313
  */
264
314
  export function rotateProviderTransportOn429(
265
315
  config: OcxConfig,
@@ -274,13 +324,43 @@ export function rotateProviderTransportOn429(
274
324
  options.now,
275
325
  options.attemptedKey,
276
326
  );
277
- return rotated
278
- ? resolveProviderTransport(
279
- providerName,
280
- { ...routedProvider, apiKey: rotated.apiKey },
281
- options.promptCacheKey,
282
- )
283
- : null;
327
+ if (!rotated) return null;
328
+ return applyRotatedTransport(providerName, routedProvider, rotated, options.promptCacheKey);
329
+ }
330
+
331
+ /** 401 counterpart of `rotateProviderTransportOn429`; shares its transport-rebuild rules. */
332
+ export function rotateProviderTransportOn401(
333
+ config: OcxConfig,
334
+ providerName: string,
335
+ routedProvider: OcxProviderTransport,
336
+ options: Omit<RotateProviderTransportOptions, "retryAfter"> = {},
337
+ ): OcxProviderTransport | null {
338
+ const rotated = rotateKeyOn401(config, providerName, options.now, options.attemptedKey);
339
+ if (!rotated) return null;
340
+ return applyRotatedTransport(providerName, routedProvider, rotated, options.promptCacheKey);
341
+ }
342
+
343
+ function applyRotatedTransport(
344
+ providerName: string,
345
+ routedProvider: OcxProviderTransport,
346
+ rotated: OcxProviderConfig,
347
+ promptCacheKey?: string,
348
+ ): OcxProviderTransport {
349
+ const committedRoute = routedProviderConfig(providerName, rotated);
350
+ const routedSession = routedProvider.headers?.[OPENCODE_GO_SESSION_HEADER];
351
+ const retryProvider: OcxProviderTransport = {
352
+ ...committedRoute,
353
+ ...(routedProvider.fetch !== undefined ? { fetch: routedProvider.fetch } : {}),
354
+ ...(routedSession !== undefined
355
+ ? {
356
+ headers: {
357
+ ...(committedRoute.headers ?? {}),
358
+ [OPENCODE_GO_SESSION_HEADER]: routedSession,
359
+ },
360
+ }
361
+ : {}),
362
+ };
363
+ return resolveProviderTransport(providerName, retryProvider, promptCacheKey);
284
364
  }
285
365
 
286
366
  /** Clear cooldown state for a provider (e.g. after manual key management). */
@@ -1,6 +1,17 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { resolveEnvValue, saveConfigPreservingClaudeCode } from "../config";
3
3
  import type { OcxConfig, OcxProviderConfig } from "../types";
4
+ import type { ProviderRegistryEntry } from "./registry";
5
+
6
+ /** Shared with routing: a key-mode override is effective only while its key resolves. */
7
+ export function providerUsesKeyAuthOverride(
8
+ entry: Pick<ProviderRegistryEntry, "authKind" | "allowKeyAuthOverride">,
9
+ provider: Pick<OcxProviderConfig, "authMode">,
10
+ resolvedKey: string | undefined,
11
+ ): boolean {
12
+ return entry.authKind === "oauth" && entry.allowKeyAuthOverride === true
13
+ && provider.authMode === "key" && typeof resolvedKey === "string" && resolvedKey.trim().length > 0;
14
+ }
4
15
 
5
16
  /**
6
17
  * Opt-in OS keychain storage for provider API keys (#1221).
@@ -194,4 +205,3 @@ export function restoreProviderKeyFromKeychain(config: OcxConfig, name: string):
194
205
  saveConfigPreservingClaudeCode(config);
195
206
  return { ok: true, restored: resolved.size };
196
207
  }
197
-
@@ -36,7 +36,7 @@ export function baseProviderLabel(provider: string): string {
36
36
  * It lives here rather than in `account-label.ts` because it needs `baseProviderLabel`, and this
37
37
  * module already imports from that one -- the reverse direction would be an import cycle. This
38
38
  * file stays Lab-clean, which matters because `core.ts` is one of the three files
39
- * `tests/core-lab-boundary.test.ts` guards.
39
+ * `tests/lab/core-lab-boundary.test.ts` guards.
40
40
  */
41
41
  export function stampOAuthAccountLabel(
42
42
  logCtx: { accountLogLabel?: string },
@@ -158,6 +158,82 @@ function appendDiscoveryQuery(url: URL, query: Readonly<Record<string, string>>
158
158
  return url;
159
159
  }
160
160
 
161
+ /**
162
+ * Whether a model-discovery request URL is a registry-owned fixed discovery
163
+ * URL — the canonical-URL proof for the transparent fake-IP (Clash/Surge/
164
+ * Mihomo TUN) exception in provider-outbound.
165
+ *
166
+ * The proof is on the FINAL URL, not the provider name: an OAuth/forward name
167
+ * matches any baseUrl by design (`providerMatchesRegistryTransport` returns
168
+ * true regardless of destination), while the bearer is pinned to the registry
169
+ * destination independently in `buildModelsRequest`. Comparing the fetched URL
170
+ * against registry spec URLs keeps a renamed custom row fetching an
171
+ * attacker-controlled URL from gaining the exception.
172
+ *
173
+ * Both spec shapes are covered: an absolute `url` spec matches its own URL
174
+ * (plus the spec's fixed query), and a `path` spec matches the URL it resolves
175
+ * to against the registry's own baseUrl (plus the spec's fixed query) — so the
176
+ * `commandcode` key preset's `path: "models"` proves the same
177
+ * `https://api.commandcode.ai/provider/v1/models` string the `command-code`
178
+ * OAuth `url` spec proves, and the `nebius` `path: "models"` plus
179
+ * `query: { verbose: "true" }` proves
180
+ * `https://api.tokenfactory.nebius.com/v1/models?verbose=true`.
181
+ *
182
+ * Registry-owned fixed query parameters are canonical only on EXACT match:
183
+ * a missing, changed, or additional parameter is not canonical, so `?token=`
184
+ * smuggling on the right origin+path stays rejected. Fragments are never
185
+ * canonical.
186
+ */
187
+ export function isRegistryModelDiscoveryUrl(providerName: string, url: string): boolean {
188
+ const entry = getProviderRegistryEntry(providerName);
189
+ const spec = entry?.modelDiscovery;
190
+ if (!spec) return false;
191
+ let candidate: URL;
192
+ try {
193
+ candidate = new URL(url);
194
+ } catch {
195
+ return false;
196
+ }
197
+ if (candidate.protocol !== "https:") return false;
198
+ if (candidate.username || candidate.password) return false;
199
+ if (candidate.hash) return false;
200
+ const sameUrl = (canonical: string): boolean => {
201
+ let expected: URL;
202
+ try {
203
+ expected = new URL(canonical);
204
+ } catch {
205
+ return false;
206
+ }
207
+ return candidate.origin === expected.origin
208
+ && candidate.pathname.replace(/\/+$/, "") === expected.pathname.replace(/\/+$/, "")
209
+ && candidate.search === expected.search;
210
+ };
211
+ // One shared construction with `resolveProviderModelDiscoveryUrl` below: the
212
+ // absolute `url` form carries the spec's fixed query (if any), and the `path`
213
+ // form resolves against the REGISTRY's own baseUrl (never a configured one)
214
+ // before appending the spec's fixed query. The candidate's own query must
215
+ // equal the registry-owned query exactly — no subset/superset matching.
216
+ if ("url" in spec && spec.url) {
217
+ try {
218
+ return sameUrl(appendDiscoveryQuery(new URL(spec.url), spec.query).toString());
219
+ } catch {
220
+ return false;
221
+ }
222
+ }
223
+ if ("path" in spec && spec.path) {
224
+ try {
225
+ const base = new URL(entry.baseUrl.endsWith("/") ? entry.baseUrl : `${entry.baseUrl}/`);
226
+ const resolved = spec.path.startsWith("/")
227
+ ? new URL(spec.path, base.origin)
228
+ : new URL(spec.path, base);
229
+ return sameUrl(appendDiscoveryQuery(resolved, spec.query).toString());
230
+ } catch {
231
+ return false;
232
+ }
233
+ }
234
+ return false;
235
+ }
236
+
161
237
  /** Apply a registry-owned URL/path/query policy to the adapter's normal discovery endpoint. */
162
238
  export function resolveProviderModelDiscoveryUrl(
163
239
  providerName: string,