@bitkyc08/opencodex 2.86.0 → 2.87.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 (378) hide show
  1. package/gui/dist/assets/App-ZfYbLHkB.js +58 -0
  2. package/gui/dist/assets/App-wT3mXqFz.css +1 -0
  3. package/gui/dist/assets/{Tray-DyR9yBee.js → Tray-DCd-A1az.js} +1 -1
  4. package/gui/dist/assets/index-CfYDMj34.js +94 -0
  5. package/gui/dist/assets/index-DPeB9HpV.css +1 -0
  6. package/gui/dist/assets/quota-summary-ChvmKGOk.js +1 -0
  7. package/gui/dist/index.html +2 -2
  8. package/package.json +1 -1
  9. package/src/adapters/anthropic/beta-allowlist.ts +9 -8
  10. package/src/adapters/anthropic/beta-body-pairs.ts +93 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -23
  12. package/src/adapters/anthropic-progress.ts +84 -0
  13. package/src/adapters/anthropic.ts +108 -21
  14. package/src/adapters/base.ts +2 -0
  15. package/src/adapters/claude-cli/adapter.ts +111 -23
  16. package/src/adapters/claude-cli/profiles.ts +2 -1
  17. package/src/adapters/claude-cli/stable-replay.ts +108 -0
  18. package/src/adapters/claude-cli/usage-admission.ts +230 -0
  19. package/src/adapters/coding-agent/protocol.ts +1 -1
  20. package/src/adapters/coding-agent/turn.ts +18 -2
  21. package/src/adapters/dsh-account.ts +97 -0
  22. package/src/adapters/google.ts +35 -8
  23. package/src/adapters/openai-chat/messages.ts +4 -3
  24. package/src/adapters/openai-chat/passthrough.ts +8 -2
  25. package/src/adapters/openai-chat/reasoning-replay.ts +52 -0
  26. package/src/adapters/openai-responses/compaction-search-history.ts +14 -7
  27. package/src/adapters/openai-responses/passthrough.ts +57 -125
  28. package/src/adapters/openai-responses/request-strips.ts +25 -3
  29. package/src/adapters/openai-responses/transport.ts +81 -0
  30. package/src/adapters/qoder/adapter.ts +4 -2
  31. package/src/adapters/registry.ts +5 -0
  32. package/src/adapters/zed.ts +26 -1
  33. package/src/advisor/activity.ts +55 -0
  34. package/src/advisor/budget.ts +62 -0
  35. package/src/advisor/consult.ts +4 -1
  36. package/src/advisor/context.ts +16 -4
  37. package/src/advisor/evidence-tool.ts +21 -0
  38. package/src/advisor/evidence.ts +117 -0
  39. package/src/advisor/runtime.ts +79 -31
  40. package/src/advisor/settings.ts +11 -2
  41. package/src/bridge/errors.ts +19 -1
  42. package/src/bridge/internal.ts +3 -1
  43. package/src/bridge/response-json.ts +18 -3
  44. package/src/bridge/sse.ts +58 -8
  45. package/src/bridge/terminal-refusal.ts +59 -0
  46. package/src/claude/desktop-3p-library.ts +32 -7
  47. package/src/claude/desktop-3p.ts +55 -13
  48. package/src/claude/desktop-first-party.ts +1 -1
  49. package/src/claude/desktop-picker-platform.ts +4 -0
  50. package/src/claude/desktop-picker.ts +15 -8
  51. package/src/claude/desktop-system-proxy.ts +50 -4
  52. package/src/claude/inbound-content-options.ts +11 -0
  53. package/src/claude/inbound.ts +33 -0
  54. package/src/claude/intercept/picker-ca-startup.ts +2 -1
  55. package/src/claude/intercept/picker-runtime.ts +6 -5
  56. package/src/claude/intercept/picker-trust-windows.ts +338 -0
  57. package/src/claude/intercept/picker-trust.ts +19 -0
  58. package/src/claude/intercept/port-policy.ts +30 -0
  59. package/src/claude/intercept/runtime.ts +97 -94
  60. package/src/claude/outbound.ts +38 -13
  61. package/src/cli/account-api.ts +1 -0
  62. package/src/cli/account-policy-dto.ts +6 -0
  63. package/src/cli/account-policy.ts +34 -5
  64. package/src/cli/account-preservation.ts +44 -0
  65. package/src/cli/account.ts +8 -2
  66. package/src/cli/advisor.ts +17 -3
  67. package/src/cli/capabilities-accounts.ts +20 -1
  68. package/src/cli/capabilities-agents-routing.ts +5 -2
  69. package/src/cli/capabilities-base.ts +38 -10
  70. package/src/cli/capabilities-observe-system.ts +63 -3
  71. package/src/cli/capabilities-provider-models.ts +11 -0
  72. package/src/cli/codex-cli-update.ts +151 -41
  73. package/src/cli/codex-provider.ts +44 -0
  74. package/src/cli/combo-input.ts +10 -1
  75. package/src/cli/combo-result.ts +1 -1
  76. package/src/cli/combo.ts +2 -1
  77. package/src/cli/cross-home-owner.ts +17 -14
  78. package/src/cli/dispatch.ts +25 -4
  79. package/src/cli/doctor.ts +6 -1
  80. package/src/cli/ensure-desktop-cold-start.ts +60 -7
  81. package/src/cli/help.ts +3 -2
  82. package/src/cli/index.ts +1 -1
  83. package/src/cli/integration-plan-dto.ts +1 -0
  84. package/src/cli/log-history.ts +172 -0
  85. package/src/cli/models-runtime-subcommands.ts +1 -0
  86. package/src/cli/models-runtime.ts +72 -8
  87. package/src/cli/observe-stream.ts +2 -0
  88. package/src/cli/observe.ts +16 -4
  89. package/src/cli/provider.ts +27 -5
  90. package/src/cli/registry.ts +28 -8
  91. package/src/cli/runtime-api.ts +5 -3
  92. package/src/cli/system-command.ts +40 -8
  93. package/src/cli/uninstall-client-state.ts +32 -0
  94. package/src/cli/usage-compare.ts +309 -0
  95. package/src/cli/usage-model-search.ts +4 -4
  96. package/src/cli/usage-report.ts +2 -2
  97. package/src/cli/v2.ts +1 -1
  98. package/src/client/machine-listener.ts +1 -3
  99. package/src/client/runtime.ts +1 -0
  100. package/src/clients/client-path.ts +51 -0
  101. package/src/clients/config-export/contracts.ts +2 -1
  102. package/src/clients/config-export.ts +18 -44
  103. package/src/clients/gentle-shell-path.ts +87 -0
  104. package/src/codex/account-auto-switch.ts +2 -0
  105. package/src/codex/account-identity.ts +29 -0
  106. package/src/codex/account-label.ts +1 -1
  107. package/src/codex/account-lifecycle.ts +21 -5
  108. package/src/codex/account-preservation.ts +28 -0
  109. package/src/codex/account-use-remaining.ts +23 -0
  110. package/src/codex/app-server-processes.ts +97 -7
  111. package/src/codex/app-server-restart-service.ts +55 -4
  112. package/src/codex/auth-api/account-list.ts +4 -0
  113. package/src/codex/auth-api/account-pause-group.ts +6 -50
  114. package/src/codex/auth-api/account-preservation.ts +57 -0
  115. package/src/codex/auth-api/routes.ts +8 -0
  116. package/src/codex/auth-api/use-remaining-route.ts +36 -0
  117. package/src/codex/auth-api.ts +0 -1
  118. package/src/codex/auth-context.ts +2 -2
  119. package/src/codex/catalog/auto-review.ts +20 -3
  120. package/src/codex/catalog/build-entries.ts +9 -1
  121. package/src/codex/catalog/combo-member.ts +14 -5
  122. package/src/codex/catalog/configured-tiers.ts +1 -1
  123. package/src/codex/catalog/gather-capture.ts +2 -0
  124. package/src/codex/catalog/metadata.ts +11 -9
  125. package/src/codex/catalog/model-hints.ts +21 -16
  126. package/src/codex/catalog/model-visibility.ts +3 -1
  127. package/src/codex/catalog/parsing.ts +30 -86
  128. package/src/codex/catalog/provider-models.ts +36 -34
  129. package/src/codex/catalog/routed-gather.ts +12 -13
  130. package/src/codex/cli-install-provenance.ts +105 -17
  131. package/src/codex/cli-update-plan.ts +545 -0
  132. package/src/codex/config-write-lock.ts +23 -2
  133. package/src/codex/desktop-app/picker-freshness.ts +96 -0
  134. package/src/codex/desktop-app-restart.ts +21 -0
  135. package/src/codex/desktop-compatibility/installed-build.ts +24 -6
  136. package/src/codex/desktop-compatibility/routing-preflight.ts +1 -1
  137. package/src/codex/desktop-compatibility/runtime.ts +12 -3
  138. package/src/codex/desktop-compatibility/windows-key-protection.ts +115 -9
  139. package/src/codex/features.ts +85 -45
  140. package/src/codex/inject/history-failure.ts +27 -0
  141. package/src/codex/inject/restore.ts +24 -2
  142. package/src/codex/inject/routing-classify.ts +4 -3
  143. package/src/codex/inject.ts +18 -27
  144. package/src/codex/main-account-hard-lock.ts +30 -1
  145. package/src/codex/native-profile-startup.ts +1 -1
  146. package/src/codex/ocx-compaction-history.ts +12 -10
  147. package/src/codex/paths.ts +86 -1
  148. package/src/codex/provider-routing.ts +174 -0
  149. package/src/codex/refresh.ts +2 -2
  150. package/src/codex/routing/health-store.ts +1 -1
  151. package/src/codex/routing/selection.ts +4 -3
  152. package/src/codex/routing/thread-affinity.ts +1 -1
  153. package/src/codex/signed-in-identity.ts +43 -0
  154. package/src/codex/subagent-model-fallback.ts +1 -1
  155. package/src/codex/sync.ts +6 -1
  156. package/src/codex/user-identity.ts +16 -5
  157. package/src/codex/windows-local-appdata.ts +84 -0
  158. package/src/combos/failover.ts +6 -1
  159. package/src/combos/index.ts +1 -0
  160. package/src/combos/resolve.ts +46 -10
  161. package/src/combos/types.ts +15 -0
  162. package/src/config/diagnostics.ts +33 -6
  163. package/src/config/live-reconcile.ts +38 -2
  164. package/src/config/load-degrade.ts +17 -0
  165. package/src/config/opencode-wire-upgrade.ts +54 -0
  166. package/src/config/opencode-zen-upgrade.ts +52 -0
  167. package/src/config/owner-registry.ts +22 -17
  168. package/src/config/provider-validation.ts +45 -2
  169. package/src/config/schema/config-schema.ts +22 -0
  170. package/src/config/schema/leaf-validators.ts +38 -9
  171. package/src/config/schema/main-account-hard-lock-fallback.ts +26 -0
  172. package/src/config/schema/native-reasoning-retention.ts +26 -0
  173. package/src/config.ts +2 -0
  174. package/src/generated/compatibility-version.json +608 -284
  175. package/src/grok/sync.ts +1 -1
  176. package/src/images/loop.ts +46 -8
  177. package/src/integrations/catalog-refresh.ts +1 -1
  178. package/src/integrations/mutation-plan.ts +1 -0
  179. package/src/integrations/registry.ts +8 -0
  180. package/src/lib/codex-restart-contract.ts +15 -0
  181. package/src/lib/desktop-upstream-tunnel.ts +18 -53
  182. package/src/lib/empty-tool-catalog.ts +62 -0
  183. package/src/lib/errors.ts +19 -3
  184. package/src/lib/gcp-adc.ts +96 -9
  185. package/src/lib/gcp-credential-input.ts +12 -0
  186. package/src/lib/lab-live-route-production.ts +5 -0
  187. package/src/lib/opaque-proxy-tunnel.ts +217 -0
  188. package/src/lib/redact.ts +2 -2
  189. package/src/oauth/account-quota-rank.ts +69 -2
  190. package/src/oauth/anthropic-account-refusal.ts +20 -2
  191. package/src/oauth/anthropic-oauth-definitions.ts +3 -1
  192. package/src/oauth/anthropic-routing.ts +1 -21
  193. package/src/oauth/anthropic.ts +38 -7
  194. package/src/oauth/callback-server.ts +27 -1
  195. package/src/oauth/chatgpt-device.ts +1 -1
  196. package/src/oauth/chatgpt-shared.ts +83 -0
  197. package/src/oauth/chatgpt.ts +8 -72
  198. package/src/oauth/dsh.ts +203 -0
  199. package/src/oauth/generic-account-failover.ts +33 -7
  200. package/src/oauth/generic-session-affinity.ts +125 -0
  201. package/src/oauth/index.ts +184 -109
  202. package/src/oauth/local-token-detect.ts +21 -1
  203. package/src/oauth/login-cli-errors.ts +44 -0
  204. package/src/oauth/login-cli.ts +69 -7
  205. package/src/oauth/login-flow-state.ts +371 -23
  206. package/src/oauth/meta-muse-errors.ts +41 -0
  207. package/src/oauth/meta-muse-keychain.ts +52 -0
  208. package/src/oauth/meta-muse.ts +51 -84
  209. package/src/oauth/model-discovery-auth.ts +3 -0
  210. package/src/oauth/pool-settings-capability.ts +4 -2
  211. package/src/oauth/refresh-flight-retirement.ts +48 -0
  212. package/src/oauth/session-affinity-key.ts +23 -0
  213. package/src/oauth/store-anthropic-instance.ts +9 -2
  214. package/src/oauth/store.ts +12 -5
  215. package/src/protocols/encoders/adapter-events.ts +5 -4
  216. package/src/protocols/encoders/messages.ts +9 -6
  217. package/src/protocols/provider-summary.ts +2 -1
  218. package/src/providers/api-key-cooldowns.ts +49 -0
  219. package/src/providers/api-key-resolve.ts +11 -6
  220. package/src/providers/api-key-response-ownership.ts +57 -0
  221. package/src/providers/api-key-selection.ts +31 -6
  222. package/src/providers/api-keys.ts +214 -3
  223. package/src/providers/claude-cli-usage.ts +96 -0
  224. package/src/providers/dsh-account-destination.ts +31 -0
  225. package/src/providers/dsh-account-models.ts +41 -0
  226. package/src/providers/gcp-credential-cleanup.ts +59 -0
  227. package/src/providers/key-failover.ts +54 -50
  228. package/src/providers/key-store.ts +7 -2
  229. package/src/providers/model-rename-fields.ts +3 -0
  230. package/src/providers/model-rename-migration.ts +2 -2
  231. package/src/providers/openai-tiers-destination.ts +11 -2
  232. package/src/providers/openai-tiers.ts +5 -12
  233. package/src/providers/opencode-go-transport.ts +3 -1
  234. package/src/providers/quota/account-cache.ts +6 -5
  235. package/src/providers/quota/account-row.ts +34 -0
  236. package/src/providers/quota/antigravity.ts +28 -7
  237. package/src/providers/quota/dsh-account.ts +70 -0
  238. package/src/providers/quota/vendor-probes-oauth.ts +1 -93
  239. package/src/providers/quota-types.ts +2 -0
  240. package/src/providers/quota.ts +14 -20
  241. package/src/providers/registry/entries-core.ts +37 -8
  242. package/src/providers/registry/types.ts +1 -1
  243. package/src/providers/request-pacing.ts +77 -19
  244. package/src/providers/routed-config.ts +339 -0
  245. package/src/providers/service-tier.ts +2 -1
  246. package/src/quota/window-mapping.ts +5 -2
  247. package/src/responses/anthropic-progress.ts +34 -0
  248. package/src/responses/assistant-replay.ts +35 -0
  249. package/src/responses/compaction-history-framing.ts +65 -0
  250. package/src/responses/compaction.ts +6 -1
  251. package/src/responses/incomplete-reason.ts +31 -0
  252. package/src/responses/parser.ts +50 -5
  253. package/src/responses/reasoning-envelope.ts +4 -0
  254. package/src/responses/reasoning-replay-cache.ts +24 -2
  255. package/src/responses/reasoning-retention.ts +173 -0
  256. package/src/responses/retained-compaction.ts +46 -0
  257. package/src/responses/schema.ts +23 -0
  258. package/src/router.ts +8 -321
  259. package/src/routing/health.ts +37 -22
  260. package/src/routing/history/indexer.ts +6 -5
  261. package/src/routing/history/ingest.ts +6 -2
  262. package/src/routing/profile.ts +1 -1
  263. package/src/server/admission-model-scope.ts +17 -0
  264. package/src/server/auth-cors.ts +11 -0
  265. package/src/server/chat-completions.ts +7 -1
  266. package/src/server/chat-native-reasoning.ts +123 -0
  267. package/src/server/chat-native-sse.ts +110 -7
  268. package/src/server/chat-native.ts +76 -17
  269. package/src/server/claude-messages.ts +55 -414
  270. package/src/server/effort-policy.ts +1 -1
  271. package/src/server/images.ts +15 -2
  272. package/src/server/index/model-alias-id.ts +36 -0
  273. package/src/server/index/optional-listeners.ts +2 -0
  274. package/src/server/index/serve-options.ts +9 -1
  275. package/src/server/index/startup-warnings.ts +2 -0
  276. package/src/server/index.ts +1 -1
  277. package/src/server/inference/advisor-diagnostics.ts +31 -0
  278. package/src/server/management/advisor-routes.ts +15 -4
  279. package/src/server/management/claude-desktop-picker-routes.ts +1 -1
  280. package/src/server/management/combo-routes.ts +176 -50
  281. package/src/server/management/companion-routes.ts +1 -1
  282. package/src/server/management/config-routes.ts +75 -4
  283. package/src/server/management/cursor-integration-routes.ts +1 -1
  284. package/src/server/management/model-routes.ts +11 -6
  285. package/src/server/management/model-rows.ts +14 -0
  286. package/src/server/management/native-integration-routes.ts +2 -7
  287. package/src/server/management/native-reasoning-retention-routes.ts +41 -0
  288. package/src/server/management/oauth-account-routes.ts +87 -14
  289. package/src/server/management/provider-gcp-credential-routes.ts +29 -0
  290. package/src/server/management/provider-key-pacing-routes.ts +31 -0
  291. package/src/server/management/provider-overwrite-carry.ts +7 -0
  292. package/src/server/management/provider-routes.ts +25 -30
  293. package/src/server/management/route-registry.ts +10 -1
  294. package/src/server/management-api.ts +3 -1
  295. package/src/server/messages-native-tool-guard.ts +185 -0
  296. package/src/server/messages-native.ts +101 -21
  297. package/src/server/messages-shared.ts +431 -0
  298. package/src/server/relay.ts +1 -1
  299. package/src/server/request-log-terminal-status.ts +1 -1
  300. package/src/server/request-log.ts +6 -1
  301. package/src/server/responses/account-change-state.ts +8 -2
  302. package/src/server/responses/adapter-continuation.ts +13 -1
  303. package/src/server/responses/adapter-delivery.ts +19 -6
  304. package/src/server/responses/adapter-dispatch.ts +19 -2
  305. package/src/server/responses/advisor-retention.ts +117 -0
  306. package/src/server/responses/advisor-slot.ts +73 -41
  307. package/src/server/responses/codex-ws-wire.ts +15 -8
  308. package/src/server/responses/compact.ts +303 -125
  309. package/src/server/responses/compaction-recovery.ts +18 -10
  310. package/src/server/responses/compaction-retention.ts +61 -0
  311. package/src/server/responses/context-overflow.ts +11 -6
  312. package/src/server/responses/core-auth.ts +6 -0
  313. package/src/server/responses/core-codex-account.ts +11 -1
  314. package/src/server/responses/core-combo-failure.ts +2 -0
  315. package/src/server/responses/core-combo.ts +120 -36
  316. package/src/server/responses/core-errors.ts +3 -3
  317. package/src/server/responses/core-lifetime.ts +5 -2
  318. package/src/server/responses/core-opaque-recovery.ts +35 -0
  319. package/src/server/responses/core-options.ts +5 -0
  320. package/src/server/responses/core-pipeline.ts +141 -0
  321. package/src/server/responses/core-replay.ts +18 -2
  322. package/src/server/responses/core.ts +2 -115
  323. package/src/server/responses/fetch-helpers.ts +73 -19
  324. package/src/server/responses/main-hard-lock-routing.ts +109 -0
  325. package/src/server/responses/native-response-control.ts +4 -2
  326. package/src/server/responses/passthrough-delivery.ts +3 -1
  327. package/src/server/responses/passthrough-dispatch.ts +15 -13
  328. package/src/server/responses/request-prepare.ts +76 -12
  329. package/src/server/responses/request-sidecar-auth.ts +2 -2
  330. package/src/server/responses/request-transport.ts +104 -27
  331. package/src/server/responses/response-effects.ts +1 -0
  332. package/src/server/responses/run-turn-execution.ts +96 -30
  333. package/src/server/responses/sidecar-execution.ts +20 -2
  334. package/src/server/responses/terminal-guard.ts +2 -16
  335. package/src/service/auth-preflight.ts +71 -0
  336. package/src/service/cli.ts +4 -2
  337. package/src/service/definition.ts +97 -0
  338. package/src/service/guards.ts +1 -61
  339. package/src/service/health-sentinel-linux.ts +112 -0
  340. package/src/service/health-sentinel-main.ts +76 -57
  341. package/src/service/health.ts +5 -89
  342. package/src/service/launchd.ts +27 -7
  343. package/src/service/logging.ts +72 -9
  344. package/src/service/repair.ts +2 -1
  345. package/src/service/state.ts +7 -8
  346. package/src/service/systemd.ts +1 -1
  347. package/src/service/windows-guarded-manager.ts +2 -1
  348. package/src/service/windows-ops.ts +1 -1
  349. package/src/service/windows-scheduler.ts +2 -7
  350. package/src/service/windows-taskxml.ts +2 -2
  351. package/src/service/windows-wscript.ts +14 -0
  352. package/src/service.ts +4 -2
  353. package/src/sidecar/auth.ts +2 -0
  354. package/src/types/config.ts +64 -3
  355. package/src/types/opencode-go.ts +61 -0
  356. package/src/types/opencode-zen.ts +29 -0
  357. package/src/types/provider.ts +32 -6
  358. package/src/types/request.ts +23 -2
  359. package/src/types/wire.ts +29 -37
  360. package/src/types.ts +1 -0
  361. package/src/update/npm-invocation.mjs +12 -2
  362. package/src/usage/cost.ts +48 -12
  363. package/src/usage/expected-prices.ts +3 -0
  364. package/src/usage/ledger-retention.ts +2 -1
  365. package/src/usage/ledger-scanner.ts +6 -5
  366. package/src/usage/log.ts +17 -8
  367. package/src/usage/summary.ts +22 -9
  368. package/src/usage/user-cost-overlays.ts +14 -19
  369. package/src/usage/user-cost-validation.ts +38 -0
  370. package/src/vision/eligibility.ts +13 -0
  371. package/src/web-search/loop.ts +55 -5
  372. package/src/web-search/progress-stream.ts +4 -1
  373. package/src/web-search/run-turn-loop.ts +9 -1
  374. package/gui/dist/assets/App-BjlttVLy.js +0 -58
  375. package/gui/dist/assets/App-CSJvmjNs.css +0 -1
  376. package/gui/dist/assets/index-BB6uK6tU.css +0 -1
  377. package/gui/dist/assets/index-BMssP4k3.js +0 -94
  378. package/gui/dist/assets/quota-summary-D8M1bc_R.js +0 -1
