@bitkyc08/opencodex 2.10.0 → 2.10.1

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 (274) hide show
  1. package/AGENTS_INSTALL.md +77 -0
  2. package/README.md +4 -10
  3. package/bin/ocx.mjs +71 -18
  4. package/gui/dist/assets/index-Cd6_PBKn.css +1 -0
  5. package/gui/dist/assets/index-ChZQsmBY.js +70 -0
  6. package/gui/dist/index.html +2 -2
  7. package/gui/dist/provider-icons/alibaba-color.svg +1 -1
  8. package/gui/dist/provider-icons/antigravity-color.svg +1 -1
  9. package/gui/dist/provider-icons/claude-color.svg +1 -1
  10. package/gui/dist/provider-icons/cline-color.svg +16 -0
  11. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
  12. package/gui/dist/provider-icons/copilot-color.svg +1 -1
  13. package/gui/dist/provider-icons/cursor-color.svg +1 -1
  14. package/gui/dist/provider-icons/deepseek-color.svg +1 -1
  15. package/gui/dist/provider-icons/firepass-color.svg +1 -1
  16. package/gui/dist/provider-icons/fireworks-color.svg +1 -1
  17. package/gui/dist/provider-icons/gemini-color.svg +1 -1
  18. package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
  19. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
  20. package/gui/dist/provider-icons/grok.svg +1 -1
  21. package/gui/dist/provider-icons/groq-color.svg +1 -1
  22. package/gui/dist/provider-icons/huggingface-color.svg +1 -1
  23. package/gui/dist/provider-icons/kimi-color.svg +1 -1
  24. package/gui/dist/provider-icons/kiro-color.svg +2 -2
  25. package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
  26. package/gui/dist/provider-icons/mistral-color.svg +1 -1
  27. package/gui/dist/provider-icons/moonshot-color.svg +1 -1
  28. package/gui/dist/provider-icons/nvidia-color.svg +1 -1
  29. package/gui/dist/provider-icons/ollama-color.svg +1 -1
  30. package/gui/dist/provider-icons/openai.svg +1 -1
  31. package/gui/dist/provider-icons/opencode.svg +2 -1
  32. package/gui/dist/provider-icons/openrouter-color.svg +1 -1
  33. package/gui/dist/provider-icons/pi.svg +2 -2
  34. package/gui/dist/provider-icons/qianfan-color.svg +1 -1
  35. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
  36. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
  37. package/gui/dist/provider-icons/vllm-color.svg +1 -1
  38. package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
  39. package/package.json +8 -4
  40. package/src/adapters/anthropic.ts +208 -14
  41. package/src/adapters/base.ts +16 -5
  42. package/src/adapters/cursor/effort-map.ts +3 -2
  43. package/src/adapters/cursor/framing.ts +39 -0
  44. package/src/adapters/cursor/live-transport.ts +105 -95
  45. package/src/adapters/cursor/native-exec.ts +32 -6
  46. package/src/adapters/cursor/protobuf-request.ts +20 -15
  47. package/src/adapters/cursor/request-builder.ts +21 -7
  48. package/src/adapters/cursor/types.ts +7 -0
  49. package/src/adapters/google-antigravity-replay.ts +237 -21
  50. package/src/adapters/google-truncation.ts +11 -0
  51. package/src/adapters/google.ts +50 -9
  52. package/src/adapters/identity.ts +39 -6
  53. package/src/adapters/kiro-errors.ts +11 -0
  54. package/src/adapters/kiro-events.ts +19 -1
  55. package/src/adapters/kiro-thinking.ts +10 -2
  56. package/src/adapters/kiro-tools.ts +10 -1
  57. package/src/adapters/kiro.ts +37 -11
  58. package/src/adapters/openai-chat.ts +284 -83
  59. package/src/adapters/openai-responses.ts +182 -24
  60. package/src/bridge.ts +177 -7
  61. package/src/chat/outbound.ts +78 -23
  62. package/src/claude/agents-inject.ts +27 -5
  63. package/src/claude/inbound.ts +11 -1
  64. package/src/claude/model-info.ts +13 -10
  65. package/src/claude/outbound.ts +17 -0
  66. package/src/cli/account-api.ts +24 -0
  67. package/src/cli/account-auth.ts +31 -6
  68. package/src/cli/account-main.ts +317 -0
  69. package/src/cli/account.ts +5 -0
  70. package/src/cli/claude.ts +2 -1
  71. package/src/cli/doctor.ts +93 -22
  72. package/src/cli/export-command.ts +26 -12
  73. package/src/cli/help.ts +8 -6
  74. package/src/cli/index.ts +56 -22
  75. package/src/cli/integrations.ts +84 -1
  76. package/src/cli/observe.ts +54 -1
  77. package/src/cli/opencode.ts +2 -1
  78. package/src/cli/provider-runtime.ts +18 -1
  79. package/src/cli/route-policy.ts +92 -0
  80. package/src/cli/runtime-api.ts +6 -3
  81. package/src/cli/star-prompt.ts +71 -15
  82. package/src/cli/status.ts +1 -1
  83. package/src/cli/v2.ts +36 -9
  84. package/src/clients/config-export.ts +687 -10
  85. package/src/codex/account-lifecycle.ts +30 -5
  86. package/src/codex/account-usability.ts +22 -2
  87. package/src/codex/admission.ts +255 -0
  88. package/src/codex/auth-api.ts +427 -140
  89. package/src/codex/auth-context.ts +155 -30
  90. package/src/codex/autostart-health.ts +8 -1
  91. package/src/codex/catalog/account-models.ts +62 -0
  92. package/src/codex/catalog/aggregation.ts +14 -1
  93. package/src/codex/catalog/bundled.ts +282 -32
  94. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  95. package/src/codex/catalog/metadata.ts +51 -6
  96. package/src/codex/catalog/parsing.ts +6 -3
  97. package/src/codex/catalog/provider-fetch.ts +576 -41
  98. package/src/codex/catalog/sync.ts +505 -66
  99. package/src/codex/catalog-admission.ts +197 -0
  100. package/src/codex/catalog-write-serialization.ts +241 -0
  101. package/src/codex/catalog.ts +2 -1
  102. package/src/codex/codex-write-lock.ts +372 -0
  103. package/src/codex/convergence-types.ts +593 -0
  104. package/src/codex/convergence.ts +441 -0
  105. package/src/codex/desired-state.ts +177 -0
  106. package/src/codex/features.ts +52 -8
  107. package/src/codex/generation.ts +202 -0
  108. package/src/codex/history-job.ts +257 -0
  109. package/src/codex/history-lock.ts +241 -0
  110. package/src/codex/history-migration-guardian.ts +18 -5
  111. package/src/codex/history-provider.ts +9 -2
  112. package/src/codex/history-transition.ts +105 -0
  113. package/src/codex/history-worker.ts +176 -0
  114. package/src/codex/inject-coordination.ts +245 -0
  115. package/src/codex/inject.ts +605 -124
  116. package/src/codex/integration-record.ts +266 -0
  117. package/src/codex/internal/catalog-writer.ts +203 -0
  118. package/src/codex/internal/history-writer.ts +80 -0
  119. package/src/codex/journal.ts +10 -1
  120. package/src/codex/main-account-cache.ts +24 -0
  121. package/src/codex/management-convergence.ts +114 -0
  122. package/src/codex/native-main-admission.ts +47 -0
  123. package/src/codex/native-main-auth-temp.ts +187 -0
  124. package/src/codex/native-main-claim.ts +167 -0
  125. package/src/codex/native-main-lock-file.ts +158 -0
  126. package/src/codex/native-main-owner.ts +315 -0
  127. package/src/codex/native-profile-api.ts +247 -0
  128. package/src/codex/native-profile-manager.ts +1512 -0
  129. package/src/codex/native-profile-processes.ts +121 -0
  130. package/src/codex/native-profile-recovery.ts +99 -0
  131. package/src/codex/native-profile-stage-store.ts +387 -0
  132. package/src/codex/native-profile-startup.ts +340 -0
  133. package/src/codex/native-profile-store.ts +855 -0
  134. package/src/codex/native-profile-types.ts +120 -0
  135. package/src/codex/native-residue.ts +557 -0
  136. package/src/codex/project-config-warnings.ts +18 -4
  137. package/src/codex/prompt-journal.ts +311 -0
  138. package/src/codex/prompt-layers.ts +967 -0
  139. package/src/codex/prompt-lock.ts +143 -0
  140. package/src/codex/quota-rejection.ts +224 -0
  141. package/src/codex/quota.ts +86 -3
  142. package/src/codex/routing.ts +299 -62
  143. package/src/codex/runtime.ts +159 -38
  144. package/src/codex/shim.ts +39 -13
  145. package/src/codex/subagent-model-fallback.ts +73 -12
  146. package/src/codex/transition-state.ts +604 -0
  147. package/src/codex/upstream-host-health.ts +70 -0
  148. package/src/codex/user-identity.ts +266 -0
  149. package/src/codex/write-coordination.ts +114 -0
  150. package/src/config.ts +562 -26
  151. package/src/generated/jawcode-model-metadata.ts +2 -2
  152. package/src/grok/inject.ts +15 -4
  153. package/src/grok/inspect.ts +45 -0
  154. package/src/images/loop.ts +113 -20
  155. package/src/integrations/config-io.ts +151 -0
  156. package/src/integrations/journal.ts +315 -0
  157. package/src/integrations/merge.ts +135 -0
  158. package/src/integrations/native/ownership-preflight.ts +165 -0
  159. package/src/integrations/ownership.ts +111 -0
  160. package/src/integrations/registry.ts +101 -0
  161. package/src/integrations/serialize.ts +235 -0
  162. package/src/integrations/state.ts +290 -0
  163. package/src/integrations/store.ts +103 -0
  164. package/src/integrations/writer.ts +492 -0
  165. package/src/lib/bounded-body.ts +46 -8
  166. package/src/lib/bun-runtime.ts +110 -1
  167. package/src/lib/bun-stream-caps.ts +2 -1
  168. package/src/lib/redact.ts +407 -2
  169. package/src/lib/shadow-call.ts +24 -0
  170. package/src/lib/translator-budget.ts +10 -0
  171. package/src/lib/upstream-reachability.ts +91 -0
  172. package/src/lib/upstream-retry.ts +154 -2
  173. package/src/lib/windows-secret-acl.ts +212 -11
  174. package/src/lib/winsw.ts +9 -3
  175. package/src/oauth/index.ts +61 -3
  176. package/src/oauth/key-providers.ts +4 -0
  177. package/src/oauth/kiro.ts +50 -6
  178. package/src/oauth/store.ts +31 -6
  179. package/src/oauth/token-guardian.ts +9 -3
  180. package/src/providers/codex-capacity.ts +288 -0
  181. package/src/providers/derive.ts +33 -1
  182. package/src/providers/free-directory.ts +3 -1
  183. package/src/providers/key-failover.ts +71 -3
  184. package/src/providers/openai-sidecar.ts +64 -4
  185. package/src/providers/openai-virtual-models.ts +1 -0
  186. package/src/providers/quota.ts +334 -26
  187. package/src/providers/registry.ts +284 -16
  188. package/src/providers/xai-transport.ts +11 -4
  189. package/src/responses/compaction.ts +8 -1
  190. package/src/responses/hosted-tool-policy.ts +9 -0
  191. package/src/responses/parser.ts +44 -2
  192. package/src/responses/reasoning-envelope.ts +9 -1
  193. package/src/responses/reasoning-replay-cache.ts +105 -0
  194. package/src/responses/spill-store.ts +45 -8
  195. package/src/responses/state.ts +161 -17
  196. package/src/router.ts +302 -16
  197. package/src/routing/analytics.ts +377 -0
  198. package/src/routing/capability.ts +204 -0
  199. package/src/routing/cost.ts +77 -0
  200. package/src/routing/evaluator.ts +444 -0
  201. package/src/routing/health.ts +401 -0
  202. package/src/routing/history/cursor.ts +43 -0
  203. package/src/routing/history/indexer.ts +590 -0
  204. package/src/routing/history/schema.ts +72 -0
  205. package/src/routing/profile.ts +423 -0
  206. package/src/routing/quota.ts +124 -0
  207. package/src/routing/request-evidence.ts +45 -0
  208. package/src/routing/trace.ts +686 -0
  209. package/src/server/auth-cors.ts +46 -6
  210. package/src/server/chat-completions.ts +28 -13
  211. package/src/server/claude-messages.ts +23 -15
  212. package/src/server/gui-static.ts +39 -10
  213. package/src/server/images.ts +10 -1
  214. package/src/server/index.ts +238 -52
  215. package/src/server/lifecycle.ts +228 -9
  216. package/src/server/live.ts +14 -3
  217. package/src/server/management/agent-settings-routes.ts +64 -14
  218. package/src/server/management/combo-routes.ts +5 -5
  219. package/src/server/management/config-routes.ts +1 -1
  220. package/src/server/management/context.ts +42 -2
  221. package/src/server/management/integration-routes.ts +538 -0
  222. package/src/server/management/logs-usage-routes.ts +1 -1
  223. package/src/server/management/model-routes.ts +32 -113
  224. package/src/server/management/model-rows.ts +117 -0
  225. package/src/server/management/native-integration-routes.ts +587 -0
  226. package/src/server/management/oauth-account-routes.ts +1 -1
  227. package/src/server/management/provider-routes.ts +218 -117
  228. package/src/server/management/request-history-routes.ts +191 -0
  229. package/src/server/management/routing-analytics-routes.ts +74 -0
  230. package/src/server/management/routing-profile-routes.ts +177 -0
  231. package/src/server/management/shared.ts +2 -2
  232. package/src/server/management/sidebar-routes.ts +47 -31
  233. package/src/server/management/sync-response.ts +69 -0
  234. package/src/server/management/system-restart.ts +276 -24
  235. package/src/server/management/system-routes.ts +4 -0
  236. package/src/server/management-api.ts +84 -9
  237. package/src/server/management-auth.ts +43 -5
  238. package/src/server/relay-eager.ts +82 -42
  239. package/src/server/relay.ts +120 -6
  240. package/src/server/request-log.ts +26 -6
  241. package/src/server/responses/collaboration.ts +63 -8
  242. package/src/server/responses/compact.ts +272 -41
  243. package/src/server/responses/core.ts +730 -132
  244. package/src/server/responses/fetch-helpers.ts +15 -1
  245. package/src/server/responses-item-id-repair.ts +32 -3
  246. package/src/server/responses-json-events.ts +52 -0
  247. package/src/server/responses-snapshot-repair.ts +621 -0
  248. package/src/server/search.ts +51 -6
  249. package/src/server/sse-payload-rewrite.ts +89 -12
  250. package/src/server/startup-health-cache.ts +7 -1
  251. package/src/server/ws-bridge.ts +11 -17
  252. package/src/service-manager-probe.ts +297 -0
  253. package/src/service.ts +222 -32
  254. package/src/tray/windows-tray.ps1 +9 -0
  255. package/src/tray/windows.ts +15 -7
  256. package/src/types.ts +194 -14
  257. package/src/update/index.ts +13 -13
  258. package/src/update/job.ts +24 -21
  259. package/src/update/notify.ts +7 -3
  260. package/src/usage/cost.ts +0 -0
  261. package/src/usage/expected-prices.ts +129 -10
  262. package/src/usage/log.ts +50 -15
  263. package/src/usage/summary.ts +4 -4
  264. package/src/vision/index.ts +6 -1
  265. package/src/web-search/loop.ts +161 -34
  266. package/gui/dist/assets/index-OY43ubAq.css +0 -1
  267. package/gui/dist/assets/index-YwNnKZcL.js +0 -67
  268. package/gui/dist/provider-icons/antigravity.svg +0 -1
  269. package/gui/dist/provider-icons/claude.svg +0 -1
  270. package/gui/dist/provider-icons/copilot.svg +0 -1
  271. package/gui/dist/provider-icons/cursor.svg +0 -2
  272. package/gui/dist/provider-icons/gemini.svg +0 -1
  273. package/gui/dist/provider-icons/grok-color.svg +0 -1
  274. package/gui/dist/provider-icons/kiro.svg +0 -14
