@bitkyc08/opencodex 2.84.0 → 2.86.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 (246) hide show
  1. package/README.md +0 -4
  2. package/bin/ocx.mjs +61 -3
  3. package/gui/dist/assets/App-BjlttVLy.js +58 -0
  4. package/gui/dist/assets/{Tray-C9TGtwad.js → Tray-DyR9yBee.js} +1 -1
  5. package/gui/dist/assets/index-BMssP4k3.js +94 -0
  6. package/gui/dist/assets/quota-summary-D8M1bc_R.js +1 -0
  7. package/gui/dist/index.html +1 -1
  8. package/package.json +1 -2
  9. package/src/adapters/anthropic/beta-allowlist.ts +87 -8
  10. package/src/adapters/anthropic/client-identity.ts +1 -1
  11. package/src/adapters/anthropic/passthrough.ts +33 -4
  12. package/src/adapters/azure.ts +19 -1
  13. package/src/adapters/client-fingerprint.ts +24 -0
  14. package/src/adapters/coding-agent/turn.ts +22 -9
  15. package/src/adapters/cursor/arg-codec.ts +17 -5
  16. package/src/adapters/cursor/catalog.ts +8 -0
  17. package/src/adapters/cursor/effort-map.ts +3 -0
  18. package/src/adapters/cursor/freeform-args.ts +75 -0
  19. package/src/adapters/cursor/protobuf-events.ts +36 -23
  20. package/src/adapters/google-antigravity-wire.ts +22 -1
  21. package/src/adapters/google.ts +2 -2
  22. package/src/adapters/openai-chat/response-events.ts +59 -1
  23. package/src/adapters/openai-chat.ts +8 -5
  24. package/src/adapters/qoder/adapter.ts +40 -1
  25. package/src/advisor/consult.ts +220 -0
  26. package/src/advisor/context.ts +226 -0
  27. package/src/advisor/disclosure.ts +20 -0
  28. package/src/advisor/runtime.ts +305 -0
  29. package/src/advisor/settings.ts +135 -0
  30. package/src/advisor/state.ts +369 -0
  31. package/src/advisor/synthetic-tool.ts +33 -0
  32. package/src/bridge/errors.ts +5 -0
  33. package/src/chat/inbound.ts +30 -6
  34. package/src/chat/outbound.ts +5 -0
  35. package/src/claude/desktop-3p-library.ts +43 -3
  36. package/src/claude/desktop-3p.ts +22 -8
  37. package/src/claude/desktop-picker-profile.ts +11 -4
  38. package/src/claude/desktop-profile.ts +10 -1
  39. package/src/claude/desktop-remote-store-io.ts +5 -3
  40. package/src/claude/inbound.ts +121 -50
  41. package/src/claude/intercept/local-ca.ts +8 -1
  42. package/src/claude/model-default-settings.ts +154 -0
  43. package/src/claude/model-info.ts +7 -7
  44. package/src/claude/outbound.ts +13 -6
  45. package/src/cli/advisor.ts +151 -0
  46. package/src/cli/auxiliary-listener-preflight.ts +37 -0
  47. package/src/cli/capabilities-agents-routing.ts +18 -0
  48. package/src/cli/capabilities-base.ts +1 -1
  49. package/src/cli/chatgpt-command.ts +35 -14
  50. package/src/cli/claude.ts +46 -6
  51. package/src/cli/dispatch.ts +5 -1
  52. package/src/cli/doctor.ts +9 -0
  53. package/src/cli/ensure-desktop-cold-start.ts +329 -0
  54. package/src/cli/export-command.ts +1 -1
  55. package/src/cli/help.ts +2 -1
  56. package/src/cli/index.ts +13 -4
  57. package/src/cli/integration-plan-dto.ts +2 -0
  58. package/src/cli/registry.ts +15 -3
  59. package/src/cli/restart-scope.ts +3 -1
  60. package/src/clients/config-export/contracts.ts +1 -0
  61. package/src/clients/config-export/model-metadata.ts +3 -2
  62. package/src/clients/config-export/qoder.ts +93 -0
  63. package/src/clients/config-export.ts +17 -4
  64. package/src/codex/auth-api/pool-mode-gate.ts +5 -2
  65. package/src/codex/auth-context.ts +79 -5
  66. package/src/codex/autostart-health.ts +4 -0
  67. package/src/codex/catalog/build-entries.ts +11 -2
  68. package/src/codex/catalog/configured-tiers.ts +50 -0
  69. package/src/codex/catalog/effort.ts +4 -1
  70. package/src/codex/catalog/model-hints.ts +21 -3
  71. package/src/codex/catalog/native-ultrafast.ts +106 -0
  72. package/src/codex/catalog/parsing.ts +5 -0
  73. package/src/codex/catalog/provider-models.ts +4 -0
  74. package/src/codex/catalog/retained-sync.ts +2 -0
  75. package/src/codex/catalog/routed-gather.ts +22 -4
  76. package/src/codex/catalog/vendor-context.ts +82 -0
  77. package/src/codex/config-write-lock.ts +40 -2
  78. package/src/codex/convergence.ts +2 -0
  79. package/src/codex/desktop-app/types.ts +2 -0
  80. package/src/codex/desktop-app/windows.ts +29 -16
  81. package/src/codex/desktop-app-restart.ts +4 -2
  82. package/src/codex/desktop-compatibility/certificate-service.ts +99 -0
  83. package/src/codex/desktop-compatibility/certificate-store.ts +193 -0
  84. package/src/codex/desktop-compatibility/connection-store.ts +87 -0
  85. package/src/codex/desktop-compatibility/installed-build.ts +55 -0
  86. package/src/codex/desktop-compatibility/json-body.ts +29 -0
  87. package/src/codex/desktop-compatibility/native-identity.ts +62 -0
  88. package/src/codex/desktop-compatibility/relay-listener.ts +93 -0
  89. package/src/codex/desktop-compatibility/routing-binding.ts +10 -0
  90. package/src/codex/desktop-compatibility/routing-preflight.ts +69 -0
  91. package/src/codex/desktop-compatibility/runtime-ownership.ts +28 -0
  92. package/src/codex/desktop-compatibility/runtime.ts +173 -0
  93. package/src/codex/desktop-compatibility/service.ts +30 -0
  94. package/src/codex/desktop-compatibility/startup-settings.ts +50 -0
  95. package/src/codex/desktop-compatibility/usage-activation.ts +86 -0
  96. package/src/codex/desktop-compatibility/usage-controlled-fetch.ts +59 -0
  97. package/src/codex/desktop-compatibility/usage-controller.ts +107 -0
  98. package/src/codex/desktop-compatibility/usage-policy.ts +59 -0
  99. package/src/codex/desktop-compatibility/usage-refresh.ts +33 -0
  100. package/src/codex/desktop-compatibility/usage-sse-controller.ts +86 -0
  101. package/src/codex/desktop-compatibility/windows-activation-source.ts +44 -0
  102. package/src/codex/desktop-compatibility/windows-certificate-trust.ts +81 -0
  103. package/src/codex/desktop-compatibility/windows-key-protection.ts +59 -0
  104. package/src/codex/desktop-compatibility/windows-package-command.ts +86 -0
  105. package/src/codex/desktop-compatibility/windows-package-launch.ts +28 -0
  106. package/src/codex/history-provider.ts +1 -1
  107. package/src/codex/inject/config-write-section.ts +4 -1
  108. package/src/codex/inject/remove.ts +18 -6
  109. package/src/codex/journal.ts +16 -1
  110. package/src/codex/main-account.ts +99 -15
  111. package/src/codex/native-main-refresh-mode.ts +217 -0
  112. package/src/codex/routing/health-store.ts +1 -1
  113. package/src/codex/routing/pool-recovery.ts +79 -0
  114. package/src/codex/shim-diagnostics.ts +6 -0
  115. package/src/codex/shim-overlay.ts +7 -2
  116. package/src/config/diagnostics.ts +2 -1
  117. package/src/config/live-reconcile.ts +13 -0
  118. package/src/config/load-degrade.ts +6 -1
  119. package/src/config/paths.ts +10 -3
  120. package/src/config/schema/config-schema.ts +17 -0
  121. package/src/config/schema/desktop-compatibility.ts +9 -0
  122. package/src/config/schema/leaf-validators.ts +1 -0
  123. package/src/generated/compatibility-version.json +451 -163
  124. package/src/generated/model-metadata.ts +1 -1
  125. package/src/integrations/catalog-refresh.ts +1 -1
  126. package/src/integrations/config-io.ts +20 -2
  127. package/src/integrations/journal.ts +15 -1
  128. package/src/integrations/mutation-plan.ts +1 -0
  129. package/src/integrations/registry.ts +7 -0
  130. package/src/integrations/store.ts +3 -0
  131. package/src/integrations/writer.ts +10 -1
  132. package/src/lib/advisor-activation.ts +16 -0
  133. package/src/lib/background-work.ts +65 -0
  134. package/src/lib/bounded-file-read.ts +58 -0
  135. package/src/lib/bun-path-runtime.d.mts +10 -1
  136. package/src/lib/bun-path-runtime.mjs +62 -3
  137. package/src/lib/desktop-proxy-route.ts +30 -0
  138. package/src/lib/desktop-upstream-tunnel.ts +107 -0
  139. package/src/lib/errors.ts +7 -0
  140. package/src/lib/local-internal-call-capability.ts +55 -0
  141. package/src/lib/package-tree-integrity.ts +2 -2
  142. package/src/lib/socks5-fetch.ts +48 -71
  143. package/src/lib/socks5-handshake.ts +112 -0
  144. package/src/lib/translator-budget.ts +7 -1
  145. package/src/oauth/generic-account-failover.ts +29 -5
  146. package/src/oauth/index.ts +4 -2
  147. package/src/oauth/kiro-device-login.ts +2 -1
  148. package/src/oauth/kiro.ts +7 -1
  149. package/src/protocols/codecs/chat-ir.ts +467 -0
  150. package/src/protocols/codecs/chat.ts +10 -1
  151. package/src/protocols/codecs/messages-ir.ts +500 -0
  152. package/src/protocols/features.ts +10 -2
  153. package/src/protocols/path.ts +11 -1
  154. package/src/protocols/plan-snapshot.ts +39 -16
  155. package/src/protocols/plan.ts +9 -1
  156. package/src/protocols/settings.ts +8 -0
  157. package/src/providers/antigravity-account-roster.ts +102 -0
  158. package/src/providers/antigravity-models.ts +85 -7
  159. package/src/providers/model-rename-fields.ts +2 -0
  160. package/src/providers/model-rename-migration.ts +5 -3
  161. package/src/providers/registry/entries-core.ts +7 -6
  162. package/src/providers/registry/entries-extended.ts +19 -0
  163. package/src/responses/parser.ts +30 -19
  164. package/src/responses/state.ts +32 -29
  165. package/src/routing/history/build-file.ts +92 -0
  166. package/src/routing/history/indexer.ts +248 -216
  167. package/src/routing/history/ingest.ts +238 -0
  168. package/src/routing/history/rebuild-job.ts +111 -0
  169. package/src/routing/history/rebuild-protocol.ts +15 -0
  170. package/src/routing/history/rebuild-worker.ts +34 -0
  171. package/src/server/admission-model-scope.ts +4 -1
  172. package/src/server/antigravity-direct-eligibility.ts +208 -0
  173. package/src/server/auth-cors.ts +4 -0
  174. package/src/server/chat-completions.ts +75 -9
  175. package/src/server/claude-messages.ts +110 -20
  176. package/src/server/index/desktop-compatibility-startup.ts +56 -0
  177. package/src/server/index/serve-options.ts +2 -2
  178. package/src/server/index/startup-warnings.ts +9 -0
  179. package/src/server/index/websocket-handler.ts +9 -0
  180. package/src/server/index.ts +13 -10
  181. package/src/server/inference/client-encoder-delivery.ts +6 -5
  182. package/src/server/login-shell-path.ts +146 -0
  183. package/src/server/management/advisor-routes.ts +191 -0
  184. package/src/server/management/agent-settings-routes.ts +22 -4
  185. package/src/server/management/companion-routes.ts +4 -0
  186. package/src/server/management/config-routes.ts +2 -2
  187. package/src/server/management/context.ts +4 -0
  188. package/src/server/management/desktop-compatibility-routes.ts +46 -0
  189. package/src/server/management/desktop-compatibility-runtime-routes.ts +46 -0
  190. package/src/server/management/desktop-compatibility-settings-routes.ts +27 -0
  191. package/src/server/management/model-routes.ts +2 -2
  192. package/src/server/management/protocol-settings-patch.ts +1 -0
  193. package/src/server/management/route-registry.ts +11 -0
  194. package/src/server/management/sibling-guard.ts +1 -0
  195. package/src/server/management-api.ts +6 -0
  196. package/src/server/messages-antigravity-direct.ts +195 -0
  197. package/src/server/messages-native.ts +5 -1
  198. package/src/server/request-log.ts +7 -0
  199. package/src/server/responses/adapter-continuation.ts +35 -18
  200. package/src/server/responses/adapter-delivery.ts +24 -2
  201. package/src/server/responses/adapter-dispatch.ts +29 -2
  202. package/src/server/responses/advisor-plan-slot.ts +27 -0
  203. package/src/server/responses/advisor-slot.ts +336 -0
  204. package/src/server/responses/code-mode-collab-surface.ts +116 -8
  205. package/src/server/responses/core-combo-failure.ts +10 -1
  206. package/src/server/responses/core-combo.ts +21 -0
  207. package/src/server/responses/core-lifetime.ts +2 -0
  208. package/src/server/responses/core-normalize.ts +11 -0
  209. package/src/server/responses/core-options.ts +46 -0
  210. package/src/server/responses/core-replay.ts +7 -4
  211. package/src/server/responses/core.ts +1 -1
  212. package/src/server/responses/passthrough-delivery.ts +16 -10
  213. package/src/server/responses/passthrough-dispatch.ts +14 -3
  214. package/src/server/responses/request-prepare.ts +68 -5
  215. package/src/server/responses/request-transport.ts +16 -0
  216. package/src/server/responses/run-turn-execution.ts +70 -8
  217. package/src/server/responses/sidecar-execution.ts +78 -3
  218. package/src/server/responses/terminal-guard.ts +2 -1
  219. package/src/server/responses-undeclared-tool-guard.ts +30 -9
  220. package/src/server/startup-action-control.ts +16 -2
  221. package/src/server/startup-health-cache.ts +1 -0
  222. package/src/service/desktop-startup.ts +14 -0
  223. package/src/service/health-sentinel-main.ts +23 -4
  224. package/src/service/health-sentinel-probe.ts +24 -7
  225. package/src/service/logging.ts +72 -0
  226. package/src/tray/windows-tray.ps1 +43 -24
  227. package/src/types/config.ts +63 -6
  228. package/src/types/provider.ts +7 -0
  229. package/src/types/request.ts +15 -0
  230. package/src/types/tools.ts +2 -0
  231. package/src/update/async-check.ts +1 -1
  232. package/src/update/badge.ts +2 -2
  233. package/src/update/index.ts +88 -15
  234. package/src/update/job-supervised-restart.ts +107 -0
  235. package/src/update/job.ts +32 -32
  236. package/src/update/notify.ts +1 -1
  237. package/src/update/refresh-scheduler.ts +2 -2
  238. package/src/update/supervised-recovery.d.mts +27 -0
  239. package/src/update/supervised-recovery.mjs +157 -0
  240. package/src/usage/cache-diagnostic.ts +2 -0
  241. package/src/usage/expected-prices.ts +12 -0
  242. package/src/web-search/gemini-executor.ts +2 -2
  243. package/assets/sponsors/packycode.png +0 -0
  244. package/gui/dist/assets/App-CZtqiGcw.js +0 -56
  245. package/gui/dist/assets/index-CTm4l-HQ.js +0 -94
  246. package/gui/dist/assets/quota-summary-BbEeRfPY.js +0 -1
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Core-owned registration slot for the optional advisor subsystem (src/advisor).
3
+ *
4
+ * This file is the ONLY thing the core Responses path knows about the advisor. It holds the
5
+ * structural plan interface and the event-stream guard — pure protocol machinery over
6
+ * src/types — and imports nothing from src/advisor at runtime. The optional subsystem registers
7
+ * a factory through advisor-plan-slot at host activation; an advisor-disabled install therefore executes no advisor
8
+ * code and imports no advisor module (same seam discipline as src/lab).
9
+ *
10
+ * Guard semantics (mirrors guardTerminalEventStream):
11
+ * - synthetic `advisor` tool-call events are HELD — the Codex client never sees the tool, the
12
+ * call, or its arguments;
13
+ * - on a clean `done`, each held call is consulted through the plan, the advice is appended as a
14
+ * paired assistant-toolCall + toolResult message pair, and the worker is re-dispatched via the
15
+ * SAME continuation machinery the terminal guard uses;
16
+ * - real (non-advisor) tool calls end interception for the leg: the turn belongs to the client;
17
+ * - usage from intercepted legs is merged into the final terminal event so worker accounting
18
+ * stays complete; the advisor's own usage is a separate loopback request and never merges here;
19
+ * - consultations and worker continuations have separate hard per-request bounds. Exhaustion
20
+ * removes the tool; one final limit-result continuation is allowed, then a typed error ends it.
21
+ */
22
+ import type {
23
+ AdapterEvent,
24
+ OcxAssistantContentPart,
25
+ OcxAssistantMessage,
26
+ OcxMessage,
27
+ OcxParsedRequest,
28
+ OcxToolResultMessage,
29
+ OcxUsage,
30
+ } from "../../types";
31
+ import { mergeUsage } from "./terminal-guard";
32
+
33
+ export const ADVISOR_TOOL_NAME = "advisor";
34
+
35
+ /** Hard bound on advisor consultations per worker request (recursion guard). */
36
+ export const MAX_ADVISOR_CONSULTATIONS_PER_REQUEST = 3;
37
+ /** At most one final worker continuation after consultation exhaustion. */
38
+ export const MAX_ADVISOR_CONTINUATIONS_PER_REQUEST = MAX_ADVISOR_CONSULTATIONS_PER_REQUEST + 1;
39
+
40
+ /** Per-leg retention caps for rebuilding the assistant message (see terminal-guard's bounded retention). */
41
+ const MAX_LEG_TEXT_CHARS = 16 * 1_024;
42
+ const MAX_LEG_THINKING_CHARS = 16 * 1_024;
43
+
44
+ /** Outcome of one consultation, as the plan reports it to the guard. */
45
+ export interface AdvisorConsultOutcome {
46
+ ok: boolean;
47
+ /** Formatted, wrapper-marked advice text ready to hand to the worker. */
48
+ content: string;
49
+ isError: boolean;
50
+ /** The consultation was cancelled by the caller (client abort) — not a provider failure. */
51
+ cancelled?: boolean;
52
+ /**
53
+ * Operator config blocked the consultation before any outbound call. Not a provider
54
+ * failure: preflight must release a claim instead of recording a cooldown.
55
+ */
56
+ blocked?: "consent" | "settings";
57
+ }
58
+
59
+ /** The structural plan the optional advisor subsystem registers per request. */
60
+ export interface AdvisorPlan {
61
+ /**
62
+ * Run one consultation for the current conversation state. Implementations own dedup,
63
+ * logging, and usage accounting; the guard only consumes the outcome.
64
+ */
65
+ consult(
66
+ parsed: OcxParsedRequest,
67
+ reason: "manual",
68
+ question: string | undefined,
69
+ ): Promise<AdvisorConsultOutcome>;
70
+ /**
71
+ * Runtime-owned text for a failed or limited consultation. The guard never composes failure
72
+ * prose itself: the implementation neutralizes untrusted text so no guard path can emit
73
+ * something the provenance detector would read as genuine advice.
74
+ */
75
+ formatUnavailable(kind: "manual" | "limit", error: string): string;
76
+ }
77
+
78
+ interface HeldAdvisorCall {
79
+ id: string;
80
+ name: string;
81
+ argsBuf: string;
82
+ closed: boolean;
83
+ providerMetadata?: import("../../types").OcxProviderOpaqueToolCallMetadata;
84
+ }
85
+
86
+ function parseAdvisorArgs(argsBuf: string): { question?: string } {
87
+ if (argsBuf.trim() === "") return {};
88
+ try {
89
+ const parsed: unknown = JSON.parse(argsBuf);
90
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
91
+ const question = (parsed as { question?: unknown }).question;
92
+ if (typeof question === "string" && question.trim() !== "") return { question: question.slice(0, 2_000) };
93
+ }
94
+ } catch { /* malformed args → no focus question */ }
95
+ return {};
96
+ }
97
+
98
+ function assistantMessageFromLeg(
99
+ events: readonly AdapterEvent[],
100
+ held: readonly HeldAdvisorCall[],
101
+ timestamp: number,
102
+ ): OcxAssistantMessage | undefined {
103
+ let text = "";
104
+ let thinking = "";
105
+ let signature: string | undefined;
106
+ const redacted: string[] = [];
107
+ for (const event of events) {
108
+ if (event.type === "text_delta") text += event.text;
109
+ else if (event.type === "thinking_delta") thinking += event.thinking;
110
+ else if (event.type === "thinking_signature") signature = event.signature;
111
+ else if (event.type === "redacted_thinking") redacted.push(event.data);
112
+ }
113
+ if (text.length > MAX_LEG_TEXT_CHARS) text = text.slice(0, MAX_LEG_TEXT_CHARS);
114
+ if (thinking.length > MAX_LEG_THINKING_CHARS) thinking = thinking.slice(0, MAX_LEG_THINKING_CHARS);
115
+ const content: OcxAssistantContentPart[] = [];
116
+ if (thinking || signature || redacted.length > 0) {
117
+ content.push({ type: "thinking", thinking, ...(signature ? { signature } : {}), ...(redacted.length > 0 ? { redacted } : {}) });
118
+ }
119
+ if (text) content.push({ type: "text", text });
120
+ for (const call of held) {
121
+ if (!call.closed) continue;
122
+ content.push({
123
+ type: "toolCall",
124
+ id: call.id,
125
+ name: call.name,
126
+ arguments: parseAdvisorArgs(call.argsBuf),
127
+ ...(call.providerMetadata ? { providerMetadata: call.providerMetadata } : {}),
128
+ });
129
+ }
130
+ if (content.length === 0) return undefined;
131
+ return { role: "assistant", content, timestamp };
132
+ }
133
+
134
+ function advisorToolResult(
135
+ call: HeldAdvisorCall,
136
+ outcome: AdvisorConsultOutcome,
137
+ timestamp: number,
138
+ ): OcxToolResultMessage {
139
+ return {
140
+ role: "toolResult",
141
+ toolCallId: call.id,
142
+ toolName: ADVISOR_TOOL_NAME,
143
+ content: outcome.content,
144
+ isError: outcome.isError,
145
+ timestamp,
146
+ };
147
+ }
148
+
149
+ export interface AdvisorGuardOptions {
150
+ parsed: OcxParsedRequest;
151
+ plan: AdvisorPlan;
152
+ firstEvents: AsyncIterable<AdapterEvent>;
153
+ /** One bounded worker continuation re-dispatch (same machinery as the terminal guard). */
154
+ continuation: (parsed: OcxParsedRequest) => AsyncIterable<AdapterEvent> | Promise<AsyncIterable<AdapterEvent>>;
155
+ }
156
+
157
+ /**
158
+ * Wrap a worker event stream with advisor interception. Registered on the parsed request as
159
+ * `_advisorGuard` by the sidecar planner; adapter delivery applies it when present.
160
+ */
161
+ export function createAdvisorStreamGuard(options: AdvisorGuardOptions): AsyncGenerator<AdapterEvent> {
162
+ const guard = createAdvisorGuard(options.plan);
163
+ return guard(options);
164
+ }
165
+ export function createAdvisorGuard(plan: AdvisorPlan): NonNullable<OcxParsedRequest["_advisorGuard"]> {
166
+ // Shared across invocations of this request's guard, including an empty-completion retry.
167
+ let consultations = 0;
168
+ let continuations = 0;
169
+ let finalContinuationUsed = false;
170
+ return async function* guardAdvisorStream(options: Omit<AdvisorGuardOptions, "plan">): AsyncGenerator<AdapterEvent> {
171
+ const maxConsultations = MAX_ADVISOR_CONSULTATIONS_PER_REQUEST;
172
+ let parsed = options.parsed;
173
+ let accumulatedUsage: OcxUsage | undefined;
174
+ let source: AsyncIterable<AdapterEvent> = options.firstEvents;
175
+
176
+ while (true) {
177
+ const held: HeldAdvisorCall[] = [];
178
+ const legEvents: AdapterEvent[] = [];
179
+ let legTextChars = 0;
180
+ let legThinkingChars = 0;
181
+ let pending: HeldAdvisorCall | null = null;
182
+ // While a tool call is open, its delta/end events belong to that call. Advisor calls are
183
+ // held from the output; real calls pass through untouched.
184
+ let holdingCurrent = false;
185
+ let hasRealToolCall = false;
186
+ let terminalConsumed = false;
187
+ let terminalEvent: Extract<AdapterEvent, { type: "done" }> | undefined;
188
+
189
+ for await (const event of source) {
190
+ if (event.type === "tool_call_start") {
191
+ if (pending) { held.push(pending); pending = null; }
192
+ pending = { id: event.id, name: event.name, argsBuf: "", closed: false, ...(event.providerMetadata ? { providerMetadata: event.providerMetadata } : {}) };
193
+ holdingCurrent = event.name === ADVISOR_TOOL_NAME;
194
+ if (!holdingCurrent) hasRealToolCall = true;
195
+ else continue;
196
+ } else if (event.type === "tool_call_delta") {
197
+ if (pending) pending.argsBuf += event.arguments;
198
+ if (holdingCurrent) continue;
199
+ } else if (event.type === "tool_call_end") {
200
+ if (pending) {
201
+ pending.closed = true;
202
+ if (pending.name === ADVISOR_TOOL_NAME) held.push(pending);
203
+ pending = null;
204
+ }
205
+ if (holdingCurrent) {
206
+ holdingCurrent = false;
207
+ continue;
208
+ }
209
+ } else if (event.type === "done") {
210
+ if (pending) { held.push(pending); pending = null; }
211
+ terminalEvent = event;
212
+ terminalConsumed = true;
213
+ break;
214
+ } else if (event.type === "incomplete" || event.type === "error") {
215
+ // A broken leg cannot safely anchor a continuation: surface the terminal as-is.
216
+ if (pending) { pending = null; }
217
+ const usage = mergeUsage(accumulatedUsage, event.usage);
218
+ yield usage ? { ...event, usage } : event;
219
+ return;
220
+ } else {
221
+ // Retain (bounded) the leg's visible content so the rebuilt assistant message is complete.
222
+ if (event.type === "text_delta") {
223
+ legTextChars += event.text.length;
224
+ if (legTextChars <= MAX_LEG_TEXT_CHARS) legEvents.push(event);
225
+ } else if (event.type === "thinking_delta") {
226
+ legThinkingChars += event.thinking.length;
227
+ if (legThinkingChars <= MAX_LEG_THINKING_CHARS) legEvents.push(event);
228
+ } else if (
229
+ event.type === "thinking_signature"
230
+ || event.type === "redacted_thinking"
231
+ ) {
232
+ legEvents.push(event);
233
+ }
234
+ }
235
+ yield event;
236
+ }
237
+
238
+ const advisorCalls = held.filter(call => call.name === ADVISOR_TOOL_NAME && call.closed);
239
+ const shouldIntercept = terminalConsumed
240
+ && terminalEvent?.stopReason !== "max_tokens"
241
+ && terminalEvent?.stopReason !== "content_filter"
242
+ && advisorCalls.length > 0
243
+ && !hasRealToolCall;
244
+
245
+ if (!shouldIntercept) {
246
+ // Plain leg: surface the terminal with merged usage from any earlier intercepted legs.
247
+ if (terminalEvent) {
248
+ const usage = mergeUsage(accumulatedUsage, terminalEvent.usage);
249
+ yield usage ? { ...terminalEvent, usage } : terminalEvent;
250
+ }
251
+ return;
252
+ }
253
+
254
+ // Consume this leg's done — the continuation replaces it.
255
+ accumulatedUsage = mergeUsage(accumulatedUsage, terminalEvent?.usage);
256
+ if (continuations >= MAX_ADVISOR_CONTINUATIONS_PER_REQUEST || finalContinuationUsed) {
257
+ yield {
258
+ type: "error",
259
+ status: 502,
260
+ errorType: "advisor_continuation_limit",
261
+ message: "Advisor worker continuation limit reached; no further worker calls were sent.",
262
+ ...(accumulatedUsage ? { usage: accumulatedUsage } : {}),
263
+ };
264
+ return;
265
+ }
266
+ // A repeated call after tool removal gets one last paired limit result, never a loop.
267
+ const isFinalContinuation = consultations >= maxConsultations;
268
+ // Reserve before any consultation await, so simultaneous guard entries share the bound.
269
+ continuations += 1;
270
+ finalContinuationUsed ||= isFinalContinuation;
271
+ const timestamp = Date.now();
272
+ const assistant = assistantMessageFromLeg(legEvents, advisorCalls, timestamp);
273
+ const messages: OcxMessage[] = [...parsed.context.messages];
274
+ if (assistant) messages.push(assistant);
275
+
276
+ for (const call of advisorCalls) {
277
+ if (consultations >= maxConsultations) {
278
+ // Runtime-owned limit text: not advice, and never a genuine advice wrapper.
279
+ messages.push(advisorToolResult(call, {
280
+ ok: false,
281
+ isError: true,
282
+ content: plan.formatUnavailable("limit", "consultation limit reached for this request"),
283
+ }, timestamp));
284
+ continue;
285
+ }
286
+ consultations += 1;
287
+ const args = parseAdvisorArgs(call.argsBuf);
288
+ // Later calls in the same leg must see what came before them: the rebuilt worker turn
289
+ // and every earlier advisor result in this leg's accumulated messages.
290
+ const consultParsed: OcxParsedRequest = {
291
+ ...parsed,
292
+ context: { ...parsed.context, messages },
293
+ };
294
+ let outcome: AdvisorConsultOutcome;
295
+ try {
296
+ outcome = await plan.consult(consultParsed, "manual", args.question);
297
+ } catch (error) {
298
+ // Runtime-owned failure text only: the implementation neutralizes untrusted exception
299
+ // text so it can never forge a genuine advice wrapper.
300
+ outcome = {
301
+ ok: false,
302
+ isError: true,
303
+ content: plan.formatUnavailable("manual", error instanceof Error ? error.message : String(error)),
304
+ };
305
+ }
306
+ messages.push(advisorToolResult(call, outcome, timestamp));
307
+ }
308
+
309
+ const exhausted = consultations >= maxConsultations;
310
+ const tools = exhausted
311
+ ? parsed.context.tools?.filter(tool => !tool.advisor && tool.name !== ADVISOR_TOOL_NAME)
312
+ : parsed.context.tools;
313
+ const choice = parsed.options.toolChoice;
314
+ const nextParsed: OcxParsedRequest = {
315
+ ...parsed,
316
+ context: { ...parsed.context, messages, tools },
317
+ options: exhausted && ((typeof choice === "object" && choice !== null && "name" in choice && choice.name === ADVISOR_TOOL_NAME)
318
+ || (choice === "required" && !tools?.length))
319
+ ? { ...parsed.options, toolChoice: "auto" }
320
+ : parsed.options,
321
+ };
322
+ parsed = nextParsed;
323
+ yield { type: "assistant_boundary" };
324
+ try {
325
+ source = await options.continuation(parsed);
326
+ } catch (error) {
327
+ yield {
328
+ type: "error",
329
+ message: error instanceof Error ? error.message : String(error),
330
+ ...(accumulatedUsage ? { usage: accumulatedUsage } : {}),
331
+ };
332
+ return;
333
+ }
334
+ }
335
+ };
336
+ }
@@ -77,13 +77,104 @@ interface RenderedTool { name: string; raw: string }
77
77
  /** The declaration fence codex-rs opens: three backticks and "ts". */
