@bitkyc08/opencodex 2.90.0 → 2.92.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 (250) hide show
  1. package/bin/ocx.mjs +37 -3
  2. package/gui/dist/assets/App-DMps4OLk.css +1 -0
  3. package/gui/dist/assets/{App-DgxkE6Md.js → App-sUf_8afJ.js} +18 -18
  4. package/gui/dist/assets/{Tray-DbTNc_88.js → Tray-BSsxznDm.js} +1 -1
  5. package/gui/dist/assets/index-BlFE_VB_.js +94 -0
  6. package/gui/dist/assets/quota-summary-BRlbXgw0.js +1 -0
  7. package/gui/dist/index.html +1 -1
  8. package/gui/dist/provider-icons/workbuddy-color.svg +29 -0
  9. package/package.json +4 -3
  10. package/src/adapters/anthropic.ts +2 -5
  11. package/src/adapters/claude-cli/usage-admission.ts +14 -0
  12. package/src/adapters/google-vertex-endpoint.ts +9 -1
  13. package/src/adapters/google-vertex-key-project.ts +223 -0
  14. package/src/adapters/google.ts +13 -3
  15. package/src/adapters/openai-chat/tool-name-registry.ts +3 -1
  16. package/src/adapters/openai-chat.ts +2 -2
  17. package/src/adapters/openai-responses/passthrough.ts +2 -1
  18. package/src/adapters/openai-responses/request-strips.ts +92 -0
  19. package/src/adapters/openai-responses/transport.ts +6 -0
  20. package/src/adapters/registry.ts +5 -0
  21. package/src/adapters/workbuddy-schema.ts +61 -0
  22. package/src/adapters/workbuddy.ts +68 -0
  23. package/src/bridge/response-json.ts +1 -1
  24. package/src/bridge/sse.ts +1 -1
  25. package/src/chat/inbound.ts +20 -2
  26. package/src/chat/outbound.ts +47 -8
  27. package/src/chat/reasoning-details.ts +137 -0
  28. package/src/cli/capabilities-agents-routing.ts +53 -3
  29. package/src/cli/capabilities-base.ts +15 -2
  30. package/src/cli/capabilities-observe-system.ts +4 -4
  31. package/src/cli/codex-cli-update.ts +8 -0
  32. package/src/cli/codex-shim-autorestore.ts +5 -0
  33. package/src/cli/combo-result.ts +6 -0
  34. package/src/cli/combo-stats.ts +3 -0
  35. package/src/cli/combo.ts +66 -42
  36. package/src/cli/dispatch.ts +30 -0
  37. package/src/cli/fallback.ts +127 -0
  38. package/src/cli/help.ts +1 -0
  39. package/src/cli/index.ts +1 -2
  40. package/src/cli/launcher-context.ts +49 -2
  41. package/src/cli/link-apply-restart.ts +72 -0
  42. package/src/cli/link-child.ts +6 -2
  43. package/src/cli/link-cleanup.ts +95 -0
  44. package/src/cli/link-enrollment.ts +4 -0
  45. package/src/cli/link.ts +123 -35
  46. package/src/cli/provider.ts +46 -3
  47. package/src/cli/registry.ts +13 -1
  48. package/src/cli/restart-failure.ts +4 -0
  49. package/src/cli/runtime-api.ts +1 -1
  50. package/src/cli/status-codex-settings.ts +56 -0
  51. package/src/cli/status-fallback.ts +29 -0
  52. package/src/cli/status-install.ts +52 -0
  53. package/src/cli/status.ts +20 -1
  54. package/src/cli/system-restart-client.ts +32 -1
  55. package/src/cli/update-restart-child.ts +4 -3
  56. package/src/cli/update-restart-home.ts +20 -8
  57. package/src/cli/usage-compare.ts +68 -12
  58. package/src/client/connect.ts +82 -6
  59. package/src/client/home-unreachable.ts +16 -0
  60. package/src/client/hub-relay.ts +8 -2
  61. package/src/client/link-home.ts +119 -6
  62. package/src/client/link-ingress.ts +1 -1
  63. package/src/client/link-parity-reporter.ts +107 -0
  64. package/src/client/link-readiness.ts +97 -0
  65. package/src/client/link-relay-transport.ts +84 -9
  66. package/src/client/link-relay.ts +98 -15
  67. package/src/client/link-tunnel.ts +45 -18
  68. package/src/client/machine-listener.ts +103 -3
  69. package/src/client/runtime-restart.ts +127 -0
  70. package/src/client/runtime.ts +155 -25
  71. package/src/codex/account-store.ts +17 -3
  72. package/src/codex/app-server-restart-service.ts +6 -5
  73. package/src/codex/auth-context.ts +10 -6
  74. package/src/codex/catalog/effort.ts +9 -2
  75. package/src/codex/catalog/gather-capture.ts +4 -2
  76. package/src/codex/catalog/parsing.ts +4 -0
  77. package/src/codex/catalog/provider-models.ts +14 -8
  78. package/src/codex/chatgpt-refresh-failure.ts +6 -2
  79. package/src/codex/cli-install-provenance.ts +418 -0
  80. package/src/codex/cli-installation-targets.ts +5 -1
  81. package/src/codex/cli-update-plan.ts +43 -16
  82. package/src/codex/desktop-app/picker-freshness.ts +10 -8
  83. package/src/codex/desktop-compatibility/usage-activation.ts +2 -1
  84. package/src/codex/desktop-compatibility/usage-controller.ts +7 -1
  85. package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
  86. package/src/codex/pool-refresh-backoff.ts +3 -1
  87. package/src/codex/quota-history.ts +7 -0
  88. package/src/codex/quota.ts +10 -0
  89. package/src/codex/subagent-model-fallback.ts +24 -1
  90. package/src/codex/user-identity.ts +35 -6
  91. package/src/codex/windows-current-sid.ts +162 -0
  92. package/src/combos/index.ts +1 -0
  93. package/src/combos/jev-quota-activation.ts +5 -1
  94. package/src/combos/jev-quota-route.ts +1 -1
  95. package/src/combos/jev-quota.ts +9 -1
  96. package/src/combos/jev-session-pin-config.ts +51 -0
  97. package/src/combos/jev.ts +6 -0
  98. package/src/combos/resolve.ts +12 -0
  99. package/src/combos/types.ts +19 -0
  100. package/src/config/diagnostics.ts +5 -0
  101. package/src/config/load-degrade.ts +2 -0
  102. package/src/config/provider-validation.ts +7 -0
  103. package/src/config/schema/config-schema.ts +3 -0
  104. package/src/config/schema/fallback-policy.ts +40 -0
  105. package/src/config/schema/leaf-validators.ts +1 -0
  106. package/src/generated/compatibility-version.json +375 -195
  107. package/src/lib/authorization-scheme.ts +95 -0
  108. package/src/lib/decision-quota-sink.ts +10 -0
  109. package/src/lib/provider-client-headers.ts +1 -0
  110. package/src/lib/redact.ts +2 -2
  111. package/src/lib/system-restart-contract.ts +24 -0
  112. package/src/lib/test-home-guard.ts +78 -11
  113. package/src/lib/translator-budget.ts +4 -4
  114. package/src/lib/upstream-retry.ts +4 -1
  115. package/src/link/child-parity.ts +124 -0
  116. package/src/link/codex-settings.ts +37 -0
  117. package/src/link/compatibility.ts +119 -0
  118. package/src/link/diagnostics.ts +193 -0
  119. package/src/link/management-routes.ts +2 -0
  120. package/src/link/peer-report.ts +100 -0
  121. package/src/link/relay-auth.ts +6 -0
  122. package/src/link/remote-platform.ts +78 -0
  123. package/src/link/ssh-argv.ts +19 -0
  124. package/src/link/status-projection.ts +63 -2
  125. package/src/link/supervisor.ts +45 -12
  126. package/src/link/tunnel-history.ts +109 -0
  127. package/src/link/tunnel-state.ts +80 -17
  128. package/src/messaging/remote-files.ts +24 -1
  129. package/src/oauth/generic-account-failover.ts +91 -7
  130. package/src/oauth/index.ts +16 -1
  131. package/src/oauth/kimi.ts +2 -0
  132. package/src/oauth/model-discovery-auth.ts +5 -0
  133. package/src/oauth/nous.ts +4 -1
  134. package/src/oauth/store.ts +6 -2
  135. package/src/oauth/token-guardian.ts +7 -0
  136. package/src/oauth/types.ts +9 -0
  137. package/src/oauth/workbuddy.ts +139 -0
  138. package/src/protocols/codecs/chat.ts +3 -3
  139. package/src/protocols/encoders/adapter-events.ts +26 -2
  140. package/src/protocols/encoders/chat.ts +55 -3
  141. package/src/providers/antigravity-quota-identity.ts +37 -0
  142. package/src/providers/base-url-choices.ts +8 -5
  143. package/src/providers/derive.ts +23 -3
  144. package/src/providers/mistral-transport.ts +37 -4
  145. package/src/providers/model-discovery.ts +2 -0
  146. package/src/providers/model-rename-fields.ts +1 -0
  147. package/src/providers/quota/account-cache.ts +3 -2
  148. package/src/providers/quota/antigravity.ts +27 -22
  149. package/src/providers/quota/report-cache.ts +13 -4
  150. package/src/providers/quota/vendor-probes-key.ts +60 -0
  151. package/src/providers/quota/workbuddy.ts +83 -0
  152. package/src/providers/quota-decision-antigravity.ts +64 -0
  153. package/src/providers/quota-decision-publication.ts +11 -0
  154. package/src/providers/quota-decision-snapshot.ts +21 -4
  155. package/src/providers/quota-types.ts +2 -0
  156. package/src/providers/quota.ts +3 -1
  157. package/src/providers/registry/entries-core.ts +9 -0
  158. package/src/providers/registry/entries-extended.ts +20 -7
  159. package/src/providers/registry/model-ids.ts +3 -0
  160. package/src/providers/registry/model-seeds.ts +27 -28
  161. package/src/providers/registry/types.ts +19 -1
  162. package/src/providers/resolved-model-policy.ts +4 -4
  163. package/src/providers/routed-config.ts +15 -0
  164. package/src/providers/stale-model-roster-migration.ts +83 -15
  165. package/src/providers/workbuddy-credit-balance.ts +81 -0
  166. package/src/providers/workbuddy-models.ts +37 -0
  167. package/src/providers/workbuddy.ts +60 -0
  168. package/src/quota-pool/assemble.ts +326 -0
  169. package/src/quota-pool/contract.ts +58 -0
  170. package/src/quota-pool/identity.ts +203 -0
  171. package/src/remote/features.ts +74 -0
  172. package/src/remote/protocol.ts +2 -0
  173. package/src/remote/served-by.ts +57 -0
  174. package/src/responses/parser.ts +7 -0
  175. package/src/responses/reasoning-envelope.ts +12 -0
  176. package/src/responses/reasoning-replay-cache.ts +18 -14
  177. package/src/router.ts +2 -0
  178. package/src/routing/analytics.ts +2 -3
  179. package/src/routing/fallback-policy.ts +290 -0
  180. package/src/routing/fallback-state.ts +179 -0
  181. package/src/server/audio-client.ts +2 -1
  182. package/src/server/audio-dictation.ts +2 -1
  183. package/src/server/audio-upstream.ts +2 -1
  184. package/src/server/auth-cors.ts +7 -5
  185. package/src/server/background-lifecycle.ts +4 -0
  186. package/src/server/chat-completions.ts +22 -2
  187. package/src/server/chat-native-reasoning.ts +10 -3
  188. package/src/server/chat-native.ts +13 -2
  189. package/src/server/claude-messages.ts +3 -0
  190. package/src/server/gui-session.ts +2 -1
  191. package/src/server/index/link-listener.ts +11 -1
  192. package/src/server/index/link-management.ts +47 -0
  193. package/src/server/index/link-relay-sessions.ts +20 -7
  194. package/src/server/index/optional-listeners.ts +11 -2
  195. package/src/server/index/serve-options.ts +19 -2
  196. package/src/server/live.ts +5 -0
  197. package/src/server/local-management-read-client.ts +3 -1
  198. package/src/server/management/combo-routes.ts +10 -4
  199. package/src/server/management/context.ts +1 -1
  200. package/src/server/management/fallback-routes.ts +75 -0
  201. package/src/server/management/link-enrollment.ts +21 -3
  202. package/src/server/management/link-route-state.ts +2 -0
  203. package/src/server/management/link-routes.ts +16 -2
  204. package/src/server/management/provider-routes.ts +1 -1
  205. package/src/server/management/quota-pool-routes.ts +24 -0
  206. package/src/server/management/route-registry.ts +3 -0
  207. package/src/server/management-api.ts +16 -0
  208. package/src/server/management-auth.ts +2 -1
  209. package/src/server/port-reclaim.ts +51 -9
  210. package/src/server/request-log-conversation.ts +49 -0
  211. package/src/server/request-log.ts +2 -0
  212. package/src/server/responses/agent-task-recovery.ts +1 -1
  213. package/src/server/responses/anthropic-thinking-recovery.ts +1 -0
  214. package/src/server/responses/compact.ts +12 -4
  215. package/src/server/responses/core-auth.ts +11 -1
  216. package/src/server/responses/core-combo.ts +125 -32
  217. package/src/server/responses/core-lifetime.ts +2 -2
  218. package/src/server/responses/core-normalize.ts +6 -3
  219. package/src/server/responses/core-opaque-recovery.ts +1 -0
  220. package/src/server/responses/core-options.ts +5 -0
  221. package/src/server/responses/core-replay.ts +2 -1
  222. package/src/server/responses/encrypted-payload.ts +3 -3
  223. package/src/server/responses/jev-combo-choice.ts +39 -0
  224. package/src/server/responses/jev-session-pin.ts +203 -0
  225. package/src/server/responses/main-hard-lock-routing.ts +54 -2
  226. package/src/server/responses/passthrough-delivery.ts +4 -0
  227. package/src/server/responses/policy-fallback.ts +44 -1
  228. package/src/server/responses/request-prepare.ts +19 -6
  229. package/src/server/responses/request-transport.ts +25 -6
  230. package/src/server/responses/run-turn-execution.ts +2 -4
  231. package/src/server/responses/sidecar-execution.ts +2 -4
  232. package/src/server/responses-phase-inference.ts +267 -0
  233. package/src/server/restart-replacement.ts +50 -2
  234. package/src/server/sse-payload-rewrite.ts +14 -1
  235. package/src/server/ws-bridge.ts +5 -0
  236. package/src/service/cli.ts +25 -2
  237. package/src/service/definition.ts +20 -8
  238. package/src/service/diagnostics.ts +20 -6
  239. package/src/service/status-json.ts +116 -0
  240. package/src/types/config.ts +13 -0
  241. package/src/types/fallback.ts +38 -0
  242. package/src/types/provider.ts +9 -1
  243. package/src/types/request.ts +1 -0
  244. package/src/types/wire.ts +1 -0
  245. package/src/update/codex-cli-update-launch-policy.d.mts +9 -0
  246. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  247. package/src/usage/jev-stats.ts +54 -14
  248. package/gui/dist/assets/App-dnkJ85te.css +0 -1
  249. package/gui/dist/assets/index-TmaHa54u.js +0 -94
  250. package/gui/dist/assets/quota-summary-B16x5DZZ.js +0 -1
