@iislee/opencodex 2.31.0 → 2.35.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 (261) hide show
  1. package/README.md +3 -3
  2. package/bin/ocx.mjs +99 -70
  3. package/gui/dist/assets/index-Be1sDpWw.css +1 -0
  4. package/gui/dist/assets/index-D4OKgUV4.js +114 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +3 -1
  7. package/src/adapters/anthropic.ts +104 -15
  8. package/src/adapters/base.ts +29 -1
  9. package/src/adapters/command-code.ts +42 -6
  10. package/src/adapters/cursor/call-id.ts +44 -0
  11. package/src/adapters/cursor/catalog.ts +541 -0
  12. package/src/adapters/cursor/checkpoint-store.ts +15 -10
  13. package/src/adapters/cursor/cursor-errors.ts +15 -0
  14. package/src/adapters/cursor/discovery.ts +69 -18
  15. package/src/adapters/cursor/effort-map.ts +79 -1
  16. package/src/adapters/cursor/envelope-echo.ts +290 -0
  17. package/src/adapters/cursor/live-models.ts +7 -2
  18. package/src/adapters/cursor/live-transport.ts +14 -0
  19. package/src/adapters/cursor/message-mapper.ts +4 -1
  20. package/src/adapters/cursor/native-exec-desktop.ts +23 -0
  21. package/src/adapters/cursor/native-exec-fs.ts +2 -2
  22. package/src/adapters/cursor/native-exec-network.ts +1 -1
  23. package/src/adapters/cursor/native-exec-shell.ts +5 -2
  24. package/src/adapters/cursor/native-exec.ts +20 -0
  25. package/src/adapters/cursor/protobuf-request.ts +68 -14
  26. package/src/adapters/cursor/request-builder.ts +40 -10
  27. package/src/adapters/cursor/tool-definitions.ts +14 -4
  28. package/src/adapters/cursor/tool-result-normalize.ts +28 -5
  29. package/src/adapters/cursor/types.ts +20 -0
  30. package/src/adapters/cursor.ts +209 -38
  31. package/src/adapters/exec-tool-result-normalize.ts +99 -0
  32. package/src/adapters/google-antigravity-replay.ts +173 -25
  33. package/src/adapters/google-antigravity-wire.ts +5 -0
  34. package/src/adapters/google-errors.ts +44 -12
  35. package/src/adapters/google.ts +136 -24
  36. package/src/adapters/image.ts +1 -1
  37. package/src/adapters/kiro-constants.ts +27 -0
  38. package/src/adapters/kiro-tools.ts +55 -16
  39. package/src/adapters/kiro.ts +182 -23
  40. package/src/adapters/openai-chat.ts +300 -242
  41. package/src/adapters/openai-responses.ts +353 -27
  42. package/src/adapters/run-turn-queue.ts +36 -1
  43. package/src/adapters/tool-catalog-nudge.ts +4 -3
  44. package/src/adapters/xai-tool-schema.ts +436 -0
  45. package/src/adapters/xai-web-search.ts +17 -16
  46. package/src/bridge.ts +90 -37
  47. package/src/chat/inbound.ts +14 -1
  48. package/src/chat/outbound.ts +15 -7
  49. package/src/claude/agents-inject.ts +8 -1
  50. package/src/claude/context-windows.ts +16 -9
  51. package/src/claude/outbound.ts +24 -11
  52. package/src/cli/access.ts +46 -3
  53. package/src/cli/account-api.ts +116 -19
  54. package/src/cli/account-extended.ts +262 -29
  55. package/src/cli/account-main.ts +12 -12
  56. package/src/cli/account.ts +69 -15
  57. package/src/cli/agent.ts +8 -1
  58. package/src/cli/alias.ts +66 -0
  59. package/src/cli/capabilities-command.ts +94 -0
  60. package/src/cli/capabilities.ts +496 -0
  61. package/src/cli/claude-desktop.ts +31 -11
  62. package/src/cli/claude.ts +26 -1
  63. package/src/cli/dispatch.ts +256 -28
  64. package/src/cli/doctor.ts +126 -14
  65. package/src/cli/help.ts +17 -6
  66. package/src/cli/index.ts +39 -9
  67. package/src/cli/init.ts +1 -0
  68. package/src/cli/inspect.ts +230 -0
  69. package/src/cli/models-runtime.ts +95 -0
  70. package/src/cli/models.ts +26 -10
  71. package/src/cli/observe.ts +31 -8
  72. package/src/cli/provider-runtime.ts +16 -2
  73. package/src/cli/provider.ts +2 -1
  74. package/src/cli/registry.ts +49 -10
  75. package/src/cli/runtime-api.ts +51 -7
  76. package/src/cli/status.ts +41 -1
  77. package/src/cli/storage.ts +234 -0
  78. package/src/cli/system-command.ts +16 -0
  79. package/src/cli/system-restart-client.ts +1 -1
  80. package/src/cli/usage-report.ts +184 -0
  81. package/src/cli/v2.ts +34 -10
  82. package/src/cli/version-skew.ts +46 -0
  83. package/src/clients/config-export.ts +85 -3
  84. package/src/clients/effective-status.ts +30 -2
  85. package/src/clients/probes/cline.ts +81 -0
  86. package/src/codex/account-label.ts +21 -0
  87. package/src/codex/account-pause.ts +2 -1
  88. package/src/codex/account-priority.ts +3 -2
  89. package/src/codex/app-server-processes.ts +83 -7
  90. package/src/codex/auth-api.ts +50 -10
  91. package/src/codex/auth-context.ts +21 -18
  92. package/src/codex/autostart-health.ts +16 -0
  93. package/src/codex/catalog/aggregation.ts +18 -0
  94. package/src/codex/catalog/effort.ts +18 -3
  95. package/src/codex/catalog/metadata.ts +27 -1
  96. package/src/codex/catalog/native-models.ts +5 -2
  97. package/src/codex/catalog/parsing.ts +72 -27
  98. package/src/codex/catalog/provider-fetch.ts +187 -31
  99. package/src/codex/catalog/sync.ts +131 -5
  100. package/src/codex/catalog.ts +1 -1
  101. package/src/codex/codex-write-lock.ts +3 -1
  102. package/src/codex/convergence-types.ts +1 -1
  103. package/src/codex/convergence.ts +27 -2
  104. package/src/codex/desired-state.ts +2 -2
  105. package/src/codex/desktop-app-restart.ts +355 -0
  106. package/src/codex/history-job.ts +32 -3
  107. package/src/codex/history-manifest.ts +112 -0
  108. package/src/codex/history-migration-guardian.ts +5 -5
  109. package/src/codex/history-provider.ts +825 -247
  110. package/src/codex/history-worker.ts +8 -5
  111. package/src/codex/inject-coordination.ts +83 -0
  112. package/src/codex/inject.ts +63 -22
  113. package/src/codex/injected-marker.ts +1 -1
  114. package/src/codex/internal/history-writer.ts +4 -3
  115. package/src/codex/log-guard/inspect.ts +22 -4
  116. package/src/codex/model-entitlements.ts +9 -2
  117. package/src/codex/native-profile-startup.ts +157 -27
  118. package/src/codex/native-residue.ts +26 -33
  119. package/src/codex/prompt-layers.ts +371 -25
  120. package/src/codex/prompt-text-probe.ts +238 -0
  121. package/src/codex/quota.ts +91 -10
  122. package/src/codex/routing.ts +9 -0
  123. package/src/codex/shim.ts +56 -3
  124. package/src/codex/subagent-model-fallback.ts +104 -25
  125. package/src/codex/transition-state.ts +116 -8
  126. package/src/combos/failover.ts +27 -0
  127. package/src/combos/types.ts +10 -0
  128. package/src/compatibility/index.ts +26 -0
  129. package/src/compatibility/manifest.ts +253 -0
  130. package/src/compatibility/openai-responses.ts +121 -0
  131. package/src/config/atomic-write.ts +219 -0
  132. package/src/config/paths.ts +40 -0
  133. package/src/config/process-state.ts +309 -0
  134. package/src/config/provider-validation.ts +177 -0
  135. package/src/config/rebase-provenance.ts +68 -0
  136. package/src/config.ts +206 -824
  137. package/src/generated/compatibility-version.json +373 -213
  138. package/src/images/loop.ts +5 -4
  139. package/src/images/plan.ts +5 -4
  140. package/src/integrations/ownership-policy.ts +160 -0
  141. package/src/integrations/ownership.ts +46 -2
  142. package/src/integrations/registry.ts +7 -0
  143. package/src/integrations/state.ts +79 -7
  144. package/src/integrations/writer.ts +26 -2
  145. package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
  146. package/src/lab/fabric/producer-child.ts +1 -1
  147. package/src/lib/admin-secrets.ts +24 -0
  148. package/src/lib/bounded-body.ts +14 -2
  149. package/src/lib/config-ownership.ts +20 -0
  150. package/src/lib/errors.ts +36 -3
  151. package/src/lib/package-tree-integrity.ts +101 -0
  152. package/src/lib/process-control.ts +2 -1
  153. package/src/lib/service-secrets.ts +15 -0
  154. package/src/lib/tool-argument-integers.ts +56 -5
  155. package/src/oauth/callback-server.ts +22 -2
  156. package/src/oauth/cline.ts +203 -0
  157. package/src/oauth/generic-account-failover.ts +231 -0
  158. package/src/oauth/health.ts +1 -1
  159. package/src/oauth/index.ts +65 -6
  160. package/src/oauth/key-providers.ts +10 -1
  161. package/src/oauth/kimi.ts +9 -1
  162. package/src/oauth/kiro.ts +45 -0
  163. package/src/oauth/local-token-detect.ts +2 -0
  164. package/src/oauth/open-browser-choice.ts +26 -0
  165. package/src/oauth/store.ts +14 -5
  166. package/src/oauth/types.ts +15 -0
  167. package/src/providers/auto-compact-budget.ts +65 -0
  168. package/src/providers/command-code-efforts.ts +38 -6
  169. package/src/providers/context-cap.ts +4 -3
  170. package/src/providers/default-aliases.ts +65 -0
  171. package/src/providers/derive.ts +28 -0
  172. package/src/providers/fastwire.ts +7 -1
  173. package/src/providers/label.ts +34 -1
  174. package/src/providers/model-presets.ts +119 -0
  175. package/src/providers/new-model-policy.ts +146 -0
  176. package/src/providers/provider-id-rewrite.ts +2 -1
  177. package/src/providers/quota.ts +65 -7
  178. package/src/providers/registry.ts +184 -69
  179. package/src/providers/slug-codec.ts +52 -0
  180. package/src/providers/xai-transport.ts +21 -0
  181. package/src/reasoning-effort.ts +19 -2
  182. package/src/responses/apply-patch-envelope.ts +63 -0
  183. package/src/responses/code-mode-helper-compat.ts +50 -0
  184. package/src/responses/custom-tool-compat.ts +157 -39
  185. package/src/responses/namespace-tool-compat.ts +84 -4
  186. package/src/responses/parser.ts +7 -2
  187. package/src/responses/reasoning-replay-cache.ts +81 -3
  188. package/src/responses/schema.ts +5 -1
  189. package/src/responses/state.ts +94 -4
  190. package/src/responses/thought-signature-replay.ts +17 -0
  191. package/src/responses/turn-termination.ts +107 -0
  192. package/src/router.ts +40 -2
  193. package/src/routing/analytics.ts +1 -0
  194. package/src/routing/quota.ts +10 -0
  195. package/src/server/auth-cors.ts +27 -7
  196. package/src/server/chat-completions.ts +24 -16
  197. package/src/server/chat-native-sse.ts +3 -3
  198. package/src/server/chat-native.ts +30 -11
  199. package/src/server/claude-messages.ts +1 -1
  200. package/src/server/index.ts +179 -32
  201. package/src/server/lifecycle.ts +52 -1
  202. package/src/server/local-management-read-client.ts +1 -1
  203. package/src/server/local-provider-reload-client.ts +1 -1
  204. package/src/server/management/agent-settings-routes.ts +45 -23
  205. package/src/server/management/codex-prompt-routes.ts +570 -0
  206. package/src/server/management/combo-routes.ts +2 -1
  207. package/src/server/management/config-routes.ts +59 -12
  208. package/src/server/management/context.ts +10 -1
  209. package/src/server/management/logs-usage-routes.ts +30 -24
  210. package/src/server/management/model-routes.ts +274 -4
  211. package/src/server/management/model-rows.ts +4 -0
  212. package/src/server/management/native-integration-routes.ts +2 -1
  213. package/src/server/management/oauth-account-routes.ts +10 -4
  214. package/src/server/management/provider-capability-config.ts +1 -1
  215. package/src/server/management/provider-routes.ts +81 -11
  216. package/src/server/management/route-registry.ts +332 -0
  217. package/src/server/management/routing-profile-routes.ts +2 -2
  218. package/src/server/management/system-restart.ts +1 -1
  219. package/src/server/management-api.ts +18 -16
  220. package/src/server/port-reclaim.ts +1 -1
  221. package/src/server/proxy-liveness.ts +29 -5
  222. package/src/server/relay-eager.ts +147 -20
  223. package/src/server/relay.ts +251 -19
  224. package/src/server/request-log-conversation.ts +51 -0
  225. package/src/server/request-log.ts +70 -16
  226. package/src/server/responses/codex-auth-error.ts +55 -0
  227. package/src/server/responses/collaboration.ts +37 -3
  228. package/src/server/responses/combo-stream-preflight.ts +178 -0
  229. package/src/server/responses/compact.ts +6 -21
  230. package/src/server/responses/core.ts +810 -208
  231. package/src/server/responses/empty-completion-guard.ts +63 -6
  232. package/src/server/responses/fetch-helpers.ts +17 -102
  233. package/src/server/responses/input-admission.ts +3 -1
  234. package/src/server/responses/passthrough-error.ts +33 -9
  235. package/src/server/responses/responses-field-backfill.ts +105 -13
  236. package/src/server/responses/ws-upstream.ts +75 -2
  237. package/src/server/responses-custom-tool-repair.ts +87 -6
  238. package/src/server/responses-terminal-repair.ts +25 -4
  239. package/src/server/responses-undeclared-tool-guard.ts +241 -18
  240. package/src/server/sse-frame-buffer.ts +31 -4
  241. package/src/server/ws-bridge.ts +14 -2
  242. package/src/service.ts +42 -4
  243. package/src/storage/policy-job.ts +14 -4
  244. package/src/storage/policy.ts +88 -23
  245. package/src/types/config.ts +66 -0
  246. package/src/types/provider.ts +69 -0
  247. package/src/types/request.ts +17 -4
  248. package/src/types/tools.ts +116 -11
  249. package/src/types.ts +3 -1
  250. package/src/update/index.ts +5 -4
  251. package/src/update/job.ts +3 -1
  252. package/src/update/transactional-install.mjs +8 -1
  253. package/src/usage/expected-prices.ts +43 -24
  254. package/src/usage/log.ts +63 -12
  255. package/src/usage/summary.ts +211 -8
  256. package/src/vision/describe.ts +18 -13
  257. package/src/web-search/exa-executor.ts +40 -9
  258. package/src/web-search/executor.ts +10 -3
  259. package/src/web-search/loop.ts +5 -4
  260. package/gui/dist/assets/index-BF38heuV.js +0 -104
  261. package/gui/dist/assets/index-DMiI18Kv.css +0 -1
