@bitkyc08/opencodex 2.7.9-preview.20260712.1 → 2.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +3 -1
  2. package/gui/dist/assets/index-BAAFKwsh.js +40 -0
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +2 -2
  5. package/src/adapters/cursor/transport-retry.ts +5 -3
  6. package/src/adapters/google-errors.ts +9 -19
  7. package/src/adapters/google-http.ts +29 -66
  8. package/src/adapters/kiro-errors.ts +10 -23
  9. package/src/adapters/kiro-retry.ts +26 -58
  10. package/src/adapters/upstream-http-error.ts +48 -0
  11. package/src/bridge.ts +6 -2
  12. package/src/claude/gateway-cache.ts +3 -3
  13. package/src/claude/outbound.ts +117 -40
  14. package/src/cli/claude.ts +36 -4
  15. package/src/config.ts +54 -3
  16. package/src/lib/destination-policy.ts +167 -0
  17. package/src/lib/injection-debug-log.ts +34 -0
  18. package/src/lib/upstream-retry.ts +53 -3
  19. package/src/lib/windows-secret-acl.ts +173 -0
  20. package/src/oauth/index.ts +9 -7
  21. package/src/oauth/store.ts +1 -0
  22. package/src/providers/registry.ts +10 -3
  23. package/src/providers/xai-transport.ts +89 -0
  24. package/src/router.ts +6 -1
  25. package/src/server/auth-cors.ts +4 -0
  26. package/src/server/claude-messages.ts +32 -2
  27. package/src/server/management-api.ts +159 -33
  28. package/src/server/request-decompress.ts +45 -12
  29. package/src/server/responses.ts +21 -12
  30. package/src/server/system-env.ts +110 -68
  31. package/src/service.ts +4 -0
  32. package/src/types.ts +25 -5
  33. package/src/vision/anthropic-describe.ts +185 -0
  34. package/src/vision/index.ts +219 -10
  35. package/src/web-search/anthropic-executor.ts +187 -0
  36. package/src/web-search/executor.ts +4 -2
  37. package/src/web-search/index.ts +80 -18
  38. package/src/web-search/loop.ts +14 -2
  39. package/gui/dist/assets/index-Csp2AZYr.js +0 -40
@@ -19,6 +19,7 @@ import {
19
19
  upsertOAuthProvider,
20
20
  } from "../oauth";
21
21
  import { removeCredential } from "../oauth/store";
22
+ import { providerDestinationResolvedError } from "../lib/destination-policy";
22
23
  import { enrichProviderFromCatalog, listKeyLoginProviders } from "../oauth/key-providers";
23
24
  import { deriveProviderPresets } from "../providers/derive";
24
25
  import { fetchProviderQuotaReports } from "../providers/quota";
@@ -28,6 +29,7 @@ import { getUsageDebugLogEntries } from "../usage/debug";
28
29
  import { parseRange, summarizeUsage } from "../usage/summary";
29
30
  import { stripCodexRuntimeProviderFields } from "../codex/auth-context";
30
31
  import { getDebugLogEntries } from "../lib/debug-log-buffer";
