@bitkyc08/opencodex 2.10.2 → 2.11.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 (121) hide show
  1. package/README.md +31 -0
  2. package/bin/ocx.mjs +10 -0
  3. package/gui/dist/assets/index-Bk-PN-70.css +1 -0
  4. package/gui/dist/assets/index-BynIEIV-.js +70 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +4 -2
  7. package/src/adapters/cursor/effort-map.ts +11 -0
  8. package/src/adapters/cursor/live-transport.ts +11 -0
  9. package/src/adapters/cursor/native-exec-fs.ts +9 -6
  10. package/src/adapters/cursor/native-exec.ts +4 -2
  11. package/src/adapters/cursor/protobuf-events.ts +176 -4
  12. package/src/adapters/cursor/request-builder.ts +15 -4
  13. package/src/adapters/cursor/tool-definitions.ts +118 -2
  14. package/src/adapters/google.ts +15 -5
  15. package/src/adapters/openai-chat.ts +24 -2
  16. package/src/adapters/openai-responses.ts +2 -1
  17. package/src/bridge.ts +9 -5
  18. package/src/chat/outbound.ts +4 -3
  19. package/src/claude/desktop-3p.ts +222 -2
  20. package/src/claude/outbound.ts +15 -6
  21. package/src/cli/account-api.ts +4 -0
  22. package/src/cli/account-extended.ts +112 -0
  23. package/src/cli/account.ts +23 -6
  24. package/src/cli/claude-desktop.ts +26 -3
  25. package/src/cli/config-command.ts +9 -0
  26. package/src/cli/help.ts +18 -2
  27. package/src/cli/index.ts +277 -55
  28. package/src/cli/models.ts +5 -1
  29. package/src/cli/provider.ts +8 -2
  30. package/src/cli/ready.ts +301 -0
  31. package/src/cli/system-restart-client.ts +146 -0
  32. package/src/cli/tray-proxy.ts +153 -6
  33. package/src/clients/config-export.ts +12 -19
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-namespaces.ts +49 -3
  36. package/src/codex/account-priority.ts +83 -0
  37. package/src/codex/auth-api.ts +83 -0
  38. package/src/codex/auth-context.ts +5 -2
  39. package/src/codex/catalog/provider-fetch.ts +11 -0
  40. package/src/codex/catalog/sync.ts +23 -1
  41. package/src/codex/codex-write-lock.ts +16 -4
  42. package/src/codex/desired-state.ts +37 -4
  43. package/src/codex/history-job.ts +15 -5
  44. package/src/codex/history-provider.ts +31 -14
  45. package/src/codex/history-worker.ts +28 -4
  46. package/src/codex/inject-coordination.ts +13 -1
  47. package/src/codex/inject.ts +360 -66
  48. package/src/codex/internal/history-writer.ts +1 -1
  49. package/src/codex/native-main-lock-file.ts +5 -1
  50. package/src/codex/native-main-owner.ts +17 -3
  51. package/src/codex/native-profile-manager.ts +19 -0
  52. package/src/codex/native-profile-startup.ts +8 -0
  53. package/src/codex/native-residue.ts +140 -27
  54. package/src/codex/pool-rotation.ts +74 -4
  55. package/src/codex/refresh.ts +7 -0
  56. package/src/codex/routing.ts +177 -36
  57. package/src/codex/subagent-model-fallback.ts +34 -4
  58. package/src/codex/sync.ts +61 -0
  59. package/src/codex/upstream-host-health.ts +329 -31
  60. package/src/combos/request.ts +2 -0
  61. package/src/config.ts +221 -2
  62. package/src/images/loop.ts +1 -1
  63. package/src/integrations/native/ownership-preflight.ts +39 -2
  64. package/src/lib/bun-stream-caps.ts +3 -3
  65. package/src/lib/sse-decoder.ts +41 -0
  66. package/src/lib/system-restart-contract.ts +73 -0
  67. package/src/lib/windows-secret-acl.ts +141 -39
  68. package/src/lib/windows-user-principal.ts +283 -0
  69. package/src/lib/winsw.ts +18 -2
  70. package/src/oauth/key-providers.ts +12 -0
  71. package/src/providers/derive.ts +54 -2
  72. package/src/providers/free-directory.ts +6 -5
  73. package/src/providers/model-discovery.ts +9 -3
  74. package/src/providers/quota.ts +592 -0
  75. package/src/providers/registry.ts +316 -13
  76. package/src/responses/parser.ts +26 -10
  77. package/src/responses/reasoning-replay-cache.ts +1 -0
  78. package/src/routing/profile-namespace.ts +15 -0
  79. package/src/routing/profile.ts +2 -1
  80. package/src/server/auth-cors.ts +44 -13
  81. package/src/server/chat-completions.ts +0 -4
  82. package/src/server/claude-messages.ts +73 -15
  83. package/src/server/github-copilot-responses-repair.ts +338 -0
  84. package/src/server/index.ts +328 -111
  85. package/src/server/lifecycle.ts +36 -0
  86. package/src/server/management/agent-settings-routes.ts +147 -56
  87. package/src/server/management/config-routes.ts +7 -2
  88. package/src/server/management/context.ts +4 -0
  89. package/src/server/management/native-integration-routes.ts +199 -20
  90. package/src/server/management/provider-routes.ts +41 -0
  91. package/src/server/management/routing-profile-routes.ts +234 -5
  92. package/src/server/management/system-restart.ts +12 -10
  93. package/src/server/management/system-routes.ts +20 -0
  94. package/src/server/management-auth.ts +51 -3
  95. package/src/server/ports.ts +41 -1
  96. package/src/server/proxy-liveness.ts +129 -4
  97. package/src/server/readiness.ts +99 -0
  98. package/src/server/relay.ts +113 -97
  99. package/src/server/request-log.ts +10 -4
  100. package/src/server/responses/compact.ts +107 -12
  101. package/src/server/responses/core.ts +220 -39
  102. package/src/server/responses-item-id-repair.ts +22 -3
  103. package/src/server/responses-model-rewrite.ts +29 -0
  104. package/src/server/sse-frame-buffer.ts +292 -0
  105. package/src/server/sse-payload-rewrite.ts +25 -14
  106. package/src/server/ws-bridge.ts +27 -22
  107. package/src/service-manager-probe.ts +520 -10
  108. package/src/service.ts +134 -2
  109. package/src/storage/worker-lifecycle.ts +14 -14
  110. package/src/tray/windows-tray.ps1 +74 -9
  111. package/src/types.ts +68 -2
  112. package/src/update/index.ts +12 -0
  113. package/src/update/job.ts +392 -18
  114. package/src/update/npm-cache-preflight.d.mts +47 -0
  115. package/src/update/npm-cache-preflight.mjs +201 -0
  116. package/src/usage/log.ts +1 -1
  117. package/src/vision/index.ts +77 -2
  118. package/src/web-search/loop.ts +1 -1
  119. package/src/web-search/parse.ts +4 -1
  120. package/gui/dist/assets/index-BKVqyYqT.js +0 -70
  121. package/gui/dist/assets/index-Ca_3269W.css +0 -1
