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
29 changes: 14 additions & 15 deletions src/providers/proxies/codex-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,15 @@ export const mergeHeaderValue = (
headers.set(name, values.join(","));
};

const hasInstructionContext = (body: JsonObject): boolean =>
Boolean(trimString(body.instructions)) ||
(Array.isArray(body.input) &&
body.input.some(
(item) =>
isObjectRecord(item) &&
(item.role === "system" || item.role === "developer")
));

export const transformCodexBodyJson = (
bodyJson: unknown,
promptCacheKey?: string | null
Expand All @@ -97,30 +106,20 @@ export const transformCodexBodyJson = (
return null;
}

// ChatGPT rejects output limits and requires instructions and stateless storage.
// ChatGPT rejects output limits and requires stateless storage.
// https://github.com/anomalyco/opencode/blob/d9987ef9c896383eb47b9103f6c93fa772a44b83/packages/core/src/plugin/provider/openai.ts#L311-L317
const {
max_completion_tokens: _maxCompletionTokens,
max_output_tokens: _maxOutputTokens,
...body
} = bodyJson;

const incomingInstructions = trimString(body.instructions);
const input = Array.isArray(body.input) ? body.input : [];
const firstInput = input[0];
const promotedInstructions =
!incomingInstructions &&
isObjectRecord(firstInput) &&
(firstInput.role === "system" || firstInput.role === "developer")
? trimString(firstInput.content)
: "";
const instructions =
incomingInstructions || promotedInstructions || CODEX_DEFAULT_INSTRUCTIONS;

return {
...body,
...(promotedInstructions ? { input: input.slice(1) } : {}),
instructions,
// Requests without any instruction context get the bundled defaults.
...(hasInstructionContext(body)
? {}
: { instructions: CODEX_DEFAULT_INSTRUCTIONS }),
store: false,
...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}),
};
Expand Down
20 changes: 14 additions & 6 deletions src/usage/prompt-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,11 +130,10 @@ const firstItem = (
include: (item: unknown) => boolean = () => true
): unknown => (Array.isArray(items) ? items.find(include) : items);

const isCodexTask = (item: unknown): boolean =>
!(
isObjectRecord(item) &&
(item.role === "system" || item.role === "developer")
);
const isCodexInstruction = (item: unknown): boolean =>
isObjectRecord(item) && (item.role === "system" || item.role === "developer");

const isCodexTask = (item: unknown): boolean => !isCodexInstruction(item);

// A conversation is one session's turns with the same model, system prompt,
// tools and first task item, so subagents and side requests in that session
Expand Down Expand Up @@ -166,7 +165,16 @@ export const readPromptCacheRequest = (
}

const sessionHash = hashJson([input.apiKeyId, provider, session]);
const system = hashJson(claude ? body.system : body.instructions);
const system = hashJson(
claude
? body.system
: [
body.instructions,
Array.isArray(body.input)
? body.input.filter(isCodexInstruction)
: null,
]
);
const tools = hashJson(body.tools);
const requestClass = input.headers.get(CLAUDE_CODE_REQUEST_CLASS_HEADER);
return {
Expand Down
35 changes: 35 additions & 0 deletions tests/http/proxy-usage.scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { Hono } from "hono";
import { migrate } from "drizzle-orm/libsql/migrator";
import { installErrorHandler } from "../../src/http/error-handler";
import CODEX_DEFAULT_INSTRUCTIONS from "../../src/providers/codex-default-instructions.txt";
import {
CLAUDE_EXTENDED_CACHE_TTL_BETA_HEADER,
CLAUDE_SYSTEM_IDENTITY,
Expand Down Expand Up @@ -520,6 +521,40 @@ test("Codex keeps client request, thread, user-agent and beta headers", async ()
}
});

test("Codex instruction context passes through unchanged and only missing context gets the defaults", async () => {
const original = globalThis.fetch;
let forwarded: unknown = null;
globalThis.fetch = (async (_input, init) => {
forwarded = JSON.parse(String(init?.body));
return Response.json({ output: [] });
}) as typeof fetch;
const user = { role: "user", content: "hi" };
try {
for (const [body, instructions] of [
[{ input: [{ role: "developer", content: " Base " }, user] }, undefined],
[{ input: [user, { role: "system", content: "sys" }] }, undefined],
[{ instructions: " Keep it short ", input: [user] }, undefined],
[{ input: [user] }, CODEX_DEFAULT_INSTRUCTIONS],
] as const) {
const response = await app.request("/openai/v1/responses", {
method: "POST",
body: JSON.stringify({ model: "gpt-fake", ...body }),
});
expect(response.status).toBe(200);
await response.text();
expect(forwarded).toEqual({
model: "gpt-fake",
...body,
...(instructions ? { instructions } : {}),
store: false,
});
}
await flushProxyUsage();
} finally {
globalThis.fetch = original;
}
});

test("route records one successful SSE request including latest tokens", async () => {
const original = globalThis.fetch;
globalThis.fetch = (async () =>
Expand Down
33 changes: 0 additions & 33 deletions tests/providers/proxy-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,39 +346,6 @@ describe("proxy contract: codex", () => {
expect(headers.get(CODEX_ACCOUNT_ID_HEADER)).toBeNull();
});

test("promotes OpenCode developer input instead of adding fallback instructions", () => {
const headers = new Headers();
const bodyJson = {
model: "gpt-5.6-sol",
input: [
{
role: "developer",
content: "Current OpenCode GPT instructions",
},
{
role: "user",
content: [{ type: "input_text", text: "hello" }],
},
],
};

const result = prepareCodexProxyRequest({
headers,
accessToken: "codex-access",
accountId: "acct-fallback",
metadata: null,
bodyText: JSON.stringify(bodyJson),
bodyJson,
});

expect(JSON.parse(result.bodyText)).toEqual({
model: "gpt-5.6-sol",
input: [bodyJson.input[1]],
instructions: "Current OpenCode GPT instructions",
store: false,
});
});

test("removes output and completion limits the ChatGPT backend rejects", () => {
const bodyJson = {
model: "gpt-5-codex",
Expand Down
2 changes: 1 addition & 1 deletion tests/usage/prompt-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ test("a turn that reads far less than the previous turn cached is reported only
null,
],
[
"a sibling Codex agent whose instructions are promoted",
"a sibling Codex agent with different developer instructions",
[
codex(codexUsage(20_000), promoted("Agent A")),
codex(codexUsage(0), promoted("Agent B"), { headers: codexSubagent }),
Expand Down
Loading