@@ -24,6 +24,66 @@ export function stripProviderInputItemMetadata(body: unknown): unknown {
24
24
  input ??= [...body.input];
25
25
  input[index] = next;
26
26
  }
27
+ return repairUrlCitationAnnotations(input ? { ...body, input } : body);
28
+ }
29
+
30
+ function isUrlCitationIndex(value: unknown): value is number {
31
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
32
+ }
33
+
34
+ /** Coerce a replayed citation index to a non-negative safe integer, falling back to 0. */
35
+ function repairUrlCitationIndex(value: unknown): number {
36
+ if (isUrlCitationIndex(value)) return value;
37
+ if (typeof value === "string" && /^\d+$/.test(value)) {
38
+ const index = Number(value);
39
+ if (isUrlCitationIndex(index)) return index;
40
+ }
41
+ return 0;
42
+ }
43
+
44
+ /**
45
+ * OpenAI Responses requires `title` (string) and integer `start_index`/`end_index` on every
46
+ * `url_citation` annotation of a replayed `output_text` part. History written by an older bridge
47
+ * (title omitted when the search source had none) or a lossy client otherwise fails every later
48
+ * request carrying it, most visibly remote compaction, with a deterministic HTTP 400
49
+ * (`Missing required parameter: input[N].content[M].annotations[K].title`).
50
+ * Repair only those fields on `url_citation` annotations of assistant message content; other
51
+ * annotation types, unknown keys and every other item are untouched. Copy on write: an already
52
+ * valid body is returned by reference.
53
+ */
54
+ export function repairUrlCitationAnnotations(body: unknown): unknown {
55
+ if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
56
+ let input: unknown[] | undefined;
57
+ for (let itemIndex = 0; itemIndex < body.input.length; itemIndex += 1) {
58
+ const item = body.input[itemIndex];
59
+ if (!isPlainObject(item) || !Array.isArray(item.content)) continue;
60
+ let content: unknown[] | undefined;
61
+ for (let partIndex = 0; partIndex < item.content.length; partIndex += 1) {
62
+ const part = item.content[partIndex];
63
+ if (!isPlainObject(part) || part.type !== "output_text" || !Array.isArray(part.annotations)) continue;
64
+ let annotations: unknown[] | undefined;
65
+ for (let annIndex = 0; annIndex < part.annotations.length; annIndex += 1) {
66
+ const ann = part.annotations[annIndex];
67
+ if (!isPlainObject(ann) || ann.type !== "url_citation") continue;
68
+ const titleOk = typeof ann.title === "string";
69
+ const startOk = isUrlCitationIndex(ann.start_index);
70
+ const endOk = isUrlCitationIndex(ann.end_index);
71
+ if (titleOk && startOk && endOk) continue;
72
+ const next: Record<string, unknown> = { ...ann };
73
+ if (!titleOk) next.title = typeof ann.url === "string" ? ann.url : "";
74
+ if (!startOk) next.start_index = repairUrlCitationIndex(ann.start_index);
75
+ if (!endOk) next.end_index = repairUrlCitationIndex(ann.end_index);
76
+ annotations ??= [...part.annotations];
77
+ annotations[annIndex] = next;
78
+ }
79
+ if (!annotations) continue;
80
+ content ??= [...item.content];
81
+ content[partIndex] = { ...part, annotations };
82
+ }
83
+ if (!content) continue;
84
+ input ??= [...body.input];
85
+ input[itemIndex] = { ...item, content };
86
+ }
27
87
  return input ? { ...body, input } : body;
28
88
  }