32
+ import { getInjectionDebugLogEntries } from "../lib/injection-debug-log";
31
33
  import {
32
34
  clearDebugSettings,
33
35
  clearDebugSetting,
@@ -35,10 +37,11 @@ import {
35
37
  setDebugSettings,
36
38
  type DebugFlag,
37
39
  } from "../lib/debug-settings";
38
- import type { OcxConfig, OcxProviderConfig } from "../types";
40
+ import type { OcxClaudeCodeConfig, OcxConfig, OcxProviderConfig } from "../types";
39
41
  import { drainAndShutdown } from "./lifecycle";
40
42
  import { filterRequestLogs, getRequestLogEntries } from "./request-log";
41
43
  import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "./auth-cors";
44
+ import { applySystemEnvToggle } from "./system-env";
42
45
 
43
46
  // Single source of truth = package.json (../ from src/), so /healthz + the GUI badge match the
44
47
  // installed npm version instead of a stale hardcode.
@@ -55,6 +58,11 @@ export interface ManagementApiDeps {
55
58
  refreshCodexCatalog?: () => Promise<void>;
56
59
  }
57
60
 
61
+ /** Narrow an unknown JSON value to a plain (non-array) object for strict request-body validation. */
62
+ function isPlainRecord(v: unknown): v is Record<string, unknown> {
63
+ return typeof v === "object" && v !== null && !Array.isArray(v);
64
+ }
65
+
58
66
  function parseDebugLogQuery(url: URL): { after: number; limit: number } {
59
67
  const after = Number(url.searchParams.get("after") ?? url.searchParams.get("since") ?? "0");
60
68
  const limit = Number(url.searchParams.get("limit") ?? "500");
@@ -190,30 +198,78 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
190
198
  const ws = config.webSearchSidecar ?? {};
191
199
  const vs = config.visionSidecar ?? {};
192
200
  return jsonResponse({
193
- webSearch: { model: ws.model ?? "gpt-5.6-luna", reasoning: ws.reasoning ?? "low" },
194
- vision: { model: vs.model ?? "gpt-5.6-luna" },
201
+ webSearch: { model: ws.model ?? "gpt-5.6-luna", backend: ws.backend },
202
+ vision: {
203
+ model: vs.model ?? "gpt-5.6-luna",
204
+ backend: vs.backend,
205
+ maxDescriptionsPerTurn: vs.maxDescriptionsPerTurn,
206
+ },
195
207
  });
196
208
  }
197
209
 
198
210
  if (url.pathname === "/api/sidecar-settings" && req.method === "PUT") {
199
- let body: { webSearch?: { model?: string; reasoning?: string }; vision?: { model?: string } };
200
- try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
211
+ let raw: unknown;
212
+ try { raw = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
213
+ // Strict shape (review F2): reject non-object bodies and non-object sections instead of throwing
214
+ // on `null` or silently accepting arrays/strings as no-op updates.
215
+ if (!isPlainRecord(raw)) return jsonResponse({ error: "body must be a JSON object" }, 400);
216
+ if (raw.webSearch !== undefined && !isPlainRecord(raw.webSearch)) return jsonResponse({ error: "webSearch must be an object" }, 400);
217
+ if (raw.vision !== undefined && !isPlainRecord(raw.vision)) return jsonResponse({ error: "vision must be an object" }, 400);
218
+ const body = raw as {
219
+ webSearch?: { model?: unknown; backend?: unknown; reasoning?: unknown };
220
+ vision?: { model?: unknown; backend?: unknown; maxDescriptionsPerTurn?: unknown };
221
+ };
222
+ if (body.webSearch && body.webSearch.backend !== undefined && body.webSearch.backend !== null
223
+ && body.webSearch.backend !== "openai" && body.webSearch.backend !== "anthropic") {
224
+ return jsonResponse({ error: "webSearch.backend must be openai, anthropic, or null" }, 400);
225
+ }
226
+ if (body.vision && body.vision.backend !== undefined
227
+ && body.vision.backend !== null && body.vision.backend !== "openai" && body.vision.backend !== "anthropic") {
228
+ return jsonResponse({ error: "vision.backend must be openai, anthropic, or null" }, 400);
229
+ }
230
+ if (body.vision && body.vision.maxDescriptionsPerTurn !== undefined
231
+ && (typeof body.vision.maxDescriptionsPerTurn !== "number"
232
+ || !Number.isInteger(body.vision.maxDescriptionsPerTurn)
233
+ || body.vision.maxDescriptionsPerTurn <= 0)) {
234
+ return jsonResponse({ error: "vision.maxDescriptionsPerTurn must be a positive integer" }, 400);
235
+ }
201
236
  if (body.webSearch) {
202
237
  config.webSearchSidecar = { ...config.webSearchSidecar };
203
- if (typeof body.webSearch.model === "string") config.webSearchSidecar.model = body.webSearch.model;
238
+ if (typeof body.webSearch.model === "string") {
239
+ if (body.webSearch.model === "") delete config.webSearchSidecar.model;
240
+ else config.webSearchSidecar.model = body.webSearch.model;
241
+ }
242
+ if (body.webSearch.backend === null) delete config.webSearchSidecar.backend;
243
+ else if (body.webSearch.backend === "openai" || body.webSearch.backend === "anthropic") {
244
+ config.webSearchSidecar.backend = body.webSearch.backend;
245
+ }
204
246
  if (typeof body.webSearch.reasoning === "string") config.webSearchSidecar.reasoning = body.webSearch.reasoning;
205
247
  }
206
248
  if (body.vision) {
207
249
  config.visionSidecar = { ...config.visionSidecar };
208
- if (typeof body.vision.model === "string") config.visionSidecar.model = body.vision.model;
250
+ if (typeof body.vision.model === "string") {
251
+ if (body.vision.model === "") delete config.visionSidecar.model;
252
+ else config.visionSidecar.model = body.vision.model;
253
+ }
254
+ if (body.vision.backend === null) delete config.visionSidecar.backend;
255
+ else if (body.vision.backend === "openai" || body.vision.backend === "anthropic") {
256
+ config.visionSidecar.backend = body.vision.backend;
257
+ }
258
+ if (typeof body.vision.maxDescriptionsPerTurn === "number") {
259
+ config.visionSidecar.maxDescriptionsPerTurn = body.vision.maxDescriptionsPerTurn;
260
+ }
209
261
  }
210
262
  saveConfig(config);
211
263
  const ws = config.webSearchSidecar ?? {};
212
264
  const vs = config.visionSidecar ?? {};
213
265
  return jsonResponse({
214
266
  ok: true,
215
- webSearch: { model: ws.model ?? "gpt-5.6-luna", reasoning: ws.reasoning ?? "low" },
216
- vision: { model: vs.model ?? "gpt-5.6-luna" },
267
+ webSearch: { model: ws.model ?? "gpt-5.6-luna", backend: ws.backend },
268
+ vision: {
269
+ model: vs.model ?? "gpt-5.6-luna",
270
+ backend: vs.backend,
271
+ maxDescriptionsPerTurn: vs.maxDescriptionsPerTurn,
272
+ },
217
273
  });
218
274
  }
219
275
 
@@ -241,6 +297,11 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
241
297
  return jsonResponse({ enabled: isClaudeDebugEnabled(), entries: getClaudeInboundDebugEntries() });
242
298
  }
243
299
 
300
+ if (url.pathname === "/api/debug/injection-logs" && req.method === "GET") {
301
+ const { after, limit } = parseDebugLogQuery(url);
302
+ return jsonResponse(getInjectionDebugLogEntries({ after, limit }));
303
+ }
304
+
244
305
  if (url.pathname === "/api/debug" && req.method === "PUT") {
245
306
  let body: { debug?: unknown; usage?: unknown; injection?: unknown; claude?: unknown; reset?: unknown };
246
307
  try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
@@ -309,6 +370,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
309
370
  return jsonResponse(Object.entries(config.providers).map(([name, p]) => ({
310
371
  name, adapter: p.adapter, baseUrl: publicProviderBaseUrl(p.baseUrl), defaultModel: p.defaultModel,
311
372
  hasApiKey: !!p.apiKey,
373
+ allowPrivateNetwork: p.allowPrivateNetwork === true,
312
374
  disabled: p.disabled === true,
313
375
  })));
314
376
  }
@@ -329,6 +391,10 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
329
391
  }