package/src/bridge.ts CHANGED
@@ -7,8 +7,18 @@ import type {
7
7
  OcxUsage,
8
8
  } from "./types";
9
9
  import { coerceIntegerToolArguments } from "./lib/tool-argument-integers";
10
- import { adapterFailureFromMessage, classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, type OcxErrorPayload } from "./lib/errors";
10
+ import {
11
+ adapterFailureFromMessage,
12
+ classifyError,
13
+ cyberPolicyErrorType,
14
+ CYBER_POLICY_ERROR_CODE,
15
+ isCyberPolicyCode,
16
+ type OcxErrorPayload,
17
+ } from "./lib/errors";
18
+ import { redactSecretString } from "./lib/redact";
19
+ import { repairFreeformToolInput } from "./responses/apply-patch-envelope";
11
20
  import { encodeCompactionSummary } from "./responses/compaction";
21
+ import { compileCodeModeHelperInput } from "./responses/code-mode-helper-compat";
12
22
  import { isTruncatedStopReason, truncationReasonFor } from "./responses/truncated-stop-reason";
13
23
  import { encodeReasoningEnvelope, type ReasoningEnvelope } from "./responses/reasoning-envelope";
14
24
  import { rememberReasoningForCall } from "./responses/reasoning-replay-cache";
@@ -18,6 +28,7 @@ import {
18
28
  awaitThoughtSignatureDurability,
19
29
  } from "./responses/thought-signature-replay";
