From 8794a54d0660b720c53f54c7ffa22563fd78b0b1 Mon Sep 17 00:00:00 2001 From: Mauro Date: Fri, 4 Sep 2026 13:38:41 +0200 Subject: [PATCH 1/8] feat(server): add pre-adapter request transform hook (#3459) --- src/config.ts | 6 ++ src/server/auth-cors.ts | 3 + src/server/responses/core.ts | 8 ++ src/transforms/index.ts | 3 + src/transforms/runner.ts | 122 ++++++++++++++++++++++++ src/transforms/types.ts | 28 ++++++ src/types/config.ts | 5 + src/types/provider.ts | 5 + src/types/request.ts | 5 + tests/request-transforms.test.ts | 158 +++++++++++++++++++++++++++++++ 10 files changed, 343 insertions(+) create mode 100644 src/transforms/index.ts create mode 100644 src/transforms/runner.ts create mode 100644 src/transforms/types.ts create mode 100644 tests/request-transforms.test.ts diff --git a/src/config.ts b/src/config.ts index d5ef05c33f..b4801ae5c8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -585,6 +585,9 @@ const providerConfigSchema = z.object({ responsesSnapshotRepair: z.boolean().optional(), xaiResponsesXSearch: z.boolean().optional(), xaiResponsesDefaultVersion: z.number().int().positive().optional().catch(undefined), + requestTransforms: z.array(z.string().min(1)) + .transform(normalizeNonBlankStringArray) + .optional(), }).passthrough(); export { isValidProviderName, hasOwnProvider } from "./config/provider-name"; @@ -1135,6 +1138,9 @@ const configSchema = z.object({ configRebaseProvenance: z.unknown().optional(), // A retry can be billable, so absence and malformed hand edits both stay off. emptyCompletionRetry: z.boolean().optional().catch(false), + requestTransforms: z.array(z.string().min(1)) + .transform(normalizeNonBlankStringArray) + .optional(), // A malformed hand edit must not silently stop opening the browser: fall back // to undefined, which resolves to the historical auto-open behavior. oauthOpenBrowser: z.boolean().optional().catch(undefined), diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 0dd49910fb..57d61269a4 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -698,6 +698,8 @@ export function providerManagementConfigError(name: unknown, provider: unknown): if (structuredOutputOptOutError) return `provider ${name} ${structuredOutputOptOutError}`; const retainModelsError = nonBlankStringArrayConfigError(raw.retainModels, "retainModels"); if (retainModelsError) return `provider ${name} ${retainModelsError}`; + const requestTransformsError = nonBlankStringArrayConfigError(raw.requestTransforms, "requestTransforms"); + if (requestTransformsError) return `provider ${name} ${requestTransformsError}`; const toolReasoningOptOutError = nonBlankStringArrayConfigError( raw.omitReasoningEffortWithToolsModels, "omitReasoningEffortWithToolsModels", @@ -847,6 +849,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { noTopPModels: "editor", noPenaltyModels: "editor", noStructuredOutputModels: "editor", + requestTransforms: "editor", omitReasoningEffortWithToolsModels: "editor", parallelToolCalls: "editor", pinParallelToolCallsFalse: "editor", diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 312af7ac43..ea1cdcf252 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1,4 +1,5 @@ import type { Server } from "bun"; +import { applyRequestTransforms } from "../../transforms"; import { randomUUID } from "node:crypto"; import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge"; import { formatPassthroughUpstreamError } from "./passthrough-error"; @@ -3607,6 +3608,13 @@ async function handleResponsesInner( inboundWire, inboundTransport: options.inboundTransport, }); + parsed = await applyRequestTransforms({ + parsed, + providerName: route.providerName, + modelId: route.modelId, + providerConfig: route.provider, + config, + }); // Attribute local auth/cooldown failures to the public selector too; exact auth may fail before // the normal post-resolution provider label is assigned. if (route.codexAccountNamespace) { diff --git a/src/transforms/index.ts b/src/transforms/index.ts new file mode 100644 index 0000000000..db23ec7874 --- /dev/null +++ b/src/transforms/index.ts @@ -0,0 +1,3 @@ +export * from "./types"; +export * from "./runner"; + diff --git a/src/transforms/runner.ts b/src/transforms/runner.ts new file mode 100644 index 0000000000..749befc2b2 --- /dev/null +++ b/src/transforms/runner.ts @@ -0,0 +1,122 @@ +import { existsSync } from "node:fs"; +import { isAbsolute, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types"; +import { expandUserPath, getConfigDir } from "../config/paths"; +import { isVisionEligibleModel } from "../vision/eligibility"; +import type { RequestTransformContext, RequestTransformFn, RequestTransformModule } from "./types"; + +const transformCache = new Map>(); + +export function resolveTransformPath(specifier: string, configDir: string = getConfigDir()): string { + const expanded = expandUserPath(specifier.trim()); + if (isAbsolute(expanded)) { + return expanded; + } + const fromConfig = resolve(configDir, expanded); + if (existsSync(fromConfig)) { + return fromConfig; + } + const fromCwd = resolve(process.cwd(), expanded); + if (existsSync(fromCwd)) { + return fromCwd; + } + return expanded; +} + +export async function loadTransform( + specifier: string, + configDir: string = getConfigDir(), +): Promise { + const resolved = resolveTransformPath(specifier, configDir); + const existing = transformCache.get(resolved); + if (existing) return existing; + + const flight = (async (): Promise => { + try { + const isFile = existsSync(resolved); + const importTarget = isFile ? pathToFileURL(resolved).href : resolved; + const mod = (await import(importTarget)) as RequestTransformModule; + const fn = mod.transform ?? mod.default; + if (typeof fn === "function") { + return fn; + } + console.warn( + `[opencodex] request transform "${specifier}" did not export a default function or "transform" function.`, + ); + return null; + } catch (err) { + console.warn(`[opencodex] failed to load request transform "${specifier}":`, err); + return null; + } + })(); + + transformCache.set(resolved, flight); + return flight; +} + +export async function applyRequestTransforms(args: { + parsed: OcxParsedRequest; + providerName: string; + modelId: string; + providerConfig: OcxProviderConfig; + config: OcxConfig; +}): Promise { + const { parsed, providerName, modelId, providerConfig, config } = args; + + if (parsed._requestTransformsApplied) { + return parsed; + } + + const specifiers: string[] = [ + ...(config.requestTransforms ?? []), + ...(providerConfig.requestTransforms ?? []), + ].filter((s): s is string => typeof s === "string" && s.trim().length > 0); + + if (specifiers.length === 0) { + parsed._requestTransformsApplied = true; + return parsed; + } + + let acceptsImageInput = false; + try { + acceptsImageInput = isVisionEligibleModel(config, { + provider: providerName, + id: modelId, + }); + } catch { + acceptsImageInput = false; + } + + const context: RequestTransformContext = { + providerName, + modelId, + providerConfig, + config, + acceptsImageInput, + }; + + const configDir = getConfigDir(); + let currentParsed = parsed; + + for (const specifier of specifiers) { + const fn = await loadTransform(specifier, configDir); + if (!fn) continue; + try { + const result = await fn(currentParsed, context); + if (result && typeof result === "object") { + currentParsed = result; + } + } catch (err) { + console.warn(`[opencodex] error running request transform "${specifier}":`, err); + } + } + + currentParsed._requestTransformsApplied = true; + return currentParsed; +} + +export function clearTransformCacheForTests(): void { + transformCache.clear(); +} + diff --git a/src/transforms/types.ts b/src/transforms/types.ts new file mode 100644 index 0000000000..b54d467f57 --- /dev/null +++ b/src/transforms/types.ts @@ -0,0 +1,28 @@ +import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types"; + +export interface RequestTransformContext { + /** The settled provider name (e.g. "anthropic", "google-antigravity", "openai"). */ + providerName: string; + /** The settled model identifier. */ + modelId: string; + /** Effective provider configuration for this route. */ + providerConfig: OcxProviderConfig; + /** Global OpenCodeX configuration. */ + config: OcxConfig; + /** + * Whether the target model accepts image input (based on OpenCodeX's vision catalog & metadata). + * Allows transforms like pxpipe to selectively convert long text blocks into images only for vision-capable models. + */ + acceptsImageInput: boolean; +} + +export type RequestTransformFn = ( + parsed: OcxParsedRequest, + context: RequestTransformContext, +) => OcxParsedRequest | Promise | void | Promise; + +export interface RequestTransformModule { + default?: RequestTransformFn; + transform?: RequestTransformFn; +} + diff --git a/src/types/config.ts b/src/types/config.ts index ee97cdf9ac..a631b7e2d5 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -337,6 +337,11 @@ export interface OcxConfig { client?: OcxClientConnectionConfig; /** Opt in to one identical-turn retry when a Responses completion has no text or tool call. */ emptyCompletionRetry?: boolean; + /** + * Optional ordered list of pre-adapter request transform handler paths or package specifiers. + * Handlers operate on OcxParsedRequest before the wire request is built by provider adapters. + */ + requestTransforms?: string[]; /** * Whether a login may open a browser on the machine running the proxy. * diff --git a/src/types/provider.ts b/src/types/provider.ts index 97a359506a..0e13f8ca95 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -185,6 +185,11 @@ export interface OcxProviderConfig { modelDisplayNames?: Record; /** Override the global built-in model-alias switch for this provider. */ defaultAliases?: boolean; + /** + * Optional provider-scoped request transform handler paths or package specifiers, + * executed after global requestTransforms. + */ + requestTransforms?: string[]; adapter: string; /** * Codex tool calling mode for routed models. diff --git a/src/types/request.ts b/src/types/request.ts index 1c6a5294da..07a82eac16 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -50,6 +50,11 @@ export interface OcxParsedRequest { stream: boolean; options: OcxRequestOptions; _rawBody?: unknown; + /** + * True when requestTransforms have already been evaluated for this request turn. + * Prevents duplicate execution across internal retries, continuations, or replays. + */ + _requestTransformsApplied?: boolean; /** * Boundary between replayed history and this turn's newly appended input. Usually the * items the proxy restored from local previous_response_id state; also set when the diff --git a/tests/request-transforms.test.ts b/tests/request-transforms.test.ts new file mode 100644 index 0000000000..959290f9cb --- /dev/null +++ b/tests/request-transforms.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { applyRequestTransforms, clearTransformCacheForTests, resolveTransformPath } from "../src/transforms"; +import { validateConfigCandidate } from "../src/config"; +import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../src/types"; + +describe("requestTransforms", () => { + let testDir: string; + + beforeEach(() => { + clearTransformCacheForTests(); + testDir = join(tmpdir(), "ocx-test-transforms-" + Math.random().toString(36).slice(2)); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + try { + rmSync(testDir, { recursive: true, force: true }); + } catch {} + }); + + test("resolveTransformPath resolves relative to configDir and absolute paths", () => { + const fileInConfig = join(testDir, "custom.ts"); + writeFileSync(fileInConfig, "export default () => {};"); + + const resolved = resolveTransformPath("custom.ts", testDir); + expect(resolved).toBe(fileInConfig); + + const absPath = fileInConfig; + expect(resolveTransformPath(absPath, testDir)).toBe(absPath); + }); + + test("applyRequestTransforms runs global and provider transforms and marks applied", async () => { + const transform1Path = join(testDir, "t1.ts"); + const transform2Path = join(testDir, "t2.ts"); + + writeFileSync( + transform1Path, + `export default function (parsed, ctx) { + parsed.context.messages.push({ + role: "user", + content: "transformed-by-t1 (" + ctx.providerName + ":" + ctx.modelId + ")", + timestamp: Date.now(), + }); + };`, + ); + + writeFileSync( + transform2Path, + `export function transform(parsed, ctx) { + parsed.context.messages.push({ + role: "assistant", + content: "transformed-by-t2 (acceptsImage:" + ctx.acceptsImageInput + ")", + timestamp: Date.now(), + }); + return parsed; + };`, + ); + + const config: OcxConfig = { + port: 10100, + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { + adapter: "google", + baseUrl: "https://example.com", + requestTransforms: [transform2Path], + }, + }, + requestTransforms: [transform1Path], + }; + + const initialParsed: OcxParsedRequest = { + modelId: "gemini-3.1-pro", + context: { + messages: [], + }, + stream: true, + options: {}, + }; + + const result = await applyRequestTransforms({ + parsed: initialParsed, + providerName: "google-antigravity", + modelId: "gemini-3.1-pro", + providerConfig: config.providers["google-antigravity"], + config, + }); + + expect(result._requestTransformsApplied).toBe(true); + expect(result.context.messages.length).toBe(2); + expect((result.context.messages[0] as any).content).toContain("transformed-by-t1 (google-antigravity:gemini-3.1-pro)"); + expect((result.context.messages[1] as any).content).toContain("transformed-by-t2"); + + // Running again does not duplicate executions (single run per turn) + await applyRequestTransforms({ + parsed: result, + providerName: "google-antigravity", + modelId: "gemini-3.1-pro", + providerConfig: config.providers["google-antigravity"], + config, + }); + expect(result.context.messages.length).toBe(2); + }); + + test("gracefully handles failing or throwing transforms without crashing", async () => { + const failingTransformPath = join(testDir, "failing.ts"); + writeFileSync(failingTransformPath, "export default () => { throw new Error(\"boom\"); };"); + + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + providers: { + openai: { adapter: "openai-responses", baseUrl: "https://example.com" }, + }, + requestTransforms: [failingTransformPath, "non-existent-module-xyz"], + }; + + const initialParsed: OcxParsedRequest = { + modelId: "gpt-5.5", + context: { messages: [] }, + stream: false, + options: {}, + }; + + const result = await applyRequestTransforms({ + parsed: initialParsed, + providerName: "openai", + modelId: "gpt-5.5", + providerConfig: config.providers.openai, + config, + }); + + expect(result._requestTransformsApplied).toBe(true); + }); + + test("configSchema and providerConfigSchema validate requestTransforms correctly", () => { + const valid = validateConfigCandidate({ + port: 10100, + defaultProvider: "openai", + requestTransforms: ["./transforms/pxpipe.ts"], + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://example.com", + requestTransforms: ["./transforms/provider-transform.ts"], + }, + }, + }); + expect(valid.ok).toBe(true); + if (valid.ok) { + expect(valid.config.requestTransforms).toEqual(["./transforms/pxpipe.ts"]); + expect(valid.config.providers.openai.requestTransforms).toEqual(["./transforms/provider-transform.ts"]); + } + }); +}); From 4879b83a931f110b9248ce281efe49cc98467d06 Mon Sep 17 00:00:00 2001 From: Mauro Date: Fri, 4 Sep 2026 14:32:00 +0200 Subject: [PATCH 2/8] fix: address review feedback on request transforms (#3459) --- src/server/auth-cors.ts | 14 ++++++- src/server/responses/core.ts | 1 + src/transforms/runner.ts | 38 ++++++++++++++++++- structure/02_config-and-codex-home.md | 9 +++++ tests/request-transforms.test.ts | 54 ++++++++++++++++++++++++++- 5 files changed, 113 insertions(+), 3 deletions(-) diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 57d61269a4..41d55cc8aa 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -576,6 +576,17 @@ function nativeContextOverlayError(raw: Record): string | null * string, or null when the provider may be persisted. Caller-controlled names/fields are * redacted and JSON-escaped so secrets never reach the response. */ +function requestTransformsConfigError(value: unknown, field = "requestTransforms"): string | null { + if (value === undefined) return null; + if (!Array.isArray(value)) return `${field} must be an array`; + for (const [index, entry] of value.entries()) { + if (typeof entry !== "string" || !entry.trim()) { + return `${field}.${index} must be a nonblank string`; + } + } + return null; +} + export function providerManagementConfigError(name: unknown, provider: unknown): string | null { if (typeof name !== "string" || !provider || typeof provider !== "object" || Array.isArray(provider)) { return "provider must be a plain object"; @@ -616,6 +627,7 @@ export function providerManagementConfigError(name: unknown, provider: unknown): // validation and then rejected by the seed comparison, so canonical OpenAI could never // set OR clear it — the value was admitted and then refused in the same request. delete canonicalCandidate.annotateEmptyToolOutputs; + delete canonicalCandidate.requestTransforms; const canonical = seed && sameCanonicalProviderSeed(canonicalCandidate, seed); if (!canonical) { return `provider ${name} must equal the canonical built-in provider seed`; @@ -698,7 +710,7 @@ export function providerManagementConfigError(name: unknown, provider: unknown): if (structuredOutputOptOutError) return `provider ${name} ${structuredOutputOptOutError}`; const retainModelsError = nonBlankStringArrayConfigError(raw.retainModels, "retainModels"); if (retainModelsError) return `provider ${name} ${retainModelsError}`; - const requestTransformsError = nonBlankStringArrayConfigError(raw.requestTransforms, "requestTransforms"); + const requestTransformsError = requestTransformsConfigError(raw.requestTransforms); if (requestTransformsError) return `provider ${name} ${requestTransformsError}`; const toolReasoningOptOutError = nonBlankStringArrayConfigError( raw.omitReasoningEffortWithToolsModels, diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index ea1cdcf252..05ecadfa77 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -3615,6 +3615,7 @@ async function handleResponsesInner( providerConfig: route.provider, config, }); + toolBridgeMaps = buildToolBridgeMaps(parsed, translatorBudget); // Attribute local auth/cooldown failures to the public selector too; exact auth may fail before // the normal post-resolution provider label is assigned. if (route.codexAccountNamespace) { diff --git a/src/transforms/runner.ts b/src/transforms/runner.ts index 749befc2b2..335859750c 100644 --- a/src/transforms/runner.ts +++ b/src/transforms/runner.ts @@ -8,6 +8,25 @@ import type { RequestTransformContext, RequestTransformFn, RequestTransformModul const transformCache = new Map>(); +/** + * Validate that an object returned by a dynamic transform matches the minimal required + * structure of an OcxParsedRequest before replacing the active request. + */ +function isValidParsedRequest(val: unknown): val is OcxParsedRequest { + if (!val || typeof val !== "object" || Array.isArray(val)) return false; + const candidate = val as Record; + return ( + typeof candidate.modelId === "string" && + candidate.context !== null && + typeof candidate.context === "object" && + Array.isArray((candidate.context as Record).messages) + ); +} + +/** + * Resolve a transform specifier into an absolute file path or module identifier. + * Checks against the config directory (~/.opencodex) first, then current working directory. + */ export function resolveTransformPath(specifier: string, configDir: string = getConfigDir()): string { const expanded = expandUserPath(specifier.trim()); if (isAbsolute(expanded)) { @@ -24,6 +43,10 @@ export function resolveTransformPath(specifier: string, configDir: string = getC return expanded; } +/** + * Dynamically import and cache a request transform handler function. + * Supports modules exporting either a default function or a named "transform" function. + */ export async function loadTransform( specifier: string, configDir: string = getConfigDir(), @@ -55,6 +78,10 @@ export async function loadTransform( return flight; } +/** + * Execute all configured global and provider-scoped request transforms sequentially on the request. + * Operates once per turn and guards against duplicate execution across retries or replays. + */ export async function applyRequestTransforms(args: { parsed: OcxParsedRequest; providerName: string; @@ -105,7 +132,13 @@ export async function applyRequestTransforms(args: { try { const result = await fn(currentParsed, context); if (result && typeof result === "object") { - currentParsed = result; + if (isValidParsedRequest(result)) { + currentParsed = result; + } else { + console.warn( + `[opencodex] request transform "${specifier}" returned an invalid request object; retaining current request.`, + ); + } } } catch (err) { console.warn(`[opencodex] error running request transform "${specifier}":`, err); @@ -116,6 +149,9 @@ export async function applyRequestTransforms(args: { return currentParsed; } +/** + * Clear the internal transform import cache. Intended for test suite isolation. + */ export function clearTransformCacheForTests(): void { transformCache.clear(); } diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index 9478343d19..1c82765187 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -490,3 +490,12 @@ the residual directory for manual review; there is no recursive-delete fallback. ## Remote client key files Client connection metadata stores a stable `apiKeyId` and a non-secret rotation `pendingOperation`. The current data secret remains only in `service-api-token`; a bounded rotation temporarily keeps the old secret in owner-only `service-api-token.prev`. Commit or recovery clears the marker before orphan cleanup. `ocx disconnect` is local-only and leaves remote revocation to the hub's **Integrations → API Keys** page. Hub and local usage stores are not mirrored. + +## Request transforms + +`requestTransforms` can be configured globally in `config.json` or scoped under individual providers in `providers..requestTransforms`. Handlers are loaded dynamically and executed sequentially on `OcxParsedRequest` in `src/server/responses/core.ts` before provider adapters construct wire requests. + +- Specifiers are resolved relative to `OPENCODEX_HOME` (`~/.opencodex`), current working directory, or treated as module specifiers. +- Handlers receive `{ providerName, modelId, providerConfig, config, acceptsImageInput }` to facilitate optimizations like `pxpipe` (text-to-image for vision models) and `headroom` (context compression). +- Execution is guarded per turn by `_requestTransformsApplied` so retries and replays do not execute transforms twice. + diff --git a/tests/request-transforms.test.ts b/tests/request-transforms.test.ts index 959290f9cb..e0c5724a92 100644 --- a/tests/request-transforms.test.ts +++ b/tests/request-transforms.test.ts @@ -4,6 +4,9 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { applyRequestTransforms, clearTransformCacheForTests, resolveTransformPath } from "../src/transforms"; import { validateConfigCandidate } from "../src/config"; +import { providerManagementConfigError } from "../src/server/auth-cors"; +import { providerConfigSeed } from "../src/providers/derive"; +import { getProviderRegistryEntry } from "../src/providers/registry"; import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../src/types"; describe("requestTransforms", () => { @@ -18,7 +21,9 @@ describe("requestTransforms", () => { afterEach(() => { try { rmSync(testDir, { recursive: true, force: true }); - } catch {} + } catch (_err) { + void _err; + } }); test("resolveTransformPath resolves relative to configDir and absolute paths", () => { @@ -136,6 +141,40 @@ describe("requestTransforms", () => { expect(result._requestTransformsApplied).toBe(true); }); + test("rejects malformed replacement objects like {} and retains current request", async () => { + const invalidTransformPath = join(testDir, "invalid.ts"); + writeFileSync(invalidTransformPath, "export default () => { return {}; };"); + + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + providers: { + openai: { adapter: "openai-responses", baseUrl: "https://example.com" }, + }, + requestTransforms: [invalidTransformPath], + }; + + const initialParsed: OcxParsedRequest = { + modelId: "gpt-5.5", + context: { messages: [{ role: "user", content: "original-message", timestamp: 123 }] }, + stream: false, + options: {}, + }; + + const result = await applyRequestTransforms({ + parsed: initialParsed, + providerName: "openai", + modelId: "gpt-5.5", + providerConfig: config.providers.openai, + config, + }); + + expect(result._requestTransformsApplied).toBe(true); + expect(result.modelId).toBe("gpt-5.5"); + expect(result.context.messages.length).toBe(1); + expect((result.context.messages[0] as any).content).toBe("original-message"); + }); + test("configSchema and providerConfigSchema validate requestTransforms correctly", () => { const valid = validateConfigCandidate({ port: 10100, @@ -155,4 +194,17 @@ describe("requestTransforms", () => { expect(valid.config.providers.openai.requestTransforms).toEqual(["./transforms/provider-transform.ts"]); } }); + + test("providerManagementConfigError validates canonical openai with requestTransforms", () => { + const entry = getProviderRegistryEntry("openai"); + if (!entry) return; + const seed = providerConfigSeed(entry); + + const validCandidate = { ...seed, codexAccountMode: "pool" as const, requestTransforms: ["./custom.ts"] }; + expect(providerManagementConfigError("openai", validCandidate)).toBeNull(); + + const invalidCandidate = { ...seed, codexAccountMode: "pool" as const, requestTransforms: [""] }; + expect(providerManagementConfigError("openai", invalidCandidate)) + .toBe("provider openai requestTransforms.0 must be a nonblank string"); + }); }); From 4de0e443e5e797c52ce63b0a07005b4b521095b5 Mon Sep 17 00:00:00 2001 From: Mauro Date: Mon, 7 Sep 2026 10:26:20 +0200 Subject: [PATCH 3/8] test: keep combo roster round-trip independent of live discovery --- tests/routing/combo-management-api.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/routing/combo-management-api.test.ts b/tests/routing/combo-management-api.test.ts index 85f6be0ff6..f253a5e3fe 100644 --- a/tests/routing/combo-management-api.test.ts +++ b/tests/routing/combo-management-api.test.ts @@ -837,6 +837,7 @@ describe("combo management API", () => { free: { ...VALID_COMBO, alias: "deepseek-v4-flash" }, }, }); + for (const provider of Object.values(config.providers)) provider.liveModels = false; config.providers.a!.modelContextWindows = { m1: 128_000 }; const response = await comboApi(config, "GET", "/api/subagent-models"); From 93ca183547cd8d9235792699a5aebf4cf1fb5133 Mon Sep 17 00:00:00 2001 From: Mauro Date: Mon, 7 Sep 2026 10:36:12 +0200 Subject: [PATCH 4/8] fix: build transformed tool bridge maps once and isolate roster tests --- src/server/responses/core.ts | 4 +--- tests/routing/combo-management-api.test.ts | 5 ++++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 05ecadfa77..14c904c3a8 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -3143,7 +3143,6 @@ async function handleResponsesInner( } let parsed: OcxParsedRequest; - let toolBridgeMaps: ReturnType; try { parsed = parseRequest(body); parsed._promptCacheKeyIsSharedCohort = options.promptCacheKeyIsSharedCohort; @@ -3172,7 +3171,6 @@ async function handleResponsesInner( if (options.comboReplaySnapshot?.recoveredPlaintext) { markBodyNonPersistable(parsed._rawBody); } - toolBridgeMaps = buildToolBridgeMaps(parsed, translatorBudget); if (previousResponseInputExpanded) parsed._previousResponseInputExpanded = true; const providerContinuationCandidate = options.comboReplaySnapshot ? options.comboReplaySnapshot.providerContinuation @@ -3615,7 +3613,7 @@ async function handleResponsesInner( providerConfig: route.provider, config, }); - toolBridgeMaps = buildToolBridgeMaps(parsed, translatorBudget); + const toolBridgeMaps = buildToolBridgeMaps(parsed, translatorBudget); // Attribute local auth/cooldown failures to the public selector too; exact auth may fail before // the normal post-resolution provider label is assigned. if (route.codexAccountNamespace) { diff --git a/tests/routing/combo-management-api.test.ts b/tests/routing/combo-management-api.test.ts index f253a5e3fe..d15c79cd27 100644 --- a/tests/routing/combo-management-api.test.ts +++ b/tests/routing/combo-management-api.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; import { mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -150,6 +150,7 @@ async function responseJson(response: Response | null): Promise { + mock.restore(); clearComboSelectionState(); clearComboTargetCooldowns(); }); @@ -831,6 +832,8 @@ describe("combo management API", () => { }); test("GET subagent models exposes a combo alias as an available round-trip value", async () => { + spyOn(await import("../../src/codex/app-server-processes"), "collectCodexAppServerCatalogState") + .mockReturnValue({ state: "not_running", processes: [], catalogMtimeMs: null }); const config = baseConfig({ subagentModels: ["deepseek-v4-flash"], combos: { From 0261628b8f849d762b521193fcaa05d9fee45239 Mon Sep 17 00:00:00 2001 From: Mauro Date: Mon, 7 Sep 2026 10:44:26 +0200 Subject: [PATCH 5/8] test: place request transform coverage in the migrated test layout --- tests/{ => usage}/request-transforms.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) rename tests/{ => usage}/request-transforms.test.ts (95%) diff --git a/tests/request-transforms.test.ts b/tests/usage/request-transforms.test.ts similarity index 95% rename from tests/request-transforms.test.ts rename to tests/usage/request-transforms.test.ts index e0c5724a92..d8ac6ed8d9 100644 --- a/tests/request-transforms.test.ts +++ b/tests/usage/request-transforms.test.ts @@ -2,12 +2,12 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { applyRequestTransforms, clearTransformCacheForTests, resolveTransformPath } from "../src/transforms"; -import { validateConfigCandidate } from "../src/config"; -import { providerManagementConfigError } from "../src/server/auth-cors"; -import { providerConfigSeed } from "../src/providers/derive"; -import { getProviderRegistryEntry } from "../src/providers/registry"; -import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../src/types"; +import { applyRequestTransforms, clearTransformCacheForTests, resolveTransformPath } from "../../src/transforms"; +import { validateConfigCandidate } from "../../src/config"; +import { providerManagementConfigError } from "../../src/server/auth-cors"; +import { providerConfigSeed } from "../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; describe("requestTransforms", () => { let testDir: string; From f605163c1416fe97a3b9f72a19ef58e9698fceeb Mon Sep 17 00:00:00 2001 From: Mauro Date: Mon, 7 Sep 2026 11:41:47 +0200 Subject: [PATCH 6/8] fix(transforms): synchronize native Responses state after hooks --- .../content/docs/reference/configuration.md | 45 ++++ src/transforms/responses-body.ts | 206 ++++++++++++++++++ src/transforms/runner.ts | 8 +- src/types/request.ts | 2 +- tests/usage/request-transforms.test.ts | 134 ++++++++++++ 5 files changed, 392 insertions(+), 3 deletions(-) create mode 100644 src/transforms/responses-body.ts diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index b1136ee86e..37d4f9bde5 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -29,6 +29,51 @@ uses the fresh-install default: one `openai` forward provider. ## Precedence and defaults +### Request transforms (pending) + +`requestTransforms` is an opt-in extension hook that runs after routing and before input admission +and adapter request construction. It is disabled when the lists are absent or empty. Global handlers +run first, followed by the selected provider's handlers: + +```jsonc +{ + "requestTransforms": ["./transforms/common.ts"], + "providers": { + "my-provider": { + "adapter": "openai-chat", + "baseUrl": "https://example.com/v1", + "requestTransforms": ["./transforms/provider.ts"] + } + } +} +``` + +A handler exports a default function or a named `transform` function. It receives the normalized +request and `{ providerName, modelId, providerConfig, config, acceptsImageInput }`. It may mutate +the request in place and return nothing, or return a complete replacement request; async handlers +are supported. Model-specific behavior belongs inside the handler, using `modelId`: + +```ts +export default function transform(parsed, { modelId, acceptsImageInput }) { + if (modelId !== "my-vision-model" || !acceptsImageInput) return; + // Apply your text-to-image or compression implementation to parsed.context.messages. +} +``` + +Paths resolve against `OPENCODEX_HOME` first, then the working directory; absolute paths and module +package specifiers are also supported. Handlers execute as trusted code with the proxy process's +permissions and access to its configuration. Only configure code you trust. Imports are cached; +restart the proxy after changing a handler. Load and execution failures warn and processing continues; +in-place mutations made before a thrown error are not rolled back. + +The returned request is marked to avoid applying the pipeline again when an internal retry reuses +that parsed request. A new inbound request runs the pipeline again, even if it replays earlier history; +handlers that edit historical messages should recognize their own output to avoid transforming it twice. +Canonical message, tool, system-prompt and generation-option changes are synchronized into native +Responses requests. Unchanged native items and provider-specific fields are retained; a no-op handler +does not rebuild the native input or tool catalog. Complete replacements retain proxy-owned metadata +needed for authentication and continuation handling. + ### Provider and model aliases Aliases are optional short request names. They never change the native model id sent upstream, and omitting every alias field preserves existing routing exactly. diff --git a/src/transforms/responses-body.ts b/src/transforms/responses-body.ts new file mode 100644 index 0000000000..e42e9198ef --- /dev/null +++ b/src/transforms/responses-body.ts @@ -0,0 +1,206 @@ +import { isDeepStrictEqual } from "node:util"; +import type { OcxContentPart, OcxMessage, OcxParsedRequest, OcxTool } from "../types"; +import { parseRequest } from "../responses/parser"; +import { isObj } from "../responses/parser-content"; +import { encodeReasoningEnvelope } from "../responses/reasoning-envelope"; +import { buildTools } from "../responses/parser-tools"; +import { responsesExtraContentFromProviderMetadata } from "../responses/provider-opaque-metadata"; + +type Row = Record; + +function overlay(raw: unknown, before: unknown, after: unknown): unknown { + if (isDeepStrictEqual(before, after)) return raw; + if (isObj(raw) && isObj(before) && isObj(after)) { + const result = { ...raw }; + for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) { + if (!(key in after)) delete result[key]; + else result[key] = overlay(raw[key], before[key], after[key]); + } + return result; + } + if (Array.isArray(raw) && Array.isArray(before) && Array.isArray(after)) { + return after.map((value, index) => overlay(raw[index], before[index], value)); + } + return after; +} + +function content(parts: string | OcxContentPart[]): unknown { + return typeof parts === "string" ? parts : parts.map(part => { + if (part.type === "text") return { type: "input_text", text: part.text }; + if (part.type === "image") return { type: "input_image", image_url: part.imageUrl, ...(part.detail ? { detail: part.detail } : {}) }; + return { type: "input_video", video_url: part.videoUrl }; + }); +} + +/** Project canonical messages onto Responses items; raw counterparts are retained below. */ +function input(messages: OcxMessage[]): Row[] { + return messages.flatMap((message): Row[] => { + if (message.role === "toolResult") { + return [{ type: "function_call_output", call_id: message.toolCallId, output: content(message.content) }]; + } + if (message.role !== "assistant") return [{ role: message.role, content: content(message.content) }]; + const rows: Row[] = []; + for (const part of message.content) { + if (part.type === "text") { + const last = rows.at(-1); + const block = { type: "output_text", text: part.text }; + if (last?.role === "assistant") (last.content as unknown[]).push(block); + else rows.push({ role: "assistant", content: [block], ...(message.phase ? { phase: message.phase } : {}) }); + } else if (part.type === "toolCall") { + rows.push(part.customWireName + ? { type: "custom_tool_call", call_id: part.id, name: part.customWireName, input: part.arguments.input ?? "" } + : { type: "function_call", call_id: part.id, name: part.name, arguments: JSON.stringify(part.arguments), + ...(part.namespace ? { namespace: part.namespace } : {}), + ...responsesExtraContentFromProviderMetadata(part.providerMetadata) }); + } else { + rows.push({ type: "reasoning", summary: [{ type: "summary_text", text: part.thinking }], + ...(part.itemId ? { id: part.itemId } : {}), + ...(part.signature || part.redacted ? { encrypted_content: encodeReasoningEnvelope({ sig: part.signature, red: part.redacted, txt: part.thinking }) } : {}) }); + } + } + return rows; + }); +} + +/** Preserve raw items whose canonical projection survived, including opaque native fields. */ +function transformedInput(raw: Row, before: OcxParsedRequest, after: OcxParsedRequest): unknown[] { + const source = typeof raw.input === "string" ? [{ role: "user", content: raw.input }] : Array.isArray(raw.input) ? raw.input : []; + const previous = input(before.context.messages); + const transformed = input(after.context.messages); + if (isDeepStrictEqual(transformed.slice(0, previous.length), previous)) { + return [...source, ...transformed.slice(previous.length)]; + } + const pools = new Map>(); + let pending: unknown[] = []; + for (let index = 0; index < source.length; index++) { + const rows = [source[index]]; + if (isObj(source[index]) && source[index].type === "reasoning") { + while (isObj(source[index + 1]) && source[index + 1].type === "reasoning") rows.push(source[++index]); + } + const projected = input(parseRequest({ model: before.modelId, input: [...rows, { role: "assistant", content: [] }] }).context.messages); + // A raw item may encode several canonical items (e.g. a native assistant turn). + // Keep it as a unit, rather than duplicating its provider-private fields. + const key = JSON.stringify(projected); + if (!projected.length) { pending.push(...rows); continue; } + const entries = pools.get(key) ?? []; + entries.push({ rows, prefix: pending }); + pools.set(key, entries); + pending = []; + } + const result: unknown[] = []; + const transformedKeys = new Set(transformed.map(row => JSON.stringify([row]))); + const lengths = [...new Set([...pools.keys()].map(key => (JSON.parse(key) as unknown[]).length))].sort((a, b) => b - a); + for (let index = 0; index < transformed.length;) { + let matched = false; + for (const length of lengths) { + const retained = pools.get(JSON.stringify(transformed.slice(index, index + length)))?.shift(); + if (!retained) continue; + result.push(...retained.prefix, ...retained.rows); + index += length; + matched = true; + break; + } + if (!matched) { + const old = previous[index]; + const next = transformed[index]!; + const oldKey = JSON.stringify([old]); + const reusable = old && old.role === next.role && old.type === next.type + && !transformedKeys.has(oldKey) + ? pools.get(oldKey)?.shift() : undefined; + if (reusable) result.push(...reusable.prefix, ...(reusable.rows.length === 1 ? [overlay(reusable.rows[0], old, next)] : [next])); + else result.push(next); + index++; + } + } + // Unrepresented native items (encrypted reasoning, hosted calls, extensions) must not + // disappear merely because an adjacent ordinary message was replaced or removed. + for (const entries of pools.values()) for (const entry of entries) result.push(...entry.prefix); + result.push(...pending); + return result; +} + +function toolIdentity(tool: OcxTool): string { + return JSON.stringify([tool.namespace ?? "", tool.name]); +} + +function toolRow(tool: OcxTool): Row { + if (tool.freeform) return { type: "custom", name: tool.name, description: tool.description }; + return { type: "function", name: tool.name, description: tool.description, parameters: tool.parameters, + ...(tool.strict !== undefined ? { strict: tool.strict } : {}) }; +} + +/** Retain hosted tools, namespace envelopes, grammar definitions and untouched tool fields. */ +function transformedTools(raw: unknown, tools: OcxTool[]): unknown[] { + const remaining = new Map(tools.map(tool => [toolIdentity(tool), tool])); + const visit = (rows: unknown[], namespace?: string): unknown[] => rows.flatMap(row => { + if (!isObj(row)) return [row]; + if (row.type === "namespace" && Array.isArray(row.tools)) { + const children = visit(row.tools, row.name === "functions" ? undefined : String(row.name)); + return children.length ? [{ ...row, tools: children }] : []; + } + const original = buildTools([row])?.[0]; + if (!original) return [row]; + if (namespace) original.namespace = namespace; + const identity = toolIdentity(original); + const changed = remaining.get(identity); + if (!changed) return []; + remaining.delete(identity); + if (isDeepStrictEqual(original, changed)) return [row]; + return [overlay(row, toolRow(original), toolRow(changed))]; + }); + const result = visit(Array.isArray(raw) ? raw : []); + for (const tool of remaining.values()) { + const row = toolRow(tool); + if (tool.namespace) { + const group = result.find(entry => isObj(entry) && entry.type === "namespace" && entry.name === tool.namespace) as Row | undefined; + if (group && Array.isArray(group.tools)) group.tools.push(row); + else result.push({ type: "namespace", name: tool.namespace, tools: [row] }); + } else result.push(row); + } + return result; +} + +/** Synchronize only fields changed by hooks; a no-op never round-trips the native wire. */ +export function syncTransformedResponsesBody(before: OcxParsedRequest, after: OcxParsedRequest): void { + if (!isObj(before._rawBody)) return; + const raw = isObj(after._rawBody) ? after._rawBody : before._rawBody; + const next = { ...raw }; + const assign = (key: string, value: unknown) => { + if (value === undefined) delete next[key]; + else next[key] = value; + }; + if (!isDeepStrictEqual(before.context.messages, after.context.messages)) next.input = transformedInput(raw, before, after); + if (!isDeepStrictEqual(before.context.tools, after.context.tools)) next.tools = transformedTools(raw.tools, after.context.tools ?? []); + if (!isDeepStrictEqual(before.context.systemPrompt, after.context.systemPrompt)) { + assign("instructions", after.context.systemPrompt?.join("\n\n")); + if (Array.isArray(next.input)) next.input = next.input.filter(row => !isObj(row) || row.role !== "system"); + } + for (const [canonical, wire] of [["modelId", "model"], ["stream", "stream"], ["previousResponseId", "previous_response_id"]] as const) { + if (!isDeepStrictEqual(before[canonical], after[canonical])) assign(wire, after[canonical]); + } + for (const [canonical, wire] of [ + ["maxOutputTokens", "max_output_tokens"], ["temperature", "temperature"], ["topP", "top_p"], + ["stopSequences", "stop"], ["parallelToolCalls", "parallel_tool_calls"], ["serviceTier", "service_tier"], + ["presencePenalty", "presence_penalty"], ["frequencyPenalty", "frequency_penalty"], ["promptCacheKey", "prompt_cache_key"], + ] as const) { + if (!isDeepStrictEqual(before.options[canonical], after.options[canonical])) assign(wire, after.options[canonical]); + } + if (!isDeepStrictEqual(before.options.reasoning, after.options.reasoning)) { + next.reasoning = { ...(isObj(raw.reasoning) ? raw.reasoning : {}), effort: after.options.reasoning }; + } + if (before.options.hideThinkingSummary !== after.options.hideThinkingSummary) { + next.reasoning = { ...(isObj(next.reasoning) ? next.reasoning : {}), summary: after.options.hideThinkingSummary ? "none" : "auto" }; + } + if (!isDeepStrictEqual(before.options.textFormat, after.options.textFormat)) { + next.text = { ...(isObj(raw.text) ? raw.text : {}), format: after.options.textFormat }; + after._structuredOutput = after.options.textFormat !== undefined; + } + if (!isDeepStrictEqual(before.options.toolChoice, after.options.toolChoice)) { + const choice = after.options.toolChoice; + assign("tool_choice", typeof choice === "object" + ? "name" in choice ? { type: "function", name: choice.name } + : { type: "allowed_tools", mode: choice.mode, tools: choice.allowedTools.map(name => ({ type: "function", name })) } + : choice); + } + after._rawBody = next; +} diff --git a/src/transforms/runner.ts b/src/transforms/runner.ts index 335859750c..6285e81e61 100644 --- a/src/transforms/runner.ts +++ b/src/transforms/runner.ts @@ -5,6 +5,7 @@ import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types"; import { expandUserPath, getConfigDir } from "../config/paths"; import { isVisionEligibleModel } from "../vision/eligibility"; import type { RequestTransformContext, RequestTransformFn, RequestTransformModule } from "./types"; +import { syncTransformedResponsesBody } from "./responses-body"; const transformCache = new Map>(); @@ -80,7 +81,7 @@ export async function loadTransform( /** * Execute all configured global and provider-scoped request transforms sequentially on the request. - * Operates once per turn and guards against duplicate execution across retries or replays. + * Runs once per parsed request; internal retries that reuse it do not re-run the handlers. */ export async function applyRequestTransforms(args: { parsed: OcxParsedRequest; @@ -124,6 +125,7 @@ export async function applyRequestTransforms(args: { }; const configDir = getConfigDir(); + const before = { ...parsed, context: structuredClone(parsed.context), options: structuredClone(parsed.options) }; let currentParsed = parsed; for (const specifier of specifiers) { @@ -133,7 +135,8 @@ export async function applyRequestTransforms(args: { const result = await fn(currentParsed, context); if (result && typeof result === "object") { if (isValidParsedRequest(result)) { - currentParsed = result; + // A complete canonical replacement must not discard proxy-owned replay/auth state. + currentParsed = { ...currentParsed, ...result, previousResponseId: result.previousResponseId }; } else { console.warn( `[opencodex] request transform "${specifier}" returned an invalid request object; retaining current request.`, @@ -145,6 +148,7 @@ export async function applyRequestTransforms(args: { } } + syncTransformedResponsesBody(before, currentParsed); currentParsed._requestTransformsApplied = true; return currentParsed; } diff --git a/src/types/request.ts b/src/types/request.ts index 07a82eac16..a6f131d20d 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -52,7 +52,7 @@ export interface OcxParsedRequest { _rawBody?: unknown; /** * True when requestTransforms have already been evaluated for this request turn. - * Prevents duplicate execution across internal retries, continuations, or replays. + * Prevents duplicate execution when internal retries reuse this parsed request. */ _requestTransformsApplied?: boolean; /** diff --git a/tests/usage/request-transforms.test.ts b/tests/usage/request-transforms.test.ts index d8ac6ed8d9..d97cf95d9b 100644 --- a/tests/usage/request-transforms.test.ts +++ b/tests/usage/request-transforms.test.ts @@ -8,6 +8,9 @@ import { providerManagementConfigError } from "../../src/server/auth-cors"; import { providerConfigSeed } from "../../src/providers/derive"; import { getProviderRegistryEntry } from "../../src/providers/registry"; import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; +import { parseRequest } from "../../src/responses/parser"; +import { handleResponses } from "../../src/server/responses"; +import { syncTransformedResponsesBody } from "../../src/transforms/responses-body"; describe("requestTransforms", () => { let testDir: string; @@ -37,6 +40,137 @@ describe("requestTransforms", () => { expect(resolveTransformPath(absPath, testDir)).toBe(absPath); }); + test("native wire keeps opaque rows and fields when messages are edited and appended", () => { + const body = { + model: "native-model", vendor_option: { keep: true }, + tools: [{ type: "web_search", search_context_size: "low" }], + input: [ + { role: "user", vendor_message: "keep", content: [ + { type: "input_text", text: "before" }, + { type: "input_image", image_url: "https://example.test/image.png", vendor_image: "keep" }, + ] }, + { type: "reasoning", encrypted_content: "opaque-fixture", id: "rs_fixture" }, + { role: "assistant", content: [{ type: "output_text", text: "answer", annotations: [{ type: "fixture" }] }] }, + ], + }; + const parsed = parseRequest(body); + const before = structuredClone(parsed); + (parsed.context.messages[0]!.content as Array<{ text?: string }>)[0]!.text = "after"; + parsed.context.messages.push({ role: "user", content: "appended", timestamp: 0 }); + syncTransformedResponsesBody(before, parsed); + expect(parsed._rawBody).toEqual({ ...body, input: [ + { ...body.input[0], content: [ + { type: "input_text", text: "after" }, + { type: "input_image", image_url: "https://example.test/image.png", vendor_image: "keep" }, + ] }, body.input[1], body.input[2], { role: "user", content: "appended" }, + ] }); + expect(body.input[0]!.content?.[0]).toEqual({ type: "input_text", text: "before" }); + }); + + test("native synchronization preserves untouched reasoning, custom outputs, files and tool grammar", () => { + const body = { + model: "native-model", instructions: "old system", temperature: 0.5, + reasoning: { effort: "high", summary: "auto", vendor_reasoning: true }, + text: { format: { type: "json_object" }, verbosity: "low" }, + tools: [{ type: "namespace", name: "mcp", vendor_namespace: true, tools: [ + { type: "custom", name: "patch", description: "old", format: { type: "grammar", syntax: "lark", definition: "start: /.+/" } }, + ] }, { type: "web_search", vendor_search: true }], + input: [ + { role: "user", content: "replace" }, + { type: "reasoning", summary: [{ text: "first" }], encrypted_content: "opaque-one" }, + { type: "reasoning", summary: [{ text: "second" }], encrypted_content: "opaque-two" }, + { type: "custom_tool_call", call_id: "call_patch", name: "patch", input: "patch data" }, + { type: "custom_tool_call_output", call_id: "call_patch", output: "done", vendor_result: true }, + { role: "user", content: [{ type: "input_file", file_id: "file_fixture" }] }, + ], + }; + const parsed = parseRequest(body); + const before = structuredClone(parsed); + parsed.context.messages[0]!.content = "replacement"; + parsed.context.systemPrompt = ["new system"]; + parsed.context.tools![0]!.description = "new"; + delete parsed.options.temperature; + parsed.options.reasoning = "low"; + delete parsed.options.textFormat; + syncTransformedResponsesBody(before, parsed); + const wire = parsed._rawBody as typeof body; + expect(wire.input).toEqual([{ role: "user", content: "replacement" }, ...body.input.slice(1)]); + expect(wire.tools).toEqual([{ ...body.tools[0], tools: [{ ...body.tools[0]!.tools![0], description: "new" }] }, body.tools[1]]); + expect(wire.reasoning).toEqual({ ...body.reasoning, effort: "low" }); + expect(wire.instructions).toBe("new system"); + expect(JSON.parse(JSON.stringify(wire.text))).toEqual({ verbosity: "low" }); + expect(wire).not.toHaveProperty("temperature"); + }); + + test("complete replacement retains raw extensions and retry reuse does not append twice", async () => { + const path = join(testDir, "replacement.ts"); + writeFileSync(path, `export default parsed => ({ + modelId: parsed.modelId, stream: parsed.stream, options: parsed.options, + context: { ...parsed.context, messages: [...parsed.context.messages, { role: "user", content: "once", timestamp: 0 }] } + });`); + const config: OcxConfig = { port: 0, defaultProvider: "fixture", requestTransforms: [path], providers: { + fixture: { adapter: "openai-responses", baseUrl: "https://fixture.test/v1" }, + } }; + const args = { providerName: "fixture", modelId: "model", providerConfig: config.providers.fixture!, config }; + const parsed = parseRequest({ model: "model", input: "first", vendor_option: "retained" }); + parsed._previousResponseInputExpanded = true; + const result = await applyRequestTransforms({ ...args, parsed }); + await applyRequestTransforms({ ...args, parsed: result }); + expect(result._rawBody).toEqual({ model: "model", vendor_option: "retained", input: [ + { role: "user", content: "first" }, { role: "user", content: "once" }, + ] }); + expect(result.context.messages).toHaveLength(2); + expect(result._previousResponseInputExpanded).toBe(true); + }); + + test("no-op transforms leave native input and catalog untouched", () => { + const body = { model: "model", input: [{ type: "item_reference", id: "opaque" }], vendor: { keep: true } }; + const parsed = parseRequest(body); + syncTransformedResponsesBody(structuredClone(parsed), parsed); + expect(parsed._rawBody).toEqual(body); + expect((parsed._rawBody as typeof body).input).toBe(body.input); + }); + + test.each(["openai-responses", "openai-chat"])("%s dispatch uses transformed messages and namespaced tool metadata", async adapter => { + const path = join(testDir, "integration.ts"); + writeFileSync(path, `export default function(parsed) { + parsed.context.messages.push({ role: "user", content: "hook-message", timestamp: 0 }); + parsed.context.tools = [{ namespace: "changed", name: "lookup", description: "new tool", parameters: { type: "object", properties: {} } }]; + }`); + const config: OcxConfig = { + port: 0, defaultProvider: "fixture", requestTransforms: [path], + providers: { fixture: { adapter, baseUrl: "https://fixture.test/v1", apiKey: "fixture", models: ["test-model"] } }, + }; + const originalFetch = globalThis.fetch; + const outbound: Record[] = []; + globalThis.fetch = (async (_url, init) => { + outbound.push(JSON.parse(String(init?.body))); + return Response.json(adapter === "openai-responses" ? { + id: "resp_transform", status: "completed", output: [{ type: "function_call", id: "fc_transform", call_id: "call_transform", name: "changed__lookup", arguments: "{}", status: "completed" }], + } : { + id: "chat_transform", choices: [{ index: 0, finish_reason: "tool_calls", message: { role: "assistant", content: null, + tool_calls: [{ id: "call_transform", type: "function", function: { name: "changed__lookup", arguments: "{}" } }] } }], + }); + }) as typeof fetch; + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "fixture/test-model", stream: false, input: "original", vendor_option: { keep: true }, + tools: [{ type: "function", name: "old", parameters: { type: "object" } }] }), + }), config, { model: "", provider: "" }); + const result = await response.json() as { output: Array> }; + expect(response.status).toBe(200); + expect(outbound).toHaveLength(1); + expect(JSON.stringify(outbound[0])).toContain("hook-message"); + expect(JSON.stringify(outbound[0]!.tools)).toContain("changed__lookup"); + expect(JSON.stringify(outbound[0]!.tools)).not.toContain('"old"'); + if (adapter === "openai-responses") expect(outbound[0]!.vendor_option).toEqual({ keep: true }); + expect(result.output).toContainEqual(expect.objectContaining({ type: "function_call", namespace: "changed", name: "lookup" })); + } finally { + globalThis.fetch = originalFetch; + } + }); + test("applyRequestTransforms runs global and provider transforms and marks applied", async () => { const transform1Path = join(testDir, "t1.ts"); const transform2Path = join(testDir, "t2.ts"); From 3e0439cfe618fa0713806e7ff20b0ae03b0d4900 Mon Sep 17 00:00:00 2001 From: Mauro Date: Mon, 7 Sep 2026 11:46:49 +0200 Subject: [PATCH 7/8] fix(transforms): retain metadata on repeated native messages --- src/transforms/responses-body.ts | 14 ++++++++++++-- tests/usage/request-transforms.test.ts | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/transforms/responses-body.ts b/src/transforms/responses-body.ts index e42e9198ef..557bff431d 100644 --- a/src/transforms/responses-body.ts +++ b/src/transforms/responses-body.ts @@ -88,7 +88,11 @@ function transformedInput(raw: Row, before: OcxParsedRequest, after: OcxParsedRe pending = []; } const result: unknown[] = []; - const transformedKeys = new Set(transformed.map(row => JSON.stringify([row]))); + const remainingMatches = new Map(); + for (const row of transformed) { + const key = JSON.stringify([row]); + remainingMatches.set(key, (remainingMatches.get(key) ?? 0) + 1); + } const lengths = [...new Set([...pools.keys()].map(key => (JSON.parse(key) as unknown[]).length))].sort((a, b) => b - a); for (let index = 0; index < transformed.length;) { let matched = false; @@ -96,6 +100,10 @@ function transformedInput(raw: Row, before: OcxParsedRequest, after: OcxParsedRe const retained = pools.get(JSON.stringify(transformed.slice(index, index + length)))?.shift(); if (!retained) continue; result.push(...retained.prefix, ...retained.rows); + for (const row of transformed.slice(index, index + length)) { + const key = JSON.stringify([row]); + remainingMatches.set(key, (remainingMatches.get(key) ?? 0) - 1); + } index += length; matched = true; break; @@ -105,10 +113,12 @@ function transformedInput(raw: Row, before: OcxParsedRequest, after: OcxParsedRe const next = transformed[index]!; const oldKey = JSON.stringify([old]); const reusable = old && old.role === next.role && old.type === next.type - && !transformedKeys.has(oldKey) + && (pools.get(oldKey)?.length ?? 0) > (remainingMatches.get(oldKey) ?? 0) ? pools.get(oldKey)?.shift() : undefined; if (reusable) result.push(...reusable.prefix, ...(reusable.rows.length === 1 ? [overlay(reusable.rows[0], old, next)] : [next])); else result.push(next); + const nextKey = JSON.stringify([next]); + remainingMatches.set(nextKey, (remainingMatches.get(nextKey) ?? 0) - 1); index++; } } diff --git a/tests/usage/request-transforms.test.ts b/tests/usage/request-transforms.test.ts index d97cf95d9b..a27dd7e479 100644 --- a/tests/usage/request-transforms.test.ts +++ b/tests/usage/request-transforms.test.ts @@ -131,6 +131,20 @@ describe("requestTransforms", () => { expect((parsed._rawBody as typeof body).input).toBe(body.input); }); + test("editing one repeated message keeps each native message's own metadata", () => { + const body = { model: "model", input: [ + { role: "user", content: "continue", vendor_id: "first" }, + { role: "user", content: "continue", vendor_id: "second" }, + ] }; + const parsed = parseRequest(body); + const before = structuredClone(parsed); + parsed.context.messages[0]!.content = "changed"; + syncTransformedResponsesBody(before, parsed); + expect((parsed._rawBody as typeof body).input).toEqual([ + { role: "user", content: "changed", vendor_id: "first" }, body.input[1], + ]); + }); + test.each(["openai-responses", "openai-chat"])("%s dispatch uses transformed messages and namespaced tool metadata", async adapter => { const path = join(testDir, "integration.ts"); writeFileSync(path, `export default function(parsed) { From 97cf9f9613acc020ecc9efb5319b31fcef594062 Mon Sep 17 00:00:00 2001 From: Mauro Date: Thu, 10 Sep 2026 11:27:59 +0200 Subject: [PATCH 8/8] fix(transforms): isolate hooks and restrict configuration to local files --- .../content/docs/reference/configuration.md | 12 +- src/config.ts | 4 +- src/server/auth-cors.ts | 22 +-- src/server/management/provider-routes.ts | 15 +- src/server/responses/core.ts | 2 + src/transforms/runner.ts | 64 ++++++--- src/transforms/types.ts | 15 +- src/types/config.ts | 1 + src/types/provider.ts | 2 +- structure/02_config-and-codex-home.md | 5 +- tests/routing/combo-management-api.test.ts | 6 +- tests/server/config.test.ts | 21 +++ .../management-provider-validation.test.ts | 70 +++++++++ tests/usage/request-transforms.test.ts | 135 +++++++++++++++++- 14 files changed, 313 insertions(+), 61 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 37d4f9bde5..3d253fb220 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -33,7 +33,9 @@ uses the fresh-install default: one `openai` forward provider. `requestTransforms` is an opt-in extension hook that runs after routing and before input admission and adapter request construction. It is disabled when the lists are absent or empty. Global handlers -run first, followed by the selected provider's handlers: +run first, followed by the selected provider's handlers. Configure these lists only by editing the +local `config.json` while the proxy is stopped, then restart it. Management API writes cannot add +or change handlers; unrelated provider edits preserve locally configured handlers: ```jsonc { @@ -51,7 +53,8 @@ run first, followed by the selected provider's handlers: A handler exports a default function or a named `transform` function. It receives the normalized request and `{ providerName, modelId, providerConfig, config, acceptsImageInput }`. It may mutate the request in place and return nothing, or return a complete replacement request; async handlers -are supported. Model-specific behavior belongs inside the handler, using `modelId`: +are supported. The configuration objects in the handler context are deeply read-only snapshots; +only the request is mutable. Model-specific behavior belongs inside the handler, using `modelId`: ```ts export default function transform(parsed, { modelId, acceptsImageInput }) { @@ -63,8 +66,9 @@ export default function transform(parsed, { modelId, acceptsImageInput }) { Paths resolve against `OPENCODEX_HOME` first, then the working directory; absolute paths and module package specifiers are also supported. Handlers execute as trusted code with the proxy process's permissions and access to its configuration. Only configure code you trust. Imports are cached; -restart the proxy after changing a handler. Load and execution failures warn and processing continues; -in-place mutations made before a thrown error are not rolled back. +restart the proxy after changing a handler. Load, execution, validation and native synchronization +failures warn and processing continues with the last valid request. Failed handlers' request +mutations are discarded; external side effects performed by trusted handler code cannot be undone. The returned request is marked to avoid applying the pipeline again when an internal retry reuses that parsed request. A new inbound request runs the pipeline again, even if it replays earlier history; diff --git a/src/config.ts b/src/config.ts index 93f2cd1389..8aee993390 100644 --- a/src/config.ts +++ b/src/config.ts @@ -648,7 +648,7 @@ const providerConfigSchema = z.object({ webSearchBridge: providerWebSearchBridgeSchema.optional().catch(undefined), xaiResponsesXSearch: z.boolean().optional(), xaiResponsesDefaultVersion: z.number().int().positive().optional().catch(undefined), - requestTransforms: z.array(z.string().min(1)) + requestTransforms: z.array(z.string().trim().min(1)) .transform(normalizeNonBlankStringArray) .optional(), }).passthrough(); @@ -1216,7 +1216,7 @@ const configSchema = z.object({ configRebaseProvenance: z.unknown().optional(), // A retry can be billable, so absence and malformed hand edits both stay off. emptyCompletionRetry: z.boolean().optional().catch(false), - requestTransforms: z.array(z.string().min(1)) + requestTransforms: z.array(z.string().trim().min(1)) .transform(normalizeNonBlankStringArray) .optional(), // A malformed hand edit must not silently stop opening the browser: fall back diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 56306c6dda..893d385340 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -578,22 +578,14 @@ function nativeContextOverlayError(raw: Record): string | null * string, or null when the provider may be persisted. Caller-controlled names/fields are * redacted and JSON-escaped so secrets never reach the response. */ -function requestTransformsConfigError(value: unknown, field = "requestTransforms"): string | null { - if (value === undefined) return null; - if (!Array.isArray(value)) return `${field} must be an array`; - for (const [index, entry] of value.entries()) { - if (typeof entry !== "string" || !entry.trim()) { - return `${field}.${index} must be a nonblank string`; - } - } - return null; -} - export function providerManagementConfigError(name: unknown, provider: unknown): string | null { if (typeof name !== "string" || !provider || typeof provider !== "object" || Array.isArray(provider)) { return "provider must be a plain object"; } const raw = provider as Record; + if (Object.hasOwn(raw, "requestTransforms")) { + return "requestTransforms may only be configured in the local config file"; + } const pinsError = providerReasoningPinsConfigError(raw); if (pinsError) return pinsError; for (const field of FORBIDDEN_PROVIDER_RUNTIME_FIELDS) { @@ -634,7 +626,6 @@ export function providerManagementConfigError(name: unknown, provider: unknown): // validation and then rejected by the seed comparison, so canonical OpenAI could never // set OR clear it — the value was admitted and then refused in the same request. delete canonicalCandidate.annotateEmptyToolOutputs; - delete canonicalCandidate.requestTransforms; const canonical = seed && sameCanonicalProviderSeed(canonicalCandidate, seed); if (!canonical) { return `provider ${name} must equal the canonical built-in provider seed`; @@ -721,8 +712,6 @@ export function providerManagementConfigError(name: unknown, provider: unknown): if (structuredOutputOptOutError) return `provider ${name} ${structuredOutputOptOutError}`; const retainModelsError = nonBlankStringArrayConfigError(raw.retainModels, "retainModels"); if (retainModelsError) return `provider ${name} ${retainModelsError}`; - const requestTransformsError = requestTransformsConfigError(raw.requestTransforms); - if (requestTransformsError) return `provider ${name} ${requestTransformsError}`; const toolReasoningOptOutError = nonBlankStringArrayConfigError( raw.omitReasoningEffortWithToolsModels, "omitReasoningEffortWithToolsModels", @@ -781,7 +770,7 @@ export function copyIfDefined( * admission. `satisfies Record` makes a newly added * provider field fail typecheck until it is deliberately classified. * - * `editor` fields are user-authored, `redacted` fields may contain credentials, + * `editor` fields are user-authored, `redacted` fields contain credentials or local-only authority, * and `runtime` fields are observations/limits that must never become editor write * authority. MCP and desktop executor blocks are redacted as a whole because both * contain arbitrary environment variables and/or headers. @@ -875,7 +864,8 @@ const PROVIDER_CONFIG_FIELD_POLICY = { noTopPModels: "editor", noPenaltyModels: "editor", noStructuredOutputModels: "editor", - requestTransforms: "editor", + // Executable local module paths are neither public DTO data nor editor authority. + requestTransforms: "redacted", omitReasoningEffortWithToolsModels: "editor", parallelToolCalls: "editor", pinParallelToolCallsFalse: "editor", diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 1439d7899c..e3dd8ea53b 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -167,10 +167,13 @@ function providerAliasOverlayOwnershipError( return null; } -/** Remove only alias overlays whose ownership has already been established by the caller. */ +/** Project transport fields after establishing ownership of alias and local-only overlays. */ function providerTransportValidationCandidate(provider: Record): Record { const candidate = { ...provider }; for (const field of PROVIDER_ALIAS_OVERLAY_FIELDS) delete candidate[field]; + // This is a transport-only projection of already-owned fields. POST must reject + // client-supplied transforms before using it; PATCH/PUT can only preserve disk values. + delete candidate.requestTransforms; return candidate; } @@ -929,6 +932,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise Object.hasOwn(rawBody, field)); if (aliasField) return jsonResponse({ error: `${aliasField} is managed by the dedicated alias API` }, 400); diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 2859d3f1ab..21512ab59a 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -3864,6 +3864,8 @@ async function handleResponsesInner( providerConfig: route.provider, config, }); + // Replacement transforms change object identity; termination tracking is WeakMap-backed. + bindTurnTerminationScope(parsed, resolvedConversationId); const toolBridgeMaps = buildToolBridgeMaps(parsed, translatorBudget); // Attribute local auth/cooldown failures to the public selector too; exact auth may fail before // the normal post-resolution provider label is assigned. diff --git a/src/transforms/runner.ts b/src/transforms/runner.ts index 6285e81e61..1250d05155 100644 --- a/src/transforms/runner.ts +++ b/src/transforms/runner.ts @@ -1,6 +1,7 @@ import { existsSync } from "node:fs"; import { isAbsolute, resolve } from "node:path"; import { pathToFileURL } from "node:url"; +import { isDeepStrictEqual } from "node:util"; import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types"; import { expandUserPath, getConfigDir } from "../config/paths"; import { isVisionEligibleModel } from "../vision/eligibility"; @@ -18,12 +19,36 @@ function isValidParsedRequest(val: unknown): val is OcxParsedRequest { const candidate = val as Record; return ( typeof candidate.modelId === "string" && + typeof candidate.stream === "boolean" && + candidate.options !== null && + typeof candidate.options === "object" && + !Array.isArray(candidate.options) && candidate.context !== null && typeof candidate.context === "object" && + !Array.isArray(candidate.context) && Array.isArray((candidate.context as Record).messages) ); } +/** Freeze an isolated configuration snapshot, including nested records and arrays. */ +function freezeSnapshot(value: T): T { + if (value !== null && typeof value === "object" && !Object.isFrozen(value)) { + Object.freeze(value); + for (const child of Object.values(value)) freezeSnapshot(child); + } + return value; +} + +/** Isolate hook-editable data without detaching shared proxy-owned replay holders. */ +function transformCandidate(parsed: OcxParsedRequest): OcxParsedRequest { + return { + ...parsed, + context: structuredClone(parsed.context), + options: structuredClone(parsed.options), + _rawBody: structuredClone(parsed._rawBody), + }; +} + /** * Resolve a transform specifier into an absolute file path or module identifier. * Checks against the config directory (~/.opencodex) first, then current working directory. @@ -69,8 +94,8 @@ export async function loadTransform( `[opencodex] request transform "${specifier}" did not export a default function or "transform" function.`, ); return null; - } catch (err) { - console.warn(`[opencodex] failed to load request transform "${specifier}":`, err); + } catch { + console.warn(`[opencodex] failed to load request transform "${specifier}".`); return null; } })(); @@ -116,39 +141,41 @@ export async function applyRequestTransforms(args: { acceptsImageInput = false; } - const context: RequestTransformContext = { + const context: RequestTransformContext = freezeSnapshot(structuredClone({ providerName, modelId, providerConfig, config, acceptsImageInput, - }; + })); const configDir = getConfigDir(); - const before = { ...parsed, context: structuredClone(parsed.context), options: structuredClone(parsed.options) }; let currentParsed = parsed; for (const specifier of specifiers) { const fn = await loadTransform(specifier, configDir); if (!fn) continue; try { - const result = await fn(currentParsed, context); - if (result && typeof result === "object") { - if (isValidParsedRequest(result)) { - // A complete canonical replacement must not discard proxy-owned replay/auth state. - currentParsed = { ...currentParsed, ...result, previousResponseId: result.previousResponseId }; - } else { - console.warn( - `[opencodex] request transform "${specifier}" returned an invalid request object; retaining current request.`, - ); - } + const candidate = transformCandidate(currentParsed); + const result = await fn(candidate, context); + if (!isValidParsedRequest(result === undefined ? candidate : result)) { + console.warn( + `[opencodex] request transform "${specifier}" produced an invalid request; retaining last valid request.`, + ); + continue; } - } catch (err) { - console.warn(`[opencodex] error running request transform "${specifier}":`, err); + // Omitted fields retain their current value; explicit undefined clears them. + // Proxy-owned holders remain shared rather than being structured-cloned. + const next = result === undefined ? candidate : { ...candidate, ...result }; + // Synchronization is part of the transaction: a malformed nested edit may throw here. + syncTransformedResponsesBody(currentParsed, next); + if (isDeepStrictEqual(next._rawBody, currentParsed._rawBody)) next._rawBody = currentParsed._rawBody; + currentParsed = next; + } catch { + console.warn(`[opencodex] request transform "${specifier}" failed; retaining last valid request.`); } } - syncTransformedResponsesBody(before, currentParsed); currentParsed._requestTransformsApplied = true; return currentParsed; } @@ -159,4 +186,3 @@ export async function applyRequestTransforms(args: { export function clearTransformCacheForTests(): void { transformCache.clear(); } - diff --git a/src/transforms/types.ts b/src/transforms/types.ts index b54d467f57..44ff821f9d 100644 --- a/src/transforms/types.ts +++ b/src/transforms/types.ts @@ -1,19 +1,23 @@ import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types"; +export type DeepReadonly = T extends object + ? { readonly [K in keyof T]: DeepReadonly } + : T; + export interface RequestTransformContext { /** The settled provider name (e.g. "anthropic", "google-antigravity", "openai"). */ - providerName: string; + readonly providerName: string; /** The settled model identifier. */ - modelId: string; + readonly modelId: string; /** Effective provider configuration for this route. */ - providerConfig: OcxProviderConfig; + readonly providerConfig: DeepReadonly; /** Global OpenCodeX configuration. */ - config: OcxConfig; + readonly config: DeepReadonly; /** * Whether the target model accepts image input (based on OpenCodeX's vision catalog & metadata). * Allows transforms like pxpipe to selectively convert long text blocks into images only for vision-capable models. */ - acceptsImageInput: boolean; + readonly acceptsImageInput: boolean; } export type RequestTransformFn = ( @@ -25,4 +29,3 @@ export interface RequestTransformModule { default?: RequestTransformFn; transform?: RequestTransformFn; } - diff --git a/src/types/config.ts b/src/types/config.ts index 39804ef93c..6137f67a1f 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -362,6 +362,7 @@ export interface OcxConfig { /** * Optional ordered list of pre-adapter request transform handler paths or package specifiers. * Handlers operate on OcxParsedRequest before the wire request is built by provider adapters. + * Trusted local config-file only; management API writes cannot configure executable handlers. */ requestTransforms?: string[]; /** diff --git a/src/types/provider.ts b/src/types/provider.ts index f628ccee2a..3b532d53bb 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -238,7 +238,7 @@ export interface OcxProviderConfig { defaultAliases?: boolean; /** * Optional provider-scoped request transform handler paths or package specifiers, - * executed after global requestTransforms. + * executed after global requestTransforms. Local config-file only, not management-writable. */ requestTransforms?: string[]; adapter: string; diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index 0cd31282bd..ac12c683e9 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -519,5 +519,6 @@ Client connection metadata stores a stable `apiKeyId` and a non-secret rotation - Specifiers are resolved relative to `OPENCODEX_HOME` (`~/.opencodex`), current working directory, or treated as module specifiers. - Handlers receive `{ providerName, modelId, providerConfig, config, acceptsImageInput }` to facilitate optimizations like `pxpipe` (text-to-image for vision models) and `headroom` (context compression). -- Execution is guarded per turn by `_requestTransformsApplied` so retries and replays do not execute transforms twice. - +- Transform lists are local-file configuration only; management API writes cannot add or change executable handlers. +- Configuration context is a deeply read-only snapshot. Each handler's request changes are committed only after validation and native synchronization succeed; failures retain the last valid request. +- Execution is guarded by `_requestTransformsApplied` for internal retries reusing a parsed request. New inbound requests, including history replays, run the pipeline again. diff --git a/tests/routing/combo-management-api.test.ts b/tests/routing/combo-management-api.test.ts index d15c79cd27..85f6be0ff6 100644 --- a/tests/routing/combo-management-api.test.ts +++ b/tests/routing/combo-management-api.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -150,7 +150,6 @@ async function responseJson(response: Response | null): Promise { - mock.restore(); clearComboSelectionState(); clearComboTargetCooldowns(); }); @@ -832,15 +831,12 @@ describe("combo management API", () => { }); test("GET subagent models exposes a combo alias as an available round-trip value", async () => { - spyOn(await import("../../src/codex/app-server-processes"), "collectCodexAppServerCatalogState") - .mockReturnValue({ state: "not_running", processes: [], catalogMtimeMs: null }); const config = baseConfig({ subagentModels: ["deepseek-v4-flash"], combos: { free: { ...VALID_COMBO, alias: "deepseek-v4-flash" }, }, }); - for (const provider of Object.values(config.providers)) provider.liveModels = false; config.providers.a!.modelContextWindows = { m1: 128_000 }; const response = await comboApi(config, "GET", "/api/subagent-models"); diff --git a/tests/server/config.test.ts b/tests/server/config.test.ts index 1b1699e0b8..ddc20995fc 100644 --- a/tests/server/config.test.ts +++ b/tests/server/config.test.ts @@ -47,6 +47,27 @@ import { providerManagementConfigError } from "../../src/server/auth-cors"; import { removeTreeWithRetry } from "../helpers/remove-tree"; let testDir = ""; +test("requestTransforms paths reject whitespace-only values and trim local paths", () => { + const base = getDefaultConfig(); + const provider = base.providers.openai!; + for (const path of ["", " ", "\t\r\n"]) { + expect(validateConfigCandidate({ ...base, requestTransforms: [path] }).ok).toBe(false); + expect(validateConfigCandidate({ + ...base, providers: { ...base.providers, openai: { ...provider, requestTransforms: [path] } }, + }).ok).toBe(false); + } + const result = validateConfigCandidate({ + ...base, + requestTransforms: [" ./global-transform.ts "], + providers: { ...base.providers, openai: { ...provider, requestTransforms: ["\t./provider-transform.ts\n"] } }, + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.config.requestTransforms).toEqual(["./global-transform.ts"]); + expect(result.config.providers.openai!.requestTransforms).toEqual(["./provider-transform.ts"]); + } +}); + /** * Windows without Developer Mode or admin cannot create a file symlink (EPERM). * Detect once so the dotfiles cases below report a visible skip there rather than diff --git a/tests/server/management-provider-validation.test.ts b/tests/server/management-provider-validation.test.ts index 09bfb1e67c..a5cef76fb6 100644 --- a/tests/server/management-provider-validation.test.ts +++ b/tests/server/management-provider-validation.test.ts @@ -137,6 +137,75 @@ afterEach(() => { }); describe("provider management validation", () => { + test("requestTransforms remain local-only across management writes and provider copies", async () => { + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const live: OcxConfig = { + port: 0, + defaultProvider: "custom", + requestTransforms: ["./trusted-global.ts"], + providers: { + custom: { + adapter: "openai-chat", + baseUrl: "https://api.example.test/v1", + liveModels: false, + requestTransforms: ["./trusted-provider.ts"], + }, + }, + }; + saveConfig(live); + const request = async (path: string, method: string, body: unknown) => { + const url = new URL(path, "http://127.0.0.1"); + return (await handleManagementAPI(new Request(url, { + method, headers: { "content-type": "application/json" }, body: JSON.stringify(body), + }), url, live, { createManagementConvergeCodex: catalogConvergenceFactory() }))!; + }; + const resolved = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + try { + expect(providerEditorConfigDTO(live).providers.custom).not.toHaveProperty("requestTransforms"); + expect((safeConfigDTO(live) as { providers: Record }).providers.custom) + .not.toHaveProperty("requestTransforms"); + for (const requestTransforms of [["./remote.ts"], ["./trusted-provider.ts"], [], null]) { + expect(providerManagementConfigError("custom", { ...live.providers.custom, requestTransforms })) + .toContain("local config file"); + const before = readFileSync(join(TEST_DIR, "config.json")); + for (const name of ["custom", "imported"]) { + const response = await request("/api/providers", "POST", { + name, provider: { ...live.providers.custom, requestTransforms }, + }); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: expect.stringContaining("local config file") }); + } + expect((await request("/api/providers?name=custom", "PATCH", { requestTransforms })).status).toBe(400); + const baseline = providerEditorConfigDTO(loadConfig()); + const next = structuredClone(baseline); + next.providers.custom!.requestTransforms = requestTransforms; + expect((await request("/api/providers", "PUT", { baseline, next })).status).toBe(400); + expect(readFileSync(join(TEST_DIR, "config.json"))).toEqual(before); + } + // Whole-config import is disabled; generic settings cannot install global modules. + expect((await request("/api/config", "PUT", { ...live, requestTransforms: ["./remote.ts"] })).status).toBe(405); + expect((await request("/api/settings", "PUT", { requestTransforms: ["./remote.ts"] })).status).toBe(400); + + expect((await request("/api/providers", "POST", { + name: "custom", provider: providerEditorConfigDTO(live).providers.custom, + })).status).toBe(200); + expect((await request("/api/providers?name=custom", "PATCH", { defaultModel: "updated" })).status).toBe(200); + const baseline = providerEditorConfigDTO(loadConfig()); + const next = structuredClone(baseline); + next.providers.custom!.defaultModel = "edited"; + next.providers.copy = structuredClone(next.providers.custom!); + expect((await request("/api/providers", "PUT", { baseline, next })).status).toBe(200); + for (const snapshot of [live, loadConfig()]) { + expect(snapshot.requestTransforms).toEqual(["./trusted-global.ts"]); + expect(snapshot.providers.custom!.requestTransforms).toEqual(["./trusted-provider.ts"]); + expect(snapshot.providers.copy!.requestTransforms).toBeUndefined(); + } + } finally { + resolved.mockRestore(); + } + }); + test("provider reload adopts only the validated disk row without rewriting config", async () => { if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); mkdirSync(TEST_DIR, { recursive: true }); @@ -164,6 +233,7 @@ describe("provider management validation", () => { ...diskConfig.providers.xai!, apiKey: "new-disk-key", headers: { "x-operator-header": "operator-owned" }, + requestTransforms: ["./trusted-local.ts"], }; saveConfig(diskConfig); const diskBefore = readFileSync(join(TEST_DIR, "config.json")); diff --git a/tests/usage/request-transforms.test.ts b/tests/usage/request-transforms.test.ts index a27dd7e479..72e297f306 100644 --- a/tests/usage/request-transforms.test.ts +++ b/tests/usage/request-transforms.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { applyRequestTransforms, clearTransformCacheForTests, resolveTransformPath } from "../../src/transforms"; @@ -11,6 +11,8 @@ import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../../src/t import { parseRequest } from "../../src/responses/parser"; import { handleResponses } from "../../src/server/responses"; import { syncTransformedResponsesBody } from "../../src/transforms/responses-body"; +import type { RequestTransformContext } from "../../src/transforms/types"; +import { repoPath } from "../helpers/repo-root"; describe("requestTransforms", () => { let testDir: string; @@ -123,6 +125,128 @@ describe("requestTransforms", () => { expect(result._previousResponseInputExpanded).toBe(true); }); + test.each([false, true])("replacement preserves omitted previous response ID, explicit clear=%s", async clear => { + const path = join(testDir, "previous-id.ts"); + writeFileSync(path, `export default parsed => ({ + modelId: parsed.modelId, stream: parsed.stream, context: parsed.context, options: parsed.options, + ${clear ? "previousResponseId: undefined," : ""} + });`); + const providerConfig: OcxProviderConfig = { adapter: "openai-responses", baseUrl: "https://fixture.test/v1" }; + const config: OcxConfig = { port: 0, defaultProvider: "fixture", requestTransforms: [path], providers: { fixture: providerConfig } }; + const parsed = parseRequest({ model: "model", input: "next", previous_response_id: "resp_previous" }); + const scope = { clientThreadId: "fixture-thread" }; + parsed._reasoningReplayScope = scope; + const result = await applyRequestTransforms({ parsed, providerName: "fixture", modelId: "model", providerConfig, config }); + expect(result.previousResponseId).toBe(clear ? undefined : "resp_previous"); + expect((result._rawBody as Record).previous_response_id).toBe(clear ? undefined : "resp_previous"); + expect(result._reasoningReplayScope).toBe(scope); + }); + + test.each([ + "parsed.context.messages = null;", + "parsed.options = null; return parsed;", + "return {};", + "throw new Error('private-request-fixture');", + "return Promise.reject(new Error('private-request-fixture'));", + // Passes the shallow shape check, but cannot be serialized as assistant content. + "parsed.context.messages.push({ role: 'assistant', content: null, timestamp: 0 });", + ])("failed hook rolls back edits and later hooks receive the last valid request: %s", async failure => { + const paths = ["first", "failed", "last"].map(name => join(testDir, `${name}.ts`)); + writeFileSync(paths[0]!, `export default parsed => { parsed.context.messages.push({ role: "user", content: "first", timestamp: 0 }); };`); + writeFileSync(paths[1]!, `export default parsed => { + parsed.context.messages[0].content = "bad"; + parsed.options.temperature = 99; + parsed._rawBody.vendor.keep = false; + ${failure} + };`); + writeFileSync(paths[2]!, `export default parsed => { parsed.context.messages.push({ role: "user", content: "last", timestamp: 0 }); };`); + const providerConfig: OcxProviderConfig = { adapter: "openai-responses", baseUrl: "https://fixture.test/v1" }; + const config: OcxConfig = { port: 0, defaultProvider: "fixture", requestTransforms: paths, providers: { fixture: providerConfig } }; + const parsed = parseRequest({ model: "model", input: "original", temperature: 0.5, vendor: { keep: true } }); + const scope = { clientThreadId: "fixture-thread" }; + parsed._reasoningReplayScope = scope; + const originalWarn = console.warn; + const warnings: unknown[][] = []; + console.warn = (...args: unknown[]) => { warnings.push(args); }; + try { + const result = await applyRequestTransforms({ parsed, providerName: "fixture", modelId: "model", providerConfig, config }); + expect(result.context.messages.map(message => message.content)).toEqual(["original", "first", "last"]); + expect(result.options.temperature).toBe(0.5); + expect(result._rawBody).toEqual({ model: "model", temperature: 0.5, vendor: { keep: true }, input: [ + { role: "user", content: "original" }, { role: "user", content: "first" }, { role: "user", content: "last" }, + ] }); + expect(result._reasoningReplayScope).toBe(scope); + expect(result._requestTransformsApplied).toBe(true); + expect(warnings).toHaveLength(1); + expect(JSON.stringify(warnings)).not.toContain("private-request-fixture"); + } finally { + console.warn = originalWarn; + } + }); + + test("transform context is deeply readonly and cannot mutate live or later-hook configuration", async () => { + // Compile-time contract: callers cannot write through either configuration view. + const assertReadonly = (context: RequestTransformContext) => { + // @ts-expect-error nested global configuration is readonly + context.config.providers.fixture.baseUrl = "changed"; + // @ts-expect-error nested effective provider configuration is readonly + context.providerConfig.headers.fixture = "changed"; + // @ts-expect-error route metadata is readonly + context.providerName = "changed"; + }; + void assertReadonly; + const paths = ["mutate-config", "observe-config"].map(name => join(testDir, `${name}.ts`)); + writeFileSync(paths[0]!, `export default (parsed, context) => { + for (const mutate of [ + () => { context.config.providers.fixture.baseUrl = "changed"; }, + () => { context.providerConfig.headers.fixture = "changed"; }, + () => { context.config.requestTransforms.push("changed"); }, + () => { context.providerName = "changed"; }, + ]) { try { mutate(); } catch (error) { if (!(error instanceof TypeError)) throw error; } } + };`); + writeFileSync(paths[1]!, `export default (parsed, context) => { + parsed.context.messages.push({ role: "user", timestamp: 0, content: JSON.stringify({ + url: context.config.providers.fixture.baseUrl, header: context.providerConfig.headers.fixture, + count: context.config.requestTransforms.length, provider: context.providerName, + frozen: [context, context.config, context.config.providers.fixture, context.providerConfig.headers, context.config.requestTransforms].every(Object.isFrozen) + }) }); + };`); + const providerConfig: OcxProviderConfig = { adapter: "openai-responses", baseUrl: "https://fixture.test/v1", headers: { fixture: "original" } }; + const config: OcxConfig = { port: 0, defaultProvider: "fixture", requestTransforms: paths, providers: { fixture: providerConfig } }; + const before = structuredClone(config); + const result = await applyRequestTransforms({ parsed: parseRequest({ model: "model", input: "original" }), providerName: "fixture", modelId: "model", providerConfig, config }); + expect(JSON.parse(result.context.messages[1]!.content as string)).toEqual({ + url: "https://fixture.test/v1", header: "original", count: 2, provider: "fixture", frozen: true, + }); + expect(config).toEqual(before); + expect(Object.isFrozen(config)).toBe(false); + expect(Object.isFrozen(providerConfig.headers)).toBe(false); + }); + + test("replacement requests are rebound to the settled turn termination scope", () => { + const source = readFileSync(repoPath("src/server/responses/core.ts"), "utf8"); + const start = source.indexOf("parsed = await applyRequestTransforms({"); + const end = source.indexOf("const toolBridgeMaps =", start); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + expect(source.slice(start, end)).toContain("bindTurnTerminationScope(parsed, resolvedConversationId)"); + }); + + test("editing a large continuation preserves every other native row and its metadata", () => { + const rows = Array.from({ length: 1000 }, (_, index) => [ + { role: "user", content: `question ${index}`, vendor_id: `user-${index}` }, + { role: "assistant", content: [{ type: "output_text", text: `answer ${index}`, annotations: [{ type: "fixture", index }] }], vendor_id: `assistant-${index}` }, + ]).flat(); + const body = { model: "model", input: rows, previous_response_id: "resp_history" }; + const parsed = parseRequest(body); + const before = structuredClone(parsed); + parsed.context.messages[0]!.content = "edited question"; + syncTransformedResponsesBody(before, parsed); + expect(parsed._rawBody).toEqual({ ...body, input: [{ ...rows[0], content: "edited question" }, ...rows.slice(1)] }); + expect(body.input).toEqual(rows); + expect(body.input[0]!.content).toBe("question 0"); + }); + test("no-op transforms leave native input and catalog untouched", () => { const body = { model: "model", input: [{ type: "item_reference", id: "opaque" }], vendor: { keep: true } }; const parsed = parseRequest(body); @@ -245,7 +369,7 @@ describe("requestTransforms", () => { expect(result._requestTransformsApplied).toBe(true); expect(result.context.messages.length).toBe(2); expect((result.context.messages[0] as any).content).toContain("transformed-by-t1 (google-antigravity:gemini-3.1-pro)"); - expect((result.context.messages[1] as any).content).toContain("transformed-by-t2"); + expect((result.context.messages[1] as any).content).toBe("transformed-by-t2 (acceptsImage:true)"); // Running again does not duplicate executions (single run per turn) await applyRequestTransforms({ @@ -343,16 +467,17 @@ describe("requestTransforms", () => { } }); - test("providerManagementConfigError validates canonical openai with requestTransforms", () => { + test("providerManagementConfigError rejects executable transform configuration even for canonical openai", () => { const entry = getProviderRegistryEntry("openai"); if (!entry) return; const seed = providerConfigSeed(entry); const validCandidate = { ...seed, codexAccountMode: "pool" as const, requestTransforms: ["./custom.ts"] }; - expect(providerManagementConfigError("openai", validCandidate)).toBeNull(); + expect(providerManagementConfigError("openai", validCandidate)) + .toBe("requestTransforms may only be configured in the local config file"); const invalidCandidate = { ...seed, codexAccountMode: "pool" as const, requestTransforms: [""] }; expect(providerManagementConfigError("openai", invalidCandidate)) - .toBe("provider openai requestTransforms.0 must be a nonblank string"); + .toBe("requestTransforms may only be configured in the local config file"); }); });