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.
@@ -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" },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
BIN
public/providers/aws-polly.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/providers/blackbox-web.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/providers/cliproxyapi.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
public/providers/databricks.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/providers/empower.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
public/providers/gigachat.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
public/providers/gitlab-duo.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/providers/gitlab.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/providers/heroku.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/providers/linkup-search.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/providers/llamagate.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/providers/maritalk.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/providers/modal.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
public/providers/nanogpt.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
public/providers/nscale.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/providers/oci.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/providers/ovhcloud.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/providers/piapi.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/providers/poe.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
public/providers/predibase.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 1.9 KiB |
BIN
public/providers/recraft.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/providers/reka.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
public/providers/runwayml.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/providers/triton.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/providers/venice.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
public/providers/voyage-ai.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/providers/wandb.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/providers/youcom-search.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||