@@ -133,10 +133,6 @@ async function handleChatCompletionsWithBudget(
133
133
  } else if (internalBody.store === undefined) {
134
134
  internalBody.store = false;
135
135
  }
136
- if (route.provider.adapter === "openai-chat" && internalBody.text !== undefined) {
137
- if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 400, { closeReason: "non_stream" });
138
- return chatCompletionsErrorResponse(400, "response_format is not supported for routed openai-chat models");
139
- }
140
136
  if (route.provider.adapter === "cursor" || route.provider.adapter === "kiro") {
141
137
  const raw = chatBody as Rec;
142
138
  const parts: string[] = [];
@@ -7,7 +7,8 @@
7
7
  * unchanged. The Responses output (SSE or JSON) is converted back to Anthropic shape.
8
8
  */
9
9
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
10
- import { enforceAnthropicImageLimits } from "../adapters/anthropic-image-guard";
10
+ import { sseFieldValue } from "../lib/sse-decoder";
11
+ import { enforceAnthropicImageLimits, sniffImageDimensions } from "../adapters/anthropic-image-guard";
11
12
  import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize";
12
13
  import { AnthropicRequestError, anthropicToResponsesTranslation, extractOcxEffortDirective, extractOcxRouteDirective, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound";
13
14
  import { resolveDesktop3pAlias } from "../claude/desktop-3p";
@@ -36,6 +37,7 @@ import { responseWithDeferredRequestLog } from "./relay";
36
37
  import { handleResponses } from "./responses";
37
38
  import type { AdmissionLease } from "../lib/admission";
38
39
  import { tryClaimNativeMainProfileForTurn } from "../codex/native-main-admission";
40
+ import { CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE } from "../codex/auth-context";
39
41
  import {
40
42
  createTranslatorBudget,
41
43
  finalizeTranslatorBudgetResponse,
@@ -170,7 +172,11 @@ export function tapAnthropicSseForLog(
170
172
  while ((sep = buffer.indexOf("\n\n")) !== -1) {
171
173
  const frame = buffer.slice(0, sep);
172
174
  buffer = buffer.slice(sep + 2);
173
- const dataLine = frame.split("\n").filter(l => l.startsWith("data: ")).map(l => l.slice(6)).join("");
175
+ const dataLine = frame
176
+ .split("\n")
177
+ .map(l => sseFieldValue(l, "data"))
178
+ .filter((v): v is string => v !== null)
179
+ .join("");
174
180
  if (!dataLine) continue;
175
181
  let data: unknown;
176
182
  try { data = JSON.parse(dataLine); } catch { continue; }
@@ -647,12 +653,7 @@ async function handleClaudeMessagesWithBudget(
647
653
  // accurate-usage adapters — the request-log merge is max(reported, estimate) and
648
654
  // would overwrite real usage (audit 133 R1#7).
649
655
  if (route.provider.adapter === "cursor" || route.provider.adapter === "kiro") {
650
- const raw = anthropicBody as Rec;
651
- const parts: string[] = [];
652
- if (raw.system !== undefined) parts.push(typeof raw.system === "string" ? raw.system : JSON.stringify(raw.system));
653
- if (raw.messages !== undefined) parts.push(JSON.stringify(raw.messages));
654
- if (raw.tools !== undefined) parts.push(JSON.stringify(raw.tools));
655
- logCtx.usageLogInputTokens = Math.max(1, estimateTokens(parts.join("\n"), requestedModel));
656
+ logCtx.usageLogInputTokens = estimateClaudeRequestTokens(anthropicBody as Rec, requestedModel);
656
657
  }
657
658
  // Effort safety valve (devlog 136 B6, audit 139 R2#2): opus-shaped aliases make
658
659
  // every routed model look like a reasoning model to Claude clients, so a forced
@@ -770,7 +771,7 @@ async function handleClaudeMessagesWithBudget(
770
771
  // share a backoff hint when the upstream omitted the header.
771
772
  const nativeMainFence = response.status === 503
772
773
  && upstreamRetryAfter?.trim() === "1"
773
- && message === "Native Codex main profile is switching; retry this request";
774
+ && message === CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE;
774
775
  const transient = !nativeMainFence && isTransientUpstreamStatus(response.status);
775
776
  const outStatus = nativeMainFence ? 503 : transient ? 529 : response.status;
776
777
  const out = new Response(JSON.stringify(anthropicErrorBody(outStatus, message)), {
@@ -873,7 +874,68 @@ async function handleClaudeMessagesWithBudget(
873
874
  });
874
875
  }
875
876
 
876
- /** Documented approximation: serialize system+messages+tools, run the char estimator. */
877
+ /** Per-attachment token estimate for a base64 payload: real image dimensions when the
878
+ * header is sniffable (Anthropic prices images at ~pixels/750), else decoded bytes/512,
879
+ * min 256 — the same shape as the Kiro usage estimator (estimateKiroImageTokens). */
880
+ function estimateBase64AttachmentTokens(data: string): number {
881
+ const dims = sniffImageDimensions(data);
882
+ if (dims) return Math.max(256, Math.ceil((dims.width * dims.height) / 750));
883
+ const unpadded = data.endsWith("==") ? data.length - 2 : data.endsWith("=") ? data.length - 1 : data.length;
884
+ return Math.max(256, Math.ceil(Math.floor((unpadded * 3) / 4) / 512));
885
+ }
886
+
887
+ /**
888
+ * Char-based token estimate for an Anthropic-shaped request body. Base64 attachment
889
+ * payloads (image/document blocks in message content, including blocks nested in
890
+ * tool_result.content) are counted as a bounded per-attachment estimate instead of raw
891
+ * characters: one 2MB screenshot is ~2.7M base64 chars, which the plain chars/token
892
+ * divide reports as hundreds of thousands of tokens versus a real cost around 1.6k.
893
+ * That breaks the >2x drift bound the estimator is held to (devlog 260711_claude_inbound
894
+ * 040 §3). Text and url sources are left in place and counted as characters, as is
895
+ * anything outside protocol content positions (tool_use.input, tool schemas).
896
+ */
897
+ export function estimateClaudeRequestTokens(
898
+ raw: { system?: unknown; messages?: unknown; tools?: unknown },
899
+ modelId: string | undefined,
900
+ ): number {
901
+ let attachmentTokens = 0;
902
+ // Blank base64 payloads ONLY in protocol content positions: message content blocks and
903
+ // blocks nested in tool_result.content. tool_use.input and tool schemas can legitimately
904
+ // contain attachment-shaped JSON, and those bytes ARE serialized into function_call
905
+ // arguments / tool definitions for routed providers, so they must keep counting as text.
906
+ // system is text-only per the Anthropic protocol (no attachment sources), so it is
907
+ // stringified as-is.
908
+ const sanitizeBlock = (block: unknown): unknown => {
909
+ if (!block || typeof block !== "object") return block;
910
+ const b = block as Record<string, unknown>;
911
+ if (b.type === "image" || b.type === "document") {
912
+ const source = b.source as { type?: unknown; data?: unknown } | undefined;
913
+ if (source && typeof source === "object" && source.type === "base64" && typeof source.data === "string") {
914
+ attachmentTokens += estimateBase64AttachmentTokens(source.data);
915
+ return { ...b, source: { ...(source as Record<string, unknown>), data: "" } };
916
+ }
917
+ return block;
918
+ }
919
+ if (b.type === "tool_result" && Array.isArray(b.content)) {
920
+ return { ...b, content: (b.content as unknown[]).map(sanitizeBlock) };
921
+ }
922
+ return block;
923
+ };
924
+ const sanitizedMessages = (messages: unknown): unknown =>
925
+ Array.isArray(messages)
926
+ ? messages.map(message => {
927
+ if (!message || typeof message !== "object") return message;
928
+ const m = message as Record<string, unknown>;
929
+ return Array.isArray(m.content) ? { ...m, content: (m.content as unknown[]).map(sanitizeBlock) } : message;
930
+ })
931
+ : messages;
932
+ const parts: string[] = [];
933
+ if (raw.system !== undefined) parts.push(typeof raw.system === "string" ? raw.system : JSON.stringify(raw.system));
934
+ if (raw.messages !== undefined) parts.push(JSON.stringify(sanitizedMessages(raw.messages)));
935
+ if (raw.tools !== undefined) parts.push(JSON.stringify(raw.tools));
936
+ return Math.max(1, estimateTokens(parts.join("\n"), modelId) + attachmentTokens);
937
+ }
938
+
877
939
  export async function handleClaudeCountTokens(req: Request, config: OcxConfig): Promise<Response> {
878
940
  const disabled = claudeInboundDisabled(config);
879
941
  if (disabled) return disabled;
@@ -910,11 +972,7 @@ export async function handleClaudeCountTokens(req: Request, config: OcxConfig):
910
972
  if (wantsNativePassthrough(req, config, model)) {
911
973
  return await anthropicNativePassthrough(req, config, { model, provider: "anthropic-native", surface: "claude" }, undefined, raw, "/v1/messages/count_tokens");
912
974
  }
913
- const parts: string[] = [];
914
- if (raw.system !== undefined) parts.push(typeof raw.system === "string" ? raw.system : JSON.stringify(raw.system));
915
- if (raw.messages !== undefined) parts.push(JSON.stringify(raw.messages));
916
- if (raw.tools !== undefined) parts.push(JSON.stringify(raw.tools));
917
- const inputTokens = Math.max(1, estimateTokens(parts.join("\n"), model));
975
+ const inputTokens = estimateClaudeRequestTokens(raw, model);
918
976
  return new Response(JSON.stringify({ input_tokens: inputTokens }), {
919
977
  status: 200,
920
978
  headers: { "Content-Type": "application/json" },
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Client-facing repair for GitHub Copilot's `vscode-chat` Responses stream.
3
+ *
4
+ * Copilot can rotate opaque response/item ids between lifecycle events, expose
5
+ * provider-private reasoning ciphertext, and attach per-delta obfuscation
6
+ * padding while the authoritative complete tool input arrives on `.done`.
7
+ * Stock Responses clients cannot reconcile that dialect. This stateful block
8
+ * rewrite emits one coherent Responses SSE stream while the inspection branch
9
+ * keeps the raw upstream snapshot for provider replay.
10
+ */
11
+ import {
12
+ TranslatorBudgetExceededError,
13
+ type TranslatorBudget,
14
+ } from "../lib/translator-budget";
15
+ import {
16
+ replaceSseDataPayload,
17
+ sseDataPayload,
18
+ type SseBlockRewrite,
19
+ } from "./sse-payload-rewrite";
20
+
21
+ const MAX_TRACKED_ITEMS = 256;
22
+ const MAX_TRACKED_DEFERRED_CALLS = 256;
23
+ const RETAINED_STATE_LIMIT_BYTES = 256 * 1024;
24
+
25
+ type JsonRecord = Record<string, unknown>;
26
+
27
+ type TrackedItem = {
28
+ id: string;
29
+ type?: string;
30
+ encryptedReasoning: boolean;
31
+ };
32
+
33
+ type DeferredCallKind = "function" | "custom";
34
+
35
+ function isRecord(value: unknown): value is JsonRecord {
36
+ return typeof value === "object" && value !== null && !Array.isArray(value);
37
+ }
38
+
39
+ function outputIndexOf(value: unknown): number | undefined {
40
+ return typeof value === "number" && Number.isInteger(value) && value >= 0
41
+ ? value
42
+ : undefined;
43
+ }
44
+
45
+ function byteLength(value: unknown): number {
46
+ return Buffer.byteLength(JSON.stringify(value), "utf8");
47
+ }
48
+
49
+ function blockNewline(block: string): "\r\n" | "\n" {
50
+ return block.includes("\r\n") ? "\r\n" : "\n";
51
+ }
52
+
53
+ /** Keep a named SSE block's `event:` field aligned with its JSON `type`. */
54
+ function withEventName(block: string, eventName: string): string {
55
+ const newline = blockNewline(block);
56
+ const lines = block.split(/\r?\n/);
57
+ let replaced = false;
58
+ const rewritten = lines.flatMap((line) => {
59
+ if (!line.startsWith("event:")) return [line];
60
+ if (replaced) return [];
61
+ replaced = true;
62
+ return [`event: ${eventName}`];
63
+ });
64
+ if (!replaced) {
65
+ const dataIndex = rewritten.findIndex(line => line.startsWith("data:"));
66
+ rewritten.splice(dataIndex >= 0 ? dataIndex : 0, 0, `event: ${eventName}`);
67
+ }
68
+ return rewritten.join(newline);
69
+ }
70
+
71
+ function rewriteJsonBlock(block: string, event: JsonRecord): string {
72
+ const type = typeof event.type === "string" ? event.type : "message";
73
+ return withEventName(replaceSseDataPayload(block, JSON.stringify(event)), type);
74
+ }
75
+
76
+ function syntheticJsonBlock(template: string, event: JsonRecord): string {
77
+ const newline = blockNewline(template);
78
+ const type = typeof event.type === "string" ? event.type : "message";
79
+ return `event: ${type}${newline}data: ${JSON.stringify(event)}`;
80
+ }
81
+
82
+ function callKindForDelta(type: unknown): DeferredCallKind | undefined {
83
+ if (type === "response.function_call_arguments.delta") return "function";
84
+ if (type === "response.custom_tool_call_input.delta") return "custom";
85
+ return undefined;
86
+ }
87
+
88
+ function callKindForDone(type: unknown): DeferredCallKind | undefined {
89
+ if (type === "response.function_call_arguments.done") return "function";
90
+ if (type === "response.custom_tool_call_input.done") return "custom";
91
+ return undefined;
92
+ }
93
+
94
+ function callKey(kind: DeferredCallKind, outputIndex: number): string {
95
+ return `${kind}:${outputIndex}`;
96
+ }
97
+
98
+ function deltaTypeFor(kind: DeferredCallKind): string {
99
+ return kind === "function"
100
+ ? "response.function_call_arguments.delta"
101
+ : "response.custom_tool_call_input.delta";
102
+ }
103
+
104
+ function completeValueFor(kind: DeferredCallKind, event: JsonRecord): string {
105
+ const value = kind === "function" ? event.arguments : event.input;
106
+ return typeof value === "string" ? value : "";
107
+ }
108
+
109
+ /**
110
+ * Create one provider-scoped block rewrite per upstream response.
111
+ *
112
+ * Retained ids and pending-call markers are charged to the request translator
113
+ * budget and hard-capped. `dispose` releases them on terminal, EOF, cancel, or
114
+ * relay failure through the shared block-rewrite lifecycle.
115
+ */
116
+ export function createGithubCopilotResponsesBlockRewrite(
117
+ budget?: TranslatorBudget,
118
+ ): SseBlockRewrite {
119
+ const items = new Map<number, TrackedItem>();
120
+ const deferredCalls = new Map<string, number>();
121
+ let responseId: string | undefined;
122
+ let itemStateBytes = 0;
123
+ let callStateBytes = 0;
124
+ let nextSequenceNumber = 0;
125
+ let itemTrackingTainted = false;
126
+ let disposed = false;
127
+
128
+ const ensureStateLimit = (nextBytes: number): void => {
129
+ if (itemStateBytes + callStateBytes + nextBytes > RETAINED_STATE_LIMIT_BYTES) {
130
+ throw new TranslatorBudgetExceededError("item_ids", RETAINED_STATE_LIMIT_BYTES);
131
+ }
132
+ };
133
+
134
+ const chargeItemState = (bytes: number): void => {
135
+ ensureStateLimit(bytes);
136
+ budget?.chargeRetained(bytes, { kind: "item_ids" });
137
+ itemStateBytes += bytes;
138
+ };
139
+
140
+ const chargeCallState = (bytes: number): void => {
141
+ ensureStateLimit(bytes);
142
+ budget?.chargeRetained(bytes, { kind: "retained_collectors" });
143
+ callStateBytes += bytes;
144
+ };
145
+
146
+ const releaseDeferredCall = (key: string): boolean => {
147
+ const bytes = deferredCalls.get(key);
148
+ if (bytes === undefined) return false;
149
+ deferredCalls.delete(key);
150
+ callStateBytes -= bytes;
151
+ budget?.releaseRetained(bytes, { kind: "retained_collectors" });
152
+ return true;
153
+ };
154
+
155
+ const rememberResponseId = (candidate: string): string => {
156
+ if (responseId !== undefined) return responseId;
157
+ const bytes = byteLength(candidate);
158
+ chargeItemState(bytes);
159
+ responseId = candidate;
160
+ return candidate;
161
+ };
162
+
163
+ const rememberItem = (
164
+ outputIndex: number,
165
+ candidateId: string,
166
+ itemType?: string,
167
+ encryptedReasoning = false,
168
+ ): TrackedItem => {
169
+ const existing = items.get(outputIndex);
170
+ if (existing) {
171
+ if (existing.type === undefined && itemType !== undefined) {
172
+ const extra = byteLength(itemType);
173
+ chargeItemState(extra);
174
+ existing.type = itemType;
175
+ }
176
+ existing.encryptedReasoning ||= encryptedReasoning;
177
+ return existing;
178
+ }
179
+ if (itemTrackingTainted || items.size >= MAX_TRACKED_ITEMS) {
180
+ // Keep relaying client-visible events after the count cap, but stop
181
+ // retaining new output-index mappings. The current event still receives
182
+ // the provider id, while earlier retained mappings remain available for
183
+ // stable ids. Byte-budget failures stay hard failures.
184
+ itemTrackingTainted = true;
185
+ return { id: candidateId, type: itemType, encryptedReasoning };
186
+ }
187
+ chargeItemState(byteLength([outputIndex, candidateId, itemType ?? null]));
188
+ const tracked = { id: candidateId, type: itemType, encryptedReasoning };
189
+ items.set(outputIndex, tracked);
190
+ return tracked;
191
+ };
192
+
193
+ const rememberDeferredCall = (key: string): void => {
194
+ if (deferredCalls.has(key)) return;
195
+ if (deferredCalls.size >= MAX_TRACKED_DEFERRED_CALLS) {
196
+ throw new TranslatorBudgetExceededError("retained_collectors", RETAINED_STATE_LIMIT_BYTES);
197
+ }
198
+ const bytes = byteLength(key);
199
+ chargeCallState(bytes);
200
+ deferredCalls.set(key, bytes);
201
+ };
202
+
203
+ const sanitizeReasoningItem = (
204
+ item: JsonRecord,
205
+ tracked: TrackedItem,
206
+ ): void => {
207
+ if (item.type !== "reasoning") return;
208
+ const encrypted = typeof item.encrypted_content === "string";
209
+ tracked.encryptedReasoning ||= encrypted;
210
+ if (encrypted) delete item.encrypted_content;
211
+ if (tracked.encryptedReasoning) {
212
+ const summary = Array.isArray(item.summary) ? item.summary : [];
213
+ const hasPlaintextSummary = summary.some(part =>
214
+ isRecord(part)
215
+ && part.type === "summary_text"
216
+ && typeof part.text === "string"
217
+ && part.text.length > 0);
218
+ if (!hasPlaintextSummary) item.summary = [{ type: "summary_text", text: "" }];
219
+ if (!Array.isArray(item.content)) item.content = [];
220
+ }
221
+ };
222
+
223
+ const normalizeNestedItem = (item: JsonRecord, outputIndex: number): void => {
224
+ const candidateId = typeof item.id === "string" ? item.id : undefined;
225
+ if (candidateId === undefined) return;
226
+ const itemType = typeof item.type === "string" ? item.type : undefined;
227
+ const tracked = rememberItem(
228
+ outputIndex,
229
+ candidateId,
230
+ itemType,
231
+ itemType === "reasoning" && typeof item.encrypted_content === "string",
232
+ );
233
+ item.id = tracked.id;
234
+ sanitizeReasoningItem(item, tracked);
235
+ };
236
+
237
+ const normalizeSequence = (event: JsonRecord): void => {
238
+ if (typeof event.sequence_number !== "number" || !Number.isFinite(event.sequence_number)) return;
239
+ event.sequence_number = nextSequenceNumber++;
240
+ };
241
+
242
+ const dispose = (): void => {
243
+ if (disposed) return;
244
+ disposed = true;
245
+ if (itemStateBytes > 0) budget?.releaseRetained(itemStateBytes, { kind: "item_ids" });
246
+ if (callStateBytes > 0) budget?.releaseRetained(callStateBytes, { kind: "retained_collectors" });
247
+ items.clear();
248
+ deferredCalls.clear();
249
+ responseId = undefined;
250
+ itemStateBytes = 0;
251
+ callStateBytes = 0;
252
+ itemTrackingTainted = false;
253
+ };
254
+
255
+ const rewrite: SseBlockRewrite = (block) => {
256
+ const payload = sseDataPayload(block);
257
+ if (payload === null || payload === "[DONE]") return [block];
258
+
259
+ let event: unknown;
260
+ try {
261
+ event = JSON.parse(payload);
262
+ } catch {
263
+ return [block];
264
+ }
265
+ if (!isRecord(event)) return [block];
266
+
267
+ if (typeof event.obfuscation === "string") delete event.obfuscation;
268
+
269
+ const type = event.type;
270
+ const outputIndex = outputIndexOf(event.output_index);
271
+ const deltaKind = callKindForDelta(type);
272
+ const deferToolDelta = deltaKind !== undefined
273
+ && outputIndex !== undefined;
274
+
275
+ if (isRecord(event.response) && typeof event.response.id === "string") {
276
+ event.response.id = rememberResponseId(event.response.id);
277
+ }
278
+
279
+ if (outputIndex !== undefined && isRecord(event.item)) {
280
+ normalizeNestedItem(event.item, outputIndex);
281
+ }
282
+ // A deferred delta is not client-visible, so it must not establish the
283
+ // public item id if a malformed provider sends it before output_item.added.
284
+ if (!deferToolDelta && outputIndex !== undefined && typeof event.item_id === "string") {
285
+ const tracked = rememberItem(outputIndex, event.item_id);
286
+ event.item_id = tracked.id;
287
+ }
288
+
289
+ if (type === "response.completed" && isRecord(event.response) && Array.isArray(event.response.output)) {
290
+ event.response.output.forEach((item, index) => {
291
+ if (isRecord(item)) normalizeNestedItem(item, index);
292
+ });
293
+ }
294
+
295
+ if (typeof type === "string" && type.startsWith("response.reasoning")) {
296
+ if (typeof event.encrypted_content === "string") delete event.encrypted_content;
297
+ // `encrypted_content` is opaque replay state; summary delta/text fields
298
+ // are a separate, potentially readable part of the public Responses
299
+ // contract and must survive when Copilot provides them.
300
+ }
301
+
302
+ if (deferToolDelta) {
303
+ rememberDeferredCall(callKey(deltaKind, outputIndex));
304
+ // The OpenAI `obfuscation` field is padding, not a decryption key. Defer
305
+ // every Copilot tool fragment anyway: mixed padded/unpadded fragments and
306
+ // rotating ids cannot then produce duplicate or irreconcilable input.
307
+ // `.done` carries the authoritative complete value, which is emitted as
308
+ // one canonical delta immediately before the original `.done` event.
309
+ return [];
310
+ }
311
+
312
+ const emitted: Array<{ event: JsonRecord; synthetic: boolean }> = [];
313
+ const doneKind = callKindForDone(type);
314
+ if (doneKind !== undefined && outputIndex !== undefined) {
315
+ const key = callKey(doneKind, outputIndex);
316
+ if (releaseDeferredCall(key)) {
317
+ emitted.push({
318
+ synthetic: true,
319
+ event: {
320
+ type: deltaTypeFor(doneKind),
321
+ output_index: outputIndex,
322
+ ...(typeof event.item_id === "string" ? { item_id: event.item_id } : {}),
323
+ ...(typeof event.sequence_number === "number" ? { sequence_number: event.sequence_number } : {}),
324
+ delta: completeValueFor(doneKind, event),
325
+ },
326
+ });
327
+ }
328
+ }
329
+ emitted.push({ event, synthetic: false });
330
+
331
+ for (const entry of emitted) normalizeSequence(entry.event);
332
+ return emitted.map(entry => entry.synthetic
333
+ ? syntheticJsonBlock(block, entry.event)
334
+ : rewriteJsonBlock(block, entry.event));
335
+ };
336
+ rewrite.dispose = dispose;
337
+ return rewrite;
338
+ }