29
89
 
@@ -187,6 +247,38 @@ export function stripCanonicalOnlyTopLevelFields(body: unknown): unknown {
187
247
  return next;
188
248
  }
189
249
 
250
+ /**
251
+ * OpenAI-private input item TYPES, the item-level sibling of the two tables above.
252
+ *
253
+ * Each one is a positional control the OpenAI Responses backend interprets where it sits in
254
+ * history, never a turn of the conversation, so dropping it leaves every message, call and output
255
+ * in place. Codex gates sending one on its own `is_openai` provider check, which loopback
256
+ * injection always satisfies, so a gateway this proxy does not operate can still receive one and
257
+ * reject the whole body as an unknown item type.
258
+ *
259
+ * Same rule as the top-level table: only types a client is OBSERVED to send.
260
+ */
261
+ const CANONICAL_ONLY_INPUT_ITEM_TYPES: ReadonlySet<string> = new Set([
262
+ // Codex 0.164 `ResponseItem::ConfigurationUpdate` ({type, reasoning: {effort}}): a durable
263
+ // reasoning-effort change recorded at its position in history. codex-rs sends it only for a
264
+ // catalog row with `supports_reasoning_effort_updates`, and keeps it in saved history after a
265
+ // model switch. Mapping the effort onto another provider is out of scope (#6895).
266
+ "configuration_update",
267
+ ]);
268
+
269
+ /**
270
+ * Remove OpenAI-private input items. The caller decides the boundary, exactly as for
271
+ * {@link stripCanonicalOnlyTopLevelFields}; the canonical and OpenAI-operated destinations keep
272
+ * the items verbatim and in order. Returns the input unchanged when none is present.
273
+ */
274
+ export function stripCanonicalOnlyInputItems(body: unknown): unknown {
275
+ if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
276
+ const isPrivate = (item: unknown): boolean => isPlainObject(item)
277
+ && typeof item.type === "string" && CANONICAL_ONLY_INPUT_ITEM_TYPES.has(item.type);
278
+ if (!body.input.some(isPrivate)) return body;
279
+ return { ...body, input: body.input.filter(item => !isPrivate(item)) };
280
+ }
281
+
190
282
  /**
191
283
  * Sampling fields a model on the provider's `noStopModels` / `noPenaltyModels` list rejects on
192
284
  * every wire. Claude inbound translates `stop_sequences` into a Responses `stop`
@@ -3,6 +3,7 @@ import type { OcxProviderConfig } from "../../types";
3
3
  import { CODEX_RESPONSES_LITE_HEADER } from "../../codex/forward-transport-headers";
4
4
  import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers-destination";
5
5
  import { normalizeForwardedClientHeaderName } from "../../lib/provider-client-headers";
6
+ import { AGENT_ASSERTION_HEADER } from "../../lib/authorization-scheme";
6
7
  import { openaiResponsesUrl } from "../openai-responses-url";
7
8
 
8
9
  // Caller credentials are forwarded only to the canonical Codex destination, never by capability.
@@ -69,6 +70,11 @@ export function buildResponsesTransport(provider: OcxProviderConfig, incoming: P
69
70
  if (override && mayForwardCallerCredentials) {
70
71
  headers.authorization = `Bearer ${override.accessToken}`;
71
72
  headers["chatgpt-account-id"] = override.chatgptAccountId;
73
+ } else if (mayForwardCallerCredentials) {
74
+ // dual_header runtime assertion: first-party Direct only, beside the caller's own Bearer.
75
+ // A pool override above never carries it, and non-canonical transports never see it.
76
+ const assertion = incoming.headers.get(AGENT_ASSERTION_HEADER);
77
+ if (assertion) headers[AGENT_ASSERTION_HEADER] = assertion;
72
78
  }
73
79
  } else {
74
80
  url = provider.responsesPath === undefined
@@ -1,4 +1,5 @@
1
1
  import { createAnthropicAdapter } from "./anthropic";
2
+ import { createWorkBuddyAdapter } from "./workbuddy";
2
3
  import { createDshAccountAdapter } from "./dsh-account";
3
4
  import { createAzureAdapter } from "./azure";
4
5
  import type { ProviderAdapter } from "./base";
@@ -74,6 +75,10 @@ type InheritedAdapterDefinition = {
74
75
  type AdapterDefinition = DirectAdapterDefinition | InheritedAdapterDefinition;
75
76
 
76
77
  export const ADAPTER_REGISTRY = {
78
+ "workbuddy-ai": {
79
+ contractParent: "openai-chat",
80
+ create: (provider: OcxProviderConfig) => withUniqueToolCallIds(createWorkBuddyAdapter(provider)),
81
+ },
77
82
  codebuddy: {
78
83
  wire: "codebuddy",
79
84
  mutation: "codex-owned",
@@ -0,0 +1,61 @@
1
+ const schemaMaps = new Set(["properties", "patternProperties", "$defs", "definitions", "dependentSchemas"]);
2
+ const schemaLists = new Set(["anyOf", "oneOf", "allOf", "prefixItems"]);
3
+ const schemaValues = new Set(["items", "additionalProperties", "unevaluatedProperties", "contains", "not", "if", "then", "else", "propertyNames"]);
4
+ const jsonTypes = new Set(["string", "number", "integer", "boolean", "object", "array", "null"]);
5
+
6
+ /** Normalize schemas only; defaults, examples and property names are data, never schemas. */
7
+ export function workBuddySchema(value: unknown, depth = 0): Record<string, unknown> {
8
+ if (depth > 64) throw new Error("WorkBuddy AI tool schema exceeds maximum depth");
9
+ if (value === false) return { not: {} };
10
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
11
+ const input = value as Record<string, unknown>, out = { ...input };
12
+ for (const [key, child] of Object.entries(input)) {
13
+ if (schemaMaps.has(key) && child && typeof child === "object" && !Array.isArray(child)) {
14
+ out[key] = Object.fromEntries(Object.entries(child).map(([name, schema]) => [name, workBuddySchema(schema, depth + 1)]));
15
+ } else if (schemaLists.has(key) && Array.isArray(child)) {
16
+ out[key] = child.map(schema => workBuddySchema(schema, depth + 1));
17
+ } else if (schemaValues.has(key)) {
18
+ if ((key === "additionalProperties" || key === "unevaluatedProperties") && typeof child === "boolean") continue;
19
+ out[key] = key === "items" && Array.isArray(child)
20
+ ? { anyOf: child.map(schema => workBuddySchema(schema, depth + 1)) } : workBuddySchema(child, depth + 1);
21
+ }
22
+ }
23
+ if (typeof out.pattern === "string") out.pattern = out.pattern.replace(/\\\\|\\0(?![0-9])/g, match => match === "\\0" ? "\\u0000" : match);
24
+ if (out.required !== undefined && !Array.isArray(out.required)) delete out.required;
25
+ if (typeof out.type === "string") {
26
+ out.type = out.type.toLowerCase();
27
+ if (!jsonTypes.has(out.type as string)) delete out.type;
28
+ }
29
+ if (Array.isArray(out.enum)) {
30
+ if (!out.enum.length) delete out.enum;
31
+ else if (out.type === undefined) {
32
+ const groups = new Map<string, unknown[]>();
33
+ for (const item of out.enum) {
34
+ const type = item === null ? "null" : Array.isArray(item) ? "array" : typeof item;
35
+ const values = groups.get(type) ?? []; values.push(item); groups.set(type, values);
36
+ }
37
+ if (groups.size === 1) out.type = groups.keys().next().value;
38
+ else {
39
+ const union = { anyOf: [...groups].map(([type, values]) => ({ type, enum: values })) };
40
+ out.allOf = [...(Array.isArray(out.allOf) ? out.allOf : []), union];
41
+ delete out.enum;
42
+ }
43
+ }
44
+ }
45
+ return out;
46
+ }
47
+
48
+ export function workBuddyToolParameters(value: unknown): Record<string, unknown> {
49
+ const out = workBuddySchema(value);
50
+ const properties: Record<string, unknown> = { ...(out.properties as Record<string, unknown> ?? {}) };
51
+ const required = new Set(Array.isArray(out.required) ? out.required.filter(x => typeof x === "string") : []);
52
+ for (const key of ["anyOf", "oneOf", "allOf"]) {
53
+ for (const variant of Array.isArray(out[key]) ? out[key] : []) {
54
+ const row = variant as Record<string, unknown>;
55
+ Object.assign(properties, row.properties);
56
+ if (key === "allOf" && Array.isArray(row.required)) for (const field of row.required) if (typeof field === "string") required.add(field);
57
+ }
58
+ delete out[key];
59
+ }
60
+ return { ...out, type: "object", properties, ...(required.size ? { required: [...required] } : {}) };
61
+ }
@@ -0,0 +1,68 @@
1
+ import { createHmac, randomBytes, randomUUID } from "node:crypto";
2
+ import type { ProviderAdapter } from "./base";
3
+ import { workBuddyToolParameters } from "./workbuddy-schema";
4
+ import { retainTranslatedEvent, releaseTranslatedEvent } from "../lib/translator-budget";
5
+ import { createOpenAIChatAdapter } from "./openai-chat";
6
+ import type { AdapterEvent, OcxProviderConfig } from "../types";
7
+ import { assertWorkBuddyDestination, WORKBUDDY_BASE_URL, workBuddyHeaders } from "../providers/workbuddy";
8
+
9
+ const sessionSalt = randomBytes(32);
10
+ const opaqueId = (value: unknown) => createHmac("sha256", sessionSalt).update(JSON.stringify(value)).digest("hex").slice(0, 32);
11
+
12
+ export function createWorkBuddyAdapter(provider: OcxProviderConfig): ProviderAdapter {
13
+ const base = createOpenAIChatAdapter({ ...provider, adapter: "openai-chat", baseUrl: WORKBUDDY_BASE_URL, headers: undefined });
14
+ return {
15
+ ...base, name: "workbuddy-ai",
16
+ async buildRequest(parsed, incoming) {
17
+ assertWorkBuddyDestination(provider.baseUrl);
18
+ if (!provider.apiKey || !parsed._workbuddyAuthContext) throw new Error("WorkBuddy AI account unavailable; run ocx login workbuddy-ai");
19
+ const req = await base.buildRequest({ ...parsed, stream: true }, incoming);
20
+ const body = JSON.parse(req.body);
21
+ const messages = body.messages as Array<Record<string, unknown>>;
22
+ for (const message of messages) if (message.role === "developer") message.role = "system";
23
+ if (messages[0]?.role !== "system") messages.unshift({ role: "system", content: "You are a helpful assistant." });
24
+ if (body.max_completion_tokens !== undefined) { body.max_tokens = body.max_completion_tokens; delete body.max_completion_tokens; }
25
+ if (body.tool_choice === "none") { delete body.tools; delete body.tool_choice; }
26
+ else if (body.tool_choice?.type === "function") body.tool_choice = body.tool_choice.function.name;
27
+ for (const tool of body.tools ?? []) {
28
+ if (tool.type === "function") tool.function.parameters = workBuddyToolParameters(tool.function.parameters);
29
+ }
30
+ const users = messages.map((m, index) => ({ m, index })).filter(({ m }) => m.role === "user");
31
+ const session = parsed._codexOwnThreadId || parsed._clientThreadId
32
+ || (!parsed._promptCacheKeyIsSharedCohort ? parsed.options.promptCacheKey : undefined)
33
+ || incoming.headers.get("session_id") || incoming.headers.get("x-session-id");
34
+ const conversation = session || users[0]?.m.content;
35
+ const latest = users.at(-1);
36
+ const requestId = randomUUID().replaceAll("-", "");
37
+ const headers = {
38
+ "Content-Type": "application/json", Accept: "text/event-stream",
39
+ ...workBuddyHeaders(provider.apiKey, parsed._workbuddyAuthContext),
40
+ "X-Request-ID": requestId, "X-Conversation-Message-ID": requestId,
41
+ "X-Conversation-Request-ID": latest ? opaqueId([conversation, latest.index, latest.m.content]) : requestId,
42
+ ...(conversation ? { "X-Conversation-ID": opaqueId(conversation) } : {}),
43
+ };
44
+ return { ...req, url: `${WORKBUDDY_BASE_URL}/chat/completions`, headers, body: JSON.stringify(body), redirect: "error" as const };
45
+ },
46
+ async parseResponse(response, budget, tier) {
47
+ const events: AdapterEvent[] = [];
48
+ try {
49
+ for await (const event of base.parseStream(response, budget, tier)) {
50
+ retainTranslatedEvent(event, budget, events.at(-1));
51
+ events.push(event);
52
+ }
53
+ return events;
54
+ } catch (error) {
55
+ for (const event of events) releaseTranslatedEvent(event, budget);
56
+ throw error;
57
+ }
58
+ },
59
+ formatErrorBody(status, _headers, payload) {
60
+ let code: unknown;
61
+ try { code = JSON.parse(payload)?.code; } catch { /* Only structured numeric codes are public. */ }
62
+ if (Number(code) === 11128) return "WorkBuddy AI rejected this client channel (11128).";
63
+ if (Number(code) === 11140) return "WorkBuddy AI restricted this account (11140); contact WorkBuddy support.";
64
+ if (Number(code) === 14018) return "WorkBuddy AI credits exhausted (14018).";
65
+ return `WorkBuddy AI request failed (HTTP ${status}).`;
66
+ },
67
+ };
68
+ }
@@ -253,7 +253,7 @@ function buildResponseJSONWithBudget(
253
253
  const text = stripCitationMarkers(currentTextStr);
254
254
  const sourceBytes = pendingWebSources.reduce((sum, source) => sum + bytesOf(JSON.stringify(source)), 0);
255
255
  const annotations = pendingWebSources.map(s => ({
256
- type: "url_citation", url: s.url, ...(s.title ? { title: s.title } : {}), start_index: 0, end_index: 0,
256
+ type: "url_citation", url: s.url, title: s.title || s.url, start_index: 0, end_index: 0,
257
257
  }));
258
258
  pendingWebSources = [];
259
259
  const item = {
package/src/bridge/sse.ts CHANGED
@@ -488,7 +488,7 @@ export function bridgeToResponsesSSE(
488
488
  const takeWebAnnotations = (): { type: string; url: string; title?: string; start_index: number; end_index: number }[] => {
489
489
  if (pendingWebSources.length === 0) return [];
490
490
  const anns = pendingWebSources.map(s => ({
491
- type: "url_citation", url: s.url, ...(s.title ? { title: s.title } : {}), start_index: 0, end_index: 0,
491
+ type: "url_citation", url: s.url, title: s.title || s.url, start_index: 0, end_index: 0,
492
492
  }));
493
493
  const annotationBytes = bytesOf(JSON.stringify(anns));
494
494
  const reservation = budget?.reserveTransient(annotationBytes, { kind: "retained_collectors" });
@@ -6,6 +6,7 @@
6
6
  * responsesRequestSchema so routing/OAuth/pool/sidecars are inherited unchanged.
7
7
  */
8
8
  import { chatImageUrlFromPart } from "./image-parts";
9
+ import { signedReasoningInputItems } from "./reasoning-details";
9
10
  import { untranslatedChatInputMedia, untranslatedInputMediaMessage } from "../responses/input-media";
10
11
 
11
12
  export class ChatCompletionsRequestError extends Error {}
@@ -439,7 +440,16 @@ function resolveReasoningSummary(raw: Rec): string | undefined {
439
440
  * Translate an OpenAI Chat Completions request body into a /v1/responses request body.
440
441
  * Throws ChatCompletionsRequestError (-> 400) on malformed input.
441
442
  */
442
- export function chatCompletionsToResponsesBody(raw: unknown): Rec {
443
+ export interface ChatResponsesProjectionOptions {
444
+ /**
445
+ * The route is one first-party Anthropic destination that cannot change credential domain, so
446
+ * signed thinking a client returns in `reasoning_details` may be replayed to it (#7429).
447
+ * Every other route keeps the plaintext projection.
448
+ */
449
+ acceptAnthropicReasoningSignatures?: boolean;
450
+ }
451
+
452
+ export function chatCompletionsToResponsesBody(raw: unknown, options: ChatResponsesProjectionOptions = {}): Rec {
443
453
  assertChatCompletionsRoutingBody(raw);
444
454
  // Only the translated path reaches this function. Native Chat can retain its
445
455
  // provider-specific file/audio blocks; projecting them here would discard them.
@@ -510,7 +520,13 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec {
510
520
  // is no attachment point on the message itself, and the parser buffers a
511
521
  // reasoning item and prepends it to the NEXT assistant message. Emitting it
512
522
  // here keeps that adjacency intact.
513
- const reasoningText = assistantReasoningText(msg);
523
+ const signedReasoning = options.acceptAnthropicReasoningSignatures === true
524
+ ? signedReasoningInputItems(msg.reasoning_details)
525
+ : undefined;
526
+ const reasoningText = signedReasoning ? undefined : assistantReasoningText(msg);
527
+ // Signed entries carry their own text, so `reasoning_content` (the same text) is not
528
+ // projected a second time beside them.
529
+ if (signedReasoning) input.push(...signedReasoning);
514
530
  if (reasoningText !== undefined) {
515
531
  // `summary` is required on a reasoning input item by the OpenAI Responses API, and our
516
532
  // own responsesRequestSchema marks it optional, so a summary-less item validated locally
@@ -525,6 +541,8 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec {
525
541
  content: [{ type: "reasoning_text", text: reasoningText }],
526
542
  });
527
543
  }
544
+ // An Opus progress update also sits in `content`. The wire has no marker that tells it
545
+ // from answer text, so the content is replayed as sent, as it was before #7429.
528
546
  const blocks = assistantContentToBlocks(msg.content);
529
547
  if (blocks.length > 0) input.push({ type: "message", role: "assistant", content: blocks });
530
548
  if (msg.tool_calls !== undefined) {
@@ -22,7 +22,9 @@ import {
22
22
  UNDECLARED_TOOL_CALL_ERROR_CODE,
23
23
  } from "../lib/errors";
24
24
  import { redactSecretString } from "../lib/redact";
25
+ import { truncationReasonFor } from "../responses/truncated-stop-reason";
25
26
  import { createSseBlockBuffer, sseDataPayload } from "../server/sse-payload-rewrite";
27
+ import { reasoningDetailEntriesForItem } from "./reasoning-details";
26
28
 
27
29
  function isRec(v: unknown): v is Rec {
28
30
  return !!v && typeof v === "object" && !Array.isArray(v);
@@ -149,7 +151,7 @@ export function chunkBase(id: string, model: string, created: number): Rec {
149
151
  };
150
152
  }
151
153
 
152
- function appendedUtf8Bytes(previous: string, previousBytes: number, fragment: string): number {
154
+ export function appendedUtf8Bytes(previous: string, previousBytes: number, fragment: string): number {
153
155
  let nextBytes = previousBytes + Buffer.byteLength(fragment);
154
156
  const previousLast = previous.charCodeAt(previous.length - 1);
155
157
  const fragmentFirst = fragment.charCodeAt(0);
@@ -242,8 +244,14 @@ export function chatCompletionsIncompleteOutcome(
242
244
  reason: unknown,
243
245
  message: unknown,
244
246
  ): { finishReason: "length" | "content_filter" } | { failMessage: string } {
245
- if (reason === "max_output_tokens") return { finishReason: "length" };
246
- if (reason === "content_filter") return { finishReason: "content_filter" };
247
+ // Upstream Responses endpoints do not all use OpenAI's `max_output_tokens`: Volcengine Ark
248
+ // (Coding Plan `/responses`) sends `incomplete_details.reason: "length"` for a hit output cap.
249
+ // Read the reason through the shared truncation vocabulary so such a turn ends with
250
+ // finish_reason "length" instead of a 502. Proxy reasons (adapter_eof, upstream_stall_timeout,
251
+ // missing_terminal_event) are not in that vocabulary and still fail.
252
+ const truncation = typeof reason === "string" ? truncationReasonFor(reason) : undefined;
253
+ if (truncation === "max_output_tokens") return { finishReason: "length" };
254
+ if (truncation === "content_filter") return { finishReason: "content_filter" };
247
255
  const why = typeof reason === "string" ? reason : "unknown";
248
256
  return {
249
257
  failMessage: typeof message === "string" && message.length > 0
@@ -511,6 +519,17 @@ export function responsesSseToChatCompletionsSse(
511
519
  frame.choices = [{ index: 0, delta: { reasoning_content: text }, finish_reason: null }];
512
520
  emit(frame);
513
521
  };
522
+ // #7429: provider-issued thinking state, one complete frame per finished reasoning item.
523
+ let nextReasoningDetailIndex = 0;
524
+ const emitReasoningDetails = (item: Rec) => {
525
+ const entries = reasoningDetailEntriesForItem(item, nextReasoningDetailIndex, translatorBudget);
526
+ if (entries.length === 0) return;
527
+ nextReasoningDetailIndex += entries.length;
528
+ ensureRole();
529
+ const frame = chunkBase(id, model, created);
530
+ frame.choices = [{ index: 0, delta: { reasoning_details: entries }, finish_reason: null }];
531
+ emit(frame);
532
+ };
514
533
  const resolveFinalArguments = (candidate: unknown, streamed: string) => {
515
534
  if (typeof candidate !== "string") return streamed;
516
535
  // Some compatible providers send an empty final snapshot. Do not let that erase
@@ -717,6 +736,7 @@ export function responsesSseToChatCompletionsSse(
717
736
  && refusalItems.has(data.output_index)) {
718
737
  refusalItem(data.output_index, data, "item_id");
719
738
  }
739
+ if (item.type === "reasoning") emitReasoningDetails(item);
720
740
  if (item.type === "function_call") {
721
741
  sawToolUse = true;
722
742
  const callId = typeof item.call_id === "string" ? item.call_id : "";
@@ -858,8 +878,8 @@ export function responsesJsonToChatCompletion(json: unknown, model: string, tran
858
878
  const incomplete = isRec(body.incomplete_details) ? body.incomplete_details : {};
859
879
  let incompleteFinish: "length" | "content_filter" | undefined;
860
880
  if (body.status === "incomplete") {
861
- if (incomplete.reason === "max_output_tokens") incompleteFinish = "length";
862
- else if (incomplete.reason === "content_filter") incompleteFinish = "content_filter";
881
+ const outcome = chatCompletionsIncompleteOutcome(incomplete.reason, undefined);
882
+ if ("finishReason" in outcome) incompleteFinish = outcome.finishReason;
863
883
  else throw new ChatCompletionsStreamError("upstream response ended without a supported completion boundary", {
864
884
  code: "upstream_incomplete", type: "upstream_error",
865
885
  });
@@ -872,6 +892,7 @@ export function responsesJsonToChatCompletion(json: unknown, model: string, tran
872
892
  let contentBytes = 0;
873
893
  let reasoningBytes = 0;
874
894
  const toolCalls: Rec[] = [];
895
+ const reasoningDetails: Rec[] = [];
875
896
  const append = (previous: string, previousBytes: number, fragment: string): { text: string; bytes: number } => {
876
897
  if (!fragment) return { text: previous, bytes: previousBytes };
877
898
  const scope = { kind: "retained_collectors" as const };
@@ -902,6 +923,11 @@ export function responsesJsonToChatCompletion(json: unknown, model: string, tran
902
923
  }
903
924
  }
904
925
  } else if (raw.type === "reasoning") {
926
+ const entries = reasoningDetailEntriesForItem(raw, reasoningDetails.length, translatorBudget);
927
+ if (entries.length > 0) {
928
+ translatorBudget?.chargeRetained(Buffer.byteLength(JSON.stringify(entries)), { kind: "retained_collectors" });
929
+ reasoningDetails.push(...entries);
930
+ }
905
931
  if (Array.isArray(raw.summary)) {
906
932
  for (const part of raw.summary) {
907
933
  if (isRec(part) && part.type === "summary_text" && typeof part.text === "string") {
@@ -948,6 +974,7 @@ export function responsesJsonToChatCompletion(json: unknown, model: string, tran
948
974
  refusal,
949
975
  };
950
976
  if (reasoning) message.reasoning_content = reasoning;
977
+ if (reasoningDetails.length > 0) message.reasoning_details = reasoningDetails;
951
978
  if (toolCalls.length > 0) message.tool_calls = toolCalls;
952
979
 
953
980
  return {
@@ -981,7 +1008,8 @@ export async function collectChatCompletion(
981
1008
  let content = "";
982
1009
  let refusal: string | null = null;
983
1010
  let reasoning = "";
984
- const retainedBytes = { content: 0, refusal: 0, reasoning: 0 };
1011
+ const reasoningDetails: Rec[] = [];
1012
+ const retainedBytes = { content: 0, refusal: 0, reasoning: 0, reasoningDetails: 0 };
985
1013
  const toolCalls = new Map<number, { id: string; name: string; arguments: string; argumentBytes: number }>();
986
1014
  // Per-call budget scopes (2 MiB/call enforced by the budget): the map key is the
987
1015
  // wire index, which is stable across deltas and present before the call id.
@@ -1005,8 +1033,8 @@ export async function collectChatCompletion(
1005
1033
  }
1006
1034
  };
1007
1035
  const releaseCollectors = () => {
1008
- translatorBudget.releaseRetained(retainedBytes.content + retainedBytes.refusal + retainedBytes.reasoning,
1009
- { kind: "retained_collectors" });
1036
+ translatorBudget.releaseRetained(retainedBytes.content + retainedBytes.refusal + retainedBytes.reasoning
1037
+ + retainedBytes.reasoningDetails, { kind: "retained_collectors" });
1010
1038
  };
1011
1039
  try {
1012
1040
  // Share native relay framing, while retaining the collector's existing admission
@@ -1055,6 +1083,16 @@ export async function collectChatCompletion(
1055
1083
  refusal = appendRetained("refusal", refusal ?? "", delta.refusal);
1056
1084
  }
1057
1085
  if (typeof delta.reasoning_content === "string") reasoning = appendRetained("reasoning", reasoning, delta.reasoning_content);
1086
+ // Each entry arrives complete in one frame (#7429), so the fold is a concatenation.
1087
+ if (Array.isArray(delta.reasoning_details)) {
1088
+ for (const entry of delta.reasoning_details) {
1089
+ if (!isRec(entry)) continue;
1090
+ const entryBytes = Buffer.byteLength(JSON.stringify(entry));
1091
+ translatorBudget.chargeRetained(entryBytes, { kind: "retained_collectors" });
1092
+ retainedBytes.reasoningDetails += entryBytes;
1093
+ reasoningDetails.push(entry);
1094
+ }
1095
+ }
1058
1096
  if (Array.isArray(delta.tool_calls)) {
1059
1097
  for (const tc of delta.tool_calls) {
1060
1098
  if (!isRec(tc)) continue;
@@ -1121,6 +1159,7 @@ export async function collectChatCompletion(
1121
1159
  refusal,
1122
1160
  };
1123
1161
  if (reasoning) message.reasoning_content = reasoning;
1162
+ if (reasoningDetails.length > 0) message.reasoning_details = reasoningDetails;
1124
1163
  if (toolCalls.size > 0) {
1125
1164
  // Final owner transfer is itself a charging operation: if it overflows,
1126
1165
  // release every copy already charged in this loop and close every scope
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Anthropic thinking state on the Chat Completions wire, as OpenRouter-style `reasoning_details`
3
+ * (#7429).
4
+ *
5
+ * Chat has no slot for a thinking block's signature, so a Chat client echoing `reasoning_content`
6
+ * gave Claude back unsigned text, which the Anthropic adapter must drop: every tool step lost its
7
+ * reasoning. The carrier is the one OpenRouter documents for Claude and Hermes and pi already round
8
+ * trip: an ordered array on the assistant message, `{type: "reasoning.text", text, signature}`
9
+ * for a signed thinking block and `{type: "reasoning.encrypted", data}` for a redacted one.
10
+ *
11
+ * Outbound, the entries are built from the signature and redacted payloads the provider issued,
12
+ * never from anything this proxy computes. A signature that signs text the client never saw
13
+ * (hidden thinking) is withheld, because pairing it with the visible text would attest to content
14
+ * Anthropic did not sign. Inbound, the entries are read only for a single-destination first-party
15
+ * Anthropic route (the caller decides that), only when every entry is well formed, and become the
16
+ * same `ocxr1` envelope the Responses wire already uses; anything else falls back to plaintext.
17
+ */
18
+ import {
19
+ decodeReasoningEnvelope,
20
+ encodeReasoningEnvelope,
21
+ isLikelyRealAnthropicThinkingSignature,
22
+ type ReasoningEnvelope,
23
+ } from "../responses/reasoning-envelope";
24
+ import type { TranslatorBudget } from "../lib/translator-budget";
25
+
26
+ type Rec = Record<string, unknown>;
27
+
28
+ function isRec(value: unknown): value is Rec {
29
+ return value !== null && typeof value === "object" && !Array.isArray(value);
30
+ }
31
+
32
+ /** OpenRouter's `format` for Anthropic thinking entries. */
33
+ export const ANTHROPIC_REASONING_DETAIL_FORMAT = "anthropic-claude-v1";
34
+
35
+ /** One closed reasoning item, as either outbound path sees it. */
36
+ export interface ReasoningDetailSource {
37
+ /** The thinking text the client was shown for this item. */
38
+ text: string;
39
+ signature?: string;
40
+ redacted?: readonly string[];
41
+ /** The signature covers thinking text the client was not shown. */
42
+ hidden?: boolean;
43
+ }
44
+
45
+ /**
46
+ * The `reasoning_details` entries for one reasoning item, numbered from `firstIndex`. Redacted
47
+ * blocks come first: that is the order the adapter replays them in. Empty when the item carries no
48
+ * provider-issued state a Chat client can return.
49
+ */
50
+ export function reasoningDetailEntries(source: ReasoningDetailSource, firstIndex: number): Rec[] {
51
+ const entries: Rec[] = [];
52
+ for (const data of source.redacted ?? []) {
53
+ if (typeof data !== "string" || data.length === 0) continue;
54
+ entries.push({
55
+ type: "reasoning.encrypted",
56
+ data,
57
+ format: ANTHROPIC_REASONING_DETAIL_FORMAT,
58
+ index: firstIndex + entries.length,
59
+ });
60
+ }
61
+ if (source.hidden !== true && isLikelyRealAnthropicThinkingSignature(source.signature)) {
62
+ entries.push({
63
+ type: "reasoning.text",
64
+ text: source.text,
65
+ signature: source.signature,
66
+ format: ANTHROPIC_REASONING_DETAIL_FORMAT,
67
+ index: firstIndex + entries.length,
68
+ });
69
+ }
70
+ return entries;
71
+ }
72
+
73
+ /** The visible text of a Responses reasoning item, read the way the parser reads it. */
74
+ export function responsesReasoningItemText(item: Rec): string {
75
+ const pick = (parts: unknown, type: string): string => Array.isArray(parts)
76
+ ? parts.filter((part): part is Rec => isRec(part) && part.type === type && typeof part.text === "string")
77
+ .map(part => part.text as string).join("")
78
+ : "";
79
+ return pick(item.summary, "summary_text") || pick(item.content, "reasoning_text");
80
+ }
81
+
82
+ /**
83
+ * The entries for a finished Responses reasoning item from the internal bridge. Its `ocxr1`
84
+ * envelope holds what the provider issued. `txt` alone means the signed text was hidden; `txt`
85
+ * with `upd` is an Opus progress update, whose signed text the client received as `content`.
86
+ */
87
+ export function reasoningDetailEntriesForItem(item: Rec, firstIndex: number, budget?: TranslatorBudget): Rec[] {
88
+ if (typeof item.encrypted_content !== "string") return [];
89
+ const envelope = decodeReasoningEnvelope(item.encrypted_content, budget);
90
+ if (!envelope || (!envelope.sig && !envelope.red)) return [];
91
+ const progress = envelope.upd !== undefined && typeof envelope.txt === "string";
92
+ return reasoningDetailEntries({
93
+ text: progress ? envelope.txt! : responsesReasoningItemText(item),
94
+ ...(envelope.sig ? { signature: envelope.sig } : {}),
95
+ ...(envelope.red ? { redacted: envelope.red } : {}),
96
+ hidden: !progress && envelope.txt !== undefined,
97
+ }, firstIndex);
98
+ }
99
+
100
+ function reasoningInputItem(text: string, envelope: ReasoningEnvelope): Rec {
101
+ return {
102
+ type: "reasoning",
103
+ summary: text ? [{ type: "summary_text", text }] : [],
104
+ ...(text ? { content: [{ type: "reasoning_text", text }] } : {}),
105
+ encrypted_content: encodeReasoningEnvelope(envelope),
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Responses reasoning input items carrying the signatures a client returned in
111
+ * `reasoning_details`, or `undefined` when the array is absent, empty or holds anything this
112
+ * carrier did not issue — an unsigned or malformed signature, a summary entry, another provider's
113
+ * encrypted format. Undefined means the caller keeps its plaintext projection unchanged.
114
+ */
115
+ export function signedReasoningInputItems(details: unknown): Rec[] | undefined {
116
+ if (!Array.isArray(details) || details.length === 0) return undefined;
117
+ const items: Rec[] = [];
118
+ let redacted: string[] = [];
119
+ for (const raw of details) {
120
+ if (!isRec(raw)) return undefined;
121
+ if (raw.type === "reasoning.encrypted") {
122
+ if (raw.format !== ANTHROPIC_REASONING_DETAIL_FORMAT || typeof raw.data !== "string" || raw.data.length === 0) {
123
+ return undefined;
124
+ }
125
+ redacted.push(raw.data);
126
+ continue;
127
+ }
128
+ if (raw.type !== "reasoning.text") return undefined;
129
+ if (raw.format !== undefined && raw.format !== ANTHROPIC_REASONING_DETAIL_FORMAT) return undefined;
130
+ const signature = typeof raw.signature === "string" ? raw.signature : undefined;
131
+ if (typeof raw.text !== "string" || !isLikelyRealAnthropicThinkingSignature(signature)) return undefined;
132
+ items.push(reasoningInputItem(raw.text, { sig: signature, ...(redacted.length > 0 ? { red: redacted } : {}) }));
133
+ redacted = [];
134
+ }
135
+ if (redacted.length > 0) items.push(reasoningInputItem("", { red: redacted }));
136
+ return items;
137
+ }