Skip to content
Merged
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
50 changes: 50 additions & 0 deletions src/adapters/agentrouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
export const AGENTROUTER_LANGUAGE_PREAMBLE =
"[Instruction: Process the user request below and respond in the appropriate language.]";

/** Match only AgentRouter itself or one of its subdomains, never a lookalike hostname. */
export function isAgentRouterEndpoint(baseUrl: string): boolean {
try {
const { hostname } = new URL(baseUrl);
return hostname === "agentrouter.org" || hostname.endsWith(".agentrouter.org");
} catch {
return false;
}
}

/** Supply AgentRouter's stable Codex admission identity unless the operator set one. */
export function agentRouterDefaultHeaders(
baseUrl: string,
configuredHeaders?: Record<string, string>,
): Record<string, string> {
if (!isAgentRouterEndpoint(baseUrl)) return {};
const hasOriginator = Object.keys(configuredHeaders ?? {}).some(name => name.toLowerCase() === "originator");
return hasOriginator ? {} : { originator: "codex_cli_rs" };
}

/** Prepend the compatibility marker as a distinct block on the first user turn. */
export function applyAgentRouterLanguageFraming(messages: unknown[]): void {
const firstUser = messages.find(
(message): message is { role: string; content: unknown } =>
typeof message === "object" && message !== null && (message as { role?: unknown }).role === "user",
);
if (!firstUser) return;
const preamble = { type: "text", text: AGENTROUTER_LANGUAGE_PREAMBLE };
if (typeof firstUser.content === "string") {
firstUser.content = firstUser.content === ""
? [preamble]
: [preamble, { type: "text", text: firstUser.content }];
return;
}
if (!Array.isArray(firstUser.content)) return;
const [head] = firstUser.content as { type?: unknown; text?: unknown }[];
if (head?.type === "text" && head.text === AGENTROUTER_LANGUAGE_PREAMBLE) return;
(firstUser.content as unknown[]).unshift(preamble);
}

/** Frame only an owned copy so translated and passthrough callers remain unchanged. */
export function frameAgentRouterMessages(baseUrl: string, messages: unknown): unknown {
if (!isAgentRouterEndpoint(baseUrl) || !Array.isArray(messages)) return messages;
const copy = structuredClone(messages) as unknown[];
applyAgentRouterLanguageFraming(copy);
return copy;
}
52 changes: 1 addition & 51 deletions src/adapters/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge";
import { decodeServerSentEvents } from "../lib/sse-decoder";
import { isTranslatorBudgetExceededError, retainTranslatedEventBatch, type TranslatorBudget } from "../lib/translator-budget";
import { isReasoningEffortOmitted, modelRecordValue } from "../reasoning-effort";
import { applyAgentRouterLanguageFraming, isAgentRouterEndpoint } from "./agentrouter";

