@bitkyc08/opencodex 2.76.0 → 2.78.0-preview.20261005

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 (353) hide show
  1. package/bin/ocx.mjs +11 -10
  2. package/gui/dist/assets/App-CppMz-6w.js +54 -0
  3. package/gui/dist/assets/App-lCIslZ1L.css +1 -0
  4. package/gui/dist/assets/{Tray-DjNwwrWS.js → Tray-CJWQqtVv.js} +1 -1
  5. package/gui/dist/assets/index-DQt9srNi.js +94 -0
  6. package/gui/dist/assets/index-DmQB8D_R.css +1 -0
  7. package/gui/dist/assets/quota-summary-BMsyoZH2.js +1 -0
  8. package/gui/dist/index.html +4 -3
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic/beta-allowlist.ts +19 -2
  11. package/src/adapters/anthropic/client-identity.ts +13 -4
  12. package/src/adapters/anthropic/native-client-preamble.ts +31 -0
  13. package/src/adapters/anthropic/passthrough.ts +103 -25
  14. package/src/adapters/antigravity-refusal.ts +28 -0
  15. package/src/adapters/cursor/protobuf-request.ts +6 -0
  16. package/src/adapters/cursor/request-builder.ts +3 -0
  17. package/src/adapters/cursor/structured-output.ts +13 -0
  18. package/src/adapters/cursor/types.ts +2 -0
  19. package/src/adapters/devin/cloud-direct/chat.ts +7 -2
  20. package/src/adapters/devin/trajectory.ts +49 -0
  21. package/src/adapters/devin.ts +13 -0
  22. package/src/adapters/ollama-native.ts +62 -19
  23. package/src/adapters/openai-responses/passthrough.ts +133 -103
  24. package/src/adapters/openai-responses/request-strips.ts +10 -4
  25. package/src/adapters/zed.ts +26 -4
  26. package/src/bridge/response-json.ts +14 -14
  27. package/src/bridge/sse.ts +15 -5
  28. package/src/chatgpt/app-server-shim/bundle-trust.ts +40 -10
  29. package/src/chatgpt/app-server-shim/gate-rewrite.ts +3 -0
  30. package/src/claude/agents-inject.ts +4 -43
  31. package/src/claude/gateway-cache.ts +16 -13
  32. package/src/claude/inbound-model-options.ts +9 -2
  33. package/src/claude/intercept/cli-catalog.ts +27 -16
  34. package/src/claude/intercept/client-hello.ts +103 -0
  35. package/src/claude/intercept/model-bindings.ts +3 -2
  36. package/src/claude/intercept/picker-bootstrap.ts +37 -16
  37. package/src/claude/intercept/picker-budget.ts +41 -0
  38. package/src/claude/intercept/picker-listener.ts +279 -66
  39. package/src/claude/outbound.ts +2 -0
  40. package/src/claude/subagent-force-status.ts +53 -0
  41. package/src/claude/subagent-model.ts +93 -0
  42. package/src/cli/access-audio-input.ts +84 -0
  43. package/src/cli/access-audio-live.ts +99 -0
  44. package/src/cli/access-audio.ts +123 -0
  45. package/src/cli/access-data-client.ts +127 -0
  46. package/src/cli/access-data-plane.ts +104 -0
  47. package/src/cli/access-data-response.ts +76 -0
  48. package/src/cli/access.ts +65 -2
  49. package/src/cli/account-api.ts +33 -6
  50. package/src/cli/account-auth.ts +111 -42
  51. package/src/cli/account-key-quota.ts +118 -0
  52. package/src/cli/account-main.ts +11 -4
  53. package/src/cli/account-next-actions.ts +63 -0
  54. package/src/cli/account-policy-dto.ts +68 -0
  55. package/src/cli/account-policy.ts +167 -0
  56. package/src/cli/account-target.ts +8 -0
  57. package/src/cli/account.ts +37 -9
  58. package/src/cli/agent-runtime-settings.ts +130 -0
  59. package/src/cli/agent-settings.ts +101 -0
  60. package/src/cli/agent.ts +29 -2
  61. package/src/cli/alias.ts +7 -2
  62. package/src/cli/aside-profiles.ts +2 -2
  63. package/src/cli/capabilities-access-remote.ts +147 -0
  64. package/src/cli/capabilities-accounts.ts +195 -0
  65. package/src/cli/capabilities-agents-routing.ts +414 -0
  66. package/src/cli/capabilities-base.ts +783 -0
  67. package/src/cli/capabilities-command.ts +1 -0
  68. package/src/cli/capabilities-integrations.ts +264 -0
  69. package/src/cli/capabilities-lab.ts +214 -0
  70. package/src/cli/capabilities-observe-system.ts +662 -0
  71. package/src/cli/capabilities-provider-models.ts +302 -0
  72. package/src/cli/capabilities.ts +30 -1190
  73. package/src/cli/capability-types.ts +54 -0
  74. package/src/cli/catalog-command-result.ts +57 -0
  75. package/src/cli/chatgpt-command.ts +35 -9
  76. package/src/cli/claude-agent-startup-sync.ts +17 -0
  77. package/src/cli/claude-desktop-profile.ts +100 -0
  78. package/src/cli/claude-desktop.ts +39 -6
  79. package/src/cli/claude.ts +22 -6
  80. package/src/cli/codex-shim-autorestore.ts +2 -1
  81. package/src/cli/codex-shim-readiness.ts +109 -0
  82. package/src/cli/combo-input.ts +88 -0
  83. package/src/cli/combo-result.ts +52 -0
  84. package/src/cli/combo-stats.ts +123 -0
  85. package/src/cli/combo.ts +37 -14
  86. package/src/cli/companion-timeline.ts +142 -0
  87. package/src/cli/companion-usage.ts +146 -0
  88. package/src/cli/companion.ts +12 -3
  89. package/src/cli/config-command.ts +24 -8
  90. package/src/cli/connect.ts +19 -0
  91. package/src/cli/dispatch.ts +55 -82
  92. package/src/cli/doctor.ts +22 -4
  93. package/src/cli/export-command.ts +20 -9
  94. package/src/cli/gui.ts +8 -3
  95. package/src/cli/help-catalog.ts +101 -0
  96. package/src/cli/help-models-context.ts +17 -0
  97. package/src/cli/help-navigation.ts +50 -0
  98. package/src/cli/help-recovery.ts +66 -0
  99. package/src/cli/help.ts +69 -11
  100. package/src/cli/index.ts +38 -27
  101. package/src/cli/injection-follow.ts +35 -0
  102. package/src/cli/inspect.ts +5 -0
  103. package/src/cli/integration-aside-sync.ts +69 -0
  104. package/src/cli/integration-cursor.ts +106 -0
  105. package/src/cli/integration-input.ts +60 -0
  106. package/src/cli/integration-journal.ts +49 -0
  107. package/src/cli/integration-plan-dto.ts +156 -0
  108. package/src/cli/integration-preview.ts +215 -0
  109. package/src/cli/integrations.ts +24 -18
  110. package/src/cli/json-input.ts +134 -0
  111. package/src/cli/link.ts +32 -4
  112. package/src/cli/local-sync-result.ts +41 -0
  113. package/src/cli/log-follow.ts +84 -0
  114. package/src/cli/log-view-filter.ts +146 -0
  115. package/src/cli/logout-command.ts +44 -0
  116. package/src/cli/model-picker-ordering.ts +137 -0
  117. package/src/cli/models-custom-input.ts +107 -0
  118. package/src/cli/models-custom-runtime.ts +112 -0
  119. package/src/cli/models-order.ts +194 -0
  120. package/src/cli/models-runtime-subcommands.ts +2 -0
  121. package/src/cli/models-runtime.ts +9 -1
  122. package/src/cli/models.ts +66 -81
  123. package/src/cli/observe-stream.ts +142 -0
  124. package/src/cli/observe.ts +75 -34
  125. package/src/cli/opencode.ts +54 -10
  126. package/src/cli/provider-batch.ts +77 -0
  127. package/src/cli/provider-lifecycle-runtime.ts +170 -0
  128. package/src/cli/provider-result.ts +124 -0
  129. package/src/cli/provider-runtime.ts +15 -12
  130. package/src/cli/provider-settings.ts +143 -0
  131. package/src/cli/provider.ts +116 -64
  132. package/src/cli/registry.ts +90 -24
  133. package/src/cli/remote-workspace-hub.ts +83 -0
  134. package/src/cli/remote-workspace.ts +6 -1
  135. package/src/cli/restart-failure.ts +27 -0
  136. package/src/cli/root.ts +42 -9
  137. package/src/cli/route-policy-write.ts +137 -0
  138. package/src/cli/route-policy.ts +8 -0
  139. package/src/cli/runtime-api.ts +57 -80
  140. package/src/cli/secret-args.ts +149 -0
  141. package/src/cli/settings-result.ts +30 -0
  142. package/src/cli/storage.ts +77 -7
  143. package/src/cli/system-command.ts +60 -2
  144. package/src/cli/system-restart-client.ts +20 -3
  145. package/src/cli/system-settings-parity.ts +83 -0
  146. package/src/cli/tray-proxy.ts +3 -2
  147. package/src/cli/update-restart-candidate.ts +21 -0
  148. package/src/cli/update-restart-child.ts +81 -0
  149. package/src/cli/update-restart-home.ts +38 -0
  150. package/src/cli/update-restart-transport.ts +193 -0
  151. package/src/cli/update-restart.ts +260 -0
  152. package/src/cli/usage-model-search.ts +58 -0
  153. package/src/cli/usage-report.ts +13 -5
  154. package/src/cli/v2-input.ts +66 -0
  155. package/src/cli/v2-local-output.ts +37 -0
  156. package/src/cli/v2-runtime.ts +90 -0
  157. package/src/cli/v2.ts +102 -144
  158. package/src/clients/config-export/contracts.ts +100 -0
  159. package/src/clients/config-export/droid.ts +54 -2
  160. package/src/clients/config-export/dsh.ts +21 -0
  161. package/src/clients/config-export/kilo.ts +12 -2
  162. package/src/clients/config-export/model-metadata.ts +10 -4
  163. package/src/clients/config-export/reasoning-metadata.ts +47 -0
  164. package/src/clients/config-export.ts +53 -67
  165. package/src/clients/omo-role-models.ts +17 -3
  166. package/src/codex/account-credit-use.ts +4 -2
  167. package/src/codex/account-runtime-state.ts +21 -0
  168. package/src/codex/account-store.ts +11 -47
  169. package/src/codex/account-usability.ts +11 -8
  170. package/src/codex/auth-api/account-list.ts +73 -63
  171. package/src/codex/auth-api/main-account-probe.ts +25 -12
  172. package/src/codex/auth-api/pool-mode-gate.ts +6 -9
  173. package/src/codex/auth-api/pool-quota-probe.ts +2 -4
  174. package/src/codex/auth-api/reset-credit-service.ts +3 -2
  175. package/src/codex/auth-context.ts +30 -11
  176. package/src/codex/catalog/aggregation.ts +3 -0
  177. package/src/codex/catalog/build-entries.ts +1 -1
  178. package/src/codex/catalog/bundled.ts +9 -2
  179. package/src/codex/catalog/heal-observation.ts +66 -0
  180. package/src/codex/catalog/model-hints.ts +2 -1
  181. package/src/codex/catalog/model-visibility.ts +6 -3
  182. package/src/codex/catalog/parsing.ts +2 -0
  183. package/src/codex/catalog/provider-models.ts +13 -4
  184. package/src/codex/catalog/publication-observer.ts +19 -0
  185. package/src/codex/catalog/remote.ts +13 -15
  186. package/src/codex/catalog/restore.ts +19 -6
  187. package/src/codex/catalog/retained-sync.ts +60 -11
  188. package/src/codex/catalog/routed-removal.ts +116 -0
  189. package/src/codex/catalog/write-audit-contract.ts +21 -0
  190. package/src/codex/catalog/write-audit.ts +171 -0
  191. package/src/codex/catalog-self-heal.ts +284 -0
  192. package/src/codex/catalog-write-serialization.ts +71 -7
  193. package/src/codex/chatgpt-refresh-failure.ts +56 -0
  194. package/src/codex/codex-home-owner.ts +124 -0
  195. package/src/codex/convergence.ts +57 -16
  196. package/src/codex/features.ts +8 -1
  197. package/src/codex/inject/restore.ts +46 -4
  198. package/src/codex/inject.ts +11 -1
  199. package/src/codex/internal/catalog-writer.ts +120 -13
  200. package/src/codex/journal.ts +49 -13
  201. package/src/codex/main-account.ts +144 -11
  202. package/src/codex/management-convergence.ts +3 -0
  203. package/src/codex/quota-refresh-outcome.ts +17 -2
  204. package/src/codex/quota-types.ts +36 -1
  205. package/src/codex/quota.ts +67 -16
  206. package/src/codex/refresh.ts +20 -9
  207. package/src/codex/routing/cooldown-math.ts +10 -4
  208. package/src/codex/routing/selection.ts +11 -4
  209. package/src/codex/routing.ts +9 -9
  210. package/src/codex/runtime.ts +7 -1
  211. package/src/codex/shim-diagnostics.ts +80 -0
  212. package/src/codex/shim-fingerprint.ts +7 -2
  213. package/src/codex/shim-migration.ts +153 -0
  214. package/src/codex/shim-overlay.ts +509 -0
  215. package/src/codex/shim-path-resolution.ts +145 -0
  216. package/src/codex/shim-probe.ts +4 -3
  217. package/src/codex/shim-state-file.ts +93 -7
  218. package/src/codex/shim.ts +54 -107
  219. package/src/codex/subagent-model-fallback.ts +2 -1
  220. package/src/codex/sync.ts +24 -4
  221. package/src/combos/failover.ts +101 -1
  222. package/src/combos/index.ts +1 -0
  223. package/src/combos/jev.ts +13 -3
  224. package/src/combos/resolve.ts +12 -0
  225. package/src/config/atomic-write.ts +13 -8
  226. package/src/config/diagnostics.ts +23 -1
  227. package/src/config/load-degrade.ts +7 -0
  228. package/src/config/provider-validation.ts +3 -0
  229. package/src/config/schema/chatgpt-desktop.ts +22 -0
  230. package/src/config/schema/compaction-triggers.ts +8 -0
  231. package/src/config/schema/config-schema.ts +43 -14
  232. package/src/config/schema/leaf-validators.ts +8 -3
  233. package/src/config/subagent-models.ts +12 -0
  234. package/src/generated/compatibility-version.json +645 -237
  235. package/src/grok/session-identity.ts +33 -0
  236. package/src/integrations/catalog-refresh.ts +1 -1
  237. package/src/integrations/merge.ts +13 -5
  238. package/src/integrations/mutation-plan.ts +50 -5
  239. package/src/integrations/omp-yaml-source.ts +181 -1
  240. package/src/integrations/ownership-policy.ts +66 -0
  241. package/src/integrations/registry.ts +50 -1
  242. package/src/integrations/state.ts +106 -19
  243. package/src/integrations/target.ts +25 -2
  244. package/src/integrations/writer.ts +107 -35
  245. package/src/lib/gui-pair-capability.ts +17 -0
  246. package/src/lib/package-tree-integrity.ts +42 -1
  247. package/src/lib/provider-client-headers.ts +62 -0
  248. package/src/lib/proxy-env.ts +3 -0
  249. package/src/lib/test-home-guard.ts +2 -1
  250. package/src/oauth/anthropic-account-refusal.ts +19 -8
  251. package/src/oauth/anthropic-continuity.ts +65 -0
  252. package/src/oauth/anthropic-identity.ts +75 -0
  253. package/src/oauth/anthropic-routing.ts +20 -15
  254. package/src/oauth/anthropic.ts +34 -3
  255. package/src/oauth/generic-account-failover.ts +21 -0
  256. package/src/oauth/health.ts +32 -5
  257. package/src/oauth/index.ts +42 -35
  258. package/src/oauth/local-token-detect.ts +7 -0
  259. package/src/oauth/pool-settings-capability.ts +9 -2
  260. package/src/oauth/refresh-policy.ts +2 -0
  261. package/src/oauth/store.ts +55 -6
  262. package/src/oauth/types.ts +9 -0
  263. package/src/protocols/encoders/messages.ts +4 -3
  264. package/src/protocols/plan-snapshot.ts +1 -1
  265. package/src/protocols/settings.ts +28 -4
  266. package/src/providers/antigravity-effort-families.ts +75 -0
  267. package/src/providers/antigravity-models.ts +54 -16
  268. package/src/providers/antigravity-wire-snapshot.ts +66 -0
  269. package/src/providers/derive.ts +2 -0
  270. package/src/providers/model-rename-fields.ts +3 -0
  271. package/src/providers/new-model-policy.ts +48 -10
  272. package/src/providers/registry/entries-extended.ts +3 -0
  273. package/src/providers/registry/model-ids.ts +2 -0
  274. package/src/providers/registry/model-seeds.ts +5 -0
  275. package/src/providers/registry/types.ts +4 -0
  276. package/src/responses/compaction.ts +19 -0
  277. package/src/router.ts +6 -0
  278. package/src/server/auth-cors.ts +9 -0
  279. package/src/server/caller-session-identity.ts +32 -0
  280. package/src/server/chat-completions.ts +10 -0
  281. package/src/server/claude-messages.ts +29 -15
  282. package/src/server/droid-reasoning-default.ts +50 -0
  283. package/src/server/github-copilot-responses-repair.ts +3 -6
  284. package/src/server/grok-responses-control-frame.ts +2 -2
  285. package/src/server/grok-responses-snapshot-repair.ts +4 -5
  286. package/src/server/gui-session.ts +33 -11
  287. package/src/server/index/claude-intercept-lifecycle.ts +4 -0
  288. package/src/server/index/serve-options.ts +13 -7
  289. package/src/server/index.ts +1 -1
  290. package/src/server/management/agent-settings-routes.ts +12 -134
  291. package/src/server/management/config-routes.ts +1 -1
  292. package/src/server/management/context.ts +8 -1
  293. package/src/server/management/cursor-integration-routes.ts +3 -3
  294. package/src/server/management/integration-routes.ts +69 -11
  295. package/src/server/management/link-routes.ts +20 -13
  296. package/src/server/management/model-routes.ts +3 -3
  297. package/src/server/management/model-row-export-metadata.ts +150 -0
  298. package/src/server/management/model-rows.ts +148 -9
  299. package/src/server/management/native-integration-routes.ts +2 -2
  300. package/src/server/management/oauth-account-routes.ts +96 -30
  301. package/src/server/management/route-registry.ts +29 -27
  302. package/src/server/management/shared.ts +19 -5
  303. package/src/server/management/subagent-model-routes.ts +214 -0
  304. package/src/server/messages-native-eligibility.ts +3 -6
  305. package/src/server/messages-native-oauth.ts +82 -35
  306. package/src/server/messages-native.ts +78 -16
  307. package/src/server/relay.ts +14 -44
  308. package/src/server/request-log-generation-window.ts +38 -0
  309. package/src/server/request-log.ts +13 -0
  310. package/src/server/responses/adapter-dispatch.ts +152 -28
  311. package/src/server/responses/codex-auth-error.ts +10 -2
  312. package/src/server/responses/combo-stream-preflight.ts +29 -3
  313. package/src/server/responses/compaction-routing.ts +10 -1
  314. package/src/server/responses/core-auth.ts +67 -30
  315. package/src/server/responses/core-codex-account.ts +6 -3
  316. package/src/server/responses/core-combo-failure.ts +5 -1
  317. package/src/server/responses/core-combo-native.ts +7 -1
  318. package/src/server/responses/core-combo.ts +64 -7
  319. package/src/server/responses/core-normalize.ts +7 -0
  320. package/src/server/responses/core-options.ts +5 -0
  321. package/src/server/responses/fetch-helpers.ts +9 -0
  322. package/src/server/responses/passthrough-delivery.ts +1 -1
  323. package/src/server/responses/policy-fallback.ts +1 -1
  324. package/src/server/responses/request-prepare.ts +5 -0
  325. package/src/server/responses/request-spend.ts +30 -30
  326. package/src/server/responses-custom-tool-repair.ts +2 -2
  327. package/src/server/responses-hosted-image-display.ts +70 -9
  328. package/src/server/responses-terminal-repair.ts +15 -2
  329. package/src/server/sse-frame-buffer.ts +38 -36
  330. package/src/server/sse-payload-rewrite.ts +78 -49
  331. package/src/server/ws-bridge.ts +1 -11
  332. package/src/service/cli.ts +6 -1
  333. package/src/service/ownership-mutation-lease.d.mts +26 -0
  334. package/src/service/ownership-mutation-lease.mjs +76 -1
  335. package/src/service/windows-taskxml.ts +1 -2
  336. package/src/service-manager-probe.ts +36 -2
  337. package/src/types/config.ts +6 -0
  338. package/src/types/provider.ts +28 -10
  339. package/src/update/index.ts +2 -1
  340. package/src/update/npm-invocation.mjs +35 -3
  341. package/src/update/proxy-liveness-probe.mjs +38 -8
  342. package/src/update/update-failure-guidance.d.mts +6 -0
  343. package/src/update/update-failure-guidance.mjs +30 -5
  344. package/src/usage/expected-prices.ts +9 -0
  345. package/src/usage/log.ts +7 -0
  346. package/src/usage/summary.ts +82 -1
  347. package/src/web-search/loop.ts +11 -8
  348. package/src/web-search/run-turn-loop.ts +10 -7
  349. package/gui/dist/assets/App-CddorHdA.js +0 -51
  350. package/gui/dist/assets/App-DMudtISW.css +0 -1
  351. package/gui/dist/assets/index-Btze1I3T.css +0 -1
  352. package/gui/dist/assets/index-CvnY7E6i.js +0 -86
  353. package/gui/dist/assets/quota-summary-B_AWpHfP.js +0 -1