78
78
  const DECLARATION_FENCE_SHAPE: Fence = { char: "`", length: 3 };
79
79
 
80
- /** A complete declaration as render_code_mode_sample emits it, single- or multi-line. */
81
- function isCompleteDeclaration(body: readonly string[], name: string): boolean {
80
+ const DECLARATION_PREFIX = "declare const tools: { ";
81
+ const PROMISE_SEPARATOR = "): Promise<";
82
+ const DECLARATION_SUFFIX = ">; };";
83
+ // Bounds keep a caller-controlled description linear work: a rendered listing is a heading, a
84
+ // description, and a small declaration per tool, so anything this large cannot be the codex-rs
85
+ // shape this parser exists to recognize.
86
+ const MAX_DESCRIPTION_LENGTH = 1 << 20;
87
+ const MAX_DECLARATION_LENGTH = 64 * 1024;
88
+
89
+ const isSpace = (ch: string | undefined): boolean => ch === undefined || /\s/.test(ch);
90
+
91
+ /**
92
+ * The declared tool name when body is a complete declaration as render_code_mode_sample emits it,
93
+ * single- or multi-line, else undefined. Fixed boundaries plus one forward scan that balances the
94
+ * parameter list's brackets and the return type's generics: no backtracking, so a fence body full
95
+ * of candidate separators stays linear work.
96
+ */
97
+ function completeDeclarationName(body: readonly string[]): string | undefined {
82
98
  const text = body.join("\n");
83
- const prefix = "declare const tools: { " + name + "(";
84
- if (!text.startsWith(prefix)) return false;
99
+ if (text.length > MAX_DECLARATION_LENGTH) return undefined;
100
+ if (!text.startsWith(DECLARATION_PREFIX) || !text.endsWith(DECLARATION_SUFFIX)) return undefined;
101
+ const signature = text.slice(DECLARATION_PREFIX.length, -DECLARATION_SUFFIX.length);
102
+ const openParen = signature.indexOf("(");
103
+ const name = openParen > 0 ? signature.slice(0, openParen) : "";
104
+ if (!/^[A-Za-z_$][\w$]*$/.test(name)) return undefined;
85
105
  // A named, typed parameter (`args` for functions, `input` for freeform) and a non-empty return type.
86
- return /^(?:args|input): \S[\s\S]*\): Promise<[^>\s][\s\S]*>; \};$/.test(text.slice(prefix.length));
106
+ const rest = signature.slice(openParen + 1);
107
+ const typed = rest.startsWith("args: ") ? 6 : rest.startsWith("input: ") ? 7 : -1;
108
+ if (typed < 0 || isSpace(rest[typed])) return undefined;
109
+ // Quoted text and comments are opaque to delimiter matching. Each skip advances
110
+ // past its text once; an unterminated region cannot contain a closing declaration.
111
+ const isOpaqueTypeText = (at: number): boolean => {
112
+ const ch = rest[at];
113
+ return ch === '"' || ch === "'" || ch === "`"
114
+ || ch === "/" && (rest[at + 1] === "/" || rest[at + 1] === "*");
115
+ };
116
+ const skipTypeText = (at: number): number | undefined => {
117
+ const quote = rest[at]!;
118
+ if (quote === "/" && rest[at + 1] === "/") {
119
+ let i = at + 2;
120
+ while (i < rest.length && rest[i] !== "\n" && rest[i] !== "\r"
121
+ && rest[i] !== "\u2028" && rest[i] !== "\u2029") i += 1;
122
+ return i < rest.length ? i : undefined;
123
+ }
124
+ if (quote === "/" && rest[at + 1] === "*") {
125
+ const end = rest.indexOf("*/", at + 2);
126
+ return end < 0 ? undefined : end + 1;
127
+ }
128
+ let i = at + 1;
129
+ while (i < rest.length) {
130
+ const ch = rest[i]!;
131
+ if (ch === "\\") i += 2;
132
+ else if (ch === quote) return i;
133
+ else i += 1;
134
+ }
135
+ return undefined;
136
+ };
137
+ // The parameter list ends at the ')' that balances the opening '(': a ')' nested inside the
138
+ // parameter's own type (e.g. `args: (a: string) => Promise<void>`) cannot pose as the separator.
139
+ // The type must also say something before that ')': `args: )` or `args: /* note */)` names no type.
140
+ let depth = 1;
141
+ let close = -1;
142
+ let typeContent = false;
143
+ for (let at = typed; at < rest.length; at += 1) {
144
+ const ch = rest[at]!;
145
+ if (isOpaqueTypeText(at)) {
146
+ if (ch !== "/") typeContent = true; // a quoted literal is a type, a comment is not
147
+ const end = skipTypeText(at);
148
+ if (end === undefined) return undefined;
149
+ at = end;
150
+ continue;
151
+ }
152
+ if (ch === "(" || ch === "[" || ch === "{") depth += 1;
153
+ else if (ch === ")" || ch === "]" || ch === "}") depth -= 1;
154
+ if (depth === 0) { close = at; break; }
155
+ if (!isSpace(ch)) typeContent = true;
156
+ }
157
+ if (close < 0 || !typeContent || !rest.startsWith(PROMISE_SEPARATOR, close)) return undefined;
158
+ const head = rest[close + PROMISE_SEPARATOR.length];
159
+ if (head === ">" || isSpace(head)) return undefined;
160
+ // The return type runs to the stripped suffix, which already supplied exactly one '>' for the
161
+ // Promise's '<': generic depth must stay positive to the end and land at exactly 1 — a '>' that
162
+ // reaches zero ('Promise<unknown>; garbage') is surplus text, a depth left above 1
163
+ // ('Promise<Map<string, unknown') never closed its own generic, and a '=>' arrow never counts.
164
+ let generic = 1;
165
+ for (let at = close + PROMISE_SEPARATOR.length; at < rest.length; at += 1) {
166
+ const ch = rest[at]!;
167
+ if (isOpaqueTypeText(at)) {
168
+ const end = skipTypeText(at);
169
+ if (end === undefined) return undefined;
170
+ at = end;
171
+ continue;
172
+ }
173
+ if (ch === "<") generic += 1;
174
+ else if (ch === ">" && rest[at - 1] !== "=") generic -= 1;
175
+ if (generic === 0) return undefined;
176
+ }
177
+ return generic === 1 ? name : undefined;
87
178
  }