/** Map a user content part to an Anthropic content block (text or image source). */
function toAnthropicContentPart(p: OcxContentPart): unknown {
Expand Down Expand Up @@ -647,57 +648,6 @@ function orphanToolResultText(msg: OcxToolResultMessage): string {
* user content, so an Anthropic `system` string cannot reach it — the framing has to sit in the
* first user turn.
*/
const AGENTROUTER_LANGUAGE_PREAMBLE =
"[Instruction: Process the user request below and respond in the appropriate language.]";

/**
* Exact host match, not a substring.
*
* A `hostname.includes("agentrouter")` test also matches `notagentrouter.example` and
* `agentrouter.org.attacker.example`, which would let an unrelated destination silently
* receive an injected instruction block. A prompt mutation keyed on a provider's identity
* must be keyed on that identity exactly.
*/
function isAgentRouterEndpoint(baseUrl: string): boolean {
try {
const { hostname } = new URL(baseUrl);
return hostname === "agentrouter.org" || hostname.endsWith(".agentrouter.org");
} catch {
return false;
}
}

/**
* Prepend the framing as its OWN text block instead of splicing it into the user's string.
*
* The distinction matters: rewriting `content` to `${marker}\n\n${original}` edits what the
* user wrote, and every downstream consumer — logs, retries, an upstream that echoes the turn —
* then sees a sentence the user never typed as if they had. A separate leading block carries the
* same signal to the filter while the original text survives byte-for-byte.
*
* Only the first user turn is framed, because only the first is what the gateway rejects.
*/
function applyAgentRouterLanguageFraming(messages: unknown[]): void {
const firstUser = messages.find(
(m): m is { role: string; content: unknown } =>
typeof m === "object" && m !== null && (m as { role?: unknown }).role === "user",
);
if (!firstUser) return;
const preamble = { type: "text", text: AGENTROUTER_LANGUAGE_PREAMBLE };
if (typeof firstUser.content === "string") {
firstUser.content = firstUser.content === ""
? [preamble]
: [preamble, { type: "text", text: firstUser.content }];
return;
}
if (!Array.isArray(firstUser.content)) return;
// Idempotence is keyed on the LEADING block being exactly the marker. A substring test would
// let a user who quotes the marker later in their own prompt suppress the framing entirely.
const [head] = firstUser.content as { type?: unknown; text?: unknown }[];
if (head?.type === "text" && head.text === AGENTROUTER_LANGUAGE_PREAMBLE) return;
(firstUser.content as unknown[]).unshift(preamble);
}

function messagesToAnthropicFormat(
parsed: OcxParsedRequest,
toolNames: { toWire: (name: string) => string },
Expand Down
10 changes: 7 additions & 3 deletions src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
} from "../providers/fastwire";
import { openaiChatCompletionsUrl } from "./openai-chat-url";
import { stripResponsesOnlyEncryptedMarker } from "./responses-tool-schema";
import { agentRouterDefaultHeaders, frameAgentRouterMessages } from "./agentrouter";
import {
isXaiSchemaTarget,
lookupLocalJsonPointer,
Expand Down Expand Up @@ -87,7 +88,10 @@ function openAIChatTransport(provider: OcxProviderConfig): {
if ((provider.authMode === "key" || provider.authMode === "oauth") && !provider.keyOptional && !hasCredential) {
throw new Error(`${provider.adapter} requires a non-empty credential (authMode: ${provider.authMode})`);
}
const headers: Record<string, string> = { "Content-Type": "application/json" };
const headers: Record<string, string> = {
"Content-Type": "application/json",
...agentRouterDefaultHeaders(provider.baseUrl, provider.headers),
};
if (hasCredential) headers.Authorization = `Bearer ${provider.apiKey}`;
if (provider.headers) Object.assign(headers, provider.headers);
return { url: openaiChatCompletionsUrl(provider.baseUrl), headers, hasCredential };
Expand All @@ -111,7 +115,7 @@ export function buildOpenAIChatPassthroughRequest(

const body: Record<string, unknown> = {
model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(modelId) : modelId,
messages: rawBody.messages,
messages: frameAgentRouterMessages(provider.baseUrl, rawBody.messages),
stream,
};
for (const field of CHAT_PASSTHROUGH_FIELDS) {
Expand Down Expand Up @@ -1379,7 +1383,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd

buildRequest(parsed: OcxParsedRequest) {
const { url, headers, hasCredential } = openAIChatTransport(provider);
const messages = messagesToChatFormat(parsed, provider);
const messages = frameAgentRouterMessages(provider.baseUrl, messagesToChatFormat(parsed, provider));
const tools = toolsToChatFormatForProvider(parsed, provider);
const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools, provider);

Expand Down
47 changes: 47 additions & 0 deletions tests/openai-chat-hardening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,53 @@ afterEach(() => {
else process.env.OCX_DEBUG = previousDebug;
});

describe("AgentRouter openai-chat compatibility", () => {
const preamble = "[Instruction: Process the user request below and respond in the appropriate language.]";

test("adds a stable Codex originator while preserving operator header precedence", () => {
const automatic = createOpenAIChatAdapter(provider({ baseUrl: "https://agentrouter.org/v1" })).buildRequest(parsed());
expect(automatic.headers.originator).toBe("codex_cli_rs");

const overridden = createOpenAIChatAdapter(provider({
baseUrl: "https://agentrouter.org/v1",
headers: { Originator: "operator-client" },
})).buildRequest(parsed());
expect(overridden.headers.Originator).toBe("operator-client");
expect(overridden.headers.originator).toBeUndefined();
});

test.each([
"https://notagentrouter.example/v1",
"https://agentrouter.org.attacker.example/v1",
])("does not add compatibility behavior to a lookalike host: %s", baseUrl => {
const request = createOpenAIChatAdapter(provider({ baseUrl })).buildRequest(parsed());
expect(request.headers.originator).toBeUndefined();
expect(request.body).not.toContain(preamble);
});

test("frames translated chat without changing the original parsed request", () => {
const source = parsed();
source.context.messages[0]!.content = "responda somente: OK";
const request = createOpenAIChatAdapter(provider({ baseUrl: "https://agentrouter.org/v1" })).buildRequest(source);
const body = JSON.parse(request.body as string) as { messages: { content: { text: string }[] }[] };
expect(body.messages[0]?.content.map(part => part.text)).toEqual([preamble, "responda somente: OK"]);
expect(source.context.messages[0]?.content).toBe("responda somente: OK");
});

test("frames passthrough chat without mutating the caller body", () => {
const rawBody = { messages: [{ role: "user", content: "responda somente: OK" }] };
const request = buildOpenAIChatPassthroughRequest(
provider({ baseUrl: "https://agentrouter.org/v1" }),
rawBody,
"test-model",
false,
);
const body = JSON.parse(request.body as string) as { messages: { content: { text: string }[] }[] };
expect(body.messages[0]?.content.map(part => part.text)).toEqual([preamble, "responda somente: OK"]);
expect(rawBody.messages[0]?.content).toBe("responda somente: OK");
});
});

function parsed(): OcxParsedRequest {
return {
modelId: "test-model",
Expand Down
Loading