330
392
  const providerError = providerManagementConfigError(name, prov);
331
393
  if (providerError) return jsonResponse({ error: providerError }, 400);
394
+ // Hostname destinations additionally get a DNS-resolved SSRF check at write time —
395
+ // the sync check above only classifies literal IPs (review finding, PR #96).
396
+ const resolvedError = await providerDestinationResolvedError(name, prov);
397
+ if (resolvedError) return jsonResponse({ error: resolvedError }, 400);
332
398
  // Catalog providers (e.g. ollama-cloud) carry a models + vision/reasoning classification the GUI
333
399
  // doesn't send — merge it in so the sidecars are gated correctly.
334
400
  enrichProviderFromCatalog(name, prov);
@@ -694,6 +760,8 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
694
760
  aliases.push({ id: claudeCodeAlias(m.provider, m.id), display_name: `${m.id} (${m.provider})` });
695
761
  }
696
762
  const contextWindows = buildClaudeContextWindows([...visibleNativeSlugs(config)], models);
763
+ const webSearchOverride = config.claudeCode?.webSearchSidecar;
764
+ const visionOverride = config.claudeCode?.visionSidecar;
697
765
  return jsonResponse({
698
766
  enabled: config.claudeCode?.enabled !== false,
699
767
  model: config.claudeCode?.model ?? "",
@@ -707,6 +775,12 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
707
775
  autoCompactWindow: config.claudeCode?.autoCompactWindow ?? null,
708
776
  blockedSkills: config.claudeCode?.blockedSkills ?? null,
709
777
  injectAgents: config.claudeCode?.injectAgents !== false,
778
+ ...(webSearchOverride && Object.keys(webSearchOverride).length > 0
779
+ ? { webSearchSidecar: { backend: webSearchOverride.backend, model: webSearchOverride.model } }
780
+ : {}),
781
+ ...(visionOverride && Object.keys(visionOverride).length > 0
782
+ ? { visionSidecar: { backend: visionOverride.backend, model: visionOverride.model } }
783
+ : {}),
710
784
  fastMode: config.fastMode,
711
785
  contextWindows,
712
786
  effectiveModelEnv: effectiveModelEnv(config.claudeCode, contextWindows),
@@ -722,9 +796,44 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
722
796
  // supersede tiers; auto-context supersedes the max-context pair; effort rides
723
797
  // regardless on 2.1.207). PUT keeps validating them so hand-written configs
724
798
  // and older GUIs stay safe; GUI saves omit them and the spread preserves them.
725
- let body: { enabled?: unknown; model?: unknown; smallFastModel?: unknown; modelMap?: unknown; systemEnv?: unknown; fastMode?: unknown; maxContextTokens?: unknown; alwaysEnableEffort?: unknown; tierModels?: unknown; autoContext?: unknown; autoCompactWindow?: unknown; blockedSkills?: unknown; injectAgents?: unknown };
726
- try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
799
+ let parsedBody: unknown;
800
+ try { parsedBody = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
801
+ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
802
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
803
+ const prototype = Object.getPrototypeOf(value);
804
+ return prototype === Object.prototype || prototype === null;
805
+ };
806
+ if (!isPlainObject(parsedBody)) return jsonResponse({ error: "body must be an object" }, 400);
807
+ const body = parsedBody as { enabled?: unknown; model?: unknown; smallFastModel?: unknown; modelMap?: unknown; systemEnv?: unknown; fastMode?: unknown; maxContextTokens?: unknown; alwaysEnableEffort?: unknown; tierModels?: unknown; autoContext?: unknown; autoCompactWindow?: unknown; blockedSkills?: unknown; injectAgents?: unknown; webSearchSidecar?: unknown; visionSidecar?: unknown };
808
+ for (const field of ["webSearchSidecar", "visionSidecar"] as const) {
809
+ const section = body[field];
810
+ if (section === undefined || section === null) continue;
811
+ if (!isPlainObject(section)) return jsonResponse({ error: `${field} must be an object or null` }, 400);
812
+ if (section.backend !== undefined && section.backend !== null
813
+ && section.backend !== "openai" && section.backend !== "anthropic") {
814
+ return jsonResponse({ error: `${field}.backend must be openai, anthropic, or null` }, 400);
815
+ }
816
+ if (section.model !== undefined && typeof section.model !== "string") {
817
+ return jsonResponse({ error: `${field}.model must be a string` }, 400);
818
+ }
819
+ }
727
820
  const next = { ...(config.claudeCode ?? {}) };
821
+ for (const field of ["webSearchSidecar", "visionSidecar"] as const) {
822
+ const section = body[field];
823
+ if (section === undefined) continue;
824
+ if (section === null || Object.keys(section as Record<string, unknown>).length === 0) {
825
+ delete next[field];
826
+ continue;
827
+ }
828
+ const requested = section as { backend?: "openai" | "anthropic" | null; model?: string };
829
+ const override: NonNullable<OcxClaudeCodeConfig[typeof field]> = { ...next[field] };
830
+ if (requested.backend === null) delete override.backend;
831
+ else if (requested.backend !== undefined) override.backend = requested.backend;
832
+ if (requested.model === "") delete override.model;
833
+ else if (requested.model !== undefined) override.model = requested.model;
834
+ if (Object.keys(override).length > 0) next[field] = override;
835
+ else delete next[field];
836
+ }
728
837
  if (body.enabled !== undefined) {
729
838
  if (typeof body.enabled !== "boolean") return jsonResponse({ error: "enabled must be a boolean" }, 400);
730
839
  next.enabled = body.enabled;
@@ -785,18 +894,23 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
785
894
  }
786
895
  if (body.tierModels !== undefined) {
787
896
  // CONFIG-ONLY back-compat (GUI pickers removed — roster agents supersede tiers).
788
- if (!body.tierModels || typeof body.tierModels !== "object" || Array.isArray(body.tierModels)) {
789
- return jsonResponse({ error: "tierModels must be an object" }, 400);
790
- }
791
- const tiers: Record<string, string> = {};
792
- for (const tier of ["opus", "sonnet", "haiku", "fable"] as const) {
793
- const value = (body.tierModels as Record<string, unknown>)[tier];
794
- if (value === undefined || value === null) continue;
795
- if (typeof value !== "string") return jsonResponse({ error: `tierModels.${tier} must be a string` }, 400);
796
- if (value.trim() !== "") tiers[tier] = value.trim();
897
+ if (body.tierModels === null) {
898
+ delete next.tierModels;
899
+ } else if (!isPlainObject(body.tierModels)) {
900
+ return jsonResponse({ error: "tierModels must be an object with string values, or null" }, 400);
901
+ } else {
902
+ for (const [tier, value] of Object.entries(body.tierModels)) {
903
+ if (typeof value !== "string") return jsonResponse({ error: `tierModels.${tier} must be a string` }, 400);
904
+ }
905
+ const tierModels = body.tierModels as Record<string, string>;
906
+ const tiers: Record<string, string> = {};
907
+ for (const tier of ["opus", "sonnet", "haiku", "fable"] as const) {
908
+ const value = tierModels[tier];
909
+ if (value !== undefined && value.trim() !== "") tiers[tier] = value.trim();
910
+ }
911
+ if (Object.keys(tiers).length > 0) next.tierModels = tiers;
912
+ else delete next.tierModels;
797
913
  }
798
- if (Object.keys(tiers).length > 0) next.tierModels = tiers;
799
- else delete next.tierModels;
800
914
  }
801
915
  if (body.fastMode !== undefined) {
802
916
  if (body.fastMode !== true && body.fastMode !== false && body.fastMode !== null) {
@@ -812,26 +926,38 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
812
926
  else next[field] = value.trim();
813
927
  }
814
928
  if (body.modelMap !== undefined) {
815
- if (!body.modelMap || typeof body.modelMap !== "object" || Array.isArray(body.modelMap)) {
816
- return jsonResponse({ error: "modelMap must be an object of string->string" }, 400);
817
- }
818
- const map: Record<string, string> = {};
819
- for (const [k, v] of Object.entries(body.modelMap as Record<string, unknown>)) {
820
- if (typeof v !== "string" || k.trim() === "" || v.trim() === "") {
821
- return jsonResponse({ error: "modelMap entries must be non-empty strings" }, 400);
929
+ if (body.modelMap === null) {
930
+ delete next.modelMap;
931
+ } else {
932
+ if (!isPlainObject(body.modelMap)) {
933
+ return jsonResponse({ error: "modelMap must be an object of string->string, or null" }, 400);
822
934
  }
823
- map[k.trim()] = v.trim();
935
+ const map: Record<string, string> = {};
936
+ for (const [k, v] of Object.entries(body.modelMap)) {
937
+ if (typeof v !== "string" || k.trim() === "" || v.trim() === "") {
938
+ return jsonResponse({ error: "modelMap entries must be non-empty strings" }, 400);
939
+ }
940
+ map[k.trim()] = v.trim();
941
+ }
942
+ if (Object.keys(map).length > 0) next.modelMap = map;
943
+ else delete next.modelMap;
824
944
  }
825
- if (Object.keys(map).length > 0) next.modelMap = map;
826
- else delete next.modelMap;
827
945
  }
828
946
  config.claudeCode = next;
829
947
  const { saveConfig: save } = await import("../config");
830
948
  save(config);
949
+ const warnings: string[] = [];
950
+ if (body.systemEnv !== undefined) {
951
+ try {
952
+ await applySystemEnvToggle(config, config.port);
953
+ } catch (err) {
954
+ warnings.push(`Failed to apply system environment setting: ${err instanceof Error ? err.message : String(err)}`);
955
+ }
956
+ }
831
957
  // Keep the file-backed live registry symmetric: OFF prunes immediately, while
832
958
  // ON and config changes restore definitions without requiring a restart.
833
959
  await syncClaudeAgentDefsBestEffort();
834
- return jsonResponse({ ok: true, enabled: next.enabled !== false });
960
+ return jsonResponse({ ok: true, enabled: next.enabled !== false, warnings });
835
961
  }
836
962
 
837
963
  // Per-provider catalog allowlist (issue #52): when a provider has a non-empty selectedModels list,
@@ -1,3 +1,5 @@
1
+ import { gunzipSync, inflateRawSync, inflateSync, zstdDecompressSync } from "node:zlib";
2
+
1
3
  /**
2
4
  * Request-body decompression for the /v1/responses data plane.
3
5
  *
@@ -25,28 +27,59 @@ export class UnsupportedContentEncodingError extends Error {
25
27
  }
26
28
 
27
29
  export class DecompressedBodyTooLargeError extends Error {
28
- constructor(readonly bytes: number) {
29
- super(`Decompressed request body exceeds ${MAX_DECOMPRESSED_BODY_BYTES} bytes`);
30
+ constructor(readonly bytes: number, limit: number = MAX_DECOMPRESSED_BODY_BYTES) {
31
+ super(`Decompressed request body exceeds ${limit} bytes`);
30
32
  }
31
33
  }
32
34
 
33
- export function decodeRequestBody(raw: Uint8Array<ArrayBuffer>, contentEncoding: string | null): Uint8Array {
35
+ function assertBodySizeWithinLimit(body: Uint8Array, maxBytes: number): Uint8Array {
36
+ if (body.byteLength > maxBytes) throw new DecompressedBodyTooLargeError(body.byteLength, maxBytes);
37
+ return body;
38
+ }
39
+
40
+ function inflateDeflateBody(compressed: Uint8Array<ArrayBuffer>, opts: { maxOutputLength: number }): Uint8Array {
41
+ // HTTP "deflate" appears both zlib-wrapped and raw in the wild (Bun.deflateSync emits raw,
42
+ // which the previous Bun.inflateSync accepted). Try zlib-wrapped first, fall back to raw —
43
+ // but never swallow the size-cap abort.
44
+ try {
45
+ return inflateSync(compressed, opts);
46
+ } catch (err) {
47
+ if ((err as NodeJS.ErrnoException | null)?.code === "ERR_BUFFER_TOO_LARGE") throw err;
48
+ return inflateRawSync(compressed, opts);
49
+ }
50
+ }
51
+
52
+ export function decodeRequestBody(
53
+ raw: Uint8Array,
54
+ contentEncoding: string | null,
55
+ maxBytes: number = MAX_DECOMPRESSED_BODY_BYTES,
56
+ ): Uint8Array {
34
57
  const encoding = (contentEncoding ?? "").trim().toLowerCase();
35
- if (encoding === "" || encoding === "identity") return raw;
58
+ if (encoding === "" || encoding === "identity") return assertBodySizeWithinLimit(raw, maxBytes);
59
+ const compressed = raw as Uint8Array<ArrayBuffer>;
60
+ // `maxOutputLength` makes zlib abort DURING inflation (ERR_BUFFER_TOO_LARGE), so a
61
+ // decompression bomb never allocates beyond the cap — checking after the fact would
62
+ // already have paid the full allocation (review finding, PR #96).
63
+ const opts = { maxOutputLength: maxBytes };
36
64
  let decoded: Uint8Array;
37
- if (encoding === "zstd") decoded = Bun.zstdDecompressSync(raw);
38
- else if (encoding === "gzip" || encoding === "x-gzip") decoded = Bun.gunzipSync(raw);
39
- else if (encoding === "deflate") decoded = Bun.inflateSync(raw);
40
- // Multi-codings ("zstd, gzip") and unknown tokens are rejected rather than guessed.
41
- else throw new UnsupportedContentEncodingError(encoding);
42
- if (decoded.byteLength > MAX_DECOMPRESSED_BODY_BYTES) throw new DecompressedBodyTooLargeError(decoded.byteLength);
43
- return decoded;
65
+ try {
66
+ if (encoding === "zstd") decoded = zstdDecompressSync(compressed, opts);
67
+ else if (encoding === "gzip" || encoding === "x-gzip") decoded = gunzipSync(compressed, opts);
68
+ else if (encoding === "deflate") decoded = inflateDeflateBody(compressed, opts);
69
+ // Multi-codings ("zstd, gzip") and unknown tokens are rejected rather than guessed.
70
+ else throw new UnsupportedContentEncodingError(encoding);
71
+ } catch (err) {
72
+ if ((err as NodeJS.ErrnoException | null)?.code === "ERR_BUFFER_TOO_LARGE") {
73
+ throw new DecompressedBodyTooLargeError(maxBytes + 1, maxBytes);
74
+ }
75
+ throw err;
76
+ }
77
+ return assertBodySizeWithinLimit(decoded, maxBytes);
44
78
  }
45
79
 
46
80
  /** Parse a JSON request body, transparently decoding compressed payloads. */
47
81
  export async function readJsonRequestBody(req: Request): Promise<unknown> {
48
82
  const encoding = req.headers.get("content-encoding");
49
- if (!encoding || encoding.trim().toLowerCase() === "identity") return await req.json();
50
83
  const decoded = decodeRequestBody(new Uint8Array(await req.arrayBuffer()), encoding);
51
84
  return JSON.parse(new TextDecoder().decode(decoded));
52
85
  }
@@ -10,6 +10,7 @@ import { FORWARD_HEADERS } from "../adapters/openai-responses";
10
10
  import { expandPreviousResponseInput, previousResponseConversationId, rememberResponseState } from "../responses/state";
11
11
  import { routeModel } from "../router";
12
12
  import { isInjectionDebugEnabled } from "../lib/debug-settings";
13
+ import { injectionDebugLog } from "../lib/injection-debug-log";
13
14
  import { modelInList, namespacedToolName } from "../types";
14
15
  import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types";
15
16
  import {
@@ -40,6 +41,7 @@ import { isUsageDebugEnabled } from "../usage/debug";
40
41
  import { readJsonRequestBody, DecompressedBodyTooLargeError, UnsupportedContentEncodingError } from "./request-decompress";
41
42
  import { resolveAdapter, resolveWireProtocolOverride } from "./adapter-resolve";
42
43
  import { hasKeyPoolFailover, rotateKeyOn429 } from "../providers/key-failover";
44
+ import { resolveProviderTransport } from "../providers/xai-transport";
43
45
  import type { WsData } from "./ws-bridge";
44
46
  import { registerTurn, trackStreamLifetime, unregisterTurn } from "./lifecycle";
45
47
  import { redactSecretString } from "../lib/redact";
@@ -505,9 +507,9 @@ export async function handleResponses(
505
507
  const guidance = await multiAgentGuidanceText(parsed, config.injectionModel, config.injectionEffort, config.subagentModels, config.injectionPrompt);
506
508
  if (guidance) {
507
509
  injectDeveloperMessage(parsed, guidance);
508
- if (isInjectionDebugEnabled()) console.log(`[opencodex] ${route.modelId}: multi-agent guidance injected (surface=${collabSurface(parsed)}, ${guidance.length} chars)`);
510
+ if (isInjectionDebugEnabled()) injectionDebugLog(`[opencodex] ${route.modelId}: multi-agent guidance injected (surface=${collabSurface(parsed)}, ${guidance.length} chars)`);
509
511
  } else if (isInjectionDebugEnabled() && collabSurface(parsed) !== null) {
510
- console.log(`[opencodex] ${route.modelId}: collab surface=${collabSurface(parsed)}, guidance silent (effort=${parsed.options.reasoning ?? "unset"}, injectionModel=${config.injectionModel ?? "unset"})`);
512
+ injectionDebugLog(`[opencodex] ${route.modelId}: collab surface=${collabSurface(parsed)}, guidance silent (effort=${parsed.options.reasoning ?? "unset"}, injectionModel=${config.injectionModel ?? "unset"})`);
511
513
  }
512
514
  }
513
515
 
@@ -530,11 +532,11 @@ export async function handleResponses(
530
532
  if (capped) {
531
533
  logCtx.requestedEffort = `${capped.from}->${capped.to}`;
532
534
  if (isInjectionDebugEnabled()) {
533
- console.log(`[opencodex] ${route.modelId}: effort cap applied (${capped.from} -> ${capped.to}, ${capped.subagent ? "sub-agent" : "main"} turn)`);
535
+ injectionDebugLog(`[opencodex] ${route.modelId}: effort cap applied (${capped.from} -> ${capped.to}, ${capped.subagent ? "sub-agent" : "main"} turn)`);
534
536
  }
535
537
  }
536
538
  } else if (isInjectionDebugEnabled() && (config.effortCap || config.subagentEffortCap)) {
537
- console.log(`[opencodex] ${route.modelId}: effort cap skipped (surface=${surface ?? "none"}, v2 feature only)`);
539
+ injectionDebugLog(`[opencodex] ${route.modelId}: effort cap skipped (surface=${surface ?? "none"}, v2 feature only)`);
538
540
  }
539
541
  }
540
542
 
@@ -611,14 +613,15 @@ export async function handleResponses(
611
613
  return formatErrorResponse(401, "authentication_error", err instanceof Error ? err.message : String(err));
612
614
  }
613
615
  }
616
+ route.provider = resolveProviderTransport(route.providerName, route.provider, parsed.options.promptCacheKey);
614
617
 
615
- // Vision sidecar: the routed model can't see images (provider.noVisionModels). Give it "eyes" —
616
- // describe each attached image with a gpt vision model via the ChatGPT passthrough and replace it
617
- // with text BEFORE the main call, so the text-only model can reason about it.
618
+ // Vision sidecar: the routed model can't see images (provider.noVisionModels). Describe each
619
+ // attached image through the selected sidecar backend and replace it with text BEFORE the main
620
+ // call, so the text-only model can reason about it.
618
621
  const visionPlan = planVisionSidecar(config, route.provider, route.modelId, parsed, selectedForwardHeaders, authCtx);
619
622
  const recordSidecarOutcome = sidecarOutcomeRecorder(config, authCtx);
620
623
  if (visionPlan) {
621
- await describeImagesInPlace(parsed, visionPlan.forwardProvider, selectedForwardHeaders, visionPlan.settings, options.abortSignal, recordSidecarOutcome);
624
+ await describeImagesInPlace(parsed, visionPlan, selectedForwardHeaders, options.abortSignal, recordSidecarOutcome);
622
625
  } else if (modelInList(route.provider.noVisionModels, route.modelId)) {
623
626
  // Sidecar-covered model but NO plan (no forward provider / missing forwarded auth / sidecar
624
627
  // disabled): fail closed — never forward raw images to a text-only upstream.
@@ -869,7 +872,9 @@ export async function handleResponses(
869
872
  parsed.context.tools = [...(parsed.context.tools ?? []), buildWebSearchTool()];
870
873
  const wsResponse = await runWithWebSearch({
871
874
  parsed, adapter,
875
+ backend: wsPlan.backend,
872
876
  forwardProvider: wsPlan.forwardProvider,
877
+ anthropicSidecar: wsPlan.anthropicSidecar,
873
878
  hostedTool: wsPlan.hostedTool,
874
879
  selectedForwardHeaders,
875
880
  settings: wsPlan.settings,
@@ -883,9 +888,11 @@ export async function handleResponses(
883
888
  on429: retryAfter => {
884
889
  const rotated = rotateKeyOn429(config, route.providerName, retryAfter, Date.now(), route.provider.apiKey);
885
890
  if (!rotated) return null;
886
- route.provider = rotated;
891
+ // Re-resolve the auth-mode transport so the conv-id / subscription headers derived at
892
+ // line ~616 survive the key rotation (rotated providers come from raw config).
893
+ route.provider = resolveProviderTransport(route.providerName, rotated, parsed.options.promptCacheKey);
887
894
  return resolveAdapter(
888
- resolveWireProtocolOverride(route.providerName, route.modelId, rotated),
895
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
889
896
  config.cacheRetention,
890
897
  );
891
898
  },
@@ -939,9 +946,11 @@ export async function handleResponses(
939
946
  // Release the failed response's socket before retrying; unread bodies otherwise linger
940
947
  // until runtime cleanup (one per rotated key under a rate-limit storm).
941
948
  try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
942
- route.provider = rotated;
949
+ // Same transport re-resolution as the streaming on429 path: keep conv-id + subscription
950
+ // headers on the retried request instead of silently reverting to the raw config provider.
951
+ route.provider = resolveProviderTransport(route.providerName, rotated, parsed.options.promptCacheKey);
943
952
  const retryAdapter = resolveAdapter(
944
- resolveWireProtocolOverride(route.providerName, route.modelId, rotated),
953
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
945
954
  config.cacheRetention,
946
955
  );
947
956
  const retryRequest = await retryAdapter.buildRequest(parsed, { headers: selectedForwardHeaders });