@@ -0,0 +1,88 @@
1
+ import { JEV_MAX_CANDIDATE_FIELD_CHARS } from "../combos/types";
2
+ import { isCodexReasoningEffort } from "../reasoning-effort";
3
+ import { readJsonInput } from "./json-input";
4
+ import { CliUsageError, takeOptionWithSyntax, type RuntimeApiDeps } from "./runtime-api";
5
+
6
+ type ComboInput = {
7
+ targets?: Array<Record<string, unknown>>;
8
+ imageInput?: "auto" | "disabled";
9
+ reasoningEffortMode?: "strict" | "adaptive";
10
+ nativeAlias?: boolean;
11
+ };
12
+ const TARGET_KEYS = new Set(["provider", "model", "weight", "reasoningEfforts", "modelProfile", "lastResort"]);
13
+
14
+ export function parseComboTargets(value: unknown): Array<Record<string, unknown>> {
15
+ if (!Array.isArray(value) || value.length === 0) throw new CliUsageError("Targets input must be a nonempty array");
16
+ return value.map(target => {
17
+ if (target === null || typeof target !== "object" || Array.isArray(target)
18
+ || Object.keys(target).some(key => !TARGET_KEYS.has(key))) {
19
+ throw new CliUsageError("Targets input contains an invalid object or unsupported field");
20
+ }
21
+ for (const key of ["provider", "model"]) {
22
+ if (typeof target[key] !== "string" || !target[key].trim() || /[\u0000-\u001f\u007f]/u.test(target[key])) {
23
+ throw new CliUsageError("Each target requires a nonblank provider and raw model ID without control characters");
24
+ }
25
+ }
26
+ if (Object.hasOwn(target, "weight") && (typeof target.weight !== "number"
27
+ || !Number.isInteger(target.weight) || target.weight < 1 || target.weight > 10_000)) {
28
+ throw new CliUsageError("Target weight must be an integer from 1 to 10000");
29
+ }
30
+ if (Object.hasOwn(target, "reasoningEfforts") && (!Array.isArray(target.reasoningEfforts)
31
+ || target.reasoningEfforts.length === 0
32
+ || target.reasoningEfforts.some((effort: unknown) => typeof effort !== "string" || !isCodexReasoningEffort(effort))
33
+ || new Set(target.reasoningEfforts).size !== target.reasoningEfforts.length)) {
34
+ throw new CliUsageError("Target reasoningEfforts must contain unique low, medium, high, xhigh, max or ultra values");
35
+ }
36
+ if (Object.hasOwn(target, "modelProfile") && (typeof target.modelProfile !== "string"
37
+ || !target.modelProfile.trim() || target.modelProfile.length > JEV_MAX_CANDIDATE_FIELD_CHARS
38
+ || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(target.modelProfile))) {
39
+ throw new CliUsageError("Target modelProfile must be nonblank, at most 512 characters, with no controls except tab or newline");
40
+ }
41
+ if (Object.hasOwn(target, "lastResort") && typeof target.lastResort !== "boolean") {
42
+ throw new CliUsageError("Target lastResort must be a boolean");
43
+ }
44
+ // Preserve raw slashes, target order, explicit false and omitted metadata.
45
+ return { ...target };
46
+ });
47
+ }
48
+
49
+ function takeNativeAlias(args: string[]): boolean | undefined {
50
+ const flag = "--native-alias";
51
+ const positions = args.flatMap((value, index) => value === flag || value.startsWith(`${flag}=`) ? [index] : []);
52
+ if (positions.length === 0) return undefined;
53
+ if (positions.length !== 1) throw new CliUsageError("--native-alias was given more than once");
54
+ const index = positions[0]!;
55
+ const token = args[index]!;
56
+ const inline = token !== flag;
57
+ const next = inline ? token.slice(flag.length + 1) : args[index + 1];
58
+ if (!inline && (next === undefined || next.startsWith("--"))) {
59
+ args.splice(index, 1);
60
+ return true;
61
+ }
62
+ if (next !== "on" && next !== "off") throw new CliUsageError("--native-alias accepts on, off, or the legacy bare flag");
63
+ args.splice(index, inline ? 1 : 2);
64
+ return next === "on";
65
+ }
66
+
67
+ /** Consume only extension options, before the parent set flow resolves a target or reads HTTP. */
68
+ export async function prepareComboInput(args: string[], deps: RuntimeApiDeps = {}): Promise<ComboInput> {
69
+ const file = takeOptionWithSyntax(args, "--targets-file")?.value;
70
+ const image = takeOptionWithSyntax(args, "--image-input")?.value;
71
+ const reasoning = takeOptionWithSyntax(args, "--reasoning-effort-mode")?.value;
72
+ const nativeAlias = takeNativeAlias(args);
73
+ if (file !== undefined && args.some(arg => arg === "--targets" || arg.startsWith("--targets="))) {
74
+ throw new CliUsageError("--targets-file cannot be combined with --targets");
75
+ }
76
+ if (image !== undefined && image !== "auto" && image !== "disabled") {
77
+ throw new CliUsageError("--image-input must be auto or disabled");
78
+ }
79
+ if (reasoning !== undefined && reasoning !== "strict" && reasoning !== "adaptive") {
80
+ throw new CliUsageError("--reasoning-effort-mode must be strict or adaptive");
81
+ }
82
+ return {
83
+ ...(file === undefined ? {} : { targets: parseComboTargets(await readJsonInput(file, deps)) }),
84
+ ...(image === undefined ? {} : { imageInput: image }),
85
+ ...(reasoning === undefined ? {} : { reasoningEffortMode: reasoning }),
86
+ ...(nativeAlias === undefined ? {} : { nativeAlias }),
87
+ };
88
+ }
@@ -0,0 +1,52 @@
1
+ import { parseComboTargets } from "./combo-input";
2
+ import { printCatalogResult } from "./catalog-command-result";
3
+
4
+ const ENUMS: Readonly<Record<string, readonly (string | null)[]>> = {
5
+ strategy: ["failover", "round-robin", "random", "least-used", "reset-window", "jev"],
6
+ defaultEffort: [null, "low", "medium", "high", "xhigh", "max", "ultra"],
7
+ defaultEffortMode: ["fallback", "force"], reasoningEffortMode: ["strict", "adaptive"],
8
+ imageInput: ["auto", "disabled"], cooldownWaitPolicy: ["before-last-resort"],
9
+ };
10
+ const STRINGS = new Set(["alias", "displayName", "decisionProvider", "decisionModel"]);
11
+ const RANGES: Readonly<Record<string, readonly [number, number]>> = {
12
+ stickyLimit: [1, 100], cooldownMs: [1, 600_000], waitForCooldownMs: [0, 600_000],
13
+ decisionTimeoutMs: [1_000, 120_000],
14
+ };
15
+ function record(value: unknown): value is Record<string, unknown> {
16
+ return value !== null && typeof value === "object" && !Array.isArray(value)
17
+ && [Object.prototype, null].includes(Object.getPrototypeOf(value));
18
+ }
19
+ function invalid(): never { throw new Error("Invalid combo receipt"); }
20
+
21
+ /** Shape/field ownership only; target graph/alias semantics remain on the server. */
22
+ function publicCombo(value: unknown): Record<string, unknown> {
23
+ if (!record(value)) invalid();
24
+ let targets: Array<Record<string, unknown>>;
25
+ try { targets = parseComboTargets(value.targets); } catch { return invalid(); }
26
+ const result: Record<string, unknown> = { targets };
27
+ for (const [key, child] of Object.entries(value)) {
28
+ if (key === "targets") continue;
29
+ if (Object.hasOwn(ENUMS, key)) {
30
+ if (!ENUMS[key]!.includes(child as string | null)) invalid();
31
+ } else if (STRINGS.has(key)) {
32
+ if (typeof child !== "string" || child.length > 4096) invalid();
33
+ } else if (Object.hasOwn(RANGES, key)) {
34
+ const [min, max] = RANGES[key]!;
35
+ if (typeof child !== "number" || !Number.isSafeInteger(child) || child < min || child > max) invalid();
36
+ } else if (key === "nativeAlias") {
37
+ if (typeof child !== "boolean") invalid();
38
+ } else invalid();
39
+ result[key] = child;
40
+ }
41
+ return result;
42
+ }
43
+
44
+ export function printComboWriteResult(value: unknown, id: string, wantsJson: boolean): number {
45
+ if (!record(value) || value.success !== true || value.id !== id || typeof value.model !== "string"
46
+ || Object.keys(value).some(key => !["success", "id", "model", "combo", "catalogRefresh"].includes(key))) invalid();
47
+ const combo = publicCombo(value.combo);
48
+ const model = typeof combo.alias === "string" && combo.alias.trim() ? combo.alias.trim() : `combo/${id}`;
49
+ if (value.model !== model) invalid();
50
+ return printCatalogResult({ success: true, id, model: value.model, combo }, value.catalogRefresh,
51
+ wantsJson, [`Saved combo ${id}.`]);
52
+ }
@@ -0,0 +1,123 @@
1
+ import { JEV_DECISION_GATES } from "../usage/jev-stats";
2
+ import { runCatalogAction } from "./catalog-command-result";
3
+ import { CliUsageError, printData, runtimeBaseUrl, runtimeRequest, takeFlag, takeOptionWithSyntax, type RuntimeApiDeps } from "./runtime-api";
4
+
5
+ const USAGE = "Usage: ocx combo stats <stored-id> [--range <7d|30d|all>] [--json]";
6
+ const SUMMARY_COUNTS = [
7
+ "decisions", "appliedDecisions", "failOpenDecisions", "successfulRequests", "requestsWithModelFallback",
8
+ "modelAttempts", "measuredModelAttempts", "modelInputTokens", "modelOutputTokens", "modelReasoningTokens",
9
+ "modelCacheReadTokens", "modelCacheWriteTokens", "modelTotalTokens", "decisionUsageReported",
10
+ "decisionInputTokens", "decisionOutputTokens", "decisionTotalTokens",
11
+ ] as const;
12
+ const MODEL_COUNTS = [
13
+ "picks", "appliedPicks", "failOpenPicks", "attempts", "measuredAttempts", "inputTokens", "outputTokens",
14
+ "reasoningTokens", "cacheReadTokens", "cacheWriteTokens", "totalTokens",
15
+ ] as const;
16
+ const EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultra"];
17
+
18
+ function invalid(): never { throw new Error("Invalid combo statistics response"); }
19
+ function record(value: unknown): Record<string, unknown> {
20
+ if (!value || typeof value !== "object" || Array.isArray(value)) return invalid();
21
+ return value as Record<string, unknown>;
22
+ }
23
+ function number(value: unknown): number {
24
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) return invalid();
25
+ return value;
26
+ }
27
+ function nullable(value: unknown, max = Number.MAX_SAFE_INTEGER): number | null {
28
+ if (value === null) return null;
29
+ const result = number(value);
30
+ return result <= max ? result : invalid();
31
+ }
32
+ function boolean(value: unknown): boolean {
33
+ return typeof value === "boolean" ? value : invalid();
34
+ }
35
+ function identity(value: unknown, empty = false): string {
36
+ if (typeof value !== "string" || (!empty && !value.trim()) || value.length > 512
37
+ || /[\u0000-\u001f\u007f]/u.test(value)) return invalid();
38
+ return value;
39
+ }
40
+ function counts<K extends string>(value: Record<string, unknown>, keys: readonly K[]): Record<K, number> {
41
+ // Every requested key is assigned only after numeric validation. fromEntries
42
+ // loses the literal-key set in its library type, so retain that set here.
43
+ return Object.fromEntries(keys.map(key => [key, number(value[key])])) as Record<K, number>;
44
+ }
45
+ function rows(value: unknown): Array<Record<string, unknown>> {
46
+ return Array.isArray(value) ? value.map(record) : invalid();
47
+ }
48
+
49
+ /** Rebuild only the real JEV wire contract; never expose unknown server fields. */
50
+ function projectStats(value: unknown, id: string, range: string) {
51
+ const dto = record(value);
52
+ if (dto.comboId !== id || dto.range !== range) return invalid();
53
+ const rawSummary = record(dto.summary);
54
+ const summary = {
55
+ ...counts(rawSummary, SUMMARY_COUNTS),
56
+ averageLatencyMs: nullable(rawSummary.averageLatencyMs),
57
+ averageConfidence: nullable(rawSummary.averageConfidence, 1),
58
+ averageChosenProbability: nullable(rawSummary.averageChosenProbability, 1),
59
+ };
60
+ const gates = rows(dto.gates).map(row => {
61
+ if (!(JEV_DECISION_GATES as readonly unknown[]).includes(row.gate)) return invalid();
62
+ return { gate: row.gate as string, decisions: number(row.decisions) };
63
+ });
64
+ const backends = rows(dto.backends).map(row => {
65
+ if (!["typesafe", "systemone", "model", "unknown"].includes(String(row.backend))) return invalid();
66
+ return { backend: row.backend as string, decisions: number(row.decisions), applied: number(row.applied), averageLatencyMs: nullable(row.averageLatencyMs) };
67
+ });
68
+ const models = rows(dto.models).map(row => {
69
+ const overflow = boolean(row.overflow);
70
+ const efforts = rows(row.efforts).map(item => {
71
+ if (item.effort !== null && (typeof item.effort !== "string" || !EFFORTS.includes(item.effort))) return invalid();
72
+ return { effort: item.effort as string | null, picks: number(item.picks) };
73
+ });
74
+ return { provider: identity(row.provider, overflow), model: identity(row.model, overflow), overflow, ...counts(row, MODEL_COUNTS), efforts };
75
+ });
76
+ const incomplete: { usageIncomplete?: boolean; usageIncompleteReason?: "oversized_rows" } = {};
77
+ if (Object.hasOwn(dto, "usageIncomplete")) incomplete.usageIncomplete = boolean(dto.usageIncomplete);
78
+ if (Object.hasOwn(dto, "usageIncompleteReason")) {
79
+ if (dto.usageIncompleteReason !== "oversized_rows" || dto.usageIncomplete !== true) return invalid();
80
+ incomplete.usageIncompleteReason = "oversized_rows";
81
+ }
82
+ if (dto.usageIncomplete === true && incomplete.usageIncompleteReason === undefined) return invalid();
83
+ return {
84
+ range, comboId: id, since: nullable(dto.since),
85
+ ...(Object.hasOwn(dto, "until") ? { until: number(dto.until) } : {}),
86
+ generatedAt: number(dto.generatedAt), summary, gates, backends, models,
87
+ snapshotWindowStart: nullable(dto.snapshotWindowStart), snapshotWindowEnd: nullable(dto.snapshotWindowEnd),
88
+ ...incomplete, historyTruncated: boolean(dto.historyTruncated), truncatedPrefixBytes: number(dto.truncatedPrefixBytes),
89
+ entriesTruncated: boolean(dto.entriesTruncated), entriesDropped: number(dto.entriesDropped),
90
+ };
91
+ }
92
+
93
+ export async function handleComboStatsCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise<number> {
94
+ return runCatalogAction(async () => {
95
+ const args = [...argv];
96
+ const id = args.shift();
97
+ const wantsJson = takeFlag(args, "--json");
98
+ const range = takeOptionWithSyntax(args, "--range")?.value ?? "30d";
99
+ // Do not normalize, truncate or resolve a public alias to another stored ID.
100
+ if (!id || id.length > 128 || id.trim() !== id || id.startsWith("--") || /[\u0000-\u001f\u007f]/u.test(id)) {
101
+ throw new CliUsageError("A nonblank exact stored combo ID of at most 128 characters is required", USAGE);
102
+ }
103
+ if (!["7d", "30d", "all"].includes(range)) throw new CliUsageError("--range must be 7d, 30d or all", USAGE);
104
+ if (args.length) throw new CliUsageError("Unexpected combo stats argument(s)", USAGE);
105
+ const pinned = { ...deps, baseUrl: await runtimeBaseUrl(deps) };
106
+ const query = new URLSearchParams({ jev: "1", comboId: id, range });
107
+ const data = projectStats(await runtimeRequest(`/api/usage?${query}`, { redirect: "error" }, pinned), id, range);
108
+ const summary = data.summary;
109
+ const incomplete = data.usageIncomplete || data.historyTruncated || data.entriesTruncated;
110
+ printData(data, wantsJson, [
111
+ `Combo ${id} — JEV observations (${range})`,
112
+ ...(summary.decisions === 0 ? ["No recorded JEV decisions in this window."] : []),
113
+ `Decisions: ${summary.decisions}; applied: ${summary.appliedDecisions}; fail-open: ${summary.failOpenDecisions}`,
114
+ `Successful requests: ${summary.successfulRequests}; requests with model fallback: ${summary.requestsWithModelFallback}`,
115
+ `Model tokens: ${summary.modelTotalTokens}; measured attempts: ${summary.measuredModelAttempts}/${summary.modelAttempts}`,
116
+ `Decision tokens: ${summary.decisionTotalTokens}; decisions reporting usage: ${summary.decisionUsageReported}/${summary.decisions}`,
117
+ `Average decision latency: ${summary.averageLatencyMs === null ? "unavailable" : `${summary.averageLatencyMs} ms`}`,
118
+ `Average confidence: ${summary.averageConfidence ?? "unavailable"}; chosen probability: ${summary.averageChosenProbability ?? "unavailable"}`,
119
+ ...(incomplete ? ["History is incomplete; totals cover only observed records."] : []),
120
+ ]);
121
+ return 0;
122
+ });
123
+ }
package/src/cli/combo.ts CHANGED
@@ -5,22 +5,30 @@ import {
5
5
  rejectArgs,
6
6
  runCliAction,
7
7
  runtimeRequest,
8
+ runtimeBaseUrl,
8
9
  takeFlag,
9
10
  takeOption,
10
11
  type RuntimeApiDeps,
11
12
  } from "./runtime-api";
