@bitkyc08/opencodex 2.8.0 → 2.9.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 +24 -0
- package/bin/ocx.mjs +32 -4
- package/gui/dist/assets/index-CHwf3tTD.css +1 -0
- package/gui/dist/assets/index-u5eFOv2y.js +67 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-normalize.ts +114 -20
- package/src/adapters/anthropic.ts +126 -10
- package/src/adapters/azure.ts +3 -3
- package/src/adapters/base.ts +7 -3
- package/src/adapters/cursor/discovery.ts +14 -4
- package/src/adapters/cursor/effort-map.ts +18 -6
- package/src/adapters/cursor/framing.ts +102 -27
- package/src/adapters/cursor/kv-store.ts +30 -3
- package/src/adapters/cursor/live-models.ts +22 -2
- package/src/adapters/cursor/live-transport.ts +245 -49
- package/src/adapters/cursor/mcp-manager.ts +105 -8
- package/src/adapters/cursor/native-exec-mcp.ts +5 -3
- package/src/adapters/cursor/native-exec-shell.ts +296 -14
- package/src/adapters/cursor/native-exec.ts +381 -33
- package/src/adapters/cursor/protobuf-events.ts +28 -1
- package/src/adapters/cursor/protobuf-request.ts +71 -39
- package/src/adapters/cursor/request-builder.ts +2 -2
- package/src/adapters/cursor/transport.ts +2 -0
- package/src/adapters/cursor.ts +13 -2
- package/src/adapters/google-antigravity-replay.ts +184 -17
- package/src/adapters/google.ts +58 -8
- package/src/adapters/kiro-thinking.ts +23 -9
- package/src/adapters/kiro-tools.ts +49 -18
- package/src/adapters/kiro.ts +377 -133
- package/src/adapters/mimo-free.ts +36 -4
- package/src/adapters/openai-chat.ts +143 -17
- package/src/adapters/openai-responses.ts +130 -14
- package/src/adapters/run-turn-queue.ts +7 -1
- package/src/bridge.ts +466 -69
- package/src/chat/outbound.ts +144 -38
- package/src/claude/inbound-debug.ts +53 -8
- package/src/claude/outbound.ts +224 -38
- package/src/cli/agent-driven.ts +34 -1
- package/src/cli/catalog-prewarm.ts +5 -2
- package/src/cli/claude-desktop.ts +2 -2
- package/src/cli/doctor.ts +12 -0
- package/src/cli/export-command.ts +187 -0
- package/src/cli/help.ts +11 -0
- package/src/cli/index.ts +13 -3
- package/src/cli/init.ts +129 -102
- package/src/cli/opencode.ts +36 -151
- package/src/cli/star-prompt.ts +13 -4
- package/src/cli/status-oauth.ts +12 -2
- package/src/clients/config-export.ts +377 -0
- package/src/codex/account-runtime-state.ts +19 -1
- package/src/codex/account-store.ts +162 -82
- package/src/codex/auth-api.ts +467 -159
- package/src/codex/auth-context.ts +15 -2
- package/src/codex/catalog/aggregation.ts +15 -0
- package/src/codex/catalog/effort.ts +16 -6
- package/src/codex/catalog/metadata.ts +6 -0
- package/src/codex/catalog/parsing.ts +3 -1
- package/src/codex/catalog/provider-fetch.ts +29 -0
- package/src/codex/catalog/sync.ts +64 -7
- package/src/codex/catalog.ts +2 -2
- package/src/codex/inject.ts +5 -5
- package/src/codex/main-account-cache.ts +8 -1
- package/src/codex/model-cache.ts +81 -2
- package/src/codex/pool-rotation.ts +39 -0
- package/src/codex/project-config-warnings.ts +12 -1
- package/src/codex/quota.ts +35 -3
- package/src/codex/routing.ts +46 -1
- package/src/codex/shim.ts +10 -4
- package/src/codex/subagent-model-fallback.ts +12 -0
- package/src/codex/websocket-registry.ts +27 -0
- package/src/combos/failover.ts +31 -1
- package/src/combos/request.ts +9 -0
- package/src/combos/resolve.ts +60 -4
- package/src/combos/types.ts +12 -0
- package/src/config.ts +510 -55
- package/src/github/star-state.ts +13 -1
- package/src/images/fulfill.ts +39 -1
- package/src/images/loop.ts +52 -12
- package/src/lib/admission.ts +83 -0
- package/src/lib/app-owned-memory-stores.ts +173 -0
- package/src/lib/app-owned-memory.ts +265 -0
- package/src/lib/bun-stream-caps.ts +31 -7
- package/src/lib/config-ownership.ts +33 -0
- package/src/lib/crash-guard.ts +65 -5
- package/src/lib/debug-log-buffer.ts +47 -6
- package/src/lib/destination-policy.ts +12 -1
- package/src/lib/errors.ts +3 -0
- package/src/lib/gcp-adc.ts +40 -2
- package/src/lib/injection-debug-log.ts +26 -2
- package/src/lib/provider-outbound.ts +3 -0
- package/src/lib/sidecar-tracker.ts +5 -2
- package/src/lib/sse-decoder.ts +257 -37
- package/src/lib/state-store-registrations.ts +109 -0
- package/src/lib/state-store-sweeper.ts +184 -0
- package/src/lib/translator-budget.ts +356 -0
- package/src/lib/windows-secret-acl.ts +33 -12
- package/src/lib/winsw.ts +14 -1
- package/src/oauth/anthropic-routing.ts +31 -7
- package/src/oauth/google-antigravity.ts +2 -1
- package/src/oauth/health.ts +30 -12
- package/src/oauth/index.ts +127 -23
- package/src/oauth/kiro-credentials.ts +72 -1
- package/src/oauth/kiro.ts +23 -4
- package/src/oauth/store.ts +165 -18
- package/src/oauth/token-guardian.ts +43 -4
- package/src/oauth/types.ts +2 -1
- package/src/providers/base-url-choices.ts +10 -0
- package/src/providers/derive.ts +12 -0
- package/src/providers/free-directory.ts +4 -1
- package/src/providers/key-failover.ts +12 -0
- package/src/providers/openai-sidecar.ts +4 -1
- package/src/providers/quota.ts +68 -7
- package/src/providers/registry.ts +279 -3
- package/src/responses/parser.ts +5 -1
- package/src/responses/spill-store.ts +394 -0
- package/src/responses/state.ts +520 -102
- package/src/router.ts +18 -1
- package/src/server/adapter-resolve.ts +20 -3
- package/src/server/auth-cors.ts +121 -28
- package/src/server/chat-completions.ts +57 -12
- package/src/server/claude-messages.ts +85 -13
- package/src/server/index.ts +242 -100
- package/src/server/lifecycle.ts +155 -25
- package/src/server/management/agent-settings-routes.ts +79 -36
- package/src/server/management/api-key-usage.ts +167 -0
- package/src/server/management/body.ts +35 -0
- package/src/server/management/combo-routes.ts +5 -1
- package/src/server/management/config-routes.ts +42 -12
- package/src/server/management/logs-usage-routes.ts +41 -21
- package/src/server/management/model-routes.ts +188 -54
- package/src/server/management/oauth-account-routes.ts +115 -26
- package/src/server/management/provider-routes.ts +56 -6
- package/src/server/management/shared.ts +16 -3
- package/src/server/management/sidebar-routes.ts +50 -1
- package/src/server/management/system-restart.ts +13 -6
- package/src/server/management/system-routes.ts +15 -3
- package/src/server/management/usage-summary-cache.ts +86 -0
- package/src/server/management-api.ts +39 -5
- package/src/server/management-auth.ts +65 -14
- package/src/server/port-reclaim.ts +58 -12
- package/src/server/ports.ts +2 -0
- package/src/server/proxy-liveness.ts +60 -14
- package/src/server/relay-eager.ts +20 -4
- package/src/server/relay.ts +548 -154
- package/src/server/request-decompress.ts +51 -4
- package/src/server/request-log.ts +134 -15
- package/src/server/responses/collaboration.ts +15 -4
- package/src/server/responses/compact.ts +3 -0
- package/src/server/responses/core.ts +241 -66
- package/src/server/responses-image-gen-repair.ts +19 -5
- package/src/server/responses-item-id-repair.ts +23 -5
- package/src/server/sse-payload-rewrite.ts +71 -12
- package/src/server/startup-health-cache.ts +14 -1
- package/src/server/system-env.ts +8 -1
- package/src/server/windows-tcp-drop.ts +15 -5
- package/src/server/ws-bridge.ts +25 -0
- package/src/service.ts +179 -13
- package/src/storage/policy-job.ts +93 -23
- package/src/storage/policy-worker.ts +6 -0
- package/src/storage/restore-job.ts +62 -16
- package/src/storage/restore-worker.ts +6 -0
- package/src/storage/storage-mutation-coordinator.ts +36 -6
- package/src/storage/worker-lifecycle.ts +181 -47
- package/src/tray/windows.ts +97 -25
- package/src/types.ts +39 -6
- package/src/update/index.ts +24 -5
- package/src/update/job.ts +598 -73
- package/src/usage/log.ts +115 -17
- package/src/usage/summary.ts +67 -2
- package/src/vision/index.ts +112 -22
- package/src/web-search/loop.ts +38 -6
- package/src/web-search/progress-stream.ts +14 -3
- package/gui/dist/assets/index-BDjpkcRN.js +0 -67
- package/gui/dist/assets/index-BHsKRFh9.css +0 -1
|
@@ -5,7 +5,7 @@ import { getConfigDir } from "../config";
|
|
|
5
5
|
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
6
6
|
import type { OcxProviderConfig, OcxParsedRequest } from "../types";
|
|
7
7
|
import { createOpenAIChatAdapter } from "./openai-chat";
|
|
8
|
-
import type { ProviderAdapter, AdapterRequest } from "./base";
|
|
8
|
+
import type { ProviderAdapter, AdapterRequest, IncomingMeta } from "./base";
|
|
9
9
|
|
|
10
10
|
const BOOTSTRAP_URL = "https://api.xiaomimimo.com/api/free-ai/bootstrap";
|
|
11
11
|
export const MIMO_CHAT_URL = "https://api.xiaomimimo.com/api/free-ai/openai/chat";
|
|
@@ -27,6 +27,8 @@ const USER_AGENTS = [
|
|
|
27
27
|
const JWT_FALLBACK_TTL_MS = 3_000_000; // 50 min
|
|
28
28
|
const JWT_EXPIRY_BUFFER_MS = 300_000; // 5 min early refresh
|
|
29
29
|
const BOOTSTRAP_TIMEOUT_MS = 15_000;
|
|
30
|
+
const MIMO_BOOTSTRAP_MAX_BYTES = 128 * 1024;
|
|
31
|
+
const MIMO_JWT_MAX_BYTES = 64 * 1024;
|
|
30
32
|
|
|
31
33
|
// In-process JWT cache -- survives across requests, reset on restart.
|
|
32
34
|
let cachedJwt: string | null = null;
|
|
@@ -107,8 +109,38 @@ async function fetchJwt(signal?: AbortSignal): Promise<string> {
|
|
|
107
109
|
try { await response.body?.cancel(); } catch { /* already consumed */ }
|
|
108
110
|
throw new Error(`MiMo bootstrap failed: ${response.status}`);
|
|
109
111
|
}
|
|
110
|
-
const
|
|
112
|
+
const announced = Number(response.headers.get("content-length") ?? 0);
|
|
113
|
+
if (Number.isFinite(announced) && announced > MIMO_BOOTSTRAP_MAX_BYTES) {
|
|
114
|
+
await response.body?.cancel().catch(() => {});
|
|
115
|
+
throw new Error("MiMo bootstrap response too large");
|
|
116
|
+
}
|
|
117
|
+
const reader = response.body?.getReader();
|
|
118
|
+
const chunks: Uint8Array[] = [];
|
|
119
|
+
let bytes = 0;
|
|
120
|
+
if (reader) {
|
|
121
|
+
while (true) {
|
|
122
|
+
const { done, value } = await reader.read();
|
|
123
|
+
if (done) break;
|
|
124
|
+
if (!value) continue;
|
|
125
|
+
bytes += value.byteLength;
|
|
126
|
+
if (bytes > MIMO_BOOTSTRAP_MAX_BYTES) {
|
|
127
|
+
await reader.cancel().catch(() => {});
|
|
128
|
+
throw new Error("MiMo bootstrap response too large");
|
|
129
|
+
}
|
|
130
|
+
chunks.push(value);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const body = new Uint8Array(bytes);
|
|
134
|
+
let offset = 0;
|
|
135
|
+
for (const chunk of chunks) {
|
|
136
|
+
body.set(chunk, offset);
|
|
137
|
+
offset += chunk.byteLength;
|
|
138
|
+
}
|
|
139
|
+
const data = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body)) as { jwt?: string };
|
|
111
140
|
if (!data.jwt) throw new Error("MiMo bootstrap returned no JWT");
|
|
141
|
+
if (new TextEncoder().encode(data.jwt).byteLength > MIMO_JWT_MAX_BYTES) {
|
|
142
|
+
throw new Error("MiMo bootstrap response too large");
|
|
143
|
+
}
|
|
112
144
|
return data.jwt;
|
|
113
145
|
}
|
|
114
146
|
|
|
@@ -170,12 +202,12 @@ export function createMimoFreeAdapter(provider: OcxProviderConfig): ProviderAdap
|
|
|
170
202
|
...base,
|
|
171
203
|
name: "mimo-free",
|
|
172
204
|
|
|
173
|
-
async buildRequest(parsed: OcxParsedRequest): Promise<AdapterRequest> {
|
|
205
|
+
async buildRequest(parsed: OcxParsedRequest, incoming: IncomingMeta): Promise<AdapterRequest> {
|
|
174
206
|
const jwt = await getMimoJwt();
|
|
175
207
|
|
|
176
208
|
// Let the base adapter build the wire body (handles reasoning, tools, etc.)
|
|
177
209
|
// but override the URL and headers after.
|
|
178
|
-
const baseReq = base.buildRequest(parsed) as AdapterRequest;
|
|
210
|
+
const baseReq = base.buildRequest(parsed, incoming) as AdapterRequest;
|
|
179
211
|
const baseBody = JSON.parse(baseReq.body as string) as unknown;
|
|
180
212
|
const markedBody = injectMimoSystemMarker(baseBody);
|
|
181
213
|
|
|
@@ -10,6 +10,12 @@ import { contentPartsToText } from "./image";
|
|
|
10
10
|
import { neutralizeIdentity } from "./identity";
|
|
11
11
|
import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge";
|
|
12
12
|
import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing";
|
|
13
|
+
import {
|
|
14
|
+
isTranslatorBudgetExceededError,
|
|
15
|
+
retainTranslatedEventBatch,
|
|
16
|
+
TRANSLATOR_MAX_SSE_EVENT_BYTES,
|
|
17
|
+
type TranslatorBudget,
|
|
18
|
+
} from "../lib/translator-budget";
|
|
13
19
|
|
|
14
20
|
// Providers may opt into stripping one trailing "[...]" group from the wire model id.
|
|
15
21
|
// Z.AI needs this because its OpenAI path rejects glm-5.2[1m] with 400 code 1211;
|
|
@@ -206,10 +212,10 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
|
|
|
206
212
|
}));
|
|
207
213
|
// "" instead of null: strict validators (xAI: "Each message must have at least one
|
|
208
214
|
// content element", langchain#34140) reject content-less assistant history entries.
|
|
209
|
-
if (!chatMsg.content) chatMsg.content =
|
|
215
|
+
if (!chatMsg.content) chatMsg.content = emptyAssistantContent(provider);
|
|
210
216
|
}
|
|
211
217
|
if (chatMsg.reasoning_content !== undefined && chatMsg.content === undefined && chatMsg.tool_calls === undefined) {
|
|
212
|
-
chatMsg.content =
|
|
218
|
+
chatMsg.content = emptyAssistantContent(provider);
|
|
213
219
|
}
|
|
214
220
|
out.push(chatMsg);
|
|
215
221
|
pendingToolCalls = wireToolCalls.map(({ tc, id }) => ({ id, name: namespacedToolName(tc.namespace, tc.name) }));
|
|
@@ -238,7 +244,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
|
|
|
238
244
|
const name = safeToolName(msg.toolName);
|
|
239
245
|
out.push({
|
|
240
246
|
role: "assistant",
|
|
241
|
-
content:
|
|
247
|
+
content: emptyAssistantContent(provider),
|
|
242
248
|
tool_calls: [{
|
|
243
249
|
id: toolCallId,
|
|
244
250
|
type: "function",
|
|
@@ -372,6 +378,43 @@ function isKimiSchemaTarget(provider: OcxProviderConfig): boolean {
|
|
|
372
378
|
}
|
|
373
379
|
}
|
|
374
380
|
|
|
381
|
+
// Volcengine Ark regional endpoints. Ark validates an assistant message's text field as a
|
|
382
|
+
// REQUIRED parameter and treats "" as absent, so a tool-call-only assistant in history 400s with
|
|
383
|
+
// `MissingParameter: input.content.text` (#796). Every other OpenAI-compatible provider accepts
|
|
384
|
+
// "", and xAI actively requires it ("Each message must have at least one content element"), so
|
|
385
|
+
// the two contracts are in direct conflict and this cannot be a global change.
|
|
386
|
+
const VOLCENGINE_ARK_HOSTNAMES = new Set([
|
|
387
|
+
"ark.cn-beijing.volces.com",
|
|
388
|
+
"ark.ap-southeast.volces.com",
|
|
389
|
+
]);
|
|
390
|
+
|
|
391
|
+
function isVolcengineArkTarget(provider: OcxProviderConfig): boolean {
|
|
392
|
+
try {
|
|
393
|
+
return VOLCENGINE_ARK_HOSTNAMES.has(new URL(provider.baseUrl).hostname);
|
|
394
|
+
} catch {
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Placeholder content for an assistant history entry carrying only tool calls or reasoning.
|
|
401
|
+
*
|
|
402
|
+
* UNVERIFIED HYPOTHESIS for Ark. The reported error names `input.content.text`, a nested path,
|
|
403
|
+
* which suggests Ark wants the structured content form `[{type:"text",text:""}]` rather than a
|
|
404
|
+
* bare string — no string value, `""` or `" "`, exposes a `content.text` path at all. But Ark's
|
|
405
|
+
* published examples only show array content for MULTIMODAL USER input, never for an assistant
|
|
406
|
+
* history entry, so this shape is inferred from the error message and not confirmed by the docs
|
|
407
|
+
* or by a live request. The empty inner text at least adds no tokens either way.
|
|
408
|
+
*
|
|
409
|
+
* Confirm against a real Ark endpoint before relying on this; #796 records what is still missing.
|
|
410
|
+
*
|
|
411
|
+
* Every other provider keeps the bare `""`, which xAI's validator specifically requires ("Each
|
|
412
|
+
* message must have at least one content element"), so this cannot be applied globally.
|
|
413
|
+
*/
|
|
414
|
+
function emptyAssistantContent(provider: OcxProviderConfig): string | { type: "text"; text: string }[] {
|
|
415
|
+
return isVolcengineArkTarget(provider) ? [{ type: "text", text: "" }] : "";
|
|
416
|
+
}
|
|
417
|
+
|
|
375
418
|
/**
|
|
376
419
|
* Kimi requires function.parameters.type to be exactly "object" at the root.
|
|
377
420
|
* Codex tools with oneOf/anyOf schemas omit the root type, causing 400 errors.
|
|
@@ -639,7 +682,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
639
682
|
};
|
|
640
683
|
},
|
|
641
684
|
|
|
642
|
-
async *parseStream(response: Response): AsyncGenerator<AdapterEvent> {
|
|
685
|
+
async *parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator<AdapterEvent> {
|
|
643
686
|
if (!response.body) {
|
|
644
687
|
yield { type: "error", message: "No response body" };
|
|
645
688
|
return;
|
|
@@ -647,7 +690,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
647
690
|
|
|
648
691
|
const reader = response.body.getReader();
|
|
649
692
|
const decoder = new TextDecoder();
|
|
693
|
+
const budgetEncoder = new TextEncoder();
|
|
650
694
|
let buffer = "";
|
|
695
|
+
let bufferBytes = 0;
|
|
651
696
|
// Streamed tool calls are BUFFERED until a terminal signal, then flushed as atomic
|
|
652
697
|
// start/delta/end sequences. The bridge treats text/reasoning deltas as barriers that
|
|
653
698
|
// close an open tool-call item (bridge.ts closeCurrentToolCall on text_delta), so
|
|
@@ -656,15 +701,18 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
656
701
|
// chunks, whole-chunk calls) cannot be represented live without overlapping sequences.
|
|
657
702
|
// Keyed by `index` (OpenAI wire standard), falling back to `id`, falling back to the
|
|
658
703
|
// last-seen call for providers that omit both on continuation chunks.
|
|
659
|
-
interface PendingToolCall { key: string; id: string; name: string; args: string }
|
|
704
|
+
interface PendingToolCall { key: string; id: string; name: string; args: string; argsBytes: number }
|
|
660
705
|
const pendingToolCalls: PendingToolCall[] = [];
|
|
661
706
|
let toolCallSeq = 0;
|
|
662
707
|
const flushToolCalls = function* (): Generator<AdapterEvent> {
|
|
708
|
+
// Do not treat flushed tool calls as user-facing output for the finish-less EOF
|
|
709
|
+
// fallback — incomplete tool args must stay on the truncation path.
|
|
663
710
|
for (const call of pendingToolCalls) {
|
|
664
711
|
if (!call.id) call.id = `call_${++toolCallSeq}`;
|
|
665
712
|
yield { type: "tool_call_start", id: call.id, name: call.name };
|
|
666
713
|
if (call.args.length > 0) yield { type: "tool_call_delta", arguments: call.args };
|
|
667
714
|
yield { type: "tool_call_end" };
|
|
715
|
+
budget.closeCall(call.key);
|
|
668
716
|
}
|
|
669
717
|
pendingToolCalls.length = 0;
|
|
670
718
|
};
|
|
@@ -674,6 +722,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
674
722
|
// explicit `[DONE]` sentinel OR a chunk carrying a non-null `finish_reason` (some
|
|
675
723
|
// OpenAI-compatible providers omit `[DONE]` but do send finish_reason).
|
|
676
724
|
let finishReason: string | undefined;
|
|
725
|
+
// Only answer text enables the finish-less EOF fallback. Reasoning-only streams can be
|
|
726
|
+
// suppressed by hideThinkingSummary and must not complete as empty successful turns.
|
|
727
|
+
let sawUserFacingOutput = false;
|
|
677
728
|
|
|
678
729
|
// Single per-line handler shared by the streaming loop and the EOF residual-frame flush, so
|
|
679
730
|
// a final frame is parsed identically wherever it lands (no duplicated, drift-prone parsing).
|
|
@@ -743,6 +794,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
743
794
|
yield { type: "reasoning_raw_delta", text: delta.reasoning_content };
|
|
744
795
|
}
|
|
745
796
|
if (typeof delta.content === "string" && delta.content.length > 0) {
|
|
797
|
+
sawUserFacingOutput = true;
|
|
746
798
|
yield { type: "text_delta", text: delta.content };
|
|
747
799
|
}
|
|
748
800
|
|
|
@@ -760,12 +812,27 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
760
812
|
// splitting into two calls that share one call_id downstream.
|
|
761
813
|
if (!call && tc.id) call = pendingToolCalls.find(c => c.id === tc.id);
|
|
762
814
|
if (!call) {
|
|
763
|
-
call = { key: key ?? `seq:${pendingToolCalls.length}`, id: "", name: "", args: "" };
|
|
815
|
+
call = { key: key ?? `seq:${pendingToolCalls.length}`, id: "", name: "", args: "", argsBytes: 0 };
|
|
764
816
|
pendingToolCalls.push(call);
|
|
817
|
+
budget.openCall(call.key);
|
|
765
818
|
}
|
|
766
819
|
if (tc.id && !call.id) call.id = tc.id;
|
|
767
820
|
if (tc.function?.name && !call.name) call.name = tc.function.name;
|
|
768
|
-
if (tc.function?.arguments)
|
|
821
|
+
if (tc.function?.arguments) {
|
|
822
|
+
const previousBytes = call.argsBytes;
|
|
823
|
+
const nextBytes = previousBytes + budgetEncoder.encode(tc.function.arguments).byteLength;
|
|
824
|
+
const scope = { kind: "tool_args" as const, callId: call.key };
|
|
825
|
+
const reservation = budget.reserveTransient(nextBytes, scope);
|
|
826
|
+
try {
|
|
827
|
+
call.args += tc.function.arguments;
|
|
828
|
+
reservation.commitRetained();
|
|
829
|
+
budget.releaseRetained(previousBytes, scope);
|
|
830
|
+
call.argsBytes = nextBytes;
|
|
831
|
+
} catch (error) {
|
|
832
|
+
reservation.release();
|
|
833
|
+
throw error;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
769
836
|
}
|
|
770
837
|
}
|
|
771
838
|
}
|
|
@@ -782,10 +849,31 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
782
849
|
while (true) {
|
|
783
850
|
const { done, value } = await reader.read();
|
|
784
851
|
if (done) break;
|
|
785
|
-
|
|
852
|
+
const decoded = decoder.decode(value, { stream: true });
|
|
853
|
+
const nextBufferBytes = bufferBytes + budgetEncoder.encode(decoded).byteLength;
|
|
854
|
+
if (nextBufferBytes > TRANSLATOR_MAX_SSE_EVENT_BYTES) {
|
|
855
|
+
throw new Error(`translation SSE event exceeded ${TRANSLATOR_MAX_SSE_EVENT_BYTES} bytes`, {
|
|
856
|
+
cause: { code: "translation_buffer_limit" },
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
const appendReservation = budget.reserveTransient(nextBufferBytes, { kind: "live_transient" });
|
|
860
|
+
try {
|
|
861
|
+
buffer += decoded;
|
|
862
|
+
appendReservation.commitRetained();
|
|
863
|
+
budget.releaseRetained(bufferBytes, { kind: "live_transient" });
|
|
864
|
+
} catch (error) {
|
|
865
|
+
appendReservation.release();
|
|
866
|
+
throw error;
|
|
867
|
+
}
|
|
868
|
+
bufferBytes = nextBufferBytes;
|
|
786
869
|
|
|
787
870
|
const lines = buffer.split("\n");
|
|
788
871
|
buffer = lines.pop() ?? "";
|
|
872
|
+
const residualBytes = budgetEncoder.encode(buffer).byteLength;
|
|
873
|
+
const residualReservation = budget.reserveTransient(residualBytes, { kind: "live_transient" });
|
|
874
|
+
residualReservation.commitRetained();
|
|
875
|
+
budget.releaseRetained(bufferBytes, { kind: "live_transient" });
|
|
876
|
+
bufferBytes = residualBytes;
|
|
789
877
|
|
|
790
878
|
for (const line of lines) {
|
|
791
879
|
if ((yield* handleDataLine(line)) === "terminate") return;
|
|
@@ -800,34 +888,68 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
800
888
|
if (buffer.length > 0) {
|
|
801
889
|
if ((yield* handleDataLine(buffer)) === "terminate") return;
|
|
802
890
|
}
|
|
803
|
-
|
|
804
|
-
//
|
|
805
|
-
//
|
|
806
|
-
//
|
|
807
|
-
//
|
|
891
|
+
// Reader EOF. Prefer failing closed before flushing pending tool calls so the bridge
|
|
892
|
+
// never sees a fabricated tool_call_end on a truncated mid-assembly stream.
|
|
893
|
+
//
|
|
894
|
+
// Checked BEFORE flushToolCalls(), because that helper emits tool_call_end and there is no
|
|
895
|
+
// taking it back: a half-assembled argument string would reach the client as a completed
|
|
896
|
+
// call. Tool calls are buffered here (unlike the Anthropic adapter, which forwards
|
|
897
|
+
// fragments live), so this adapter can still decide.
|
|
808
898
|
const sawFinish = finishReason !== undefined;
|
|
809
|
-
if (!sawFinish &&
|
|
899
|
+
if (!sawFinish && pendingToolCalls.length > 0) {
|
|
900
|
+
debugProviderDiagnostic("openai-chat", "stream-truncated", {
|
|
901
|
+
finishReason: null,
|
|
902
|
+
hadUsage: pendingUsage !== undefined,
|
|
903
|
+
pendingToolCalls: pendingToolCalls.length,
|
|
904
|
+
});
|
|
905
|
+
yield { type: "error", message: "upstream stream ended mid tool call without a terminal signal — possible truncation" };
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
// Finish-less EOF is only safe when answer text was emitted. Reasoning-only / usage-only
|
|
909
|
+
// truncations must stay on the error path (hideThinkingSummary can suppress reasoning).
|
|
910
|
+
// Trailing usage alone is not a terminal signal for this adapter (#735 / restore #773).
|
|
911
|
+
if (!sawFinish && !sawUserFacingOutput) {
|
|
810
912
|
debugProviderDiagnostic("openai-chat", "stream-truncated", {
|
|
811
913
|
finishReason: finishReason ?? null,
|
|
812
|
-
hadUsage:
|
|
914
|
+
hadUsage: pendingUsage !== undefined,
|
|
813
915
|
});
|
|
814
916
|
yield { type: "error", message: "upstream stream ended without a terminal signal ([DONE] or finish_reason) — possible truncation" };
|
|
815
917
|
return;
|
|
816
918
|
}
|
|
817
|
-
|
|
919
|
+
yield* flushToolCalls();
|
|
920
|
+
// Graceful close that omitted [DONE] but delivered finish_reason and/or answer text.
|
|
818
921
|
const stopReason = finishReason === "length"
|
|
819
922
|
? "max_tokens"
|
|
820
923
|
: finishReason === "content_filter"
|
|
821
924
|
? "content_filter"
|
|
822
925
|
: undefined;
|
|
823
926
|
yield { type: "done", usage: pendingUsage, ...(stopReason ? { stopReason } : {}) };
|
|
927
|
+
} catch (error) {
|
|
928
|
+
if (isTranslatorBudgetExceededError(error)
|
|
929
|
+
|| (error instanceof Error && (error.cause as { code?: unknown } | undefined)?.code === "translation_buffer_limit")) {
|
|
930
|
+
yield {
|
|
931
|
+
type: "error",
|
|
932
|
+
status: 502,
|
|
933
|
+
errorType: "upstream_error",
|
|
934
|
+
code: "translation_buffer_limit",
|
|
935
|
+
message: "upstream translation buffer exceeded the safe limit",
|
|
936
|
+
};
|
|
937
|
+
try { await reader.cancel(error); } catch { /* already closed */ }
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
throw error;
|
|
824
941
|
} finally {
|
|
942
|
+
budget.releaseRetained(bufferBytes, { kind: "live_transient" });
|
|
943
|
+
for (const call of pendingToolCalls) budget.closeCall(call.key);
|
|
825
944
|
reader.releaseLock();
|
|
826
945
|
}
|
|
827
946
|
},
|
|
828
947
|
|
|
829
|
-
async parseResponse(response: Response): Promise<AdapterEvent[]> {
|
|
948
|
+
async parseResponse(response: Response, budget: TranslatorBudget): Promise<AdapterEvent[]> {
|
|
830
949
|
const json = await response.json() as Record<string, unknown>;
|
|
950
|
+
const responseBytes = new TextEncoder().encode(JSON.stringify(json)).byteLength;
|
|
951
|
+
budget.chargeRetained(responseBytes, { kind: "retained_collectors" });
|
|
952
|
+
try {
|
|
831
953
|
if (json.error) {
|
|
832
954
|
const upstreamError = json.error as { message?: unknown; code?: unknown; type?: unknown; status?: unknown };
|
|
833
955
|
const message = typeof upstreamError.message === "string" ? upstreamError.message : "upstream error";
|
|
@@ -873,7 +995,11 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
873
995
|
type: "done",
|
|
874
996
|
usage: usageFromOpenAIChat(usage),
|
|
875
997
|
});
|
|
998
|
+
retainTranslatedEventBatch(events, budget);
|
|
876
999
|
return events;
|
|
1000
|
+
} finally {
|
|
1001
|
+
budget.releaseRetained(responseBytes, { kind: "retained_collectors" });
|
|
1002
|
+
}
|
|
877
1003
|
},
|
|
878
1004
|
};
|
|
879
1005
|
}
|
|
@@ -8,6 +8,7 @@ import { decodeServerSentEvents } from "../lib/sse-decoder";
|
|
|
8
8
|
import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
|
|
9
9
|
import { OCX_REASONING_PREFIX } from "../responses/reasoning-envelope";
|
|
10
10
|
import { modelRecordValue } from "../reasoning-effort";
|
|
11
|
+
import type { TranslatorBudget } from "../lib/translator-budget";
|
|
11
12
|
|
|
12
13
|
// Headers relayed verbatim from the caller in OAuth-passthrough ("forward") mode.
|
|
13
14
|
// Exported so the web-search sidecar reuses the exact same forwarded-auth set for its ChatGPT call.
|
|
@@ -354,6 +355,47 @@ function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
|
354
355
|
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
355
356
|
}
|
|
356
357
|
|
|
358
|
+
function normalizeFunctionToolSchema(tool: unknown): unknown {
|
|
359
|
+
if (!isPlainObject(tool) || tool.type !== "function") return tool;
|
|
360
|
+
if (isPlainObject(tool.parameters) && tool.parameters.type === "object") return tool;
|
|
361
|
+
return {
|
|
362
|
+
...tool,
|
|
363
|
+
parameters: { ...(isPlainObject(tool.parameters) ? tool.parameters : {}), type: "object" },
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function normalizeToolSchemas(body: unknown): unknown {
|
|
368
|
+
if (!isPlainObject(body)) return body;
|
|
369
|
+
|
|
370
|
+
const normalizeTools = (tools: unknown[]): unknown[] => {
|
|
371
|
+
let changed = false;
|
|
372
|
+
const normalized = tools.map((tool) => {
|
|
373
|
+
const fixed = normalizeFunctionToolSchema(tool);
|
|
374
|
+
if (fixed !== tool) changed = true;
|
|
375
|
+
return fixed;
|
|
376
|
+
});
|
|
377
|
+
return changed ? normalized : tools;
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
let normalizedBody = body;
|
|
381
|
+
if (Array.isArray(body.tools)) {
|
|
382
|
+
const tools = normalizeTools(body.tools);
|
|
383
|
+
if (tools !== body.tools) normalizedBody = { ...normalizedBody, tools };
|
|
384
|
+
}
|
|
385
|
+
if (Array.isArray(normalizedBody.input)) {
|
|
386
|
+
let inputChanged = false;
|
|
387
|
+
const input = normalizedBody.input.map((item) => {
|
|
388
|
+
if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item;
|
|
389
|
+
const tools = normalizeTools(item.tools);
|
|
390
|
+
if (tools === item.tools) return item;
|
|
391
|
+
inputChanged = true;
|
|
392
|
+
return { ...item, tools };
|
|
393
|
+
});
|
|
394
|
+
if (inputChanged) normalizedBody = { ...normalizedBody, input };
|
|
395
|
+
}
|
|
396
|
+
return normalizedBody;
|
|
397
|
+
}
|
|
398
|
+
|
|
357
399
|
const MAX_RESPONSES_CALL_ID_LENGTH = 64;
|
|
358
400
|
const REPAIRED_CALL_ID_PREFIX = "call_ocx_";
|
|
359
401
|
const REPAIRED_CALL_ID_DIGEST_LENGTH = MAX_RESPONSES_CALL_ID_LENGTH - REPAIRED_CALL_ID_PREFIX.length;
|
|
@@ -484,6 +526,40 @@ function stripPreviousResponseId(body: unknown, strip: boolean): unknown {
|
|
|
484
526
|
return rest;
|
|
485
527
|
}
|
|
486
528
|
|
|
529
|
+
/**
|
|
530
|
+
* Drop request parameters a stateless Responses upstream cannot implement, and pin
|
|
531
|
+
* `store` false.
|
|
532
|
+
*
|
|
533
|
+
* `previous_response_id` is listed here as well as in `stripPreviousResponseId`
|
|
534
|
+
* because that helper's strip is conditional on replay expansion, and it keeps the
|
|
535
|
+
* field for API-key providers on the premise that the platform offers real
|
|
536
|
+
* server-side storage. DeepSeek documents the opposite: "the API is stateless:
|
|
537
|
+
* responses and conversations are not stored on the server", so the field can never
|
|
538
|
+
* be honoured regardless of expansion state.
|
|
539
|
+
*
|
|
540
|
+
* `prompt` is a reference to a server-stored prompt template — the most stateful
|
|
541
|
+
* field in the accepted schema.
|
|
542
|
+
*
|
|
543
|
+
* `service_tier` is deliberately NOT dropped: the server writes it for fast mode
|
|
544
|
+
* (`responses/core.ts`), and silently deleting a configured knob inside an adapter is
|
|
545
|
+
* worse than forwarding a parameter the upstream ignores.
|
|
546
|
+
*
|
|
547
|
+
* MUST run before the composed sanitize chain below: `stripItemIdsWhenUnstored` keys
|
|
548
|
+
* off `store === false`, and a stateless upstream cannot resolve a stored item id.
|
|
549
|
+
* Returns a copy, so `parsed._rawBody` keeps the client's original `store` value and
|
|
550
|
+
* the local replay cache still records the turn.
|
|
551
|
+
*/
|
|
552
|
+
function stripStatefulResponsesParams(body: unknown): unknown {
|
|
553
|
+
if (!isPlainObject(body)) return body;
|
|
554
|
+
const drop = ["previous_response_id", "conversation", "background", "metadata", "prompt"] as const;
|
|
555
|
+
const present = drop.some(key => Object.prototype.hasOwnProperty.call(body, key));
|
|
556
|
+
if (!present && body.store === false) return body;
|
|
557
|
+
const next: Record<string, unknown> = { ...body };
|
|
558
|
+
for (const key of drop) delete next[key];
|
|
559
|
+
next.store = false;
|
|
560
|
+
return next;
|
|
561
|
+
}
|
|
562
|
+
|
|
487
563
|
/**
|
|
488
564
|
* Remove top-level parameters the ChatGPT backend (`authMode: "forward"`) rejects
|
|
489
565
|
* with `{"detail":"Unsupported parameter: …"}` (strict allowlist). Codex CLI never
|
|
@@ -881,7 +957,8 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
|
|
|
881
957
|
name: "openai-responses",
|
|
882
958
|
passthrough: true as const,
|
|
883
959
|
|
|
884
|
-
buildRequest(parsed: OcxParsedRequest, incoming
|
|
960
|
+
buildRequest(parsed: OcxParsedRequest, incoming: IncomingMeta) {
|
|
961
|
+
const translatorBudget = incoming.translatorBudget;
|
|
885
962
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
886
963
|
let url: string;
|
|
887
964
|
|
|
@@ -923,8 +1000,17 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
|
|
|
923
1000
|
parsed._rawBody,
|
|
924
1001
|
forward || parsed._previousResponseInputExpanded === true,
|
|
925
1002
|
);
|
|
926
|
-
|
|
1003
|
+
const stateless = provider.statelessResponses === true;
|
|
1004
|
+
if (stateless) outBody = stripStatefulResponsesParams(outBody);
|
|
1005
|
+
// A replay miss can leave a function_call_output whose paired function_call sat
|
|
1006
|
+
// in the prefix that was never expanded. A stateless upstream cannot resolve the
|
|
1007
|
+
// pair from its own storage either, so it needs the same repair the forward
|
|
1008
|
+
// backend gets — dropping previous_response_id is not much use if the body that
|
|
1009
|
+
// reaches the wire is unparseable.
|
|
1010
|
+
if (forward || stateless) {
|
|
927
1011
|
outBody = repairOrphanedInputItems(outBody, unexpandedMiss);
|
|
1012
|
+
}
|
|
1013
|
+
if (forward) {
|
|
928
1014
|
outBody = stripUnsupportedForwardParams(outBody);
|
|
929
1015
|
}
|
|
930
1016
|
else outBody = normalizeImageGenClientTools(outBody);
|
|
@@ -938,41 +1024,62 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
|
|
|
938
1024
|
if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) {
|
|
939
1025
|
outBody = buildRoutedCompactionBody(outBody);
|
|
940
1026
|
}
|
|
941
|
-
const sanitizedBody = stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody)))))));
|
|
1027
|
+
const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody))))))));
|
|
1028
|
+
const body = JSON.stringify(stripDisabledReasoningSummaries(
|
|
1029
|
+
normalizeConfiguredReasoningSummaryDelivery(sanitizedBody, provider, parsed.modelId),
|
|
1030
|
+
provider,
|
|
1031
|
+
parsed.modelId,
|
|
1032
|
+
));
|
|
1033
|
+
const releaseBodyObservation = translatorBudget.observeExternallyCapped(
|
|
1034
|
+
"passthrough_serialization",
|
|
1035
|
+
new TextEncoder().encode(body).byteLength,
|
|
1036
|
+
);
|
|
942
1037
|
return {
|
|
943
1038
|
url,
|
|
944
1039
|
method: "POST",
|
|
945
1040
|
headers,
|
|
946
|
-
body
|
|
947
|
-
|
|
948
|
-
provider,
|
|
949
|
-
parsed.modelId,
|
|
950
|
-
)),
|
|
1041
|
+
body,
|
|
1042
|
+
releaseBodyObservation,
|
|
951
1043
|
};
|
|
952
1044
|
},
|
|
953
1045
|
|
|
954
1046
|
// The passthrough normally relays the upstream stream verbatim and never parses.
|
|
955
1047
|
// The exception is a routed compaction turn: the server drives this adapter like
|
|
956
1048
|
// an ordinary one so the bridge can build the single compaction item (#422).
|
|
957
|
-
async *parseStream(response: Response): AsyncGenerator<AdapterEvent> {
|
|
1049
|
+
async *parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator<AdapterEvent> {
|
|
958
1050
|
if (!response.body) {
|
|
959
1051
|
yield { type: "error", message: "passthrough adapter received no response body" };
|
|
960
1052
|
return;
|
|
961
1053
|
}
|
|
1054
|
+
const budgetEncoder = new TextEncoder();
|
|
962
1055
|
let deltas = "";
|
|
963
1056
|
let doneText = "";
|
|
964
1057
|
let snapshot = "";
|
|
965
1058
|
let usage: OcxUsage | undefined;
|
|
966
|
-
for await (const event of decodeServerSentEvents(response.body)) {
|
|
1059
|
+
for await (const event of decodeServerSentEvents(response.body, { translatorBudget: budget })) {
|
|
967
1060
|
let payload: unknown;
|
|
968
1061
|
try { payload = JSON.parse(event.data); } catch { continue; }
|
|
969
1062
|
if (!isPlainObject(payload)) continue;
|
|
970
1063
|
switch (payload.type) {
|
|
971
1064
|
case "response.output_text.delta":
|
|
972
|
-
if (typeof payload.delta === "string")
|
|
1065
|
+
if (typeof payload.delta === "string") {
|
|
1066
|
+
const next = deltas + payload.delta;
|
|
1067
|
+
const previousBytes = budgetEncoder.encode(deltas).byteLength;
|
|
1068
|
+
const reservation = budget.reserveTransient(budgetEncoder.encode(next).byteLength, { kind: "retained_collectors" });
|
|
1069
|
+
deltas = next;
|
|
1070
|
+
reservation.commitRetained();
|
|
1071
|
+
budget.releaseRetained(previousBytes, { kind: "retained_collectors" });
|
|
1072
|
+
}
|
|
973
1073
|
break;
|
|
974
1074
|
case "response.output_text.done":
|
|
975
|
-
if (typeof payload.text === "string")
|
|
1075
|
+
if (typeof payload.text === "string") {
|
|
1076
|
+
const next = doneText + payload.text;
|
|
1077
|
+
const previousBytes = budgetEncoder.encode(doneText).byteLength;
|
|
1078
|
+
const reservation = budget.reserveTransient(budgetEncoder.encode(next).byteLength, { kind: "retained_collectors" });
|
|
1079
|
+
doneText = next;
|
|
1080
|
+
reservation.commitRetained();
|
|
1081
|
+
budget.releaseRetained(previousBytes, { kind: "retained_collectors" });
|
|
1082
|
+
}
|
|
976
1083
|
break;
|
|
977
1084
|
case "response.failed":
|
|
978
1085
|
case "error":
|
|
@@ -982,7 +1089,14 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
|
|
|
982
1089
|
yield { type: "incomplete", reason: responsesErrorMessage(payload.response ?? payload) };
|
|
983
1090
|
return;
|
|
984
1091
|
case "response.completed":
|
|
985
|
-
|
|
1092
|
+
{
|
|
1093
|
+
const next = responsesPayloadText(payload.response);
|
|
1094
|
+
const previousBytes = budgetEncoder.encode(snapshot).byteLength;
|
|
1095
|
+
const reservation = budget.reserveTransient(budgetEncoder.encode(next).byteLength, { kind: "retained_collectors" });
|
|
1096
|
+
snapshot = next;
|
|
1097
|
+
reservation.commitRetained();
|
|
1098
|
+
budget.releaseRetained(previousBytes, { kind: "retained_collectors" });
|
|
1099
|
+
}
|
|
986
1100
|
usage = usageFromResponsesPayload(payload.response);
|
|
987
1101
|
break;
|
|
988
1102
|
}
|
|
@@ -991,14 +1105,16 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
|
|
|
991
1105
|
// completed snapshot so text is never double-counted.
|
|
992
1106
|
const text = snapshot || doneText || deltas;
|
|
993
1107
|
if (text) yield { type: "text_delta", text };
|
|
1108
|
+
budget.releaseRetained(budgetEncoder.encode(deltas).byteLength + budgetEncoder.encode(doneText).byteLength + budgetEncoder.encode(snapshot).byteLength, { kind: "retained_collectors" });
|
|
994
1109
|
yield { type: "done", ...(usage ? { usage } : {}) };
|
|
995
1110
|
},
|
|
996
1111
|
|
|
997
|
-
async parseResponse(response: Response): Promise<AdapterEvent[]> {
|
|
1112
|
+
async parseResponse(response: Response, budget: TranslatorBudget): Promise<AdapterEvent[]> {
|
|
998
1113
|
let payload: unknown;
|
|
999
1114
|
try { payload = await response.json(); } catch {
|
|
1000
1115
|
return [{ type: "error", message: "malformed upstream compaction response" }];
|
|
1001
1116
|
}
|
|
1117
|
+
budget.chargeRetained(new TextEncoder().encode(JSON.stringify(payload)).byteLength, { kind: "retained_collectors" });
|
|
1002
1118
|
if (!isPlainObject(payload)) {
|
|
1003
1119
|
return [{ type: "error", message: "malformed upstream compaction response" }];
|
|
1004
1120
|
}
|
|
@@ -2,6 +2,8 @@ import type { AdapterEvent } from "../types";
|
|
|
2
2
|
|
|
3
3
|
type QueueReader = (result: IteratorResult<AdapterEvent>) => void;
|
|
4
4
|
|
|
5
|
+
export const PREFLIGHT_HEARTBEAT_RETAIN_LIMIT = 16;
|
|
6
|
+
|
|
5
7
|
export interface AdapterEventQueue {
|
|
6
8
|
push(event: AdapterEvent): void;
|
|
7
9
|
close(): void;
|
|
@@ -39,8 +41,12 @@ export async function preflightAdapterEvents(
|
|
|
39
41
|
while (true) {
|
|
40
42
|
const next = await iterator.next();
|
|
41
43
|
if (next.done) return { stream: replay(buffered, iterator), empty: true };
|
|
44
|
+
if (next.value.type === "heartbeat") {
|
|
45
|
+
buffered.push(next.value);
|
|
46
|
+
if (buffered.length > PREFLIGHT_HEARTBEAT_RETAIN_LIMIT) buffered.shift();
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
42
49
|
buffered.push(next.value);
|
|
43
|
-
if (next.value.type === "heartbeat") continue;
|
|
44
50
|
if (next.value.type === "error") {
|
|
45
51
|
await iterator.return?.();
|
|
46
52
|
return { stream: replay(buffered, iterator), error: next.value, empty: false };
|