20
30
  import { resolveStallTimeoutSec } from "./stall-timeout";
31
+ import { normalizeDeclaredToolName } from "./types";
21
32
  import { usageDisplayTotalTokens } from "./usage/totals";
22
33
  import { appendSafeWebSearchSource, safeWebSearchSources } from "./web-search/sources";
23
34
  import {
@@ -112,18 +123,19 @@ function toolCallArgumentsUsable(args: string): boolean {
112
123
  }
113
124
 
114
125
  function adapterFailureFromEvent(event: Extract<AdapterEvent, { type: "error" }>): { httpStatus: number; error: OcxErrorPayload } {
126
+ const message = redactSecretString(event.message);
115
127
  if (event.status === undefined && event.errorType === undefined && event.code === undefined) {
116
- return adapterFailureFromMessage(event.message);
128
+ return adapterFailureFromMessage(message);
117
129
  }
118
- const fallback = adapterFailureFromMessage(event.message);
130
+ const fallback = adapterFailureFromMessage(message);
119
131
  let httpStatus = event.status ?? fallback.httpStatus;
120
- const error = classifyError(httpStatus, event.errorType ?? fallback.error.type, event.message);
132
+ const error = classifyError(httpStatus, event.errorType ?? fallback.error.type, message);
121
133
  if (event.errorType !== undefined) error.type = event.errorType;
122
134
  if (event.code !== undefined) error.code = event.code;
123
135
  // Codex maps cyber_policy on HTTP 400 (body) or mid-stream code; never leave it as 502.
124
136
  if (isCyberPolicyCode(error.code) || isCyberPolicyCode(event.code)) {
125
137
  error.code = CYBER_POLICY_ERROR_CODE;
126
- error.type = "invalid_request_error";
138
+ error.type = cyberPolicyErrorType(event.errorType);
127
139
  httpStatus = 400;
128
140
  }
129
141
  return { httpStatus, error };
@@ -230,12 +242,17 @@ export function bridgeToResponsesSSE(
230
242
  const replayCacheScope = options?.replayCacheScope;
231
243
  const setBeatInterval = options?.timers?.setInterval ?? ((handler: () => void, ms: number) => setInterval(handler, ms));
232
244
  const clearBeatInterval = options?.timers?.clearInterval ?? ((id: unknown) => clearInterval(id as ReturnType<typeof setInterval>));
233
- // Freeform/custom tools (apply_patch) carry their body in `input`; the model is given a
234
- // function with `{input:string}`, so unwrap it here when relaying back as a custom_tool_call.
235
- const freeformInput = (args: string): string => {
236
- try { const o = JSON.parse(args); if (o && typeof o.input === "string") return o.input; } catch { /* raw */ }
237
- return args;
238
- };
245
+ // Freeform/custom tools (apply_patch, code-mode exec) carry their body in `input`; the
246
+ // model is given a function with `{input:string}`, so unwrap it here when relaying back
247
+ // as a custom_tool_call. Decorated apply_patch envelopes are repaired at this boundary.
248
+ const freeformInput = (
249
+ args: string,
250
+ toolName: string,
251
+ namespace?: string,
252
+ codeModeHelperName?: string,
253
+ ): string => codeModeHelperName
254
+ ? compileCodeModeHelperInput(args, codeModeHelperName)
255
+ : repairFreeformToolInput(args, toolName, namespace);
239
256
  // Best-effort unwrap of a PARTIAL freeform arg buffer for live input streaming
240
257
  // (`response.custom_tool_call_input.delta` — codex-rs uses it for UI preview only;
241
258
  // the completed custom_tool_call item stays authoritative). Compact `{"input":"...`
@@ -519,7 +536,7 @@ export function bridgeToResponsesSSE(
519
536
  // synthetic compaction item's payload on done.
520
537
  let compactionText = "";
521
538
  let compactionTextBytes = 0;
522
- let currentToolCall: { itemId: string; outputIndex: number; callId: string; name: string; args: string; argsBytes: number; namespace?: string; freeform?: boolean; toolSearch?: boolean; inputEmitted?: string; providerMetadata?: OcxProviderOpaqueToolCallMetadata } | null = null;
539
+ let currentToolCall: { itemId: string; outputIndex: number; callId: string; name: string; args: string; argsBytes: number; namespace?: string; freeform?: boolean; toolSearch?: boolean; inputEmitted?: string; codeModeHelperName?: string; providerMetadata?: OcxProviderOpaqueToolCallMetadata } | null = null;
523
540
  // Open native web-search cell (between begin and end). Holds the output index allocated on
524
541
  // begin so the matching done reuses it; closed as `failed` if the stream terminates early.
525
542
  let currentWebSearch: { itemId: string; eventId: string; outputIndex: number } | null = null;
@@ -626,6 +643,7 @@ export function bridgeToResponsesSSE(
626
643
  const argsStr = coerceIntegerToolArguments(
627
644
  currentToolCall.args || "{}",
628
645
  options?.toolParameterSchemas?.get(currentToolCall.name),
646
+ currentToolCall.namespace === undefined ? currentToolCall.name : undefined,
629
647
  );
630
648
  // Finalize streamed function-call arguments so Codex commits the call (incl. MCP / computer_use).
631
649
  if (!currentToolCall.freeform && !currentToolCall.toolSearch) {
@@ -636,7 +654,8 @@ export function bridgeToResponsesSSE(
636
654
  if (currentToolCall.freeform) {
637
655
  emit("response.custom_tool_call_input.done", {
638
656
  item_id: currentToolCall.itemId, output_index: currentToolCall.outputIndex,
639
- input: freeformInput(currentToolCall.args),
657
+ ...(currentToolCall.namespace ? { namespace: currentToolCall.namespace } : {}),
658
+ input: freeformInput(currentToolCall.args, currentToolCall.name, currentToolCall.namespace, currentToolCall.codeModeHelperName),
640
659
  });
641
660
  }
642
661
  // Freeform tools serialize as custom_tool_call without extra_content; remember the
@@ -652,7 +671,8 @@ export function bridgeToResponsesSSE(
652
671
  ? {
653
672
  type: "custom_tool_call", id: currentToolCall.itemId,
654
673
  call_id: currentToolCall.callId, name: currentToolCall.name,
655
- input: freeformInput(currentToolCall.args), status: "completed",
674
+ ...(currentToolCall.namespace ? { namespace: currentToolCall.namespace } : {}),
675
+ input: freeformInput(currentToolCall.args, currentToolCall.name, currentToolCall.namespace, currentToolCall.codeModeHelperName), status: "completed",
656
676
  }
657
677
  : {
658
678
  type: "function_call", id: currentToolCall.itemId,
@@ -691,7 +711,8 @@ export function bridgeToResponsesSSE(
691
711
  ? {
692
712
  type: "custom_tool_call", id: currentToolCall.itemId,
693
713
  call_id: currentToolCall.callId, name: currentToolCall.name,
694
- input: freeformInput(currentToolCall.args), status: "incomplete",
714
+ ...(currentToolCall.namespace ? { namespace: currentToolCall.namespace } : {}),
715
+ input: freeformInput(currentToolCall.args, currentToolCall.name, currentToolCall.namespace, currentToolCall.codeModeHelperName), status: "incomplete",
695
716
  }
696
717
  : {
697
718
  type: "function_call", id: currentToolCall.itemId,
@@ -1036,13 +1057,17 @@ export function bridgeToResponsesSSE(
1036
1057
  rememberReasoningForCall(event.id, rawReasoningForNextToolCall, replayCacheScope);
1037
1058
  }
1038
1059
  if (currentToolCall) closeCurrentToolCall();
1039
- const mapped = toolNsMap?.get(event.name);
1040
- const realName = mapped?.name ?? event.name;
1041
- if (options?.declaredToolNames && !options.declaredToolNames.has(event.name)) {
1060
+ const effectiveName = normalizeDeclaredToolName(event.name, options?.declaredToolNames);
1061
+ const codeModeHelperName = effectiveName === "exec" && event.name !== effectiveName
1062
+ ? event.name
1063
+ : undefined;
1064
+ const mapped = toolNsMap?.get(effectiveName);
1065
+ const realName = mapped?.name ?? effectiveName;
1066
+ if (options?.declaredToolNames && !options.declaredToolNames.has(effectiveName)) {
1042
1067
  const failure = responseError(
1043
1068
  502,
1044
1069
  "upstream_error",
1045
- `routed provider emitted undeclared client tool "${event.name}"; only request-declared tools may be called`,
1070
+ `routed provider emitted undeclared client tool "${effectiveName}"; only request-declared tools may be called`,
1046
1071
  );
1047
1072
  emit("response.failed", {
1048
1073
  response: {
@@ -1064,10 +1089,10 @@ export function bridgeToResponsesSSE(
1064
1089
  const item = toolSearch
1065
1090
  ? { type: "tool_search_call", id: itemId, call_id: event.id, execution: "client", arguments: {}, status: "in_progress" }
1066
1091
  : freeform
1067
- ? { type: "custom_tool_call", id: itemId, call_id: event.id, name: realName, input: "", status: "in_progress" }
1092
+ ? { type: "custom_tool_call", id: itemId, call_id: event.id, name: realName, ...(ns ? { namespace: ns } : {}), input: "", status: "in_progress" }
1068
1093
  : { type: "function_call", id: itemId, call_id: event.id, name: realName, arguments: "", status: "in_progress", ...(ns ? { namespace: ns } : {}) };
1069
1094
  emit("response.output_item.added", { output_index: outputIndex, item });
1070
- currentToolCall = { itemId, outputIndex, callId: event.id, name: realName, args: "", argsBytes: 0, namespace: ns, freeform, toolSearch, providerMetadata: event.providerMetadata };
1095
+ currentToolCall = { itemId, outputIndex, callId: event.id, name: realName, args: "", argsBytes: 0, namespace: ns, freeform, toolSearch, codeModeHelperName, providerMetadata: event.providerMetadata };
1071
1096
  budget?.openCall(event.id);
1072
1097
  break;
1073
1098
  }
@@ -1086,7 +1111,7 @@ export function bridgeToResponsesSSE(
1086
1111
  delta: event.arguments,
1087
1112
  });
1088
1113
  }
1089
- if (currentToolCall.freeform) {
1114
+ if (currentToolCall.freeform && !currentToolCall.codeModeHelperName) {
1090
1115
  // Hold while the buffer is still an ambiguous prefix of the JSON wrapper,
1091
1116
  // then stream only the unwrapped input suffix (never rewind on mode flips).
1092
1117
  if (!FREEFORM_WRAP_PREFIX.startsWith(currentToolCall.args)) {
@@ -1289,7 +1314,9 @@ export function bridgeToResponsesSSE(
1289
1314
  ...(event.usage ? { usage: responsesUsage(event.usage) } : {}),
1290
1315
  error: failure.error,
1291
1316
  last_error: failure.error,
1292
- ...(event.retryable !== undefined ? { retryable: event.retryable } : {}),
1317
+ ...(isCyberPolicyCode(failure.error.code)
1318
+ ? { retryable: false }
1319
+ : event.retryable !== undefined ? { retryable: event.retryable } : {}),
1293
1320
  },
1294
1321
  });
1295
1322
  reportTerminal("failed");
@@ -1313,11 +1340,17 @@ export function bridgeToResponsesSSE(
1313
1340
  if (currentToolCall) failCurrentToolCall();
1314
1341
  if (currentWebSearch) closeCurrentWebSearch("failed", []);
1315
1342
  releasePendingWebSources();
1343
+ const failure = responseError(
1344
+ 500,
1345
+ "proxy_error",
1346
+ redactSecretString(err instanceof Error ? err.message : String(err)),
1347
+ );
1316
1348
  emit("response.failed", {
1317
1349
  response: {
1318
1350
  ...responseSnapshot("failed", finishedItems),
1319
- error: responseError(500, "proxy_error", err instanceof Error ? err.message : String(err)),
1320
- last_error: responseError(500, "proxy_error", err instanceof Error ? err.message : String(err)),
1351
+ error: failure,
1352
+ last_error: failure,
1353
+ ...(isCyberPolicyCode(failure.code) ? { retryable: false } : {}),
1321
1354
  },
1322
1355
  });
1323
1356
  reportTerminal("failed");
@@ -1370,6 +1403,8 @@ export function bridgeToResponsesSSE(
1370
1403
 
1371
1404
  const startStream = () => {
1372
1405
  emit("response.created", { response: responseSnapshot("in_progress", []) });
1406
+ // Responses spec parity: clients expect an explicit in_progress frame after created.
1407
+ emit("response.in_progress", { response: responseSnapshot("in_progress", []) });
1373
1408
  // The default ReadableStream strategy has HWM=1. Once one event's frames fill that
1374
1409
  // queue, pull stepping pauses; no custom FIFO or queuing strategy is layered on top.
1375
1410
  gated = true;
@@ -1561,16 +1596,21 @@ function buildResponseJSONWithBudget(
1561
1596
  let batchKiroRedactedBytes = 0;
1562
1597
  let currentToolCallId = "";
1563
1598
  let currentToolCallName = "";
1599
+ let currentToolCallCodeModeHelperName: string | undefined;
1564
1600
  let currentToolCallArgs = "";
1565
1601
  let currentToolCallProviderMetadata: OcxProviderOpaqueToolCallMetadata | undefined;
1566
1602
  let currentToolCallArgsBytes = 0;
1567
1603
  // Web-search citations awaiting the next assistant message (attached as url_citation annotations).
1568
1604
  let pendingWebSources: { url: string; title?: string }[] = [];
1569
1605
 
1570
- const freeformInput = (args: string): string => {
1571
- try { const o = JSON.parse(args); if (o && typeof o.input === "string") return o.input; } catch { /* raw */ }
1572
- return args;
1573
- };
1606
+ const freeformInput = (
1607
+ args: string,
1608
+ toolName: string,
1609
+ namespace?: string,
1610
+ codeModeHelperName?: string,
1611
+ ): string => codeModeHelperName
1612
+ ? compileCodeModeHelperInput(args, codeModeHelperName)
1613
+ : repairFreeformToolInput(args, toolName, namespace);
1574
1614
  const parseArgsObj = (args: string): Record<string, unknown> => {
1575
1615
  try { const o = JSON.parse(args); return o && typeof o === "object" ? o : {}; } catch { return {}; }
1576
1616
  };
@@ -1656,6 +1696,7 @@ function buildResponseJSONWithBudget(
1656
1696
  const coercedArgs = coerceIntegerToolArguments(
1657
1697
  currentToolCallArgs,
1658
1698
  options?.toolParameterSchemas?.get(currentToolCallName),
1699
+ ns === undefined ? realName : undefined,
1659
1700
  );
1660
1701
  // Freeform tools serialize as custom_tool_call without extra_content; remember the
1661
1702
  // signature server-side regardless so the replayed call can be re-signed (#1735).
@@ -1670,7 +1711,8 @@ function buildResponseJSONWithBudget(
1670
1711
  pushOutput({
1671
1712
  type: "custom_tool_call", id: `ctc_${uuid()}`,
1672
1713
  call_id: currentToolCallId, name: realName,
1673
- input: freeformInput(currentToolCallArgs), status,
1714
+ ...(ns ? { namespace: ns } : {}),
1715
+ input: freeformInput(currentToolCallArgs, realName, ns, currentToolCallCodeModeHelperName), status,
1674
1716
  });
1675
1717
  } else {
1676
1718
  pushOutput({
@@ -1684,6 +1726,7 @@ function buildResponseJSONWithBudget(
1684
1726
  budget?.closeCall(currentToolCallId);
1685
1727
  currentToolCallId = "";
1686
1728
  currentToolCallName = "";
1729
+ currentToolCallCodeModeHelperName = undefined;
1687
1730
  currentToolCallProviderMetadata = undefined;
1688
1731
  currentToolCallArgs = "";
1689
1732
  currentToolCallArgsBytes = 0;
@@ -1777,7 +1820,7 @@ function buildResponseJSONWithBudget(
1777
1820
  ));
1778
1821
  }
1779
1822
  break;
1780
- case "tool_call_start":
1823
+ case "tool_call_start": {
1781
1824
  if (currentText) flushText("commentary");
1782
1825
  if (currentSummaryReasoning) flushSummaryReasoning();
1783
1826
  if (currentRawReasoning) flushRawReasoning();
@@ -1785,10 +1828,11 @@ function buildResponseJSONWithBudget(
1785
1828
  rememberReasoningForCall(e.id, rawReasoningForNextToolCall, replayCacheScope);
1786
1829
  }
1787
1830
  flushToolCall();
1788
- if (options?.declaredToolNames && !options.declaredToolNames.has(e.name)) {
1831
+ const effectiveName = normalizeDeclaredToolName(e.name, options?.declaredToolNames);
1832
+ if (options?.declaredToolNames && !options.declaredToolNames.has(effectiveName)) {
1789
1833
  errorEvent = {
1790
1834
  type: "error",
1791
- message: `routed provider emitted undeclared client tool "${e.name}"; only request-declared tools may be called`,
1835
+ message: `routed provider emitted undeclared client tool "${effectiveName}"; only request-declared tools may be called`,
1792
1836
  status: 502,
1793
1837
  errorType: "upstream_error",
1794
1838
  };
@@ -1796,11 +1840,15 @@ function buildResponseJSONWithBudget(
1796
1840
  }
1797
1841
  currentToolCallId = e.id;
1798
1842
  budget?.openCall(e.id);
1799
- currentToolCallName = e.name;
1843
+ currentToolCallName = effectiveName;
1844
+ currentToolCallCodeModeHelperName = effectiveName === "exec" && e.name !== effectiveName
1845
+ ? e.name
1846
+ : undefined;
1800
1847
  currentToolCallArgs = "";
1801
1848
  currentToolCallArgsBytes = 0;
1802
1849
  currentToolCallProviderMetadata = e.providerMetadata;
1803
1850
  break;
1851
+ }
1804
1852
  case "tool_call_delta":
1805
1853
  {
1806
1854
  ({ value: currentToolCallArgs, bytes: currentToolCallArgsBytes } = appendBatchString(
@@ -1942,7 +1990,9 @@ function buildResponseJSONWithBudget(
1942
1990
  model: modelId, output,
1943
1991
  ...(endTurn !== undefined ? { end_turn: endTurn } : {}),
1944
1992
  ...(failure ? { error: failure.error, last_error: failure.error } : {}),
1945
- ...(errorEvent?.retryable !== undefined ? { retryable: errorEvent.retryable } : {}),
1993
+ ...(failure && isCyberPolicyCode(failure.error.code)
1994
+ ? { retryable: false }
1995
+ : errorEvent?.retryable !== undefined ? { retryable: errorEvent.retryable } : {}),
1946
1996
  ...(incompleteEvent ? {
1947
1997
  incomplete_details: {
1948
1998
  reason: incompleteEvent.reason,
@@ -1971,12 +2021,15 @@ export function formatErrorResponse(
1971
2021
  const error = classifyError(status, type, message);
1972
2022
  if (isCyberPolicyCode(options?.code)) {
1973
2023
  error.code = CYBER_POLICY_ERROR_CODE;
1974
- error.type = "invalid_request_error";
2024
+ error.type = cyberPolicyErrorType(type);
1975
2025
  }
1976
2026
  const finalStatus = error.code === CYBER_POLICY_ERROR_CODE ? 400 : status;
1977
2027
  const headers = new Headers({ "Content-Type": "application/json" });
1978
2028
  const retryAfter = options?.retryAfter?.trim();
1979
- if (retryAfter && retryAfter.length > 0 && retryAfter.length <= 128) {
2029
+ if (error.code !== CYBER_POLICY_ERROR_CODE
2030
+ && retryAfter
2031
+ && retryAfter.length > 0
2032
+ && retryAfter.length <= 128) {
1980
2033
  headers.set("Retry-After", retryAfter);
1981
2034
  }
1982
2035
  return new Response(JSON.stringify({ error }), {
@@ -53,6 +53,14 @@ function imageUrlFromPart(part: Rec): string | null {
53
53
  return null;
54
54
  }
55
55
 
56
+ function videoUrlFromPart(part: Rec): string | null {
57
+ if (part.type !== "video_url") return null;
58
+ const videoUrl = part.video_url;
59
+ if (typeof videoUrl === "string" && videoUrl.length > 0) return videoUrl;
60
+ if (isRec(videoUrl) && typeof videoUrl.url === "string" && videoUrl.url.length > 0) return videoUrl.url;
61
+ return null;
62
+ }
63
+
56
64
  function userContentToBlocks(content: unknown): Rec[] {
57
65
  if (typeof content === "string") {
58
66
  return content.length > 0 ? [{ type: "input_text", text: content }] : [];
@@ -70,7 +78,12 @@ function userContentToBlocks(content: unknown): Rec[] {
70
78
  continue;
71
79
  }
72
80
  const imageUrl = imageUrlFromPart(raw);
73
- if (imageUrl) blocks.push({ type: "input_image", image_url: imageUrl });
81
+ if (imageUrl) {
82
+ blocks.push({ type: "input_image", image_url: imageUrl });
83
+ continue;
84
+ }
85
+ const videoUrl = videoUrlFromPart(raw);
86
+ if (videoUrl) blocks.push({ type: "input_video", video_url: videoUrl });
74
87
  }
75
88
  return blocks;
76
89
  }
@@ -9,7 +9,14 @@ type Rec = Record<string, unknown>;
9
9
 
10
10
  import { decodeServerSentEvents, sseFieldValue } from "../lib/sse-decoder";
11
11
  import { isTranslatorBudgetExceededError, type TranslatorBudget } from "../lib/translator-budget";
12
- import { classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, isCyberPolicyMessage } from "../lib/errors";
12
+ import {
13
+ classifyError,
14
+ cyberPolicyErrorType,
15
+ CYBER_POLICY_ERROR_CODE,
16
+ isCyberPolicyCode,
17
+ isCyberPolicyMessage,
18
+ } from "../lib/errors";
19
+ import { redactSecretString } from "../lib/redact";
13
20
 
14
21
  function isRec(v: unknown): v is Rec {
15
22
  return !!v && typeof v === "object" && !Array.isArray(v);
@@ -48,14 +55,14 @@ export function chatCompletionsUsage(usage: unknown): Rec {
48
55
  export function chatCompletionsErrorBody(
49
56
  status: number,
50
57
  message: string,
51
- type = "invalid_request_error",
58
+ type?: string,
52
59
  code?: string | null,
53
60
  ): Rec {
54
61
  if (isCyberPolicyCode(code) || isCyberPolicyMessage(message)) {
55
62
  return {
56
63
  error: {
57
64
  message,
58
- type: "invalid_request_error",
65
+ type: cyberPolicyErrorType(type),
59
66
  param: null,
60
67
  code: CYBER_POLICY_ERROR_CODE,
61
68
  },
@@ -64,7 +71,7 @@ export function chatCompletionsErrorBody(
64
71
  return {
65
72
  error: {
66
73
  message,
67
- type,
74
+ type: type ?? "invalid_request_error",
68
75
  param: null,
69
76
  code: code !== undefined
70
77
  ? code
@@ -311,8 +318,9 @@ export function responsesSseToChatCompletionsSse(
311
318
  // Deliver the error frame then close the stream abnormally (no [DONE]).
312
319
  // Do not controller.error() — that can drop already-enqueued bytes from consumers
313
320
  // like response.text().
314
- const statusHint = details?.status ?? streamErrorStatus(message);
315
- const classified = classifyError(statusHint, details?.type ?? "upstream_error", message);
321
+ const safeMessage = redactSecretString(message);
322
+ const statusHint = details?.status ?? streamErrorStatus(safeMessage);
323
+ const classified = classifyError(statusHint, details?.type ?? "upstream_error", safeMessage);
316
324
  const translatorOverflow = details?.code === "translation_buffer_limit";
317
325
  if (translatorOverflow) {
318
326
  upstreamAbort.abort(new Error("upstream translation buffer exceeded the safe limit"));
@@ -324,7 +332,7 @@ export function responsesSseToChatCompletionsSse(
324
332
  classified.type = "upstream_error";
325
333
  } else if (isCyberPolicyCode(details?.code) || classified.code === CYBER_POLICY_ERROR_CODE) {
326
334
  classified.code = CYBER_POLICY_ERROR_CODE;
327
- classified.type = "invalid_request_error";
335
+ classified.type = cyberPolicyErrorType(details?.type);
328
336
  } else if (details?.code !== undefined && details.code !== null && !classified.code) {
329
337
  classified.code = details.code;
330
338
  }
@@ -218,7 +218,14 @@ function isOwnedFile(path: string): boolean {
218
218
  export function syncClaudeAgentDefs(defs: readonly ClaudeAgentDef[], configDir = claudeConfigDir()): string[] | null {
219
219
  try {
220
220
  const dir = join(configDir, "agents");
221
- mkdirSync(dir, { recursive: true });
221
+ if (defs.length === 0) {
222
+ try { lstatSync(dir); } catch (error) {
223
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
224
+ throw error;
225
+ }
226
+ } else {
227
+ mkdirSync(dir, { recursive: true });
228
+ }
222
229
  const keep = new Set(defs.map(d => d.file));
223
230
  for (const existing of readdirSync(dir)) {
224
231
  if (!existing.startsWith(OWNED_PREFIX) || !existing.endsWith(".md")) continue;
@@ -118,17 +118,24 @@ export function buildClaudeContextWindows(
118
118
  put(desktop3pAlias("native", slug), window);
119
119
  put(aliasForNative(slug), window);
120
120
  }
121
+ // Anthropic passthrough guard (audit 021 #3): canonical claude ids ride the
122
+ // subscription passthrough — marking a sub-1M one would strap [1m]/1M-beta onto
123
+ // a model that cannot host it. Register anthropic rows only at >=1M.
124
+ const registrable = routedModels.filter(
125
+ m =>
126
+ typeof m.contextWindow === "number" &&
127
+ m.contextWindow > 0 &&
128
+ !(m.provider === "anthropic" && m.contextWindow < ONE_MILLION),
129
+ );
121
130
  // Bare routed ids are registered only when unambiguous across providers (audit
122
- // 021 #5) — natives are registered first, so a native slug always wins the bare key.
131
+ // 021 #5) — natives are registered first, so a native slug always wins the bare
132
+ // key. Counted over the rows that can actually claim the key: a row this loop
133
+ // skips contributes no window, so letting it veto the bare key withholds an
134
+ // answer that was never in doubt.
123
135
  const bareCounts = new Map<string, number>();
124
- for (const m of routedModels) bareCounts.set(m.id, (bareCounts.get(m.id) ?? 0) + 1);
125
- for (const m of routedModels) {
126
- const window = m.contextWindow;
127
- if (typeof window !== "number" || window <= 0) continue;
128
- // Anthropic passthrough guard (audit 021 #3): canonical claude ids ride the
129
- // subscription passthrough — marking a sub-1M one would strap [1m]/1M-beta onto
130
- // a model that cannot host it. Register anthropic rows only at >=1M.
131
- if (m.provider === "anthropic" && window < ONE_MILLION) continue;
136
+ for (const m of registrable) bareCounts.set(m.id, (bareCounts.get(m.id) ?? 0) + 1);
137
+ for (const m of registrable) {
138
+ const window = m.contextWindow as number;
132
139
  put(`${m.provider}/${m.id}`, window);
133
140
  put(desktop3pAlias(m.provider, m.id), window);
134
141
  put(aliasForRoute(m.provider, m.id), window);
@@ -2,14 +2,16 @@
2
2
  * Claude Code outbound: internal /v1/responses output -> Anthropic Messages API shapes.
3
3
  *
4
4
  * Wire contract pinned in devlog/260711_claude_inbound/003_evidence.md (all Tier 2):
5
- * - SSE order: message_start -> (content_block_start -> deltas -> content_block_stop)*
6
- * -> message_delta -> message_stop; any number of `ping`.
5
+ * - Transport-only `ping` events may appear at any point, including before
6
+ * message_start. Semantic framing stays message_start ->
7
+ * (content_block_start -> deltas -> content_block_stop)* -> message_delta -> message_stop.
7
8
  * - thinking blocks get thinking_delta(s) then ONE synthetic signature_delta just
8
9
  * before content_block_stop (CCR precedent: Claude Code does not verify signatures).
9
10
  * - message_delta.usage is cumulative; message_start embeds a full message snapshot.
10
11
  * - errors: {type:"error", error:{type,message}}; may arrive mid-stream after HTTP 200.
11
12
  */
12
13
  import { createHash } from "node:crypto";
14
+ import { httpStatusFromTerminalError } from "../lib/errors";
13
15
  import { isTransientUpstreamStatus } from "../lib/upstream-retry";
14
16
  import {
15
17
  isTranslatorBudgetExceededError,
@@ -267,12 +269,12 @@ export function responsesSseToAnthropicSse(
267
269
  emit("message_start", { type: "message_start", message: messageSnapshot(model) });
268
270
  emit("ping", { type: "ping" });
269
271
  };
270
- // Once a semantic Anthropic message has started, keepalive pings protect remote
271
- // deployments behind LB/NAT idle timeouts. Transport-only Responses prelude frames
272
- // must not manufacture a message before a possible initial error.
272
+ // Keepalive pings protect remote deployments behind LB/NAT idle timeouts even
273
+ // before semantic output. They are transport-only and must not manufacture a
274
+ // message before a possible initial error.
273
275
  if (pingIntervalMs > 0) {
274
276
  pingTimer = setInterval(() => {
275
- if (terminated || !started) return;
277
+ if (terminated || (controller.desiredSize ?? 0) <= 0) return;
276
278
  try {
277
279
  emit("ping", { type: "ping" });
278
280
  } catch { /* controller torn down; the read loop is ending anyway */ }
@@ -352,7 +354,8 @@ export function responsesSseToAnthropicSse(
352
354
  const type = upstreamDerived && isTransientUpstreamStatus(status) ? "overloaded_error" : undefined;
353
355
  if (!started) {
354
356
  // An initial upstream failure is an Anthropic error stream, not a partial message.
355
- // Do not manufacture message_start/ping before the terminal error.
357
+ // Do not manufacture message_start before the terminal error. Earlier transport-only
358
+ // pings remain valid and do not turn the failure into a partial message.
356
359
  emit("error", anthropicErrorBody(status, message, type, code));
357
360
  return;
358
361
  }
@@ -366,7 +369,7 @@ export function responsesSseToAnthropicSse(
366
369
  // Transport prelude only. Start Anthropic framing on semantic output or completion.
367
370
  break;
368
371
  case "response.heartbeat":
369
- if (started) emit("ping", { type: "ping" });
372
+ if ((controller.desiredSize ?? 0) > 0) emit("ping", { type: "ping" });
370
373
  break;
371
374
  case "response.output_text.delta": {
372
375
  if (typeof data.delta !== "string" || data.delta.length === 0) break;
@@ -553,9 +556,19 @@ export function responsesSseToAnthropicSse(
553
556
  }
554
557
  const status = code === "translation_buffer_limit"
555
558
  ? 413
556
- : typeof error.status === "number" ? error.status : 500;
557
- // status-absent response.failed (relaySseWithFailedTail synthetic tail) defaults
558
- // to 500, which is in the transient set the mid-stream reset shape maps to
559
+ : typeof error.status === "number"
560
+ ? error.status
561
+ // Internal response.failed envelopes carry the classified {type, code, message}
562
+ // but no numeric status. Derive it with the same mapping /api/logs uses so a
563
+ // classified 429/401/400 reaches Claude Code as its real Anthropic error type
564
+ // instead of being masked as retryable overload.
565
+ : httpStatusFromTerminalError({
566
+ type: typeof error.type === "string" ? error.type : undefined,
567
+ code: typeof error.code === "string" ? error.code : null,
568
+ message,
569
+ });
570
+ // Unclassified status-absent response.failed (relaySseWithFailedTail synthetic
571
+ // tail) still lands on a transient 5xx here — the mid-stream reset shape maps to
559
572
  // overloaded_error by design.
560
573
  fail(
561
574
  status,
package/src/cli/access.ts CHANGED
@@ -17,6 +17,51 @@ const USAGE = `Usage:
17
17
  ocx access models [--json]
18
18
  ocx access test <model> [--protocol <chat|responses|messages>] [--json]`;
19
19
 
20
+ /**
21
+ * Render the key table with the usage fields the API already returns (#2705).
22
+ *
23
+ * `usage` is a DISCRIMINATED UNION server-side (`api-key-usage.ts`): the `{ambiguous:true}`
24
+ * variant carries no numbers at all, because when two config entries share an id there IS no
25
+ * per-key total. The union exists specifically so a consumer cannot print a number beside an
26
+ * ambiguity marker, so this renders the word `ambiguous` across the numeric columns rather
27
+ * than a fabricated 0 -- reporting 0 requests for a key that may be in heavy use is the
28
+ * dangerous answer to hand someone deciding what to delete.
29
+ *
30
+ * `attributionSince` and `historyTruncated` describe the DATA SET, not a key, so they print
31
+ * once as a footer. Without `attributionSince`, an absent `lastUsedAt` is unreadable: it
32
+ * could mean "never used" or "nothing is attributable yet".
33
+ */
34
+ function formatKeyRows(payload: Record<string, unknown>, keys: Array<Record<string, unknown>>): string[] {
35
+ const cells: string[][] = [["ID", "NAME", "PREFIX", "REQ 7D", "TOTAL", "LAST USED"]];
36
+ for (const entry of keys) {
37
+ const usage = (entry.usage ?? {}) as Record<string, unknown>;
38
+ const ambiguous = usage.ambiguous === true;
39
+ const num = (value: unknown): string => (typeof value === "number" ? value.toLocaleString("en-US") : "-");
40
+ cells.push([
41
+ String(entry.id ?? ""),
42
+ String(entry.name ?? ""),
43
+ String(entry.prefix ?? ""),
44
+ // One marker spanning both numeric columns: the union guarantees neither exists.
45
+ ambiguous ? "ambiguous" : num(usage.requests7d),
46
+ ambiguous ? "" : num(usage.totalRequests),
47
+ ambiguous ? "" : (typeof usage.lastUsedAt === "string" ? usage.lastUsedAt : "never"),
48
+ ]);
49
+ }
50
+ const widths = cells[0]!.map((_, column) => Math.max(...cells.map(row => (row[column] ?? "").length)));
51
+ const lines = cells.map(row => row.map((cell, i) => (cell ?? "").padEnd(widths[i]!)).join(" ").trimEnd());
52
+ const footer: string[] = [];
53
+ if (typeof payload.attributionSince === "string") {
54
+ footer.push(`attribution since ${payload.attributionSince}`);
55
+ }
56
+ if (payload.historyTruncated === true) {
57
+ footer.push("older history truncated");
58
+ }
59
+ if (keys.some(entry => (entry.usage as Record<string, unknown> | undefined)?.ambiguous === true)) {
60
+ footer.push("ambiguous: two configured keys share an id, so per-key totals do not exist");
61
+ }
62
+ return footer.length > 0 ? [...lines, "", ...footer] : lines;
63
+ }
64
+
20
65
  async function key(argv: string[], deps: RuntimeApiDeps): Promise<void> {
21
66
  const args = [...argv];
22
67
  const action = (args.shift() ?? "list").toLowerCase();
@@ -25,9 +70,7 @@ async function key(argv: string[], deps: RuntimeApiDeps): Promise<void> {
25
70
  rejectArgs(args, USAGE);
26
71
  const result = await runtimeRequest<Record<string, unknown>>("/api/keys", {}, deps);
27
72
  const keys = Array.isArray(result.keys) ? result.keys as Array<Record<string, unknown>> : [];
28
- printData(result, wantsJson, keys.length
29
- ? keys.map(entry => `${String(entry.id)} ${String(entry.name)} ${String(entry.prefix ?? "")}`)
30
- : ["No API access keys configured."]);
73
+ printData(result, wantsJson, keys.length ? formatKeyRows(result, keys) : ["No API access keys configured."]);
31
74
  return;
32
75
  }
33
76
  if (action === "create") {