@bitkyc08/opencodex 2.8.2-preview.20260731 → 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 +86 -5
- 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 +89 -14
- 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/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/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/parsing.ts +3 -1
- package/src/codex/catalog/provider-fetch.ts +29 -0
- package/src/codex/catalog/sync.ts +64 -7
- 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/errors.ts +3 -0
- package/src/lib/gcp-adc.ts +40 -2
- package/src/lib/injection-debug-log.ts +26 -2
- 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 +8 -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.ts +10 -2
- 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/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 +239 -97
- package/src/server/lifecycle.ts +155 -25
- package/src/server/management/agent-settings-routes.ts +75 -32
- 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 +37 -12
- package/src/server/management/logs-usage-routes.ts +41 -21
- package/src/server/management/model-routes.ts +173 -53
- package/src/server/management/oauth-account-routes.ts +115 -26
- package/src/server/management/provider-routes.ts +56 -6
- 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 +51 -12
- 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 +23 -3
- 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 +45 -18
- package/src/types.ts +34 -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-BHsKRFh9.css +0 -1
- package/gui/dist/assets/index-GC0Vlu1Z.js +0 -67
|
@@ -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;
|
|
@@ -676,7 +682,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
676
682
|
};
|
|
677
683
|
},
|
|
678
684
|
|
|
679
|
-
async *parseStream(response: Response): AsyncGenerator<AdapterEvent> {
|
|
685
|
+
async *parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator<AdapterEvent> {
|
|
680
686
|
if (!response.body) {
|
|
681
687
|
yield { type: "error", message: "No response body" };
|
|
682
688
|
return;
|
|
@@ -684,7 +690,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
684
690
|
|
|
685
691
|
const reader = response.body.getReader();
|
|
686
692
|
const decoder = new TextDecoder();
|
|
693
|
+
const budgetEncoder = new TextEncoder();
|
|
687
694
|
let buffer = "";
|
|
695
|
+
let bufferBytes = 0;
|
|
688
696
|
// Streamed tool calls are BUFFERED until a terminal signal, then flushed as atomic
|
|
689
697
|
// start/delta/end sequences. The bridge treats text/reasoning deltas as barriers that
|
|
690
698
|
// close an open tool-call item (bridge.ts closeCurrentToolCall on text_delta), so
|
|
@@ -693,15 +701,18 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
693
701
|
// chunks, whole-chunk calls) cannot be represented live without overlapping sequences.
|
|
694
702
|
// Keyed by `index` (OpenAI wire standard), falling back to `id`, falling back to the
|
|
695
703
|
// last-seen call for providers that omit both on continuation chunks.
|
|
696
|
-
interface PendingToolCall { key: string; id: string; name: string; args: string }
|
|
704
|
+
interface PendingToolCall { key: string; id: string; name: string; args: string; argsBytes: number }
|
|
697
705
|
const pendingToolCalls: PendingToolCall[] = [];
|
|
698
706
|
let toolCallSeq = 0;
|
|
699
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.
|
|
700
710
|
for (const call of pendingToolCalls) {
|
|
701
711
|
if (!call.id) call.id = `call_${++toolCallSeq}`;
|
|
702
712
|
yield { type: "tool_call_start", id: call.id, name: call.name };
|
|
703
713
|
if (call.args.length > 0) yield { type: "tool_call_delta", arguments: call.args };
|
|
704
714
|
yield { type: "tool_call_end" };
|
|
715
|
+
budget.closeCall(call.key);
|
|
705
716
|
}
|
|
706
717
|
pendingToolCalls.length = 0;
|
|
707
718
|
};
|
|
@@ -711,6 +722,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
711
722
|
// explicit `[DONE]` sentinel OR a chunk carrying a non-null `finish_reason` (some
|
|
712
723
|
// OpenAI-compatible providers omit `[DONE]` but do send finish_reason).
|
|
713
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;
|
|
714
728
|
|
|
715
729
|
// Single per-line handler shared by the streaming loop and the EOF residual-frame flush, so
|
|
716
730
|
// a final frame is parsed identically wherever it lands (no duplicated, drift-prone parsing).
|
|
@@ -780,6 +794,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
780
794
|
yield { type: "reasoning_raw_delta", text: delta.reasoning_content };
|
|
781
795
|
}
|
|
782
796
|
if (typeof delta.content === "string" && delta.content.length > 0) {
|
|
797
|
+
sawUserFacingOutput = true;
|
|
783
798
|
yield { type: "text_delta", text: delta.content };
|
|
784
799
|
}
|
|
785
800
|
|
|
@@ -797,12 +812,27 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
797
812
|
// splitting into two calls that share one call_id downstream.
|
|
798
813
|
if (!call && tc.id) call = pendingToolCalls.find(c => c.id === tc.id);
|
|
799
814
|
if (!call) {
|
|
800
|
-
call = { key: key ?? `seq:${pendingToolCalls.length}`, id: "", name: "", args: "" };
|
|
815
|
+
call = { key: key ?? `seq:${pendingToolCalls.length}`, id: "", name: "", args: "", argsBytes: 0 };
|
|
801
816
|
pendingToolCalls.push(call);
|
|
817
|
+
budget.openCall(call.key);
|
|
802
818
|
}
|
|
803
819
|
if (tc.id && !call.id) call.id = tc.id;
|
|
804
820
|
if (tc.function?.name && !call.name) call.name = tc.function.name;
|
|
805
|
-
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
|
+
}
|
|
806
836
|
}
|
|
807
837
|
}
|
|
808
838
|
}
|
|
@@ -819,10 +849,31 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
819
849
|
while (true) {
|
|
820
850
|
const { done, value } = await reader.read();
|
|
821
851
|
if (done) break;
|
|
822
|
-
|
|
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;
|
|
823
869
|
|
|
824
870
|
const lines = buffer.split("\n");
|
|
825
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;
|
|
826
877
|
|
|
827
878
|
for (const line of lines) {
|
|
828
879
|
if ((yield* handleDataLine(line)) === "terminate") return;
|
|
@@ -837,10 +888,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
837
888
|
if (buffer.length > 0) {
|
|
838
889
|
if ((yield* handleDataLine(buffer)) === "terminate") return;
|
|
839
890
|
}
|
|
840
|
-
// Reader EOF.
|
|
841
|
-
//
|
|
842
|
-
// at end-of-generation). If NONE of those were seen, the stream was cut mid-flight — fail
|
|
843
|
-
// closed so the bridge emits a classified response.failed rather than a silent truncation.
|
|
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.
|
|
844
893
|
//
|
|
845
894
|
// Checked BEFORE flushToolCalls(), because that helper emits tool_call_end and there is no
|
|
846
895
|
// taking it back: a half-assembled argument string would reach the client as a completed
|
|
@@ -856,29 +905,51 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
856
905
|
yield { type: "error", message: "upstream stream ended mid tool call without a terminal signal — possible truncation" };
|
|
857
906
|
return;
|
|
858
907
|
}
|
|
859
|
-
|
|
860
|
-
|
|
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) {
|
|
861
912
|
debugProviderDiagnostic("openai-chat", "stream-truncated", {
|
|
862
913
|
finishReason: finishReason ?? null,
|
|
863
|
-
hadUsage:
|
|
914
|
+
hadUsage: pendingUsage !== undefined,
|
|
864
915
|
});
|
|
865
916
|
yield { type: "error", message: "upstream stream ended without a terminal signal ([DONE] or finish_reason) — possible truncation" };
|
|
866
917
|
return;
|
|
867
918
|
}
|
|
868
|
-
|
|
919
|
+
yield* flushToolCalls();
|
|
920
|
+
// Graceful close that omitted [DONE] but delivered finish_reason and/or answer text.
|
|
869
921
|
const stopReason = finishReason === "length"
|
|
870
922
|
? "max_tokens"
|
|
871
923
|
: finishReason === "content_filter"
|
|
872
924
|
? "content_filter"
|
|
873
925
|
: undefined;
|
|
874
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;
|
|
875
941
|
} finally {
|
|
942
|
+
budget.releaseRetained(bufferBytes, { kind: "live_transient" });
|
|
943
|
+
for (const call of pendingToolCalls) budget.closeCall(call.key);
|
|
876
944
|
reader.releaseLock();
|
|
877
945
|
}
|
|
878
946
|
},
|
|
879
947
|
|
|
880
|
-
async parseResponse(response: Response): Promise<AdapterEvent[]> {
|
|
948
|
+
async parseResponse(response: Response, budget: TranslatorBudget): Promise<AdapterEvent[]> {
|
|
881
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 {
|
|
882
953
|
if (json.error) {
|
|
883
954
|
const upstreamError = json.error as { message?: unknown; code?: unknown; type?: unknown; status?: unknown };
|
|
884
955
|
const message = typeof upstreamError.message === "string" ? upstreamError.message : "upstream error";
|
|
@@ -924,7 +995,11 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
924
995
|
type: "done",
|
|
925
996
|
usage: usageFromOpenAIChat(usage),
|
|
926
997
|
});
|
|
998
|
+
retainTranslatedEventBatch(events, budget);
|
|
927
999
|
return events;
|
|
1000
|
+
} finally {
|
|
1001
|
+
budget.releaseRetained(responseBytes, { kind: "retained_collectors" });
|
|
1002
|
+
}
|
|
928
1003
|
},
|
|
929
1004
|
};
|
|
930
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 };
|