mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 13:22:11 +03:00
feat(codex): support GPT-5.5 responses websocket (#1573)
Integrated into release/v3.7.0
This commit is contained in:
@@ -25,8 +25,9 @@ export default function CodexToolCard({
|
||||
const [message, setMessage] = useState(null);
|
||||
const [showInstallGuide, setShowInstallGuide] = useState(false);
|
||||
const [selectedApiKey, setSelectedApiKey] = useState("");
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [selectedModel, setSelectedModel] = useState("gpt-5.5");
|
||||
const CODEX_DEFAULT_MODELS = [
|
||||
"gpt-5.5",
|
||||
"gpt-5.3-codex",
|
||||
"gpt-5.4",
|
||||
"gpt-5.2-codex",
|
||||
@@ -35,7 +36,7 @@ export default function CodexToolCard({
|
||||
"gpt-5.1-codex-mini",
|
||||
];
|
||||
const [modelMappings, setModelMappings] = useState<Record<string, string>>({});
|
||||
const [reasoningEffort, setReasoningEffort] = useState("medium");
|
||||
const [reasoningEffort, setReasoningEffort] = useState("xhigh");
|
||||
const [wireApi, setWireApi] = useState("chat");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalTarget, setModalTarget] = useState<string | null>(null); // null = default model, string = mapping key
|
||||
@@ -584,7 +585,7 @@ openai_base_url = "${getEffectiveBaseUrl()}"
|
||||
type="text"
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
placeholder="gpt-5.4"
|
||||
placeholder="gpt-5.5"
|
||||
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
/>
|
||||
{selectedModel && (
|
||||
@@ -615,6 +616,7 @@ openai_base_url = "${getEffectiveBaseUrl()}"
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="xhigh">XHigh</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
185
src/app/api/internal/codex-responses-ws/route.ts
Normal file
185
src/app/api/internal/codex-responses-ws/route.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { CodexExecutor } from "@omniroute/open-sse/executors/codex.ts";
|
||||
import { getApiKeyMetadata } from "@/lib/db/apiKeys";
|
||||
import { authorizeWebSocketHandshake, extractWsTokenFromRequest } from "@/lib/ws/handshake";
|
||||
import { getModelInfo } from "@/sse/services/model";
|
||||
import { getProviderCredentialsWithQuotaPreflight } from "@/sse/services/auth";
|
||||
import { checkAndRefreshToken } from "@/sse/services/tokenRefresh";
|
||||
|
||||
const CODEX_RESPONSES_WS_URL = "wss://chatgpt.com/backend-api/codex/responses";
|
||||
const executor = new CodexExecutor();
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getBridgeSecret(): string {
|
||||
return process.env.OMNIROUTE_WS_BRIDGE_SECRET || "";
|
||||
}
|
||||
|
||||
function getAuthRequest(body: JsonRecord): Request {
|
||||
const requestUrl = typeof body.requestUrl === "string" ? body.requestUrl : "/api/v1/responses";
|
||||
const headers = isRecord(body.headers) ? body.headers : {};
|
||||
const url = new URL(requestUrl, "http://omniroute.local");
|
||||
const requestHeaders = new Headers();
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (typeof value === "string") {
|
||||
requestHeaders.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
return new Request(url, { headers: requestHeaders });
|
||||
}
|
||||
|
||||
function jsonError(status: number, code: string, message: string) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
},
|
||||
},
|
||||
{ status }
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeUpstreamHeaders(headers: Record<string, string>): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
const lower = key.toLowerCase();
|
||||
if (
|
||||
lower === "host" ||
|
||||
lower === "connection" ||
|
||||
lower === "upgrade" ||
|
||||
lower === "sec-websocket-key" ||
|
||||
lower === "sec-websocket-version" ||
|
||||
lower === "sec-websocket-extensions"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
result[key] = value;
|
||||
}
|
||||
result.Origin = "https://chatgpt.com";
|
||||
return result;
|
||||
}
|
||||
|
||||
async function authenticate(body: JsonRecord) {
|
||||
const authRequest = getAuthRequest(body);
|
||||
const auth = await authorizeWebSocketHandshake(authRequest);
|
||||
if (!auth.authorized) {
|
||||
return jsonError(
|
||||
auth.hasCredential ? 403 : 401,
|
||||
auth.hasCredential ? "ws_auth_invalid" : "ws_auth_required",
|
||||
auth.hasCredential ? "Invalid WebSocket credential" : "WebSocket auth required"
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
authenticated: auth.authenticated,
|
||||
authType: auth.authType,
|
||||
wsAuth: auth.wsAuth,
|
||||
});
|
||||
}
|
||||
|
||||
async function prepare(body: JsonRecord) {
|
||||
const authResponse = await authenticate(body);
|
||||
if (!authResponse.ok) return authResponse;
|
||||
|
||||
const authRequest = getAuthRequest(body);
|
||||
const apiKey = extractWsTokenFromRequest(authRequest);
|
||||
const metadata = apiKey ? await getApiKeyMetadata(apiKey).catch(() => null) : null;
|
||||
const allowedConnections =
|
||||
metadata && Array.isArray(metadata.allowedConnections) && metadata.allowedConnections.length > 0
|
||||
? metadata.allowedConnections
|
||||
: null;
|
||||
|
||||
const responseBody = isRecord(body.response) ? body.response : {};
|
||||
const requestedModel =
|
||||
typeof responseBody.model === "string" && responseBody.model.trim()
|
||||
? responseBody.model.trim()
|
||||
: "gpt-5.5";
|
||||
const modelInfo = await getModelInfo(requestedModel);
|
||||
const provider = modelInfo.provider;
|
||||
const model = modelInfo.model || requestedModel;
|
||||
|
||||
if (provider !== "codex") {
|
||||
return jsonError(
|
||||
400,
|
||||
"codex_ws_provider_required",
|
||||
`Responses WebSocket bridge only supports Codex models, got ${provider || "unknown"}`
|
||||
);
|
||||
}
|
||||
|
||||
const credentials = await getProviderCredentialsWithQuotaPreflight(
|
||||
provider,
|
||||
null,
|
||||
allowedConnections,
|
||||
model
|
||||
);
|
||||
|
||||
if (!credentials || "allRateLimited" in credentials) {
|
||||
return jsonError(
|
||||
503,
|
||||
"codex_credentials_unavailable",
|
||||
"No available Codex OAuth connection for Responses WebSocket"
|
||||
);
|
||||
}
|
||||
|
||||
const refreshedCredentials = await checkAndRefreshToken(provider, credentials);
|
||||
if (!refreshedCredentials?.accessToken) {
|
||||
return jsonError(401, "codex_oauth_token_missing", "Codex OAuth access token is missing");
|
||||
}
|
||||
|
||||
const transformed = (await executor.transformRequest(
|
||||
model,
|
||||
responseBody,
|
||||
true,
|
||||
refreshedCredentials
|
||||
)) as JsonRecord;
|
||||
transformed.model = model;
|
||||
delete transformed.stream;
|
||||
delete transformed.stream_options;
|
||||
|
||||
const headers = normalizeUpstreamHeaders(executor.buildHeaders(refreshedCredentials, true));
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
upstreamUrl: CODEX_RESPONSES_WS_URL,
|
||||
browser: "chrome_142",
|
||||
os: "windows",
|
||||
connectionId: refreshedCredentials.connectionId,
|
||||
model,
|
||||
headers,
|
||||
response: transformed,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const expectedSecret = getBridgeSecret();
|
||||
const receivedSecret = request.headers.get("x-omniroute-ws-bridge-secret") || "";
|
||||
if (!expectedSecret || receivedSecret !== expectedSecret) {
|
||||
return jsonError(403, "internal_bridge_forbidden", "Forbidden");
|
||||
}
|
||||
|
||||
let body: JsonRecord;
|
||||
try {
|
||||
const parsed = await request.json();
|
||||
body = isRecord(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return jsonError(400, "invalid_json", "Request body must be JSON");
|
||||
}
|
||||
|
||||
const action = typeof body.action === "string" ? body.action : "";
|
||||
if (action === "authenticate") {
|
||||
return authenticate(body);
|
||||
}
|
||||
if (action === "prepare") {
|
||||
return prepare(body);
|
||||
}
|
||||
|
||||
return jsonError(400, "invalid_action", "Unsupported bridge action");
|
||||
}
|
||||
@@ -1710,7 +1710,7 @@ export const cliModelConfigSchema = z.object({
|
||||
baseUrl: z.string().trim().min(1, "baseUrl and model are required"),
|
||||
apiKey: z.string().optional(),
|
||||
model: z.string().trim().min(1, "baseUrl and model are required"),
|
||||
reasoningEffort: z.enum(["none", "low", "medium", "high"]).optional(),
|
||||
reasoningEffort: z.enum(["none", "low", "medium", "high", "xhigh"]).optional(),
|
||||
wireApi: z.enum(["chat", "responses"]).optional(),
|
||||
modelMappings: z.record(z.string().trim().min(1), z.string().trim().min(1)).optional(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user