feat(providers): register codex auto review and expand icon coverage

Add the Codex Auto Review model to the provider registry and prefer
Codex when resolving unprefixed `codex-auto-review` and `gpt-5.5`
requests.

Broaden provider icon mappings and bundled PNG assets so more providers
render correctly in the shared UI. Also tighten chatCore stream cleanup
behavior so streaming responses return immediately while semaphore slots
and model locks are released on completion or failure, with tests and
artifact policy coverage updated accordingly.
This commit is contained in:
diegosouzapw
2026-04-24 16:58:14 -03:00
parent af5d800759
commit 0e15988233
36 changed files with 336 additions and 70 deletions

View File

@@ -480,6 +480,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
tokenUrl: "https://auth.openai.com/oauth/token",
},
models: [
{ id: "codex-auto-review", name: "Codex Auto Review", targetFormat: "openai-responses" },
{ id: "gpt-5.5", name: "GPT 5.5", targetFormat: "openai-responses" },
{ id: "gpt-5.4", name: "GPT 5.4", targetFormat: "openai-responses" },
{ id: "gpt-5.4-mini", name: "GPT 5.4 Mini", targetFormat: "openai-responses" },

View File

@@ -24,7 +24,7 @@ import {
parseUpstreamError,
formatProviderError,
} from "../utils/error.ts";
import { HTTP_STATUS, PROVIDER_MAX_TOKENS } from "../config/constants.ts";
import { COOLDOWN_MS, HTTP_STATUS, PROVIDER_MAX_TOKENS } from "../config/constants.ts";
import {
classifyProviderError,
PROVIDER_ERROR_TYPES,
@@ -96,6 +96,7 @@ import {
buildAccountSemaphoreKey,
markBlocked as markAccountSemaphoreBlocked,
} from "../services/accountSemaphore.ts";
import { lockModelIfPerModelQuota } from "../services/accountFallback.ts";
import {
generateSignature,
getCachedResponse,
@@ -490,6 +491,45 @@ function isSemaphoreTimeoutError(error: unknown): error is Error & { code: strin
);
}
function wrapReadableStreamWithFinalize<T>(
readable: ReadableStream<T>,
finalize: () => void
): ReadableStream<T> {
const reader = readable.getReader();
let finalized = false;
const runFinalize = () => {
if (finalized) return;
finalized = true;
finalize();
};
return new ReadableStream<T>({
async pull(controller) {
try {
const { done, value } = await reader.read();
if (done) {
runFinalize();
controller.close();
return;
}
controller.enqueue(value);
} catch (error) {
runFinalize();
controller.error(error);
}
},
async cancel(reason) {
try {
await reader.cancel(reason);
} finally {
runFinalize();
}
},
});
}
function resolveAccountSemaphoreAccountKey(
connectionId: string | null | undefined,
credentials: Record<string, unknown> | null | undefined
@@ -1968,82 +2008,90 @@ export async function handleChatCore({
})
: () => {};
const rawResult = await withRateLimit(provider, connectionId, modelToCall, async () => {
let attempts = 0;
const maxAttempts = provider === "qwen" ? 3 : 1;
try {
const rawResult = await withRateLimit(provider, connectionId, modelToCall, async () => {
let attempts = 0;
const maxAttempts = provider === "qwen" ? 3 : 1;
while (attempts < maxAttempts) {
const res = await executor.execute({
model: modelToCall,
body: bodyToSend,
stream: upstreamStream,
credentials: executionCredentials,
signal: streamController.signal,
log,
extendedContext,
upstreamExtraHeaders: buildUpstreamHeadersForExecute(modelToCall),
clientHeaders: buildExecutorClientHeaders(clientRawRequest?.headers, userAgent),
onCredentialsRefreshed,
});
while (attempts < maxAttempts) {
const res = await executor.execute({
model: modelToCall,
body: bodyToSend,
stream: upstreamStream,
credentials: executionCredentials,
signal: streamController.signal,
log,
extendedContext,
upstreamExtraHeaders: buildUpstreamHeadersForExecute(modelToCall),
clientHeaders: buildExecutorClientHeaders(clientRawRequest?.headers, userAgent),
onCredentialsRefreshed,
});
// Qwen 429 strict quota backoff (wait 1.5s, 3s and retry)
if (provider === "qwen" && res.response.status === 429 && attempts < maxAttempts - 1) {
const bodyPeek = await res.response
.clone()
.text()
.catch(() => "");
if (bodyPeek.toLowerCase().includes("exceeded your current quota")) {
const delay = 1500 * (attempts + 1);
log?.warn?.("QWEN_RETRY", `Quota 429 hit. Retrying in ${delay}ms...`);
await new Promise((r) => setTimeout(r, delay));
attempts++;
continue;
// Qwen 429 strict quota backoff (wait 1.5s, 3s and retry)
if (provider === "qwen" && res.response.status === 429 && attempts < maxAttempts - 1) {
const bodyPeek = await res.response
.clone()
.text()
.catch(() => "");
if (bodyPeek.toLowerCase().includes("exceeded your current quota")) {
const delay = 1500 * (attempts + 1);
log?.warn?.("QWEN_RETRY", `Quota 429 hit. Retrying in ${delay}ms...`);
await new Promise((r) => setTimeout(r, delay));
attempts++;
continue;
}
}
}
// For streaming: wrap response body to release semaphore only when stream is fully consumed
if (stream) {
const originalBody = res.response.body;
const wrappedBody = originalBody
? originalBody.pipeThrough(
new TransformStream({
flush: () => {
acquireAccountSemaphoreRelease();
},
})
)
: null;
return {
...res,
response: new Response(wrappedBody, {
status: res.response.status,
statusText: res.response.statusText,
headers: res.response.headers,
}),
};
}
// For streaming: release the semaphore when the client drains or cancels the stream.
if (stream) {
const originalBody = res.response.body;
if (!originalBody) {
acquireAccountSemaphoreRelease();
return res;
}
return res;
return {
...res,
response: new Response(
wrapReadableStreamWithFinalize(originalBody, acquireAccountSemaphoreRelease),
{
status: res.response.status,
statusText: res.response.statusText,
headers: res.response.headers,
}
),
};
}
return res;
}
});
if (stream) {
return rawResult;
}
});
// Non-stream: release semaphore immediately after reading full response body
const status = rawResult.response.status;
const statusText = rawResult.response.statusText;
const headers = Array.from(rawResult.response.headers.entries()) as [string, string][];
const payload = await rawResult.response.text();
acquireAccountSemaphoreRelease();
// Non-stream: release semaphore immediately after reading full response body.
const status = rawResult.response.status;
const statusText = rawResult.response.statusText;
const headers = Array.from(rawResult.response.headers.entries()) as [string, string][];
const payload = await rawResult.response.text();
acquireAccountSemaphoreRelease();
return {
...rawResult,
response: new Response(payload, { status, statusText, headers }),
_dedupSnapshot: {
status,
statusText,
headers,
payload,
},
};
return {
...rawResult,
response: new Response(payload, { status, statusText, headers }),
_dedupSnapshot: {
status,
statusText,
headers,
payload,
},
};
} catch (error) {
acquireAccountSemaphoreRelease();
throw error;
}
};
if (allowDedup && dedupEnabled && dedupHash) {

View File

@@ -73,6 +73,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(["codex-auto-review", "gpt-5.5"]);
/**
* Resolve provider alias to provider ID
@@ -286,6 +287,14 @@ function resolveModelByProviderInference(modelId, extendedContext) {
}
const nonOpenAIProviders = providers.filter((p) => p !== "openai");
if (providers.includes("codex") && CODEX_PREFERRED_UNPREFIXED_MODELS.has(modelId)) {
return {
provider: "codex",
model: modelId,
extendedContext,
};
}
if (nonOpenAIProviders.length === 1) {
const provider = nonOpenAIProviders[0];
const canonicalModel = resolveProviderModelAlias(provider, modelId);

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
public/providers/gitlab.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
public/providers/heroku.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
public/providers/modal.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
public/providers/nscale.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
public/providers/oci.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
public/providers/piapi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
public/providers/poe.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
public/providers/reka.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
public/providers/triton.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
public/providers/venice.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
public/providers/wandb.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -6,7 +6,7 @@ API (like OmniRoute itself) to translate any English paragraphs into the
target language.
Usage:
python3 scripts/i18n_autotranslate.py --api-url http://192.168.0.15:20128/v1 --api-key sk-14e76c286e84ff2d-agn73z-5a1fd283 --model cx/gpt-5.4
python3 scripts/i18n_autotranslate.py --api-url http://localhost:20128/v1 --api-key sk-your-omniroute-key --model cx/gpt-5.4
"""
import os

View File

@@ -79,6 +79,34 @@ const LOBEHUB_PROVIDER_MAP: Record<string, string> = {
"github-copilot": "githubcopilot",
github: "github",
mistralai: "mistral",
"azure-ai": "azureai",
baseten: "baseten",
"black-forest-labs": "bfl",
deepinfra: "deepinfra",
"fal-ai": "fal",
"featherless-ai": "featherless",
friendliai: "friendli",
"glm-cn": "zhipu",
"inference-net": "inference",
"jina-ai": "jina",
"lambda-ai": "lambda",
"lm-studio": "lmstudio",
"meta-llama": "meta",
"muse-spark-web": "meta",
"nous-research": "nousresearch",
novita: "novita",
sambanova: "sambanova",
"searchapi-search": "searchapi",
snowflake: "snowflake",
upstage: "upstage",
"v0-vercel": "v0",
"vercel-ai-gateway": "vercelaigateway",
"vertex-partner": "vertexai",
vllm: "vllm",
volcengine: "volcengine",
watsonx: "ibm",
"xiaomi-mimo": "xiaomimimo",
xinference: "xinference",
};
interface ProviderIconProps {
@@ -186,6 +214,35 @@ const KNOWN_PNGS = new Set([
"together",
"xai",
"zeroclaw",
"aws-polly",
"blackbox-web",
"cliproxyapi",
"databricks",
"empower",
"gigachat",
"gitlab-duo",
"gitlab",
"heroku",
"linkup-search",
"llamagate",
"maritalk",
"modal",
"nanogpt",
"nscale",
"oci",
"ovhcloud",
"piapi",
"poe",
"predibase",
"qoder",
"recraft",
"reka",
"runwayml",
"triton",
"venice",
"voyage-ai",
"wandb",
"youcom-search",
]);
const KNOWN_SVGS = new Set([
"apikey",

View File

@@ -18,6 +18,13 @@ const { clearCache, getCachedResponse, generateSignature } =
await import("../../src/lib/semanticCache.ts");
const { clearIdempotency } = await import("../../src/lib/idempotencyLayer.ts");
const { clearInflight } = await import("../../open-sse/services/requestDedup.ts");
const {
buildAccountSemaphoreKey,
getStats: getAccountSemaphoreStats,
resetAll: resetAccountSemaphores,
} = await import("../../open-sse/services/accountSemaphore.ts");
const { clearModelLock, isModelLocked } =
await import("../../open-sse/services/accountFallback.ts");
const { saveModelsDevCapabilities, clearModelsDevCapabilities } =
await import("../../src/lib/modelsDevSync.ts");
const {
@@ -346,12 +353,14 @@ async function invokeChatCore({
test.afterEach(async () => {
globalThis.fetch = originalFetch;
resetAccountSemaphores();
await waitForAsyncSideEffects();
await resetStorage();
});
test.after(async () => {
globalThis.fetch = originalFetch;
resetAccountSemaphores();
await waitForAsyncSideEffects();
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
@@ -2007,6 +2016,146 @@ test("chatCore maps upstream aborts to request-aborted errors", async () => {
assert.equal(result.error, "Request aborted");
});
test("chatCore returns streaming responses without waiting for upstream completion", async () => {
const encoder = new TextEncoder();
let closeUpstream: (() => void) | null = null;
const invocation = invokeChatCore({
provider: "openai",
model: "gpt-4o-mini",
accept: "text/event-stream",
body: {
model: "gpt-4o-mini",
stream: true,
messages: [{ role: "user", content: "do not buffer streaming" }],
},
responseFactory() {
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
id: "chatcmpl-stream",
object: "chat.completion.chunk",
choices: [
{
index: 0,
delta: { role: "assistant", content: "streamed-without-buffering" },
},
],
})}\n\n`
)
);
closeUpstream = () => {
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
};
},
}),
{
status: 200,
headers: { "Content-Type": "text/event-stream" },
}
);
},
});
const raceResult = await Promise.race([
invocation.then(() => "returned"),
new Promise((resolve) => setTimeout(() => resolve("blocked"), 1000)),
]);
if (raceResult !== "returned") {
closeUpstream?.();
}
const { result } = await invocation;
assert.equal(raceResult, "returned");
closeUpstream?.();
const streamText = await result.response.text();
assert.equal(result.success, true);
assert.match(streamText, /streamed-without-buffering/);
});
test("chatCore releases account semaphore slots when upstream execution throws", async () => {
const connectionId = "sem-exception";
const semaphoreKey = buildAccountSemaphoreKey({
provider: "openai",
accountKey: connectionId,
});
const { result } = await invokeChatCore({
provider: "openai",
model: "gpt-4o-mini",
connectionId,
credentials: {
apiKey: "sk-test",
maxConcurrent: 1,
providerSpecificData: {},
},
body: {
model: "gpt-4o-mini",
stream: false,
messages: [{ role: "user", content: "executor throws" }],
},
responseFactory() {
throw new Error("simulated upstream network failure");
},
});
await waitForAsyncSideEffects();
assert.equal(result.success, false);
assert.equal(result.status, 502);
assert.equal(getAccountSemaphoreStats()[semaphoreKey], undefined);
});
test("chatCore locks per-model quota failures without dropping quota helper references", async () => {
const model = "gemini-1.5-pro";
const connection = await providersDb.createProviderConnection({
provider: "gemini",
authType: "apikey",
name: "gemini-quota-lock",
apiKey: "gemini-key",
isActive: true,
providerSpecificData: {},
});
try {
const { result } = await invokeChatCore({
provider: "gemini",
model,
connectionId: connection.id,
credentials: {
apiKey: "gemini-key",
providerSpecificData: {},
},
body: {
model,
stream: false,
messages: [{ role: "user", content: "quota lock" }],
},
responseFactory() {
return new Response(
JSON.stringify({ error: { message: "insufficient_quota: quota exhausted" } }),
{
status: 402,
headers: { "Content-Type": "application/json" },
}
);
},
});
assert.equal(result.success, false);
assert.equal(result.status, 402);
assert.equal(isModelLocked("gemini", connection.id, model), true);
} finally {
clearModelLock("gemini", connection.id, model);
}
});
// ── Streaming semantic cache tests ──────────────────────────────────────────
test("chatCore caches streaming response and serves cache HIT on repeat", async () => {

View File

@@ -56,6 +56,8 @@ test("findMissingArtifactPaths flags missing root runtime files in the tarball",
);
assert.deepEqual(missingPaths, [
"app/responses-ws-proxy.mjs",
"app/server-ws.mjs",
"bin/mcp-server.mjs",
"bin/nodeRuntimeSupport.mjs",
"scripts/native-binary-compat.mjs",