diff --git a/next.config.mjs b/next.config.mjs index 395c5305c9..a3a7251c71 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -121,6 +121,10 @@ const nextConfig = { source: "/responses", destination: "/api/v1/responses", }, + { + source: "/responses/:path*", + destination: "/api/v1/responses/:path*", + }, { source: "/models", destination: "/api/v1/models", diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 12489d67e7..7ca8f0534f 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -26,6 +26,7 @@ export type ProviderCredentials = { expiresAt?: string; connectionId?: string; // T07: used for API key rotation index providerSpecificData?: JsonRecord; + requestEndpointPath?: string; }; export type ExecutorLog = { diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 6b8a0f5460..eeae18bd39 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -9,6 +9,17 @@ type EffortLevel = (typeof EFFORT_ORDER)[number]; const CODEX_FAST_WIRE_VALUE = "priority"; let defaultFastServiceTierEnabled = false; +function getResponsesSubpath(endpointPath: unknown): string | null { + const normalizedEndpoint = String(endpointPath || "").replace(/\/+$/, ""); + const match = normalizedEndpoint.match(/(?:^|\/)responses(?:(\/.*))?$/i); + if (!match) return null; + return match[1] || ""; +} + +function isCompactResponsesEndpoint(endpointPath: unknown): boolean { + return getResponsesSubpath(endpointPath)?.toLowerCase() === "/compact"; +} + function normalizeServiceTierValue(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const normalized = value.trim().toLowerCase(); @@ -60,13 +71,31 @@ export class CodexExecutor extends BaseExecutor { super("codex", PROVIDERS.codex); } + buildUrl(model, stream, urlIndex = 0, credentials = null) { + void model; + void stream; + void urlIndex; + + const responsesSubpath = getResponsesSubpath(credentials?.requestEndpointPath); + if (responsesSubpath !== null) { + const baseUrl = String(this.config.baseUrl || "").replace(/\/$/, ""); + if (baseUrl.endsWith("/responses")) { + return `${baseUrl}${responsesSubpath}`; + } + return `${baseUrl}/responses${responsesSubpath}`; + } + + return super.buildUrl(model, stream, urlIndex, credentials); + } + /** * Codex Responses endpoint is SSE-first. * Always request event-stream from upstream, even when client requested stream=false. * Includes chatgpt-account-id header for strict workspace binding. */ buildHeaders(credentials, stream = true) { - const headers = super.buildHeaders(credentials, true); + const isCompactRequest = isCompactResponsesEndpoint(credentials?.requestEndpointPath); + const headers = super.buildHeaders(credentials, isCompactRequest ? false : true); // Add workspace binding header if workspaceId is persisted const workspaceId = credentials?.providerSpecificData?.workspaceId; @@ -107,9 +136,15 @@ export class CodexExecutor extends BaseExecutor { */ transformRequest(model, body, stream, credentials) { const nativeCodexPassthrough = body?._nativeCodexPassthrough === true; + const isCompactRequest = isCompactResponsesEndpoint(credentials?.requestEndpointPath); - // Codex /responses rejects stream=false; we aggregate SSE back to JSON when needed. - body.stream = true; + // Codex /responses rejects stream=false, but /responses/compact rejects the stream field entirely. + if (isCompactRequest) { + delete body.stream; + delete body.stream_options; + } else { + body.stream = true; + } delete body._nativeCodexPassthrough; const requestServiceTier = normalizeServiceTierValue(body.service_tier); diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index fd795ceb1f..ae3a5ee9c8 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -60,9 +60,8 @@ export function shouldUseNativeCodexPassthrough({ }): boolean { if (provider !== "codex") return false; if (sourceFormat !== FORMATS.OPENAI_RESPONSES) return false; - return String(endpointPath || "") - .toLowerCase() - .endsWith("/responses"); + const normalizedEndpoint = String(endpointPath || "").replace(/\/+$/, ""); + return /(?:^|\/)responses(?:\/.*)?$/i.test(normalizedEndpoint); } /** @@ -140,8 +139,8 @@ export async function handleChatCore({ } const sourceFormat = detectFormat(body); - const endpointPath = (clientRawRequest?.endpoint || "").toLowerCase(); - const isResponsesEndpoint = endpointPath.endsWith("/responses"); + const endpointPath = String(clientRawRequest?.endpoint || ""); + const isResponsesEndpoint = /(?:^|\/)responses(?:\/.*)?$/i.test(endpointPath); const nativeCodexPassthrough = shouldUseNativeCodexPassthrough({ provider, sourceFormat, @@ -385,6 +384,8 @@ export async function handleChatCore({ // Get executor for this provider const executor = getExecutor(provider); + const getExecutionCredentials = () => + nativeCodexPassthrough ? { ...credentials, requestEndpointPath: endpointPath } : credentials; // Create stream controller for disconnect detection const streamController = createStreamController({ onDisconnect, log, provider, model }); @@ -405,7 +406,7 @@ export async function handleChatCore({ model: modelToCall, body: bodyToSend, stream, - credentials, + credentials: getExecutionCredentials(), signal: streamController.signal, log, extendedContext, @@ -545,7 +546,7 @@ export async function handleChatCore({ model, body: translatedBody, stream, - credentials, + credentials: getExecutionCredentials(), signal: streamController.signal, log, extendedContext, diff --git a/src/app/api/v1/responses/[...path]/route.ts b/src/app/api/v1/responses/[...path]/route.ts new file mode 100644 index 0000000000..bf967e5d60 --- /dev/null +++ b/src/app/api/v1/responses/[...path]/route.ts @@ -0,0 +1,33 @@ +import { CORS_ORIGIN } from "@/shared/utils/cors"; +import { handleChat } from "@/sse/handlers/chat"; +import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; + +let initialized = false; + +async function ensureInitialized() { + if (!initialized) { + await initTranslators(); + initialized = true; + console.log("[SSE] Translators initialized for /v1/responses/*"); + } +} + +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": CORS_ORIGIN, + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "*", + }, + }); +} + +/** + * POST /v1/responses/:path* - OpenAI Responses subpaths + * Reuses the shared chat handler so native Codex passthrough can keep + * arbitrary Responses suffixes all the way to the upstream provider. + */ +export async function POST(request) { + await ensureInitialized(); + return await handleChat(request); +} diff --git a/tests/unit/plan3-p0.test.mjs b/tests/unit/plan3-p0.test.mjs index 49e97b9b77..51cc9e91df 100644 --- a/tests/unit/plan3-p0.test.mjs +++ b/tests/unit/plan3-p0.test.mjs @@ -108,6 +108,24 @@ test("shouldUseNativeCodexPassthrough only enables responses-native Codex reques false ); + assert.equal( + shouldUseNativeCodexPassthrough({ + provider: "codex", + sourceFormat: FORMATS.OPENAI_RESPONSES, + endpointPath: "/v1/responses/compact", + }), + true + ); + + assert.equal( + shouldUseNativeCodexPassthrough({ + provider: "codex", + sourceFormat: FORMATS.OPENAI_RESPONSES, + endpointPath: "/v1/responses/items/history", + }), + true + ); + assert.equal( shouldUseNativeCodexPassthrough({ provider: "codex", @@ -140,6 +158,18 @@ test("CodexExecutor always requests SSE accept header", () => { assert.equal(headers.Accept, "text/event-stream"); }); +test("CodexExecutor does not request SSE accept header for compact requests", () => { + const executor = new CodexExecutor(); + const headers = executor.buildHeaders( + { + accessToken: "test-token", + requestEndpointPath: "/v1/responses/compact", + }, + false + ); + assert.equal(headers.Accept, undefined); +}); + test("CodexExecutor preserves native responses payloads for Codex passthrough", () => { const executor = new CodexExecutor(); const transformed = executor.transformRequest( @@ -167,6 +197,41 @@ test("CodexExecutor preserves native responses payloads for Codex passthrough", assert.ok(!("_nativeCodexPassthrough" in transformed)); }); +test("CodexExecutor strips streaming fields for compact passthrough", () => { + const executor = new CodexExecutor(); + const transformed = executor.transformRequest( + "gpt-5.1-codex", + { + model: "gpt-5.1-codex", + input: "compact this session", + stream: false, + stream_options: { include_usage: true }, + _nativeCodexPassthrough: true, + }, + false, + { + requestEndpointPath: "/v1/responses/compact", + } + ); + + assert.equal("stream" in transformed, false); + assert.equal("stream_options" in transformed, false); + assert.ok(!("_nativeCodexPassthrough" in transformed)); +}); + +test("CodexExecutor routes responses subpaths to matching upstream paths", () => { + const executor = new CodexExecutor(); + const compactUrl = executor.buildUrl("gpt-5.1-codex", true, 0, { + requestEndpointPath: "/v1/responses/compact", + }); + assert.match(compactUrl, /\/responses\/compact$/); + + const genericSubpathUrl = executor.buildUrl("gpt-5.1-codex", true, 0, { + requestEndpointPath: "/v1/responses/items/history", + }); + assert.match(genericSubpathUrl, /\/responses\/items\/history$/); +}); + test("translateNonStreamingResponse converts Responses API payload to OpenAI chat.completion", () => { const responseBody = { id: "resp_123",