mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat: add responses subpath passthrough for codex (#457)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
33
src/app/api/v1/responses/[...path]/route.ts
Normal file
33
src/app/api/v1/responses/[...path]/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user