From 8794a54d0660b720c53f54c7ffa22563fd78b0b1 Mon Sep 17 00:00:00 2001 From: Mauro Date: Fri, 4 Sep 2026 13:38:41 +0200 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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/7] 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/7] 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/7] 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) {