package/src/usage/cost.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  resolveMetadataProvider,
16
16
  } from "../generated/model-metadata";
17
17
  import type { AttemptTierOutcome, OcxUsage } from "../types";
18
+ import type { PromptLengthPricing } from "../types/provider";
18
19
  import { canonicalFastTierMarker } from "../providers/fastwire";
19
20
  import { baseProviderLabel } from "../providers/label";
20
21
  import type { PersistedUsageAttempt, UsageStatus } from "./log";
@@ -79,6 +80,7 @@ export interface MatchedPrice {
79
80
  modelId: string;
80
81
  jawcodeProvider?: string;
81
82
  cost4: Cost4;
83
+ promptLengthPricing?: PromptLengthPricing;
82
84
  source: "jawcode" | "expected" | "user";
83
85
  sourceRef?: string;
84
86
  verifiedAt?: string;
@@ -97,6 +99,8 @@ export interface AttemptCostEstimate {
97
99
  priorityMultiplier?: number;
98
100
  /** Set when the published long-context rate was applied (#908). */
99
101
  contextTier?: ContextTierName;
102
+ /** User absolute rates selected for this attempt, separate from catalog tiers. */
103
+ customThresholdApplied?: true;
100
104
  /** The numeric estimate is a known floor because the exact Priority price is unavailable. */
101
105
  priorityLowerBound?: boolean;
102
106
  }
@@ -111,6 +115,8 @@ export interface CostEstimate {
111
115
  priorityMultiplier?: number;
112
116
  /** Set when any priced attempt used the published long-context rate (#908). */
113
117
  contextTier?: ContextTierName;
118
+ /** At least one request/attempt selected the user threshold rates. */
119
+ customThresholdApplied?: true;
114
120
  /** The aggregate is a known floor because every priced attempt is a lower bound. */
115
121
  priorityLowerBound?: boolean;
116
122
  }
@@ -343,6 +349,7 @@ function userOverlayMatch(
343
349
  modelId,
344
350
  cost4: overlay.cost4,
345
351
  source: "user",
352
+ ...(overlay.promptLengthPricing ? { promptLengthPricing: overlay.promptLengthPricing } : {}),
346
353
  sourceRef: overlay.source,
347
354
  verifiedAt: overlay.verifiedAt,
348
355
  status: "verified",
@@ -397,6 +404,15 @@ function vendorPrefixedCost(modelId: string): ReturnType<typeof findVendorCostBy
397
404
  return normalize(found.provider) === normalize(claimedVendor) ? found : undefined;
398
405
  }
399
406
 
407
+ /**
408
+ * Only measured usage is priced. An `unreported` or `unsupported` row can still carry tokens — a
409
+ * combo whose first leg failed without usage keeps the second leg's — and pricing them reports an
410
+ * understated cost as if it were complete.
411
+ */
412
+ export function isMeasuredStatus(usageStatus: UsageStatus): boolean {
413
+ return usageStatus === "reported" || usageStatus === "estimated";
414
+ }
415
+
400
416
  function isEstimated(usage: OcxUsage, usageStatus: UsageStatus, priceStatus: ExpectedPriceStatus | "verified"): boolean {
401
417
  return usage.estimated === true || usageStatus === "estimated" || priceStatus === "verified-derived";
402
418
  }
@@ -475,8 +491,8 @@ function isConfirmedFast(tier?: ServiceTierInput): boolean {
475
491
  }
476
492
 
477
493
  /**
478
- * Apply the published long-context rate when raw prompt size crosses the vendor
479
- * threshold. Returns [effectiveCost4, tierName].
494
+ * Select user prompt-length rates, or apply the published context rule in automatic mode.
495
+ * Returns [effectiveCost4, catalogTier, priorityLowerBound, customThresholdApplied].
480
496
  *
481
497
  * `rawInputTokens` MUST be `usage.inputTokens` (total prompt size), never the
482
498
  * normalized billable input — normalization subtracts cache read/write, so a
@@ -486,13 +502,27 @@ function isConfirmedFast(tier?: ServiceTierInput): boolean {
486
502
  * API rows can stack the bands. xAI publishes neither a combined rate nor an exclusion,
487
503
  * so its long-context rate remains the known lower bound instead of inventing a stacked multiplier.
488
504
  */
489
- function applyContextTier(
505
+ function applyPromptLengthPricing(
490
506
  cost4: Cost4,
491
507
  provider: string,
492
508
  modelId: string,
493
509
  rawInputTokens: number | undefined,
494
510
  tier?: ServiceTierInput,
495
- ): [Cost4, ContextTierName | undefined, boolean] {
511
+ policy?: PromptLengthPricing,
512
+ ): [Cost4, ContextTierName | undefined, boolean, true?] {
513
+ // A user policy replaces the catalog rule on BOTH sides of its threshold.
514
+ // Keep custom provenance separate while honoring the published Priority relation.
515
+ if (policy?.mode === "flat") return [cost4, undefined, false];
516
+ if (policy?.mode === "custom") {
517
+ const crossed = rawInputTokens !== undefined && Number.isFinite(rawInputTokens)
518
+ && (policy.comparison === "gte"
519
+ ? rawInputTokens >= policy.thresholdTokens : rawInputTokens > policy.thresholdTokens);
520
+ if (!crossed) return [cost4, undefined, false];
521
+ const relation = findContextTier(provider, modelId)?.confirmedPriorityRelation;
522
+ const confirmedFast = isConfirmedFast(tier);
523
+ if (confirmedFast && relation === "exclusive") return [cost4, undefined, false];
524
+ return [policy.rates, undefined, confirmedFast && relation === "lower-bound", true];
525
+ }
496
526
  if (rawInputTokens === undefined) return [cost4, undefined, false];
497
527
  const rule = findContextTier(provider, modelId);
498
528
  if (!rule || !isLongContext(rule, rawInputTokens)) return [cost4, undefined, false];
@@ -520,9 +550,12 @@ function applyPriorityMultiplier(
520
550
  modelId: string,
521
551
  serviceTier?: ServiceTierInput,
522
552
  contextTier?: ContextTierName,
553
+ customThresholdApplied?: true,
523
554
  ): [Cost4, number] {
524
555
  if (canonicalFastTierMarker(tierScalar(serviceTier)) !== "priority") return [cost4, 1];
525
556
  if (contextTier && findContextTier(provider, modelId)?.confirmedPriorityRelation !== "stack") return [cost4, 1];
557
+ if (customThresholdApplied && isConfirmedFast(serviceTier)
558
+ && findContextTier(provider, modelId)?.confirmedPriorityRelation === "lower-bound") return [cost4, 1];
526
559
  const rule = findPriorityPricingRule(provider, modelId);
527
560
  if (rule?.requiresResponseConfirmation && !isConfirmedFast(serviceTier)) return [cost4, 1];
528
561
  const multiplier = rule?.multiplier ?? 1;
@@ -568,7 +601,7 @@ export function estimateAttemptCost(
568
601
  serviceTier?: ServiceTierInput,
569
602
  userOverlays: readonly ExpectedPriceOverlay[] = activeUserCostOverlays(),
570
603
  ): AttemptCostEstimate | null {
571
- if (!attempt.usage) return null;
604
+ if (!isMeasuredStatus(attempt.usageStatus) || !attempt.usage) return null;
572
605
  const tokens = normalizeCostTokens(attempt.usage);
573
606
  if (!tokens) return null;
574
607
  const price = resolveMatchedPrice(attempt.provider, attempt.model, overlays, userOverlays, attempt);
@@ -576,11 +609,11 @@ export function estimateAttemptCost(
576
609
  const attemptServiceTier = attempt.tierOutcome
577
610
  ? serviceTierContextFromOutcome(attempt.tierOutcome)
578
611
  : serviceTier;
579
- const [tieredCost4, contextTier, contextPriorityLowerBound] = applyContextTier(
580
- price.cost4, price.provider, attempt.model, attempt.usage.inputTokens, attemptServiceTier,
612
+ const [tieredCost4, contextTier, contextPriorityLowerBound, customThresholdApplied] = applyPromptLengthPricing(
613
+ price.cost4, price.provider, attempt.model, attempt.usage.inputTokens, attemptServiceTier, price.promptLengthPricing,
581
614
  );
582
615
  const [effectiveCost4, multiplier] = applyPriorityMultiplier(
583
- tieredCost4, price.provider, attempt.model, attemptServiceTier, contextTier,
616
+ tieredCost4, price.provider, attempt.model, attemptServiceTier, contextTier, customThresholdApplied,
584
617
  );
585
618
  const priorityLowerBound = contextPriorityLowerBound
586
619
  || isOpenRouterPriorityLowerBound(price.provider, attempt.tierOutcome);
@@ -594,6 +627,7 @@ export function estimateAttemptCost(
594
627
  estimated: isEstimated(attempt.usage, attempt.usageStatus, price.status),
595
628
  ...(multiplier !== 1 ? { priorityMultiplier: multiplier } : {}),
596
629
  ...(contextTier ? { contextTier } : {}),
630
+ ...(customThresholdApplied ? { customThresholdApplied } : {}),
597
631
  ...(priorityLowerBound ? { priorityLowerBound: true } : {}),
598
632
  };
599
633
  }
@@ -637,6 +671,7 @@ export function estimateComboCost(
637
671
  ? { priorityMultiplier: estimates.find(est => est.priorityMultiplier)?.priorityMultiplier }
638
672
  : {}),
639
673
  ...(estimates.some(est => est.contextTier) ? { contextTier: "long" as const } : {}),
674
+ ...(estimates.some(est => est.customThresholdApplied) ? { customThresholdApplied: true as const } : {}),
640
675
  ...(estimates.every(est => est.priorityLowerBound === true)
641
676
  ? { priorityLowerBound: true as const }
642
677
  : {}),
@@ -656,16 +691,16 @@ export function estimateRequestCost(
656
691
  overlays: readonly ExpectedPriceOverlay[] = EXPECTED_PRICE_OVERLAYS,
657
692
  userOverlays: readonly ExpectedPriceOverlay[] = activeUserCostOverlays(),
658
693
  ): CostEstimate | null {
659
- if (!input.usage) return null;
694
+ if (!isMeasuredStatus(input.usageStatus) || !input.usage) return null;
660
695
  const tokens = normalizeCostTokens(input.usage);
661
696
  if (!tokens) return null;
662
697
  const price = resolveMatchedPrice(input.provider, input.model, overlays, userOverlays, input);
663
698
  if (!price) return null;
664
- const [tieredCost4, contextTier, contextPriorityLowerBound] = applyContextTier(
665
- price.cost4, price.provider, input.model, input.usage.inputTokens, input.serviceTier,
699
+ const [tieredCost4, contextTier, contextPriorityLowerBound, customThresholdApplied] = applyPromptLengthPricing(
700
+ price.cost4, price.provider, input.model, input.usage.inputTokens, input.serviceTier, price.promptLengthPricing,
666
701
  );
667
702
  const [effectiveCost4, multiplier] = applyPriorityMultiplier(
668
- tieredCost4, price.provider, input.model, input.serviceTier, contextTier,
703
+ tieredCost4, price.provider, input.model, input.serviceTier, contextTier, customThresholdApplied,
669
704
  );
670
705
  const priorityLowerBound = contextPriorityLowerBound || isOpenRouterPriorityLowerBound(
671
706
  price.provider,
@@ -678,6 +713,7 @@ export function estimateRequestCost(
678
713
  estimated: isEstimated(input.usage, input.usageStatus, price.status),
679
714
  ...(multiplier !== 1 ? { priorityMultiplier: multiplier } : {}),
680
715
  ...(contextTier ? { contextTier } : {}),
716
+ ...(customThresholdApplied ? { customThresholdApplied } : {}),
681
717
  ...(priorityLowerBound ? { priorityLowerBound: true } : {}),
682
718
  };
683
719
  }
@@ -10,6 +10,7 @@ import { isAnthropicInstanceId } from "../providers/anthropic-instance-id";
10
10
  export function pricingFamilyProvider(provider: string): string {
11
11
  return isAnthropicInstanceId(provider) ? "anthropic" : provider;
12
12
  }
13
+ import type { PromptLengthPricing } from "../types/provider";
13
14
 
14
15
  /**
15
16
  * Expected-price overlay for models whose jawcode cost rows are missing or all-zero
@@ -46,6 +47,8 @@ export interface ExpectedPriceOverlay {
46
47
  provider: string;
47
48
  modelId: string;
48
49
  cost4: Cost4;
50
+ /** User-owned context policy; catalog rows leave this absent. */
51
+ promptLengthPricing?: PromptLengthPricing;
49
52
  source: string;
50
53
  verifiedAt: string;
51
54
  status: ExpectedPriceStatus;
@@ -22,6 +22,7 @@ import { closeSync, fstatSync, openSync, readSync, writeSync } from "node:fs";
22
22
  import { atomicWriteFileStreamed } from "../config/atomic-write";
23
23
  import {
24
24
  currentUsageLogRevision,
25
+ exactFileIdentity,
25
26
  usageLogIdentityKey,
26
27
  usageLogPath,
27
28
  usageLogRevisionKey,
@@ -127,7 +128,7 @@ export function enforceUsageLedgerSizeLimit(
127
128
  let sourceClosed = false;
128
129
  try {
129
130
  const opened = fstatSync(sourceFd);
130
- if (Number(opened.size) !== captured.size || Number(opened.ino) !== captured.ino) {
131
+ if (Number(opened.size) !== captured.size || exactFileIdentity(sourceFd).ino !== captured.ino) {
131
132
  return { kind: "deferred", currentBytes: captured.size, reason: "revision-changed" };
132
133
  }
133
134
  const start = firstRowBoundary(sourceFd, Math.max(0, captured.size - target), captured.size);
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { closeSync, fstatSync, openSync, readSync } from "node:fs";
3
3
  import {
4
4
  currentUsageLogRevision,
5
+ exactFileIdentity,
5
6
  normalizePersistedUsageRow,
6
7
  usageLogIdentityKey,
7
8
  usageLogPath,
@@ -63,13 +64,13 @@ export class UsageLedgerRebuildRequiredError extends Error {
63
64
 
64
65
  function revisionFromStat(
65
66
  path: string,
67
+ fd: number,
66
68
  stat: ReturnType<typeof fstatSync>,
67
69
  ): UsageLogRevision {
68
70
  if (!stat.isFile()) throw new Error("usage log is not a regular file");
69
71
  return {
70
72
  path,
71
- dev: Number(stat.dev),
72
- ino: Number(stat.ino),
73
+ ...exactFileIdentity(fd),
73
74
  birthtimeMs: Number(stat.birthtimeMs),
74
75
  size: Number(stat.size),
75
76
  mtimeMs: Number(stat.mtimeMs),
@@ -276,7 +277,7 @@ export async function scanUsageLedgerCooperatively(
276
277
  let pendingLineBytes = 0;
277
278
 
278
279
  try {
279
- const openedRevision = revisionFromStat(path, fstatSync(fd));
280
+ const openedRevision = revisionFromStat(path, fd, fstatSync(fd));
280
281
  const scanEnd = openedRevision.size;
281
282
  const openedIdentityKey = usageLogIdentityKey(openedRevision);
282
283
  if (options.expectedIdentityKey && options.expectedIdentityKey !== openedIdentityKey) {
@@ -391,7 +392,7 @@ export async function scanUsageLedgerCooperatively(
391
392
  }
392
393
 
393
394
  throwIfAborted(options.signal);
394
- const endingRevision = revisionFromStat(path, fstatSync(fd));
395
+ const endingRevision = revisionFromStat(path, fd, fstatSync(fd));
395
396
  if (usageLogIdentityKey(endingRevision) !== openedIdentityKey) {
396
397
  throw rebuildRequired("identity_mismatch");
397
398
  }
@@ -417,7 +418,7 @@ export async function scanUsageLedgerCooperatively(
417
418
  line,
418
419
  options.signal,
419
420
  );
420
- const verifiedFdRevision = revisionFromStat(path, fstatSync(fd));
421
+ const verifiedFdRevision = revisionFromStat(path, fd, fstatSync(fd));
421
422
  const verifiedPathRevision = currentUsageLogRevision();
422
423
  if (capturedDigest !== verifiedDigest) throw rebuildRequired("content_changed");
423
424
  if (!verifiedPathRevision
package/src/usage/log.ts CHANGED
@@ -1163,8 +1163,12 @@ export function appendUsageEntry(entry: PersistedUsageEntry): void {
1163
1163
 
1164
1164
  export type UsageLogRevision = {
1165
1165
  path: string;
1166
- dev: number;
1167
- ino: number;
1166
+ /**
1167
+ * Exact decimal device and file id. NTFS file ids carry a sequence number in their
1168
+ * high bits and routinely exceed 2^53, so a Number would let two files compare equal.
1169
+ */
1170
+ dev: string;
1171
+ ino: string;
1168
1172
  birthtimeMs: number;
1169
1173
  size: number;
1170
1174
  mtimeMs: number;
@@ -1367,12 +1371,17 @@ function readExactly(fd: number, length: number, position: number): Buffer | nul
1367
1371
  return output;
1368
1372
  }
1369
1373
 
1370
- function usageLogRevision(path: string, stat: ReturnType<typeof fstatSync>): UsageLogRevision {
1374
+ /** Exact dev/ino of an open file, read through bigint stats so large ids keep every bit. */
1375
+ export function exactFileIdentity(fd: number): { dev: string; ino: string } {
1376
+ const exact = fstatSync(fd, { bigint: true });
1377
+ return { dev: exact.dev.toString(), ino: exact.ino.toString() };
1378
+ }
1379
+
1380
+ function usageLogRevision(path: string, fd: number, stat: ReturnType<typeof fstatSync>): UsageLogRevision {
1371
1381
  if (!stat.isFile()) throw new Error("usage log is not a regular file");
1372
1382
  return {
1373
1383
  path,
1374
- dev: Number(stat.dev),
1375
- ino: Number(stat.ino),
1384
+ ...exactFileIdentity(fd),
1376
1385
  birthtimeMs: Number(stat.birthtimeMs),
1377
1386
  size: Number(stat.size),
1378
1387
  mtimeMs: Number(stat.mtimeMs),
@@ -1405,7 +1414,7 @@ export function currentUsageLogRevision(): UsageLogRevision | null {
1405
1414
  let fd: number | undefined;
1406
1415
  try {
1407
1416
  fd = openSync(path, "r");
1408
- return usageLogRevision(path, fstatSync(fd));
1417
+ return usageLogRevision(path, fd, fstatSync(fd));
1409
1418
  } finally {
1410
1419
  if (fd !== undefined) closeSync(fd);
1411
1420
  }
@@ -1530,7 +1539,7 @@ async function readUsageEntriesFullCooperatively(
1530
1539
  if (prefixDigest === null) throw new Error("usage log changed while it was being read");
1531
1540
  return {
1532
1541
  entries: parsed.entries,
1533
- revision: usageLogRevision(path, stat),
1542
+ revision: usageLogRevision(path, fd, stat),
1534
1543
  truncatedPrefixBytes,
1535
1544
  entriesTruncated: parsed.entriesDropped > 0,
1536
1545
  entriesDropped: parsed.entriesDropped,
@@ -1562,7 +1571,7 @@ async function readUsageEntriesIncrementally(
1562
1571
  try {
1563
1572
  fd = openSync(path, "r");
1564
1573
  const stat = fstatSync(fd);
1565
- const revision = usageLogRevision(path, stat);
1574
+ const revision = usageLogRevision(path, fd, stat);
1566
1575
  if (usageLogIdentityKey(revision) !== retained.identityKey) return null;
1567
1576
  const size = Number(stat.size);
1568
1577
  // A shrink means truncation or replacement-in-place; the retained rows may no
@@ -10,7 +10,7 @@ import {
10
10
  type PersistedUsageEntry,
11
11
  type UsageStatus,
12
12
  } from "./log";
13
- import { type AttemptCostEstimate, type CostEstimate, estimateAttemptCost, estimateRequestCost, serviceTierContext, type ServiceTierContext, tokensPerSecond } from "./cost";
13
+ import { type AttemptCostEstimate, type CostEstimate, estimateAttemptCost, estimateRequestCost, isMeasuredStatus, serviceTierContext, type ServiceTierContext, tokensPerSecond } from "./cost";
14
14
 
15
15
  /**
16
16
  * Canonical range members. The warm-up loop in the management usage route
@@ -303,7 +303,10 @@ export function computeEntryCost(entry: PersistedUsageEntry): EntryCostInfo {
303
303
  isPriced = true;
304
304
  }
305
305
  }
306
- return { tier, estimate: null, attemptEstimates, costTotal, isPriced };
306
+ // An unmeasured leg that may have generated billed tokens leaves the total unknown, not short.
307
+ const unknownCost = entry.attempts.some((attempt, index) =>
308
+ !attemptEstimates[index] && (!attempt.usage || !isMeasuredStatus(attempt.usageStatus)) && !spentNothing(attempt));
309
+ return { tier, estimate: null, attemptEstimates, costTotal, isPriced: isPriced && !unknownCost };
307
310
  }
308
311
  const estimate = estimateRequestCost({
309
312
  ...usageModelPriceOptions(entry, entry),
@@ -428,10 +431,6 @@ function blankTotals(): UsageSummaryTotals {
428
431
  };
429
432
  }
430
433
 
431
- function isMeasuredStatus(status: UsageStatus): boolean {
432
- return status === "reported" || status === "estimated";
433
- }
434
-
435
434
  export interface UsageAttribution {
436
435
  requestId: string;
437
436
  provider: string;
@@ -814,14 +813,28 @@ function blankRequestCounts(): UsageRequestCounts {
814
813
  };
815
814
  }
816
815
 
816
+ /**
817
+ * A usage-less attempt that provably cost nothing: it never reached upstream, or upstream refused it
818
+ * with an HTTP error before any output (providers do not bill a pre-generation 429/401/5xx). One
819
+ * whose stream began, aborted, or ended without a status may have generated billed tokens, so it
820
+ * stays an unknown cost. A request made only of such zeros carries neither fact and counts unpriced.
821
+ */
822
+ function spentNothing(attempt: NonNullable<PersistedUsageEntry["attempts"]>[number] | undefined): boolean {
823
+ if (!attempt || attempt.usage) return false;
824
+ if (attempt.sendCount === 0 || attempt.locallyAnswered === true) return true;
825
+ return attempt.status >= 400 && attempt.streamAborted !== true && attempt.firstOutputMs === undefined;
826
+ }
827
+
817
828
  function bumpRequestCounts(counts: UsageRequestCounts, facts: number, amount = 1): void {
818
829
  counts.requests += amount;
819
830
  const status = statusFromRequestFacts(facts);
820
831
  if (isMeasuredStatus(status)) counts.measuredRequests += amount;
821
832
  if (status === "reported") counts.reportedRequests += amount;
822
833
  else if (status === "estimated") counts.estimatedRequests += amount;
823
- if ((facts & REQUEST_PRICED) !== 0) counts.pricedRequests += amount;
824
- if ((facts & REQUEST_UNPRICED) !== 0) counts.unpricedRequests += amount;
834
+ // Unpriced dominates: one unpriceable part makes the request's cost unknown, and counting it
835
+ // on both sides overstated price coverage.
836
+ if ((facts & REQUEST_PRICED) !== 0 && (facts & REQUEST_UNPRICED) === 0) counts.pricedRequests += amount;
837
+ else counts.unpricedRequests += amount;
825
838
  }
826
839
 
827
840
  function mergeRequestCounts(target: UsageRequestCounts, source: UsageRequestCounts): void {
@@ -1557,7 +1570,7 @@ class StreamingUsageSummaryAccumulator implements UsageSummaryAccumulator {
1557
1570
  : costInfo.estimate;
1558
1571
  const ordinal = this.nextOrdinal++;
1559
1572
  const facts = requestStatusFact(attribution.usageStatus)
1560
- | (estimate ? REQUEST_PRICED : REQUEST_UNPRICED);
1573
+ | (estimate ? REQUEST_PRICED : spentNothing(entry.attempts?.[index]) ? 0 : REQUEST_UNPRICED);
1561
1574
  const modelKey = this.addModelAttribution(partition, attribution, estimate, ordinal);
1562
1575
  modelFacts.set(modelKey, (modelFacts.get(modelKey) ?? 0) | facts);
1563
1576
  if (this.mode === "row-unique") {
@@ -18,8 +18,10 @@
18
18
  *
19
19
  * Display-time estimation only — these rows never affect billing.
20
20
  */
21
- import type { OcxConfig, OcxProviderConfig, ProviderCostOverlay } from "../types";
22
- import { MAX_COST4_RATE, type ExpectedPriceOverlay } from "./expected-prices";
21
+ import type { OcxConfig, OcxProviderConfig, ProviderCostOverlay, PromptLengthPricing } from "../types";
22
+ import type { ExpectedPriceOverlay } from "./expected-prices";
23
+ import { validCost4 } from "./user-cost-validation";
24
+ export { COST4_RATE_KEYS, isValidCost4Rate, validCost4, isValidPromptLengthPricing } from "./user-cost-validation";
23
25
  import { redactSecretString } from "../lib/redact";
24
26
  import { isSelectableCodexPoolAccount, MAIN_CODEX_ACCOUNT_ID } from "../codex/account-id";
25
27
  import { codexAccountLogLabel } from "../codex/account-label";
@@ -236,22 +238,13 @@ export function resetPreservedDiskOnlyProvidersForTests(): void {
236
238
  preservationOwnerStates.clear();
237
239
  }
238
240
 
239
- /** Exact four fields accepted for a user-configured cost tuple. */
240
- export const COST4_RATE_KEYS = ["input", "output", "cacheRead", "cacheWrite"] as const;
241
-
242
- /** Shared per-rate predicate used by config validation, display sanitization, and runtime lifting. */
243
- export function isValidCost4Rate(rate: unknown): rate is number {
244
- return typeof rate === "number"
245
- && Number.isFinite(rate)
246
- && rate >= 0
247
- && rate <= MAX_COST4_RATE;
248
- }
249
-
250
- /** True when `value` is a complete cost entry: all four rates are non-negative finite numbers. */
251
- function validCost4(value: unknown): value is ProviderCostOverlay {
252
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
253
- const entry = value as Record<string, unknown>;
254
- return COST4_RATE_KEYS.every(key => isValidCost4Rate(entry[key]));
241
+ /** Copy a validated override; automatic is represented by absence. */
242
+ export function copyPromptLengthPricing(policy: PromptLengthPricing): PromptLengthPricing | undefined {
243
+ if (policy.mode === "automatic") return undefined;
244
+ if (policy.mode !== "custom") return { mode: policy.mode };
245
+ const { input, output, cacheRead, cacheWrite } = policy.rates;
246
+ return { mode: "custom", thresholdTokens: policy.thresholdTokens, comparison: policy.comparison,
247
+ rates: { input, output, cacheRead, cacheWrite } };
255
248
  }
256
249
 
257
250
  /**
@@ -273,10 +266,11 @@ export function refreshUserCostOverlays(config: OcxConfig): void {
273
266
  if (!costs || typeof costs !== "object" || Array.isArray(costs)) continue;
274
267
  for (const [modelId, cost4] of Object.entries(costs)) {
275
268
  if (!modelId.trim() || !validCost4(cost4)) continue;
269
+ const policy = cost4.promptLengthPricing && copyPromptLengthPricing(cost4.promptLengthPricing);
276
270
  rows.push({
277
271
  provider: providerName,
278
272
  modelId,
279
- // Copy ONLY the four validated rate fields: a hand-edited row may
273
+ // Copy only validated rates and policy: a hand-edited row may
280
274
  // carry extra properties (e.g. a misplaced apiKey) that must never
281
275
  // reach display estimates or /api/logs through the registry.
282
276
  cost4: {
@@ -285,6 +279,7 @@ export function refreshUserCostOverlays(config: OcxConfig): void {
285
279
  cacheRead: cost4.cacheRead,
286
280
  cacheWrite: cost4.cacheWrite,
287
281
  },
282
+ ...(policy ? { promptLengthPricing: policy } : {}),
288
283
  // Display provenance only: redact token-shaped provider/model ids so
289
284
  // the source string can never echo a pasted key. Matching still uses
290
285
  // the raw fields, and the change-detection signature below MUST keep
@@ -0,0 +1,38 @@
1
+ /** Pure model-cost validation shared by config, migration, and display lifting. */
2
+ import type { ProviderCostOverlay, PromptLengthPricing } from "../types";
3
+ import { MAX_COST4_RATE } from "./expected-prices";
4
+
5
+ /** Exact four fields accepted for a user-configured cost tuple. */
6
+ export const COST4_RATE_KEYS = ["input", "output", "cacheRead", "cacheWrite"] as const;
7
+
8
+ /** Shared per-rate predicate used by config validation, display sanitization, and runtime lifting. */
9
+ export function isValidCost4Rate(rate: unknown): rate is number {
10
+ return typeof rate === "number"
11
+ && Number.isFinite(rate)
12
+ && rate >= 0
13
+ && rate <= MAX_COST4_RATE;
14
+ }
15
+
16
+ /** Complete base rates with a valid optional policy; extra top-level fields are never copied. */
17
+ export function validCost4(value: unknown): value is ProviderCostOverlay {
18
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
19
+ const entry = value as Record<string, unknown>;
20
+ return COST4_RATE_KEYS.every(key => Object.hasOwn(entry, key) && isValidCost4Rate(entry[key]))
21
+ && (!Object.hasOwn(entry, "promptLengthPricing") || isValidPromptLengthPricing(entry.promptLengthPricing));
22
+ }
23
+
24
+ /** Strict nested policy boundary; diagnostics never echo untrusted policy fields. */
25
+ export function isValidPromptLengthPricing(value: unknown): value is PromptLengthPricing {
26
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
27
+ const policy = value as Record<string, unknown>;
28
+ if (!Object.hasOwn(policy, "mode")) return false;
29
+ if (policy.mode === "automatic" || policy.mode === "flat") return Object.keys(policy).length === 1;
30
+ if (policy.mode !== "custom" || Object.keys(policy).length !== 4
31
+ || !["mode", "thresholdTokens", "comparison", "rates"].every(key => Object.hasOwn(policy, key))
32
+ || typeof policy.thresholdTokens !== "number" || !Number.isSafeInteger(policy.thresholdTokens)
33
+ || policy.thresholdTokens <= 0 || (policy.comparison !== "gt" && policy.comparison !== "gte")) return false;
34
+ const rates = policy.rates;
35
+ return !!rates && typeof rates === "object" && !Array.isArray(rates)
36
+ && Object.keys(rates).length === COST4_RATE_KEYS.length
37
+ && COST4_RATE_KEYS.every(key => Object.hasOwn(rates, key) && isValidCost4Rate((rates as Record<string, unknown>)[key]));
38
+ }
@@ -226,6 +226,19 @@ export function isVisionSidecarConsumer(config: VisionCapabilityConfig, provider
226
226
  return isVisionSidecarConsumerWithCache(config, providerName, modelId, new Map());
227
227
  }
228
228
 
229
+ /**
230
+ * `isVisionSidecarConsumer` for many rows of one config. Rows of the same provider share one
231
+ * registry enrichment instead of rebuilding it per row, which matters for `/api/models`
232
+ * listings with thousands of rows. Bind it to a single config snapshot; it does not observe
233
+ * provider changes made after the first lookup.
234
+ */
235
+ export function visionSidecarConsumerClassifier(
236
+ config: VisionCapabilityConfig,
237
+ ): (providerName: string, modelId: string) => boolean {
238
+ const cache: EnrichedProviderCache = new Map();
239
+ return (providerName, modelId) => isVisionSidecarConsumerWithCache(config, providerName, modelId, cache);
240
+ }
241
+
229
242
  /**
230
243
  * Can this model accept an image on the wire? Sources are consulted in descending
231
244
  * trustworthiness; the first that speaks wins. `undefined` means nothing knows,