@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.
- package/README.md +3 -1
- package/gui/dist/assets/index-BAAFKwsh.js +40 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/cursor/transport-retry.ts +5 -3
- package/src/adapters/google-errors.ts +9 -19
- package/src/adapters/google-http.ts +29 -66
- package/src/adapters/kiro-errors.ts +10 -23
- package/src/adapters/kiro-retry.ts +26 -58
- package/src/adapters/upstream-http-error.ts +48 -0
- package/src/bridge.ts +6 -2
- package/src/claude/gateway-cache.ts +3 -3
- package/src/claude/outbound.ts +117 -40
- package/src/cli/claude.ts +36 -4
- package/src/config.ts +54 -3
- package/src/lib/destination-policy.ts +167 -0
- package/src/lib/injection-debug-log.ts +34 -0
- package/src/lib/upstream-retry.ts +53 -3
- package/src/lib/windows-secret-acl.ts +173 -0
- package/src/oauth/index.ts +9 -7
- package/src/oauth/store.ts +1 -0
- package/src/providers/registry.ts +10 -3
- package/src/providers/xai-transport.ts +89 -0
- package/src/router.ts +6 -1
- package/src/server/auth-cors.ts +4 -0
- package/src/server/claude-messages.ts +32 -2
- package/src/server/management-api.ts +159 -33
- package/src/server/request-decompress.ts +45 -12
- package/src/server/responses.ts +21 -12
- package/src/server/system-env.ts +110 -68
- package/src/service.ts +4 -0
- package/src/types.ts +25 -5
- package/src/vision/anthropic-describe.ts +185 -0
- package/src/vision/index.ts +219 -10
- package/src/web-search/anthropic-executor.ts +187 -0
- package/src/web-search/executor.ts +4 -2
- package/src/web-search/index.ts +80 -18
- package/src/web-search/loop.ts +14 -2
- 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",
|
|
194
|
-
vision: {
|
|
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
|
|
200
|
-
try {
|
|
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")
|
|
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")
|
|
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",
|
|
216
|
-
vision: {
|
|
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
|
|
726
|
-
try {
|
|
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 (
|
|
789
|
-
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
const value
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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 (
|
|
816
|
-
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
|
|
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
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
}
|
package/src/server/responses.ts
CHANGED
|
@@ -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())
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
|
616
|
-
//
|
|
617
|
-
//
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
953
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
|
|
945
954
|
config.cacheRetention,
|
|
946
955
|
);
|
|
947
956
|
const retryRequest = await retryAdapter.buildRequest(parsed, { headers: selectedForwardHeaders });
|