diff --git a/src/providers/proxies/codex-proxy.ts b/src/providers/proxies/codex-proxy.ts index 6a854a7..2951bc6 100644 --- a/src/providers/proxies/codex-proxy.ts +++ b/src/providers/proxies/codex-proxy.ts @@ -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 @@ -97,7 +106,7 @@ 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, @@ -105,22 +114,12 @@ export const transformCodexBodyJson = ( ...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 } : {}), }; diff --git a/src/usage/prompt-cache.ts b/src/usage/prompt-cache.ts index 3fd409e..22b1a34 100644 --- a/src/usage/prompt-cache.ts +++ b/src/usage/prompt-cache.ts @@ -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 @@ -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 { diff --git a/tests/http/proxy-usage.scenarios.ts b/tests/http/proxy-usage.scenarios.ts index b72d4ce..ca9b6f0 100644 --- a/tests/http/proxy-usage.scenarios.ts +++ b/tests/http/proxy-usage.scenarios.ts @@ -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, @@ -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 () => diff --git a/tests/providers/proxy-contract.test.ts b/tests/providers/proxy-contract.test.ts index 16f4076..14fa463 100644 --- a/tests/providers/proxy-contract.test.ts +++ b/tests/providers/proxy-contract.test.ts @@ -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", diff --git a/tests/usage/prompt-cache.test.ts b/tests/usage/prompt-cache.test.ts index 29d993b..aa65872 100644 --- a/tests/usage/prompt-cache.test.ts +++ b/tests/usage/prompt-cache.test.ts @@ -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 }),