Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions docs-site/src/content/docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,55 @@ 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. 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
{
"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. 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 }) {
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, 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;
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.
Expand Down
6 changes: 6 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,9 @@ 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().trim().min(1))
.transform(normalizeNonBlankStringArray)
.optional(),
}).passthrough();

export { isValidProviderName, hasOwnProvider } from "./config/provider-name";
Expand Down Expand Up @@ -1213,6 +1216,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().trim().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),
Expand Down
7 changes: 6 additions & 1 deletion src/server/auth-cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,9 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
return "provider must be a plain object";
}
const raw = provider as Record<string, unknown>;
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) {
Expand Down Expand Up @@ -767,7 +770,7 @@ export function copyIfDefined<K extends keyof OcxProviderConfig>(
* admission. `satisfies Record<keyof OcxProviderConfig, ...>` 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.
Expand Down Expand Up @@ -861,6 +864,8 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
noTopPModels: "editor",
noPenaltyModels: "editor",
noStructuredOutputModels: "editor",
// Executable local module paths are neither public DTO data nor editor authority.
requestTransforms: "redacted",
omitReasoningEffortWithToolsModels: "editor",
parallelToolCalls: "editor",
pinParallelToolCallsFalse: "editor",
Expand Down
15 changes: 14 additions & 1 deletion src/server/management/provider-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): Record<string, unknown> {
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;
}

Expand Down Expand Up @@ -929,6 +932,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
const name = typeof body.name === "string" ? body.name.trim() : "";
if (!isPlainRecord(body.provider)) return jsonResponse({ error: "provider must be a plain object" }, 400);
if (Object.hasOwn(body.provider, "requestTransforms")) {
return jsonResponse({ error: "requestTransforms may only be configured in the local config file" }, 400);
}
const existing = config.providers[name];
const aliasOwnershipError = providerAliasOverlayOwnershipError(body.provider, existing);
if (aliasOwnershipError) return jsonResponse({ error: aliasOwnershipError }, 400);
Expand Down Expand Up @@ -1050,6 +1056,10 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
// completed during that wait remains authoritative instead of being overwritten by the
// older ownership snapshot used to admit this POST.
restorePersistedAliasOverlays(prov, config.providers[name]);
// A full remote edit cannot install, replace, or erase locally trusted modules.
// Bind preservation to this exact provider name; a copied/new row gets no authority.
const existingTransforms = config.providers[name]?.requestTransforms;
if (existingTransforms !== undefined) prov.requestTransforms = [...existingTransforms];
// The add/edit form omits wire choices. Read after DNS so a concurrent switch
// remains authoritative, including the marker that protects it on the next boot.
if (name === "xai") {
Expand Down Expand Up @@ -1121,6 +1131,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
let rawBody: unknown;
try { rawBody = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
if (!isPlainRecord(rawBody)) return jsonResponse({ error: "provider patch body must be a plain object" }, 400);
if (Object.hasOwn(rawBody, "requestTransforms")) {
return jsonResponse({ error: "requestTransforms may only be configured in the local config file" }, 400);
}
const keys = Object.keys(rawBody);
const aliasField = PROVIDER_ALIAS_OVERLAY_FIELDS.find(field => Object.hasOwn(rawBody, field));
if (aliasField) return jsonResponse({ error: `${aliasField} is managed by the dedicated alias API` }, 400);
Expand Down
13 changes: 11 additions & 2 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -3378,7 +3379,6 @@ async function handleResponsesInner(
}

let parsed: OcxParsedRequest;
let toolBridgeMaps: ReturnType<typeof buildToolBridgeMaps>;
try {
parsed = parseRequest(body);
parsed._promptCacheKeyIsSharedCohort = options.promptCacheKeyIsSharedCohort;
Expand Down Expand Up @@ -3407,7 +3407,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
Expand Down Expand Up @@ -3858,6 +3857,16 @@ async function handleResponsesInner(
inboundWire,
inboundTransport: options.inboundTransport,
});
parsed = await applyRequestTransforms({
Comment thread
coderabbitai[bot] marked this conversation as resolved.
parsed,
providerName: route.providerName,
modelId: route.modelId,
providerConfig: route.provider,
config,
});
Comment thread
drakonkat marked this conversation as resolved.
// 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.
if (route.codexAccountNamespace) {
Expand Down
3 changes: 3 additions & 0 deletions src/transforms/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./types";
export * from "./runner";

Loading
Loading