package/src/router.ts CHANGED
@@ -1,19 +1,69 @@
1
1
  import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "./types";
2
- import { preservesPhysicalComboProvider, tryPickComboModel, type ComboPick } from "./combos";
2
+ import {
3
+ getCombo,
4
+ isComboTargetInCooldown,
5
+ preservesPhysicalComboProvider,
6
+ targetKey,
7
+ tryPickComboModel,
8
+ type ComboPick,
9
+ } from "./combos";
10
+ import type { NormalizedComboConfig } from "./combos/types";
3
11
  import { hasOwnProvider, resolveEnvValue } from "./config";
4
12
  import { assertProviderDestinationAllowed } from "./lib/destination-policy";
5
13
  import { redactSecretString, redactUrlForLog } from "./lib/redact";
6
14
  import { PROVIDER_REGISTRY, providerCodexAccountMode, providerMatchesRegistryTransport } from "./providers/registry";
7
- import { LEGACY_CHATGPT_PROVIDER_ID, LEGACY_OPENAI_MULTI_PROVIDER_ID, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers";
15
+ import {
16
+ isCanonicalOpenAiForwardProvider,
17
+ LEGACY_CHATGPT_PROVIDER_ID,
18
+ LEGACY_OPENAI_MULTI_PROVIDER_ID,
19
+ OPENAI_API_PROVIDER_ID,
20
+ OPENAI_CODEX_PROVIDER_ID,
21
+ } from "./providers/openai-tiers";
8
22
  import { decodeRoutedModelId, encodeRoutedModelId } from "./providers/slug-codec";
9
23
  import { getStaleCached } from "./codex/model-cache";
24
+ import { codexAccountNamespaceEntries } from "./codex/account-namespaces";
25
+ import { getEffectiveActiveCodexAccountId } from "./codex/routing";
26
+ import { getAccountSet } from "./oauth/store";
27
+ import {
28
+ buildRouteDecisionTrace,
29
+ type RouteDecisionKind,
30
+ type RouteDecisionTraceV1,
31
+ type TraceCandidateInput,
32
+ } from "./routing/trace";
33
+ import { getRoutingProfile, resolvePolicyProfileId } from "./routing/profile";
34
+ import { evaluatePolicyProfile, type PolicyRequestEvidence } from "./routing/evaluator";
35
+ import { candidateCapabilityEvidence } from "./routing/capability";
36
+ import { policyCandidateHealthEvidence } from "./routing/health";
37
+ import { quotaEvidenceForCandidate } from "./routing/quota";
38
+ import { costEvidenceForCandidate } from "./routing/cost";
39
+
40
+ export class NoEligiblePolicyCandidateError extends Error {
41
+ /** Evaluation trace (with per-candidate exclusions) when nothing qualified. */
42
+ readonly trace?: RouteDecisionTraceV1;
43
+
44
+ constructor(readonly profileId: string, trace?: RouteDecisionTraceV1) {
45
+ super(`No eligible candidates for policy profile: ${profileId}`);
46
+ this.name = "NoEligiblePolicyCandidateError";
47
+ this.trace = trace;
48
+ }
49
+ }
10
50
 
11
51
  export interface RouteResult {
12
52
  providerName: string;
13
53
  provider: OcxProviderConfig;
14
54
  modelId: string;
55
+ /** Which deterministic routing path produced this route (RI-01). */
56
+ routeKind: RouteDecisionKind;
57
+ /** Stable wire reason code for the selected route (RI-01). */
58
+ routeReason: string;
15
59
  codexAccountMode?: CodexAccountMode;
60
+ /** Exact account selected by an account-qualified native model. */
61
+ codexAccountId?: string;
62
+ /** Public namespace used by the account-qualified selector. */
63
+ codexAccountNamespace?: string;
16
64
  combo?: ComboPick;
65
+ /** Bounded route-decision trace (RI-01); never contains secrets. */
66
+ routeDecision?: RouteDecisionTraceV1;
17
67
  }
18
68
 
19
69
  const MODEL_PROVIDER_PATTERNS: Array<{ providerNames: string[]; prefixes: string[] }> = [
@@ -259,6 +309,29 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
259
309
  ...(provider.responsesPath === undefined && registryEntry.responsesPath !== undefined
260
310
  ? { responsesPath: registryEntry.responsesPath }
261
311
  : {}),
312
+ ...(provider.supportsServiceTier === undefined && registryEntry.supportsServiceTier !== undefined
313
+ ? { supportsServiceTier: registryEntry.supportsServiceTier }
314
+ : {}),
315
+ ...(provider.preserveResponsesReasoningContent === undefined && registryEntry.preserveResponsesReasoningContent !== undefined
316
+ ? { preserveResponsesReasoningContent: registryEntry.preserveResponsesReasoningContent }
317
+ : {}),
318
+ // Registry-only client-facing repair policy (#938): fill only when the
319
+ // saved provider has no explicit policy; clone so runtime never aliases
320
+ // the registry constant.
321
+ ...(provider.responsesItemIdRepair === undefined && registryEntry.responsesItemIdRepair
322
+ ? {
323
+ responsesItemIdRepair: {
324
+ ...(registryEntry.responsesItemIdRepair.message ? { message: [...registryEntry.responsesItemIdRepair.message] } : {}),
325
+ ...(registryEntry.responsesItemIdRepair.reasoning ? { reasoning: [...registryEntry.responsesItemIdRepair.reasoning] } : {}),
326
+ ...(registryEntry.responsesItemIdRepair.repairMissingTerminalIds !== undefined
327
+ ? { repairMissingTerminalIds: registryEntry.responsesItemIdRepair.repairMissingTerminalIds }
328
+ : {}),
329
+ ...(registryEntry.responsesItemIdRepair.repairInvalidIds !== undefined
330
+ ? { repairInvalidIds: registryEntry.responsesItemIdRepair.repairInvalidIds }
331
+ : {}),
332
+ },
333
+ }
334
+ : {}),
262
335
  authMode: canonicalAuthMode,
263
336
  apiKey: resolvedApiKey,
264
337
  // Backfill the Google wire mode + Vertex project/location from the registry when the user
@@ -276,6 +349,9 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
276
349
  // opt-in, while an explicit user `false` keeps overriding registry `true`.
277
350
  ...(provider.parallelToolCalls === undefined && registryEntry.parallelToolCalls !== undefined ? { parallelToolCalls: registryEntry.parallelToolCalls } : {}),
278
351
  ...(provider.promptCacheKey === undefined && registryEntry.promptCacheKey !== undefined ? { promptCacheKey: registryEntry.promptCacheKey } : {}),
352
+ ...(provider.reasoningWireFormat === undefined && registryEntry.reasoningWireFormat !== undefined
353
+ ? { reasoningWireFormat: registryEntry.reasoningWireFormat }
354
+ : {}),
279
355
  ...(provider.defaultMaxOutputTokens === undefined && registryEntry.defaultMaxOutputTokens !== undefined
280
356
  ? { defaultMaxOutputTokens: registryEntry.defaultMaxOutputTokens }
281
357
  : {}),
@@ -315,6 +391,34 @@ export class NoEnabledOpenAiProviderError extends Error {
315
391
  }
316
392
  }
317
393
 
394
+ /**
395
+ * One immutable selection trace for a combo request: built once from the
396
+ * initial pick, before any child dispatch. Fallback execution stays in the
397
+ * usage entry's `attempts[]`; the trace never changes after selection.
398
+ */
399
+ export function comboRouteDecisionTrace(
400
+ config: OcxConfig,
401
+ comboId: string,
402
+ pick: ComboPick,
403
+ requestedModel: string,
404
+ ): RouteDecisionTraceV1 {
405
+ const combo = getCombo(config, comboId);
406
+ return buildRouteDecisionTrace({
407
+ requestedModel,
408
+ routeKind: "combo",
409
+ selected: {
410
+ provider: pick.target.provider,
411
+ model: pick.target.model,
412
+ reason: "combo-pick",
413
+ candidateIndex: pick.targetIndex,
414
+ ...(combo
415
+ ? { tieBreak: combo.strategy === "round-robin" ? "round-robin" : "failover" }
416
+ : {}),
417
+ },
418
+ candidates: combo ? comboRouteCandidates(config, pick, combo) : undefined,
419
+ });
420
+ }
421
+
318
422
  // Codex uses a small number of control-plane model ids that are not part of the public GPT/o
319
423
  // naming families. Keep this exact: a broad `codex-*` rule could capture a third-party model.
320
424
  const CODEX_INTERNAL_OPENAI_MODELS = new Set(["codex-auto-review"]);
@@ -324,25 +428,170 @@ function isBareOpenAiFamilyModel(modelId: string): boolean {
324
428
  && (/^(?:gpt-|o1-|o3-|o4-)/.test(modelId) || CODEX_INTERNAL_OPENAI_MODELS.has(modelId));
325
429
  }
326
430
 
327
- function routeResult(providerName: string, provider: OcxProviderConfig, modelId: string): RouteResult {
431
+ function routeResult(
432
+ providerName: string,
433
+ provider: OcxProviderConfig,
434
+ modelId: string,
435
+ routeKind: RouteDecisionKind,
436
+ routeReason: string,
437
+ ): RouteResult {
328
438
  const codexAccountMode = providerCodexAccountMode(providerName, provider);
329
439
  return {
330
440
  providerName,
331
441
  provider: routedProviderConfig(providerName, provider),
332
442
  modelId,
443
+ routeKind,
444
+ routeReason,
333
445
  ...(codexAccountMode ? { codexAccountMode } : {}),
334
446
  };
335
447
  }
336
448
 
337
- function routeModelInternal(config: OcxConfig, modelId: string, bypassCombos: boolean): RouteResult {
449
+ /**
450
+ * Candidate evidence for a combo route: every configured target with its
451
+ * selection-time eligibility and exclusion reasons. Purely observational; the
452
+ * pick already happened and this never re-selects.
453
+ */
454
+ function comboRouteCandidates(
455
+ config: OcxConfig,
456
+ pick: NonNullable<RouteResult["combo"]>,
457
+ combo: NormalizedComboConfig,
458
+ ): TraceCandidateInput[] {
459
+ const now = Date.now();
460
+ return combo.targets.map((target, index) => {
461
+ const key = targetKey(target);
462
+ const provider = config.providers[target.provider];
463
+ const configured = provider !== undefined;
464
+ const enabled = configured && provider.disabled !== true;
465
+ const inCooldown = isComboTargetInCooldown(pick.comboId, target, now);
466
+ const isSelected = index === pick.targetIndex;
467
+ // The pick's `attempted` list includes the winner itself; only non-selected
468
+ // targets can be "already-attempted" (fallback picks exclude earlier tries).
469
+ const alreadyAttempted = !isSelected && pick.attempted.includes(key);
470
+ const exclusions: TraceCandidateInput["exclusions"] = [];
471
+ if (!configured) exclusions.push({ code: "unconfigured" });
472
+ if (configured && !enabled) exclusions.push({ code: "disabled" });
473
+ if (inCooldown) exclusions.push({ code: "cooldown" });
474
+ if (isSelected && inCooldown) exclusions.push({ code: "selected-despite-cooldown" });
475
+ if (!isSelected && alreadyAttempted && exclusions.length === 0) {
476
+ exclusions.push({ code: "already-attempted" });
477
+ }
478
+ if (!isSelected && exclusions.length === 0) exclusions.push({ code: "not-selected" });
479
+ return {
480
+ provider: target.provider,
481
+ model: target.model,
482
+ eligible: enabled && !inCooldown && !alreadyAttempted,
483
+ exclusions,
484
+ };
485
+ });
486
+ }
487
+
488
+ function routeModelInternal(
489
+ config: OcxConfig,
490
+ modelId: string,
491
+ bypassCombos: boolean,
492
+ policyEvidence?: PolicyRequestEvidence,
493
+ ): RouteResult {
494
+ const slash = modelId.indexOf("/");
495
+ // Policy namespace is system-reserved: an explicit `policy/<id>` or a
496
+ // configured profile alias executes the policy evaluator and routes the
497
+ // selected candidate. Only explicit requests reach this branch; concrete
498
+ // recursive targets skip policy resolution entirely (bypassCombos) so an
499
+ // alias matching a selected candidate can never recurse, and a
500
+ // `policy/<id>` without a configured profile falls through to normal
501
+ // provider/default resolution instead of failing.
502
+ const policyId = !bypassCombos ? resolvePolicyProfileId(config, modelId) : null;
503
+ const profile = policyId ? getRoutingProfile(config, policyId) : undefined;
504
+ if (profile && policyId) {
505
+ // One clock read per decision keeps candidate evidence, exclusions, and
506
+ // scores mutually consistent and reproducible.
507
+ const now = Date.now();
508
+ const candidateEvidence = profile.candidates.map(candidate => ({
509
+ provider: candidate.provider,
510
+ model: candidate.model,
511
+ capability: candidateCapabilityEvidence(config, candidate.provider, candidate.model),
512
+ health: policyCandidateHealthEvidence(config, candidate, now),
513
+ quota: quotaEvidenceForCandidate({
514
+ provider: candidate.provider,
515
+ model: candidate.model,
516
+ ...(candidate.provider === OPENAI_CODEX_PROVIDER_ID
517
+ && providerCodexAccountMode(
518
+ OPENAI_CODEX_PROVIDER_ID,
519
+ config.providers[OPENAI_CODEX_PROVIDER_ID],
520
+ ) === "pool"
521
+ ? (() => {
522
+ const codexAccountId = getEffectiveActiveCodexAccountId(config);
523
+ return {
524
+ codexAccountId,
525
+ codexAccountPlan: codexAccountId
526
+ ? config.codexAccounts?.find(account => account.id === codexAccountId)?.plan
527
+ : undefined,
528
+ };
529
+ })()
530
+ : {}),
531
+ accountRef: candidate.provider === "anthropic"
532
+ ? getAccountSet("anthropic")?.activeAccountId
533
+ : undefined,
534
+ }),
535
+ cost: costEvidenceForCandidate({
536
+ provider: candidate.provider,
537
+ model: candidate.model,
538
+ limitUsd: profile.limits.maxEstimatedCostUsd,
539
+ }),
540
+ }));
541
+ const evaluation = evaluatePolicyProfile(config, policyId, policyEvidence ?? {}, candidateEvidence, now);
542
+ if (evaluation.selectedIndex === null) {
543
+ throw new NoEligiblePolicyCandidateError(policyId, evaluation.trace);
544
+ }
545
+ const selected = evaluation.candidates[evaluation.selectedIndex]!;
546
+ const concrete = `${selected.provider}/${selected.model}`;
547
+ const routed = routeModelInternal(config, concrete, true);
548
+ return {
549
+ ...routed,
550
+ routeKind: "policy" as const,
551
+ routeReason: "policy-selected",
552
+ routeDecision: evaluation.trace,
553
+ };
554
+ }
555
+ if (slash > 0) {
556
+ const namespace = modelId.slice(0, slash);
557
+ const binding = codexAccountNamespaceEntries(config)
558
+ .find(([candidate]) => candidate === namespace);
559
+ if (binding) {
560
+ const nativeModelId = modelId.slice(slash + 1);
561
+ if (!isBareOpenAiFamilyModel(nativeModelId)) {
562
+ throw new Error(`Codex account namespace ${namespace} only supports native OpenAI model ids`);
563
+ }
564
+ const provider = config.providers[OPENAI_CODEX_PROVIDER_ID];
565
+ if (!provider || provider.disabled === true) {
566
+ throw new NoEnabledOpenAiProviderError(nativeModelId);
567
+ }
568
+ // Registry routing backfills an omitted authMode on the built-in OpenAI row to forward.
569
+ // Mirror only that default here; explicit non-forward modes still fail closed.
570
+ const providerForCanonicalCheck = provider.authMode === undefined
571
+ ? { ...provider, authMode: "forward" as const }
572
+ : provider;
573
+ if (!isCanonicalOpenAiForwardProvider(providerForCanonicalCheck)) {
574
+ throw new NoEnabledOpenAiProviderError(nativeModelId);
575
+ }
576
+ return {
577
+ ...routeResult(OPENAI_CODEX_PROVIDER_ID, provider, nativeModelId, "explicit-account", "account-namespace"),
578
+ // Exact account injection uses the pool credential machinery even when the canonical
579
+ // provider is globally Direct. The fixed id bypasses pool selection entirely.
580
+ codexAccountMode: "pool",
581
+ codexAccountId: binding[1],
582
+ codexAccountNamespace: namespace,
583
+ };
584
+ }
585
+ }
586
+
338
587
  if (!bypassCombos && !preservesPhysicalComboProvider(config)) {
339
588
  const combo = tryPickComboModel(config, modelId);
340
589
  if (combo) {
341
590
  const concrete = `${combo.target.provider}/${combo.target.model}`;
342
591
  // The selected target is already a concrete provider/model reference. Resolve it without
343
592
  // consulting combo aliases again, otherwise an alias that shadows the target can recurse.
344
- const routed = routeModelInternal(config, concrete, true);
345
- return { ...routed, combo };
593
+ const routed = routeModelInternal(config, concrete, true, undefined);
594
+ return { ...routed, combo, routeKind: "combo" as const, routeReason: "combo-pick" };
346
595
  }
347
596
  }
348
597
 
@@ -350,7 +599,6 @@ function routeModelInternal(config: OcxConfig, modelId: string, bypassCombos: bo
350
599
  // Only triggers when the prefix matches a CONFIGURED provider, so genuine
351
600
  // slash-containing model ids (e.g. "anthropic/claude-...") fall through when
352
601
  // no such provider exists.
353
- const slash = modelId.indexOf("/");
354
602
  if (slash > 0) {
355
603
  const provName = modelId.slice(0, slash);
356
604
  if (provName === LEGACY_CHATGPT_PROVIDER_ID || provName === LEGACY_OPENAI_MULTI_PROVIDER_ID) {
@@ -363,23 +611,33 @@ function routeModelInternal(config: OcxConfig, modelId: string, bypassCombos: bo
363
611
  // Self-namespaced native id — the vendor segment equals the provider id, so the FULL ref is
364
612
  // itself a known model (e.g. orcarouter/auto). Route it whole instead of stripping to the
365
613
  // remainder, which would send a bare `auto` the upstream cannot resolve.
366
- if (known.includes(modelId)) return routeResult(provName, prov, modelId);
614
+ if (known.includes(modelId)) {
615
+ return routeResult(provName, prov, modelId, "explicit-provider", "explicit-provider-namespace");
616
+ }
367
617
  // Codex-facing alias ids (`provider/vendor-model`) decode back to the native
368
618
  // slash id via an exact known-id lookup; raw full-slash selectors keep working.
369
- return routeResult(provName, prov, decodeRoutedModelId(modelId.slice(slash + 1), known));
619
+ return routeResult(
620
+ provName,
621
+ prov,
622
+ decodeRoutedModelId(modelId.slice(slash + 1), known),
623
+ "explicit-provider",
624
+ "explicit-provider-namespace",
625
+ );
370
626
  }
371
627
  }
372
628
 
373
629
  if (isBareOpenAiFamilyModel(modelId)) {
374
630
  const provider = config.providers[OPENAI_CODEX_PROVIDER_ID];
375
- if (provider && provider.disabled !== true) return routeResult(OPENAI_CODEX_PROVIDER_ID, provider, modelId);
631
+ if (provider && provider.disabled !== true) {
632
+ return routeResult(OPENAI_CODEX_PROVIDER_ID, provider, modelId, "native", "native-family");
633
+ }
376
634
  throw new NoEnabledOpenAiProviderError(modelId);
377
635
  }
378
636
 
379
637
  for (const [provName, prov] of activeProviderEntries(config)) {
380
638
  if (prov.defaultModel === modelId
381
639
  || (typeof prov.defaultModel === "string" && encodeRoutedModelId(prov.defaultModel) === modelId)) {
382
- return routeResult(provName, prov, prov.defaultModel as string);
640
+ return routeResult(provName, prov, prov.defaultModel as string, "explicit-provider", "configured-default-model");
383
641
  }
384
642
  }
385
643
 
@@ -389,7 +647,9 @@ function routeModelInternal(config: OcxConfig, modelId: string, bypassCombos: bo
389
647
  for (const [provName, prov] of activeProviderEntries(config)) {
390
648
  if (prov.models && Array.isArray(prov.models)) {
391
649
  const hit = (prov.models as string[]).find(id => id === modelId || encodeRoutedModelId(id) === modelId);
392
- if (hit !== undefined) return routeResult(provName, prov, hit);
650
+ if (hit !== undefined) {
651
+ return routeResult(provName, prov, hit, "explicit-provider", "configured-model-list");
652
+ }
393
653
  }
394
654
  }
395
655
 
@@ -399,14 +659,40 @@ function routeModelInternal(config: OcxConfig, modelId: string, bypassCombos: bo
399
659
  if (hasOwnProvider(config.providers, config.defaultProvider)) {
400
660
  const defaultProv = config.providers[config.defaultProvider];
401
661
  if (defaultProv.disabled === true) throw new Error(`Default provider is disabled: ${config.defaultProvider}`);
402
- return routeResult(config.defaultProvider, defaultProv, modelId);
662
+ return routeResult(config.defaultProvider, defaultProv, modelId, "default-provider", "default-provider");
403
663
  }
404
664
 
405
665
  throw new Error(`No provider configured for model: ${modelId}`);
406
666
  }
407
667
 
408
- export function routeModel(config: OcxConfig, modelId: string): RouteResult {
409
- return routeModelInternal(config, modelId, false);
668
+ export function routeModel(
669
+ config: OcxConfig,
670
+ modelId: string,
671
+ policyEvidence?: PolicyRequestEvidence,
672
+ ): RouteResult {
673
+ const route = routeModelInternal(config, modelId, false, policyEvidence);
674
+ // Policy routes carry a full evaluation trace already; never rebuild it.
675
+ if (route.routeDecision) return route;
676
+ const accountRef = route.codexAccountNamespace;
677
+ const combo = route.combo ? getCombo(config, route.combo.comboId) : undefined;
678
+ route.routeDecision = buildRouteDecisionTrace({
679
+ requestedModel: modelId,
680
+ routeKind: route.routeKind,
681
+ selected: {
682
+ provider: route.providerName,
683
+ model: route.modelId,
684
+ ...(accountRef ? { accountRef } : {}),
685
+ reason: route.routeReason,
686
+ ...(route.combo ? { candidateIndex: route.combo.targetIndex } : {}),
687
+ ...(combo
688
+ ? { tieBreak: combo.strategy === "round-robin" ? "round-robin" : "failover" }
689
+ : {}),
690
+ },
691
+ candidates: route.routeKind === "combo" && route.combo && combo
692
+ ? comboRouteCandidates(config, route.combo, combo)
693
+ : undefined,
694
+ });
695
+ return route;
410
696
  }
411
697
 
412
698
  function routeByKnownModelPattern(config: OcxConfig, modelId: string): RouteResult | undefined {
@@ -417,7 +703,7 @@ function routeByKnownModelPattern(config: OcxConfig, modelId: string): RouteResu
417
703
  );
418
704
  if (matchingProvider) {
419
705
  const [provName, prov] = matchingProvider;
420
- return routeResult(provName, prov, modelId);
706
+ return routeResult(provName, prov, modelId, "explicit-provider", "model-pattern");
421
707
  }
422
708
  }
423
709
  }