12
13
 
14
+ import { prepareComboInput } from "./combo-input";
15
+ import { printComboWriteResult } from "./combo-result";
16
+ import { runCatalogAction } from "./catalog-command-result";
17
+ import { serializeManagementJson } from "./json-input";
18
+
13
19
  const USAGE = `Usage:
14
20
  ocx combo [list] [--json]
15
21
  ocx combo show <id> [--json]
16
- ocx combo set <id> [--targets <provider/model[:weight],...>]
22
+ ocx combo set <id> [--targets <provider/model[:weight],...> | --targets-file <FILE|->]
17
23
  [--strategy <failover|round-robin|random|least-used|reset-window|jev>] [--sticky <1-100|->]
18
24
  [--effort <low|medium|high|xhigh|max|ultra|->] [--effort-mode <fallback|force|->]
19
25
  (force overrides valid client effort and can increase cost/latency) [--alias <name|->]
20
- [--native-alias] [--display-name <label|->]
26
+ [--native-alias [on|off]] [--display-name <label|->]
27
+ [--image-input <auto|disabled>] [--reasoning-effort-mode <strict|adaptive>]
21
28
  [--decision-provider <provider|-> | --decision-model <route|->] [--decision-timeout <ms|->]
22
29
  (jev only; the provider must be a configured jev-decision row)
23
30
  [--rename-from <id>] [--json]
31
+ ocx combo stats <id> [--range <7d|30d|all>] [--json]
24
32
  ocx combo remove <id> --yes [--json]
25
33
  ocx combo test [--combo <id>] [--decision-provider <provider|jev> | --decision-model <route>]
26
34
  [--decision-timeout <ms>] [--json]
@@ -72,18 +80,22 @@ async function show(argv: string[], deps: RuntimeApiDeps): Promise<void> {
72
80
  printData(combo, wantsJson);
73
81
  }
74
82
 
75
- async function set(argv: string[], deps: RuntimeApiDeps): Promise<void> {
83
+ async function set(argv: string[], deps: RuntimeApiDeps): Promise<number> {
76
84
  const args = [...argv];
77
85
  const id = args.shift()?.trim();
78
86
  const wantsJson = takeFlag(args, "--json");
79
87
  if (!id) throw new CliUsageError("combo id is required", USAGE);
88
+ const additional = await prepareComboInput(args, deps);
80
89
  const targetsRaw = takeOption(args, "--targets");
81
90
  const renameFrom = takeOption(args, "--rename-from");
91
+ const replacingTargets = targetsRaw !== undefined || additional.targets !== undefined;
92
+ deps = { ...deps, baseUrl: await runtimeBaseUrl(deps) };
93
+ // A structured target file replaces the target list, not unrelated combo settings.
82
94
  const partialCurrent = targetsRaw === undefined
83
- ? await runtimeRequest<{ combos?: ComboRow[] }>("/api/combos", {}, deps)
95
+ ? await runtimeRequest<{ combos?: ComboRow[] }>("/api/combos", { redirect: "error" }, deps)
84
96
  : undefined;
85
97
  const partialExisting = partialCurrent?.combos?.find(row => row.id === (renameFrom ?? id));
86
- if (targetsRaw === undefined && !partialExisting) {
98
+ if (!replacingTargets && !partialExisting) {
87
99
  throw new CliUsageError("--targets is required when creating a combo", USAGE);
88
100
  }
89
101
  const strategyRaw = takeOption(args, "--strategy");
@@ -104,7 +116,7 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise<void> {
104
116
  throw new CliUsageError("--effort-mode must be fallback, force, or -", USAGE);
105
117
  }
106
118
  const alias = takeOption(args, "--alias");
107
- const nativeAlias = takeFlag(args, "--native-alias");
119
+
108
120
  const displayName = takeOption(args, "--display-name");
109
121
  const decisionProvider = takeOption(args, "--decision-provider");
110
122
  const decisionModel = takeOption(args, "--decision-model");
@@ -138,15 +150,18 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise<void> {
138
150
  // Listing-only nulls must not be resent as explicit clears (alias rejects null).
139
151
  const combo: Record<string, unknown> = partialExisting
140
152
  ? Object.fromEntries(Object.entries(partialExisting).filter(([, value]) => value !== null))
141
- : { strategy, stickyLimit: stickyLimit ?? 1, targets: parseTargets(targetsRaw!) };
153
+ : { strategy, stickyLimit: stickyLimit ?? 1, targets: additional.targets ?? parseTargets(targetsRaw!) };
142
154
  delete combo.id;
143
155
  delete combo.model;
156
+ if (additional.targets !== undefined) combo.targets = additional.targets;
144
157
  if (strategyRaw !== undefined) combo.strategy = strategy;
145
158
  if (stickyLimit !== undefined) combo.stickyLimit = stickyLimit;
146
159
  if (effort !== undefined) combo.defaultEffort = effort === "-" ? null : effort;
147
160
  if (effortMode !== undefined) combo.defaultEffortMode = effortMode === "-" ? "fallback" : effortMode;
148
161
  if (alias !== undefined) combo.alias = alias === "-" ? "" : alias;
149
- if (nativeAlias) combo.nativeAlias = true;
162
+ if (additional.nativeAlias !== undefined) combo.nativeAlias = additional.nativeAlias;
163
+ if (additional.imageInput !== undefined) combo.imageInput = additional.imageInput;
164
+ if (additional.reasoningEffortMode !== undefined) combo.reasoningEffortMode = additional.reasoningEffortMode;
150
165
  if (displayName !== undefined) combo.displayName = displayName === "-" ? "" : displayName;
151
166
  if (decisionProvider !== undefined) combo.decisionProvider = decisionProvider === "-" ? null : decisionProvider;
152
167
  if (decisionModel !== undefined) combo.decisionModel = decisionModel === "-" ? null : decisionModel;
@@ -158,17 +173,20 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise<void> {
158
173
  delete combo.decisionModel;
159
174
  delete combo.decisionTimeoutMs;
160
175
  }
161
- const current = partialCurrent ?? await runtimeRequest<{ combos?: ComboRow[] }>("/api/combos", {}, deps);
176
+ const current = partialCurrent ?? await runtimeRequest<{ combos?: ComboRow[] }>("/api/combos", { redirect: "error" }, deps);
162
177
  const existing = (current.combos ?? []).find(row => row.id === (renameFrom ?? id));
163
- if (existing?.imageInput === "disabled") combo.imageInput = "disabled";
178
+ if (additional.imageInput === undefined && existing?.imageInput === "disabled") combo.imageInput = "disabled";
179
+ if (effort === undefined && existing?.defaultEffort != null && combo.defaultEffort === undefined) {
180
+ combo.defaultEffort = existing.defaultEffort;
181
+ }
164
182
  if (effortMode === undefined && existing?.defaultEffortMode === "force") {
165
183
  combo.defaultEffortMode = effort === "-" ? "fallback" : "force";
166
184
  }
167
185
  const result = await runtimeRequest("/api/combos", {
168
- method: "PUT",
169
- body: JSON.stringify({ id, combo, ...(renameFrom ? { renameFrom } : {}) }),
186
+ method: "PUT", redirect: "error",
187
+ body: serializeManagementJson({ id, combo, ...(renameFrom ? { renameFrom } : {}) }),
170
188
  }, deps);
171
- printData(result, wantsJson, [`Saved combo ${id}.`]);
189
+ return printComboWriteResult(result, id, wantsJson);
172
190
  }
173
191
 
174
192
  async function remove(argv: string[], deps: RuntimeApiDeps): Promise<void> {
@@ -249,11 +267,16 @@ async function discover(argv: string[], deps: RuntimeApiDeps): Promise<void> {
249
267
  }
250
268
 
251
269
  export async function handleComboCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise<number> {
270
+ const [subcommand, ...writeArgs] = argv;
271
+ if (subcommand === "set" || subcommand === "create" || subcommand === "update") return runCatalogAction(() => set(writeArgs, deps));
272
+ if (subcommand === "stats") {
273
+ const { handleComboStatsCommand } = await import("./combo-stats");
274
+ return handleComboStatsCommand(writeArgs, deps);
275
+ }
252
276
  return runCliAction(async () => {
253
277
  const [sub = "list", ...rest] = argv;
254
278
  if (sub === "list") await list(rest, deps);
255
279
  else if (sub === "show") await show(rest, deps);
256
- else if (sub === "set" || sub === "create" || sub === "update") await set(rest, deps);
257
280
  else if (sub === "remove" || sub === "delete") await remove(rest, deps);
258
281
  else if (sub === "test") await testDecision(rest, deps);
259
282
  else if (sub === "discover") await discover(rest, deps);
@@ -0,0 +1,142 @@
1
+ /** Read the existing usage timeline without changing companion settings. */
2
+ import { isTimelineModelId, normalizeTimelineModelId, parseTimelineQuery, type TimelineQuery, type TimelineSeries, type UsageTimeline } from "../usage/timeline";
3
+ import { runCatalogAction } from "./catalog-command-result";
4
+ import { CliUsageError, printData, runtimeRequest, takeFlag, takeOptionWithSyntax, type RuntimeApiDeps } from "./runtime-api";
5
+
6
+ const USAGE = `Usage: ocx companion timeline [--hours <6|24|72|168>] [--bucket-minutes <1..1440>]
7
+ [--metric <total|input|output|cached>] [--aggregation <sum|average|max>]
8
+ [--grouping <model|modelAccount>] [--model <provider/model>]... [--hide-provider <name>]... [--json]
9
+ Providers are excluded with --hide-provider; --provider is not supported.`;
10
+
11
+ function repeatedOption(args: string[], flag: string): string[] {
12
+ const values: string[] = [];
13
+ for (let index = 0; index < args.length;) {
14
+ const token = args[index]!;
15
+ if (token === flag) {
16
+ const next = args[index + 1];
17
+ if (!next || next.startsWith("--")) throw new CliUsageError(`${flag} requires a value`, USAGE);
18
+ values.push(next); args.splice(index, 2);
19
+ } else if (token.startsWith(`${flag}=`)) {
20
+ const value = token.slice(flag.length + 1);
21
+ if (!value) throw new CliUsageError(`${flag} requires a value`, USAGE);
22
+ values.push(value); args.splice(index, 1);
23
+ } else index++;
24
+ }
25
+ return values;
26
+ }
27
+
28
+ function timelineInput(argv: string[]): { query: TimelineQuery; params: URLSearchParams; wantsJson: boolean } {
29
+ const args = [...argv], wantsJson = takeFlag(args, "--json"), params = new URLSearchParams();
30
+ for (const [flag, name] of [
31
+ ["--hours", "hours"], ["--bucket-minutes", "bucketMinutes"], ["--metric", "metric"],
32
+ ["--aggregation", "aggregation"], ["--grouping", "grouping"],
33
+ ] as const) {
34
+ const option = takeOptionWithSyntax(args, flag);
35
+ if (option) params.set(name, option.value);
36
+ }
37
+ const models = repeatedOption(args, "--model");
38
+ // Each occurrence is one identifier, not an implicit CSV escape hatch.
39
+ if (models.some(model => !isTimelineModelId(model) || model.includes(","))) {
40
+ throw new CliUsageError("Each --model must be one provider/model identifier", USAGE);
41
+ }
42
+ if (models.length) params.set("models", models.join(","));
43
+ for (const provider of repeatedOption(args, "--hide-provider")) params.append("hiddenProvider", provider);
44
+ if (args.length) throw new CliUsageError("Unsupported timeline arguments; use --hide-provider to exclude providers", USAGE);
45
+ const query = parseTimelineQuery(params, Date.now());
46
+ if ("error" in query) throw new CliUsageError("Invalid timeline options; check window, bucket count, enums and the 100-item filter limits", USAGE);
47
+ return { query, params, wantsJson };
48
+ }
49
+
50
+ function record(value: unknown): Record<string, unknown> {
51
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid timeline object");
52
+ return value as Record<string, unknown>;
53
+ }
54
+ function number(value: unknown): number {
55
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error("Invalid timeline measurement");
56
+ return value;
57
+ }
58
+ function integer(value: unknown): number {
59
+ const result = number(value);
60
+ if (!Number.isSafeInteger(result)) throw new Error("Invalid timeline integer");
61
+ return result;
62
+ }
63
+ function text(value: unknown): string {
64
+ if (typeof value !== "string") throw new Error("Invalid timeline text");
65
+ return value;
66
+ }
67
+ function strings(value: unknown): string[] {
68
+ if (!Array.isArray(value)) throw new Error("Invalid timeline list");
69
+ return value.map(text);
70
+ }
71
+ function sameStrings(left: string[], right: string[]): boolean {
72
+ return left.length === right.length && left.every((value, index) => value === right[index]);
73
+ }
74
+
75
+ function timelineDto(raw: unknown, query: TimelineQuery): UsageTimeline {
76
+ const value = record(raw), filters = record(value.appliedFilters);
77
+ const models = filters.models === null ? null : strings(filters.models);
78
+ const hiddenProviders = strings(filters.hiddenProviders), expectedModels = query.models === null ? null : [...query.models].sort();
79
+ if ((models === null) !== (expectedModels === null)
80
+ || (models !== null && expectedModels !== null && !sameStrings(models, expectedModels))
81
+ || !sameStrings(hiddenProviders, query.hiddenProviders)) throw new Error("Timeline filters were not acknowledged");
82
+ const start = integer(value.start), end = integer(value.end), buckets = integer(value.buckets), bucketSeconds = integer(value.bucketSeconds);
83
+ const expectedBucketSeconds = query.bucketMinutes * 60;
84
+ const requestedEnd = (Math.floor(query.now / 1000 / expectedBucketSeconds) + 1) * expectedBucketSeconds;
85
+ const observedNow = Math.floor(Date.now() / 1000);
86
+ const currentWindow = end === requestedEnd
87
+ || (observedNow >= requestedEnd && end === requestedEnd + expectedBucketSeconds);
88
+ if (bucketSeconds !== query.bucketMinutes * 60 || buckets !== Math.ceil(query.hours * 60 / query.bucketMinutes)
89
+ || end <= start || end - start !== buckets * bucketSeconds || start % bucketSeconds !== 0 || end % bucketSeconds !== 0
90
+ // Match the requested current bucket, or its immediate successor after an observed rollover.
91
+ || !currentWindow || end > observedNow + bucketSeconds
92
+ || value.metric !== query.metric || value.aggregation !== query.aggregation || value.grouping !== query.grouping
93
+ || typeof value.truncated !== "boolean" || !Array.isArray(value.series) || value.series.length > 24) {
94
+ throw new Error("Invalid timeline bounds or metadata");
95
+ }
96
+ const availableModels = strings(value.availableModels);
97
+ if (availableModels.some(model => !isTimelineModelId(model)) || new Set(availableModels).size !== availableModels.length) {
98
+ throw new Error("Invalid available timeline models");
99
+ }
100
+ const selectedModels = models === null ? null : new Set(models.map(normalizeTimelineModelId));
101
+ const available = new Set(availableModels);
102
+ const series: TimelineSeries[] = value.series.map(rawSeries => {
103
+ const row = record(rawSeries), id = text(row.id), provider = text(row.provider), model = text(row.model);
104
+ const accountLogLabel = row.accountLogLabel === undefined ? undefined : text(row.accountLogLabel);
105
+ const total = number(row.total);
106
+ if (!Array.isArray(row.points) || row.points.length !== buckets) throw new Error("Invalid timeline points");
107
+ const points = row.points.map(number), pointTotal = points.reduce((sum, point) => sum + point, 0);
108
+ if (!Number.isFinite(pointTotal) || Math.abs(total - pointTotal) > 1e-9 * Math.max(1, total, pointTotal)) throw new Error("Invalid timeline total");
109
+ const other = id === "other" && provider === "" && model === "other" && accountLogLabel === undefined;
110
+ if (!other && (!isTimelineModelId(`${provider}/${model}`) || hiddenProviders.includes(provider)
111
+ || !available.has(`${provider}/${model}`) || (selectedModels !== null && !selectedModels.has(`${provider}/${model}`))
112
+ || id !== (query.grouping === "model" ? `${provider}/${model}` : `${provider}/${model} · ${accountLogLabel}`)
113
+ || (query.grouping === "modelAccount" ? accountLogLabel === undefined : accountLogLabel !== undefined))) {
114
+ throw new Error("Invalid timeline series identity");
115
+ }
116
+ return { id, provider, model, ...(accountLogLabel !== undefined ? { accountLogLabel } : {}), total, points };
117
+ });
118
+ if (new Set(series.map(row => row.id)).size !== series.length) throw new Error("Duplicate timeline series");
119
+ return { appliedFilters: { models, hiddenProviders }, start, end, bucketSeconds, buckets,
120
+ metric: query.metric, aggregation: query.aggregation, grouping: query.grouping, series, availableModels,
121
+ missingMeasurements: integer(value.missingMeasurements), truncated: value.truncated };
122
+ }
123
+
124
+ /** argv starts after `companion timeline`; numeric errors propagate through the public companion dispatcher. */
125
+ export async function handleCompanionTimelineCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise<number> {
126
+ return runCatalogAction(async () => {
127
+ const { params, query, wantsJson } = timelineInput(argv);
128
+ const result = timelineDto(await runtimeRequest(`/api/usage/timeline${params.size ? `?${params}` : ""}`,
129
+ { method: "GET", redirect: "error" }, deps), query);
130
+ printData(result, wantsJson, [
131
+ `Usage timeline: ${result.metric}, ${result.aggregation}, grouped by ${result.grouping}`,
132
+ `Window: ${new Date(result.start * 1000).toISOString()} to ${new Date(result.end * 1000).toISOString()} (end exclusive)`,
133
+ `Buckets: ${result.buckets} × ${result.bucketSeconds} seconds`,
134
+ `Models: ${result.appliedFilters.models?.join(", ") ?? "all"}; excluded providers: ${result.appliedFilters.hiddenProviders.join(", ") || "none"}`,
135
+ `Missing measurements: ${result.missingMeasurements}; truncated: ${result.truncated}`,
136
+ ...(result.missingMeasurements || result.truncated ? ["Incomplete evidence: plotted zeros do not establish zero usage."] : []),
137
+ ...(result.series.length ? result.series.map(row => `${row.id}: ${row.total} (${row.points.join(", ")})`)
138
+ : ["No matching series in the available timeline. Try a wider --hours window or fewer filters."]),
139
+ `Available models: ${result.availableModels.join(", ") || "none"}`,
140
+ ]);
141
+ });
142
+ }