@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.
- package/README.md +31 -0
- package/bin/ocx.mjs +10 -0
- package/gui/dist/assets/index-Bk-PN-70.css +1 -0
- package/gui/dist/assets/index-BynIEIV-.js +70 -0
- package/gui/dist/index.html +2 -2
- package/package.json +4 -2
- package/src/adapters/cursor/effort-map.ts +11 -0
- package/src/adapters/cursor/live-transport.ts +11 -0
- package/src/adapters/cursor/native-exec-fs.ts +9 -6
- package/src/adapters/cursor/native-exec.ts +4 -2
- package/src/adapters/cursor/protobuf-events.ts +176 -4
- package/src/adapters/cursor/request-builder.ts +15 -4
- package/src/adapters/cursor/tool-definitions.ts +118 -2
- package/src/adapters/google.ts +15 -5
- package/src/adapters/openai-chat.ts +24 -2
- package/src/adapters/openai-responses.ts +2 -1
- package/src/bridge.ts +9 -5
- package/src/chat/outbound.ts +4 -3
- package/src/claude/desktop-3p.ts +222 -2
- package/src/claude/outbound.ts +15 -6
- package/src/cli/account-api.ts +4 -0
- package/src/cli/account-extended.ts +112 -0
- package/src/cli/account.ts +23 -6
- package/src/cli/claude-desktop.ts +26 -3
- package/src/cli/config-command.ts +9 -0
- package/src/cli/help.ts +18 -2
- package/src/cli/index.ts +277 -55
- package/src/cli/models.ts +5 -1
- package/src/cli/provider.ts +8 -2
- package/src/cli/ready.ts +301 -0
- package/src/cli/system-restart-client.ts +146 -0
- package/src/cli/tray-proxy.ts +153 -6
- package/src/clients/config-export.ts +12 -19
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-namespaces.ts +49 -3
- package/src/codex/account-priority.ts +83 -0
- package/src/codex/auth-api.ts +83 -0
- package/src/codex/auth-context.ts +5 -2
- package/src/codex/catalog/provider-fetch.ts +11 -0
- package/src/codex/catalog/sync.ts +23 -1
- package/src/codex/codex-write-lock.ts +16 -4
- package/src/codex/desired-state.ts +37 -4
- package/src/codex/history-job.ts +15 -5
- package/src/codex/history-provider.ts +31 -14
- package/src/codex/history-worker.ts +28 -4
- package/src/codex/inject-coordination.ts +13 -1
- package/src/codex/inject.ts +360 -66
- package/src/codex/internal/history-writer.ts +1 -1
- package/src/codex/native-main-lock-file.ts +5 -1
- package/src/codex/native-main-owner.ts +17 -3
- package/src/codex/native-profile-manager.ts +19 -0
- package/src/codex/native-profile-startup.ts +8 -0
- package/src/codex/native-residue.ts +140 -27
- package/src/codex/pool-rotation.ts +74 -4
- package/src/codex/refresh.ts +7 -0
- package/src/codex/routing.ts +177 -36
- package/src/codex/subagent-model-fallback.ts +34 -4
- package/src/codex/sync.ts +61 -0
- package/src/codex/upstream-host-health.ts +329 -31
- package/src/combos/request.ts +2 -0
- package/src/config.ts +221 -2
- package/src/images/loop.ts +1 -1
- package/src/integrations/native/ownership-preflight.ts +39 -2
- package/src/lib/bun-stream-caps.ts +3 -3
- package/src/lib/sse-decoder.ts +41 -0
- package/src/lib/system-restart-contract.ts +73 -0
- package/src/lib/windows-secret-acl.ts +141 -39
- package/src/lib/windows-user-principal.ts +283 -0
- package/src/lib/winsw.ts +18 -2
- package/src/oauth/key-providers.ts +12 -0
- package/src/providers/derive.ts +54 -2
- package/src/providers/free-directory.ts +6 -5
- package/src/providers/model-discovery.ts +9 -3
- package/src/providers/quota.ts +592 -0
- package/src/providers/registry.ts +316 -13
- package/src/responses/parser.ts +26 -10
- package/src/responses/reasoning-replay-cache.ts +1 -0
- package/src/routing/profile-namespace.ts +15 -0
- package/src/routing/profile.ts +2 -1
- package/src/server/auth-cors.ts +44 -13
- package/src/server/chat-completions.ts +0 -4
- package/src/server/claude-messages.ts +73 -15
- package/src/server/github-copilot-responses-repair.ts +338 -0
- package/src/server/index.ts +328 -111
- package/src/server/lifecycle.ts +36 -0
- package/src/server/management/agent-settings-routes.ts +147 -56
- package/src/server/management/config-routes.ts +7 -2
- package/src/server/management/context.ts +4 -0
- package/src/server/management/native-integration-routes.ts +199 -20
- package/src/server/management/provider-routes.ts +41 -0
- package/src/server/management/routing-profile-routes.ts +234 -5
- package/src/server/management/system-restart.ts +12 -10
- package/src/server/management/system-routes.ts +20 -0
- package/src/server/management-auth.ts +51 -3
- package/src/server/ports.ts +41 -1
- package/src/server/proxy-liveness.ts +129 -4
- package/src/server/readiness.ts +99 -0
- package/src/server/relay.ts +113 -97
- package/src/server/request-log.ts +10 -4
- package/src/server/responses/compact.ts +107 -12
- package/src/server/responses/core.ts +220 -39
- package/src/server/responses-item-id-repair.ts +22 -3
- package/src/server/responses-model-rewrite.ts +29 -0
- package/src/server/sse-frame-buffer.ts +292 -0
- package/src/server/sse-payload-rewrite.ts +25 -14
- package/src/server/ws-bridge.ts +27 -22
- package/src/service-manager-probe.ts +520 -10
- package/src/service.ts +134 -2
- package/src/storage/worker-lifecycle.ts +14 -14
- package/src/tray/windows-tray.ps1 +74 -9
- package/src/types.ts +68 -2
- package/src/update/index.ts +12 -0
- package/src/update/job.ts +392 -18
- package/src/update/npm-cache-preflight.d.mts +47 -0
- package/src/update/npm-cache-preflight.mjs +201 -0
- package/src/usage/log.ts +1 -1
- package/src/vision/index.ts +77 -2
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/parse.ts +4 -1
- package/gui/dist/assets/index-BKVqyYqT.js +0 -70
- 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 {
|
|
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
|
|
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
|
-
|
|
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 ===
|
|
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
|
-
/**
|
|
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
|
|
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
|
+
}
|