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
9 changes: 9 additions & 0 deletions open-sse/services/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ for (const [aliasOrId, models] of Object.entries(PROVIDER_MODELS)) {
}
const KNOWN_MODEL_IDS = new Set(MODEL_TO_PROVIDERS.keys());
const CODEX_PREFERRED_UNPREFIXED_MODELS = new Set(["gpt-5.5"]);
export const CODEX_NATIVE_UNPREFIXED_MODELS = new Set(["codex-auto-review"]);

/**
* Resolve provider alias to provider ID
Expand Down Expand Up @@ -279,6 +280,14 @@ function resolveModelByProviderInference(modelId, extendedContext) {

const nonOpenAIProviders = providers.filter((p) => p !== "openai");

if (CODEX_NATIVE_UNPREFIXED_MODELS.has(modelId)) {
return {
provider: "codex",
model: modelId,
extendedContext,
};
}

if (providers.includes("codex") && CODEX_PREFERRED_UNPREFIXED_MODELS.has(modelId)) {
return {
provider: "codex",
Expand Down
28 changes: 28 additions & 0 deletions src/app/api/v1/models/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { getAllModerationModels } from "@omniroute/open-sse/config/moderationReg
import { getAllVideoModels } from "@omniroute/open-sse/config/videoRegistry.ts";
import { getAllMusicModels } from "@omniroute/open-sse/config/musicRegistry.ts";
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
import { CODEX_NATIVE_UNPREFIXED_MODELS } from "@omniroute/open-sse/services/model.ts";
import { getAllSyncedAvailableModels } from "@/lib/db/models";
import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableModels";
import { hasEligibleConnectionForModel } from "@/domain/connectionModelRules";
Expand Down Expand Up @@ -377,6 +378,33 @@ export async function getUnifiedModelsResponse(
}
}

for (const modelId of CODEX_NATIVE_UNPREFIXED_MODELS) {
if (!providerSupportsModel("codex", modelId)) continue;
if (getModelIsHidden("codex", modelId)) continue;

const alias = providerIdToAlias.codex || "cx";
const aliasId = `${alias}/${modelId}`;
const providerIdModel = `codex/${modelId}`;
const entries = [
{ id: aliasId, parent: null },
{ id: providerIdModel, parent: aliasId },
{ id: modelId, parent: providerIdModel },
];

for (const entry of entries) {
if (models.some((existingModel) => existingModel.id === entry.id)) continue;
models.push({
id: entry.id,
object: "model",
created: timestamp,
owned_by: "codex",
permission: [],
root: modelId,
parent: entry.parent,
});
}
}

try {
const syncedModelsByProvider = await getAllSyncedAvailableModels();
for (const [providerId, syncedModels] of Object.entries(syncedModelsByProvider)) {
Expand Down
7 changes: 6 additions & 1 deletion src/sse/handlers/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,12 @@ async function handleSingleModelChat(
isCombo: boolean = false
) {
// 1. Resolve model → provider/model
const resolved = await resolveModelOrError(modelStr, body, clientRawRequest?.endpoint);
const resolved = await resolveModelOrError(
modelStr,
body,
clientRawRequest?.endpoint,
clientRawRequest?.headers
);
if (resolved.error) return resolved.error;

const { provider, model, sourceFormat, targetFormat, extendedContext } = resolved;
Expand Down
54 changes: 52 additions & 2 deletions src/sse/handlers/chatHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,59 @@ const PREFERRED_BY_FAMILY: Record<string, string> = {
mimo: "moonshot",
};

export async function resolveModelOrError(modelStr: string, body: any, endpointPath: string = "") {
const CODEX_NATIVE_RESPONSES_MODELS = new Set(["gpt-5.5"]);

function getHeaderValue(headers: Record<string, unknown> | null | undefined, name: string) {
if (!headers || typeof headers !== "object") return "";
const lowerName = name.toLowerCase();
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() !== lowerName) continue;
return Array.isArray(value) ? value.join(",") : String(value ?? "");
}
return "";
}

function isCodexNativeResponsesRequest(
body: any,
endpointPath: string,
headers: Record<string, unknown> | null | undefined
) {
const normalizedEndpoint = String(endpointPath || "").replace(/\/+$/, "");
if (!/(^|\/)responses(?=\/|$)/i.test(normalizedEndpoint)) return false;
if (/\/responses\/compact$/i.test(normalizedEndpoint)) return true;

const userAgent = getHeaderValue(headers, "user-agent").toLowerCase();
if (userAgent.includes("codex")) return true;
if (getHeaderValue(headers, "x-codex-session-id")) return true;
if (getHeaderValue(headers, "x-codex-window-id")) return true;
if (getHeaderValue(headers, "x-codex-turn-metadata")) return true;

const metadataSource =
body && typeof body === "object" && body.metadata && typeof body.metadata === "object"
? String(body.metadata.source || "")
: "";
return metadataSource.toLowerCase().includes("codex");
}

export async function resolveModelOrError(
modelStr: string,
body: any,
endpointPath: string = "",
requestHeaders: Record<string, unknown> | null | undefined = null
) {
const modelInfo = await getModelInfo(modelStr);
const sourceFormat = detectFormatFromEndpoint(body, endpointPath);

if (
modelInfo.provider === "openai" &&
typeof modelInfo.model === "string" &&
CODEX_NATIVE_RESPONSES_MODELS.has(modelInfo.model) &&
sourceFormat === "openai-responses" &&
isCodexNativeResponsesRequest(body, endpointPath, requestHeaders)
) {
log.info("ROUTING", `${modelStr} → codex/${modelInfo.model} (Codex native responses)`);
modelInfo.provider = "codex";
}

// Forced-rewrite: codex provider doesn't serve DeepSeek/Qwen/Kimi/etc. Reroute
// these to their canonical native provider so the request lands on the right
Expand Down Expand Up @@ -113,7 +164,6 @@ export async function resolveModelOrError(modelStr: string, body: any, endpointP
}

const { provider, model, extendedContext } = modelInfo;
const sourceFormat = detectFormatFromEndpoint(body, endpointPath);
const providerAlias = PROVIDER_ID_TO_ALIAS[provider] || provider;
let targetFormat = getModelTargetFormat(providerAlias, model) || getTargetFormat(provider);
if ((modelInfo as any).apiFormat === "responses") {
Expand Down
24 changes: 24 additions & 0 deletions tests/unit/chat-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,30 @@ test("resolveModelOrError rejects malformed model strings", async () => {
assert.match(json.error.message, /Invalid model format/i);
});

test("resolveModelOrError routes Codex native compact gpt-5.5 requests to Codex", async () => {
const result = await resolveModelOrError(
"gpt-5.5",
{ model: "gpt-5.5", input: "compact this session", reasoning: { effort: "xhigh" } },
"/v1/responses/compact",
{ "user-agent": "codex-cli/0.128.0" }
);

assert.equal(result.provider, "codex");
assert.equal(result.model, "gpt-5.5");
});

test("resolveModelOrError keeps non-Codex gpt-5.5 Responses requests on OpenAI", async () => {
const result = await resolveModelOrError(
"gpt-5.5",
{ model: "gpt-5.5", input: "hello" },
"/v1/responses",
{ "user-agent": "OpenAI/Node" }
);

assert.equal(result.provider, "openai");
assert.equal(result.model, "gpt-5.5");
});

test("checkPipelineGates blocks providers with an open circuit breaker", async () => {
const breaker = getCircuitBreaker("openai");
breaker.state = STATE.OPEN;
Expand Down
30 changes: 30 additions & 0 deletions tests/unit/models-catalog-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,36 @@ test("v1 models catalog exposes refreshed GitHub Copilot aliases and drops retir
);
});

test("v1 models catalog exposes bare Codex-preferred IDs for native Codex clients", async () => {
await seedConnection("codex", {
authType: "oauth",
name: "codex-native",
apiKey: null,
accessToken: "codex-access",
});

const response = await v1ModelsCatalog.getUnifiedModelsResponse(
new Request("http://localhost/api/v1/models")
);
const body = (await response.json()) as any;
const getModel = (id: string) => body.data.find((item) => item.id === id);

assert.equal(response.status, 200);
const modelId = "codex-auto-review";
const bareModel = getModel(modelId);
const providerModel = getModel(`codex/${modelId}`);
const aliasModel = getModel(`cx/${modelId}`);
const openAiModel = getModel(`openai/${modelId}`);

assert.ok(bareModel, `expected bare ${modelId} model`);
assert.ok(providerModel, `expected codex/${modelId} model`);
assert.ok(aliasModel, `expected cx/${modelId} model`);
assert.equal(openAiModel, undefined);
assert.equal(bareModel.owned_by, "codex");
assert.equal(bareModel.parent, providerModel.id);
assert.equal(providerModel.parent, aliasModel.id);
});

test("v1 models catalog exposes Antigravity client-visible preview aliases instead of upstream internal IDs", async () => {
await seedConnection("antigravity", {
authType: "oauth",
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/plan3-p0.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ test("getModelInfoCore keeps openai fallback for gpt-4o", async () => {
assert.equal(info.model, "gpt-4o");
});

test("getModelInfoCore routes removed codex-auto-review through the default fallback", async () => {
test("getModelInfoCore routes native codex-auto-review to codex", async () => {
const info = await getModelInfoCore("codex-auto-review", {});
assert.equal(info.provider, "openai");
assert.equal(info.provider, "codex");
assert.equal(info.model, "codex-auto-review");
});

Expand Down