88
179
 
89
180
  /** codex-rs normalize_code_mode_identifier: invalid identifier characters become `_`. */
@@ -114,9 +205,21 @@ function renderedHeading(match: RegExpExecArray): RenderedTool | undefined {
114
205
  * alone. Lines inside a quoted fence can neither open a section nor declare a tool.
115
206
  */
116
207
  function renderedNestedTools(description: string): RenderedTool[] {
208
+ if (description.length > MAX_DESCRIPTION_LENGTH) return [];
117
209
  const lines = description.split("\n");
118
210
  const rendered: RenderedTool[] = [];
119
211
  const pending: RenderedTool[] = [];
212
+ // Ascending indexes into `pending` per name, so a declaration finds its owner in one lookup
213
+ // instead of rescanning every pending heading.
214
+ const pendingByName = new Map<string, number[]>();
215
+ const dropPendingTo = (length: number): void => {
216
+ while (pending.length > length) {
217
+ const removed = pending.pop()!;
218
+ const indexes = pendingByName.get(removed.name)!;
219
+ indexes.pop();
220
+ if (indexes.length === 0) pendingByName.delete(removed.name);
221
+ }
222
+ };
120
223
  let fence: Fence | undefined;
121
224
  for (let i = 0; i < lines.length; i += 1) {
122
225
  const line = lines[i]!;
@@ -127,7 +230,11 @@ function renderedNestedTools(description: string): RenderedTool[] {
127
230
  const match = TOOL_HEADING.exec(line);
128
231
  if (match) {
129
232
  const heading = renderedHeading(match);
130
- if (heading) pending.push(heading);
233
+ if (heading) {
234
+ const indexes = pendingByName.get(heading.name);
235
+ if (indexes) indexes.push(pending.length); else pendingByName.set(heading.name, [pending.length]);
236
+ pending.push(heading);
237
+ }
131
238
  continue;
132
239
  }
133
240
  if (line === DECLARATION_LEAD && lines[i + 1] === DECLARATION_FENCE) {
@@ -137,10 +244,11 @@ function renderedNestedTools(description: string): RenderedTool[] {
137
244
  const body = lines.slice(i + 2, close);
138
245
  // The outermost pending heading with the declared name owns it, so a same-name heading quoted
139
246
  // inside the tool's own description cannot take over the genuine section.
140
- const owner = pending.findIndex(tool => isCompleteDeclaration(body, tool.name));
247
+ const declared = completeDeclarationName(body);
248
+ const owner = declared === undefined ? -1 : pendingByName.get(declared)?.[0] ?? -1;
141
249
  if (owner >= 0) {
142
250
  rendered.push(pending[owner]!);
143
- pending.length = owner;
251
+ dropPendingTo(owner);
144
252
  }
145
253
  i = close;
146
254
  continue;
@@ -16,6 +16,7 @@ import { resolveClientRetryAfter } from "../../lib/retry-after";
16
16
  import { formatErrorResponse } from "../../bridge";
17
17
  import { OLLAMA_LATE_ATTRIBUTION_LIMIT_MESSAGE } from "../../adapters/ollama-native";
18
18
  import { isNonReplayableResponse, markResponseNonReplayable } from "../../lib/upstream-retry";
19
+ import { MODEL_NOT_ALLOWED_FOR_KEY } from "../admission-model-scope";
19
20
  import { usageFromResponsesPayload } from "../request-log";
20
21
  import type { ResponsesTerminalStatus } from "../../bridge";
21
22
 
@@ -49,6 +50,9 @@ export async function consumeComboFailure(
49
50
  // display-safe body carries a recognized quota message.
50
51
  let quotaConfirmedByBody = false;
51
52
  const serverError = response.status >= 500 && response.status < 600;
53
+ // A local key-scope refusal is already the client's answer: rebuilding it as an upstream_error
54
+ // would hide the stable model_not_allowed_for_key type clients branch on.
55
+ let scopeRefusalBody: string | undefined;
52
56
  try {
53
57
  const body = await readBoundedResponseBody(response, { signal, reportUtf8Validity: serverError });
54
58
  // A 5xx body counts as quota or classification evidence only when it decoded as valid
@@ -71,6 +75,9 @@ export async function consumeComboFailure(
71
75
  upstreamMessage = normalized.message;
72
76
  upstreamType = normalized.type;
73
77
  }
78
+ if (nonReplayable && response.status === 403 && upstreamType === MODEL_NOT_ALLOWED_FOR_KEY) {
79
+ scopeRefusalBody = body.text;
80
+ }
74
81
  }
75
82
  } catch (error) {
76
83
  if (signal?.aborted) throw error;
@@ -106,7 +113,9 @@ export async function consumeComboFailure(
106
113
  now,
107
114
  includeDefault: false,
108
115
  });
109
- const failureResponse = formatErrorResponse(
116
+ const failureResponse = scopeRefusalBody !== undefined
117
+ ? new Response(scopeRefusalBody, { status: 403, headers: { "Content-Type": "application/json" } })
118
+ : formatErrorResponse(
110
119
  response.status,
111
120
  cyberFailure ? (upstreamType ?? CYBER_POLICY_ERROR_CODE) : "upstream_error",
112
121
  message,
@@ -1,3 +1,5 @@
1
+ import { effectiveAzureOutputBudget } from "../../adapters/azure";
2
+ import { resolveWireProtocolOverride } from "../adapter-resolve";
1
3
  import type { CodexAccountModelRefusal } from "../../combos/failover";
2
4
  import {
3
5
  isCodexReasoningEffort,
@@ -712,12 +714,31 @@ export async function executeComboResponses(
712
714
  retryAfter: String(Math.max(1, Math.ceil(remainingQuotaCooldownMs / 1000))),
713
715
  }) : lastFailure;
714
716
  };
717
+ // The caller's output ceiling, captured once so a floor applied to one target never
718
+ // leaks into the next. An established parent estimate wins; otherwise (combo dispatch
719
+ // precedes request preparation) the caller's own positive cap is reserved.
720
+ const callerBudget = isPlainObject(body) ? body.max_output_tokens : undefined;
721
+ const callerOutputCeilingTokens = logCtx.spendOutputCeilingTokens
722
+ ?? (typeof callerBudget === "number" && Number.isFinite(callerBudget) && callerBudget > 0
723
+ ? Math.trunc(callerBudget) : undefined);
715
724
  while (pick) {
716
725
  if (options.abortSignal?.aborted) return clientCancelledResponse();
717
726
  const firstComboTarget = comboTargetsDispatched === 0;
718
727
  const targetRoute = routeConcreteModel(config, `${pick.target.provider}/${pick.target.model}`);
719
728
  // The inherited spend tracker observes this parent log, before the child has its own label.
720
729
  logCtx.spendPoolId = targetRoute.providerName;
730
+ // Combo prepays before its child normalizes the route; the parent tracker must
731
+ // already see this target's effective cap rather than a previous target's cap.
732
+ const spendProvider = resolveWireProtocolOverride(targetRoute.providerName, targetRoute.modelId,
733
+ targetRoute.provider, options.inboundWire ?? "responses", targetRoute.staticPolicy);
734
+ const outputBudget = effectiveAzureOutputBudget(spendProvider, callerBudget);
735
+ if (outputBudget !== callerBudget && typeof outputBudget === "number" && outputBudget > 0) {
736
+ logCtx.spendOutputCeilingTokens = Math.trunc(outputBudget);
737
+ } else if (callerOutputCeilingTokens === undefined) {
738
+ delete logCtx.spendOutputCeilingTokens;
739
+ } else {
740
+ logCtx.spendOutputCeilingTokens = callerOutputCeilingTokens;
741
+ }
721
742
  // Derive the target's allowance before booking its initial send; the booking occupies it.
722
743
  const targetSendBudget = comboSendScope
723
744
  ? comboTargetSendBudget(comboSendScope, combo.targets.length - 1 - comboTargetsDispatched)
@@ -1,5 +1,6 @@
1
1
  import type { TranslatorBudget } from "../../lib/translator-budget";
2
2
  import { finalizeTranslatorBudgetResponse } from "../../lib/translator-budget";
3
+ import { isNonReplayableResponse, markResponseNonReplayable } from "../../lib/upstream-retry";
3
4
  import {
4
5
  isNativePassthroughSseResponse,
5
6
  markNativePassthroughSseResponse,
@@ -67,6 +68,7 @@ export function finalizeAccountLease(response: Response, release: () => void): R
67
68
  async cancel(reason) { try { await reader.cancel(reason); } finally { finish(); } },
68
69
  });
69
70
  const wrapped = new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
71
+ if (isNonReplayableResponse(response)) markResponseNonReplayable(wrapped);
70
72
  if (isNativePassthroughSseResponse(response)) markNativePassthroughSseResponse(wrapped);
71
73
  if (isEagerRelaySseResponse(response)) markEagerRelaySseResponse(wrapped);
72
74
  if (isPreinspectedJsonResponse(response)) markPreinspectedJsonResponse(wrapped);
@@ -1,3 +1,4 @@
1
+ import { effectiveAzureOutputBudget } from "../../adapters/azure";
1
2
  import { sanitizeLogMetadataString } from "../../lib/redact";
2
3
  import type { RouteResult } from "../../router";
3
4
  import type { InboundWire } from "../../providers/registry";
@@ -211,6 +212,16 @@ export async function applyFinalRouteRequestNormalization(args: {
211
212
  logCtx.model = virtualModel?.selectedModelId ?? route.modelId;
212
213
  logCtx.provider = route.providerName;
213
214
  logCtx.spendPoolId = route.providerName;
215
+ // Admission precedes adapter serialization. Reserve the same explicit output cap
216
+ // the final destination will send, and recompute from the caller on every fallback.
217
+ const rawBudget = parsed._rawBody && typeof parsed._rawBody === "object"
218
+ ? (parsed._rawBody as { max_output_tokens?: unknown }).max_output_tokens : undefined;
219
+ const outputBudget = effectiveAzureOutputBudget(route.provider,
220
+ route.provider.adapter === "azure" || route.provider.adapter === "azure-openai"
221
+ ? rawBudget : parsed.options.maxOutputTokens);
222
+ if (typeof outputBudget === "number" && outputBudget > 0) {
223
+ logCtx.spendOutputCeilingTokens = Math.trunc(outputBudget);
224
+ }
214
225
  logCtx.providerAdapter = route.provider.adapter;
215
226
  logCtx.routeDecision = route.routeDecision;
216
227
  logCtx.policyEligibility = route.policyEligibility;