[](https://www.npmjs.com/package/omniroute)
-[](https://www.npmjs.com/package/omniroute)
[](https://hub.docker.com/r/diegosouzapw/omniroute)
-[](https://hub.docker.com/r/diegosouzapw/omniroute)
+
+
+
+
+
+
+
+
+[](https://github.com/diegosouzapw/OmniRoute/stargazers)
+[](https://github.com/diegosouzapw/OmniRoute/issues)
+[](https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE)
+[](https://github.com/diegosouzapw/OmniRoute/commits/main)
+[](https://github.com/diegosouzapw)
+[](https://github.com/diegosouzapw/OmniRoute)
+[](https://github.com/diegosouzapw/OmniRoute/pulls?q=is%3Apr+is%3Aclosed)
+[](https://github.com/diegosouzapw/OmniRoute/tags)
+[](https://github.com/diegosouzapw)
+[](https://github.com/diegosouzapw?tab=followers)
+[](https://github.com/diegosouzapw/OmniRoute/network/members)
+[](https://github.com/diegosouzapw/OmniRoute/watchers)
+
[](https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE)
[](https://omniroute.online)
[](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
diff --git a/docs/i18n/vi/CHANGELOG.md b/docs/i18n/vi/CHANGELOG.md
index a265db79d2..ecce0c711e 100644
--- a/docs/i18n/vi/CHANGELOG.md
+++ b/docs/i18n/vi/CHANGELOG.md
@@ -8,6 +8,16 @@
---
+## [3.5.1] ā 2026-04-04
+
+### ⨠New Features
+
+- **API Provider Advanced Settings:** Added per-connection custom `User-Agent` overrides for API-key provider connections. The override is stored in `providerSpecificData.customUserAgent` and now applies to validation probes and upstream execution requests.
+
+### š Bug Fixes
+
+- **Qwen OAuth Reliability:** Resolved a series of OAuth integration issues including a 400 Bad Request blocker on expired tokens, fallback generation for parsing OIDC `access_token` properties when `id_token` is omitted, model catalog discovery errors, and strict filtering of `X-Dashscope-*` headers to avoid 400 rejection from OpenAI-compatible endpoints.
+
## [3.5.0] ā 2026-04-03
### ⨠New Features
diff --git a/docs/i18n/vi/README.md b/docs/i18n/vi/README.md
index 1a90784f15..70bc7e8b5f 100644
--- a/docs/i18n/vi/README.md
+++ b/docs/i18n/vi/README.md
@@ -15,9 +15,28 @@ _Your universal API proxy ā one endpoint, 60+ providers, zero downtime. Now wi
[](https://www.npmjs.com/package/omniroute)
-[](https://www.npmjs.com/package/omniroute)
[](https://hub.docker.com/r/diegosouzapw/omniroute)
-[](https://hub.docker.com/r/diegosouzapw/omniroute)
+
+
+
+
+
+
+
+
+[](https://github.com/diegosouzapw/OmniRoute/stargazers)
+[](https://github.com/diegosouzapw/OmniRoute/issues)
+[](https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE)
+[](https://github.com/diegosouzapw/OmniRoute/commits/main)
+[](https://github.com/diegosouzapw)
+[](https://github.com/diegosouzapw/OmniRoute)
+[](https://github.com/diegosouzapw/OmniRoute/pulls?q=is%3Apr+is%3Aclosed)
+[](https://github.com/diegosouzapw/OmniRoute/tags)
+[](https://github.com/diegosouzapw)
+[](https://github.com/diegosouzapw?tab=followers)
+[](https://github.com/diegosouzapw/OmniRoute/network/members)
+[](https://github.com/diegosouzapw/OmniRoute/watchers)
+
[](https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE)
[](https://omniroute.online)
[](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
diff --git a/docs/i18n/zh-CN/CHANGELOG.md b/docs/i18n/zh-CN/CHANGELOG.md
index 76be0e177e..18cf53b1a9 100644
--- a/docs/i18n/zh-CN/CHANGELOG.md
+++ b/docs/i18n/zh-CN/CHANGELOG.md
@@ -8,6 +8,16 @@
---
+## [3.5.1] ā 2026-04-04
+
+### ⨠New Features
+
+- **API Provider Advanced Settings:** Added per-connection custom `User-Agent` overrides for API-key provider connections. The override is stored in `providerSpecificData.customUserAgent` and now applies to validation probes and upstream execution requests.
+
+### š Bug Fixes
+
+- **Qwen OAuth Reliability:** Resolved a series of OAuth integration issues including a 400 Bad Request blocker on expired tokens, fallback generation for parsing OIDC `access_token` properties when `id_token` is omitted, model catalog discovery errors, and strict filtering of `X-Dashscope-*` headers to avoid 400 rejection from OpenAI-compatible endpoints.
+
## [3.5.0] ā 2026-04-03
### ⨠New Features
diff --git a/docs/i18n/zh-CN/README.md b/docs/i18n/zh-CN/README.md
index e72a891e01..04098591e3 100644
--- a/docs/i18n/zh-CN/README.md
+++ b/docs/i18n/zh-CN/README.md
@@ -15,9 +15,28 @@ _Your universal API proxy ā one endpoint, 60+ providers, zero downtime. Now wi
[](https://www.npmjs.com/package/omniroute)
-[](https://www.npmjs.com/package/omniroute)
[](https://hub.docker.com/r/diegosouzapw/omniroute)
-[](https://hub.docker.com/r/diegosouzapw/omniroute)
+
+
+
+
+
+
+
+
+[](https://github.com/diegosouzapw/OmniRoute/stargazers)
+[](https://github.com/diegosouzapw/OmniRoute/issues)
+[](https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE)
+[](https://github.com/diegosouzapw/OmniRoute/commits/main)
+[](https://github.com/diegosouzapw)
+[](https://github.com/diegosouzapw/OmniRoute)
+[](https://github.com/diegosouzapw/OmniRoute/pulls?q=is%3Apr+is%3Aclosed)
+[](https://github.com/diegosouzapw/OmniRoute/tags)
+[](https://github.com/diegosouzapw)
+[](https://github.com/diegosouzapw?tab=followers)
+[](https://github.com/diegosouzapw/OmniRoute/network/members)
+[](https://github.com/diegosouzapw/OmniRoute/watchers)
+
[](https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE)
[](https://omniroute.online)
[](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index 8c1bf41251..0bd1da7dd5 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
- version: 3.5.0
+ version: 3.5.1
description: |
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
endpoint that routes requests to multiple AI providers with load balancing,
diff --git a/electron/package.json b/electron/package.json
index f80078e654..c68817142a 100644
--- a/electron/package.json
+++ b/electron/package.json
@@ -1,6 +1,6 @@
{
"name": "omniroute-desktop",
- "version": "3.5.0",
+ "version": "3.5.1",
"description": "OmniRoute Desktop Application",
"main": "main.js",
"author": {
diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts
index 25b08115fd..68202d9b0a 100644
--- a/open-sse/executors/default.ts
+++ b/open-sse/executors/default.ts
@@ -111,6 +111,17 @@ export class DefaultExecutor extends BaseExecutor {
}
if (stream) headers["Accept"] = "text/event-stream";
+
+ // Qwen header cleanup: Remove X-Dashscope-* headers since Qwen uses an OpenAI-compatible endpoint
+ // (e.g. portal.qwen.ai) via its DefaultExecutor buildUrl override, which rejects native DashScope headers.
+ if (this.provider === "qwen") {
+ for (const key of Object.keys(headers)) {
+ if (key.toLowerCase().startsWith("x-dashscope-")) {
+ delete headers[key];
+ }
+ }
+ }
+
return headers;
}
diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts
index 61faa0ae43..ad216dfcbf 100644
--- a/open-sse/handlers/chatCore.ts
+++ b/open-sse/handlers/chatCore.ts
@@ -1321,11 +1321,37 @@ export async function handleChatCore({
console.log(`${COLORS.red}[ERROR] ${failureMessage}${COLORS.reset}`);
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, failureMessage);
}
+ // We need to peek at the error text if it's 400 for Qwen
+ let upstreamErrorParsed = false;
+ let parsedStatusCode = providerResponse.status;
+ let parsedMessage = "";
+ let parsedRetryAfterMs: number | null = null;
+ let upstreamErrorBody: unknown = null;
- // Handle 401/403 - try token refresh using executor
+ if (provider === "qwen" && providerResponse.status === HTTP_STATUS.BAD_REQUEST) {
+ const errorDetails = await parseUpstreamError(providerResponse, provider);
+ parsedStatusCode = errorDetails.statusCode;
+ parsedMessage = errorDetails.message;
+ parsedRetryAfterMs = errorDetails.retryAfterMs;
+ upstreamErrorBody = errorDetails.responseBody;
+ upstreamErrorParsed = true;
+ }
+
+ const isQwenExpiredError =
+ provider === "qwen" &&
+ parsedStatusCode === HTTP_STATUS.BAD_REQUEST &&
+ parsedMessage &&
+ (parsedMessage.toLowerCase().includes("session has expired") ||
+ parsedMessage.toLowerCase().includes("invalid_parameter_error"));
+
+ const streamOptionsOnlyFailed = false; // TODO: properly track stream options failure? (placeholder from existing logic)
+
+ // Handle 401/403 (and Qwen explicit expiration) - try token refresh using executor
if (
- providerResponse.status === HTTP_STATUS.UNAUTHORIZED ||
- providerResponse.status === HTTP_STATUS.FORBIDDEN
+ (providerResponse.status === HTTP_STATUS.UNAUTHORIZED ||
+ providerResponse.status === HTTP_STATUS.FORBIDDEN ||
+ isQwenExpiredError) &&
+ !streamOptionsOnlyFailed // Keep constraint if stream options failed originally
) {
const newCredentials = (await refreshWithRetry(
() => executor.refreshCredentials(credentials, log),
@@ -1368,6 +1394,10 @@ export async function handleChatCore({
providerHeaders = retryResult.headers;
finalBody = retryResult.transformedBody;
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
+ upstreamErrorParsed = false; // Reset since new response is OK
+ } else {
+ providerResponse = retryResult.response;
+ upstreamErrorParsed = false; // Let it be parsed downstream
}
} catch {
log?.warn?.("TOKEN", `${provider.toUpperCase()} | retry after refresh failed`);
@@ -1382,12 +1412,22 @@ export async function handleChatCore({
// Check provider response - return error info for fallback handling
if (!providerResponse.ok) {
trackPendingRequest(model, provider, connectionId, false);
- const {
- statusCode,
- message,
- retryAfterMs,
- responseBody: upstreamErrorBody,
- } = await parseUpstreamError(providerResponse, provider);
+
+ let statusCode = providerResponse.status;
+ let message = "";
+ let retryAfterMs: number | null = null;
+
+ if (upstreamErrorParsed) {
+ statusCode = parsedStatusCode;
+ message = parsedMessage;
+ retryAfterMs = parsedRetryAfterMs;
+ } else {
+ const details = await parseUpstreamError(providerResponse, provider);
+ statusCode = details.statusCode;
+ message = details.message;
+ retryAfterMs = details.retryAfterMs;
+ upstreamErrorBody = details.responseBody;
+ }
// T06/T10/T36: classify provider errors and persist terminal account states.
const errorType = classifyProviderError(statusCode, message);
diff --git a/open-sse/package.json b/open-sse/package.json
index 8359191ff3..a12ff92fcf 100644
--- a/open-sse/package.json
+++ b/open-sse/package.json
@@ -1,6 +1,6 @@
{
"name": "@omniroute/open-sse",
- "version": "3.5.0",
+ "version": "3.5.1",
"description": "Express SSE sidecar for OmniRoute ā handles streaming, protocol translation, and provider orchestration",
"type": "module",
"main": "index.js",
diff --git a/package-lock.json b/package-lock.json
index e05bef8c99..ec68047fc5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "omniroute",
- "version": "3.5.0",
+ "version": "3.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
- "version": "3.5.0",
+ "version": "3.5.1",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -21047,7 +21047,7 @@
},
"open-sse": {
"name": "@omniroute/open-sse",
- "version": "3.5.0"
+ "version": "3.5.1"
}
}
}
diff --git a/package.json b/package.json
index 267f6a9b86..e97f14f27a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "omniroute",
- "version": "3.5.0",
+ "version": "3.5.1",
"description": "Smart AI Router with auto fallback ā route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
"type": "module",
"bin": {
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx
index 3d2e20fb22..98257ca453 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx
+++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx
@@ -2949,11 +2949,15 @@ function PassthroughModelsSection({
(model as string).startsWith(`${providerAlias}/`)
);
- const allModels = providerAliases.map(([alias, fullModel]: [string, any]) => ({
- modelId: (fullModel as string).replace(`${providerAlias}/`, ""),
- fullModel,
- alias,
- }));
+ const allModels = providerAliases.map(([alias, fullModel]: [string, any]) => {
+ const fmStr = fullModel as string;
+ const prefix = `${providerAlias}/`;
+ return {
+ modelId: fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr,
+ fullModel,
+ alias,
+ };
+ });
// Generate default alias from modelId (last part after /)
const generateDefaultAlias = (modelId) => {
@@ -3640,10 +3644,14 @@ function CompatibleModelsSection({
);
const allModels = useMemo(() => {
- const rows = providerAliases.map(([alias, fullModel]: [string, any]) => ({
- modelId: (fullModel as string).replace(`${providerStorageAlias}/`, ""),
- alias,
- }));
+ const rows = providerAliases.map(([alias, fullModel]: [string, any]) => {
+ const fmStr = fullModel as string;
+ const prefix = `${providerStorageAlias}/`;
+ return {
+ modelId: fmStr.startsWith(prefix) ? fmStr.slice(prefix.length) : fmStr,
+ alias,
+ };
+ });
const seenModelIds = new Set(rows.map((row) => row.modelId));
for (const model of fallbackModels) {
diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts
index 91309ab369..8468994dd7 100755
--- a/src/app/api/providers/[id]/models/route.ts
+++ b/src/app/api/providers/[id]/models/route.ts
@@ -669,6 +669,21 @@ export async function GET(
});
}
+ // Qwen OAuth Fallback: The Dashscope /models API rejects OAuth tokens with 401
+ if (provider === "qwen" && connection.authType === "oauth") {
+ const qwenModels = PROVIDER_MODELS["qwen"] || [];
+ return buildResponse({
+ provider,
+ connectionId,
+ models: qwenModels.map((m: any) => ({
+ id: m.id,
+ name: m.name || m.id,
+ owned_by: "qwen",
+ })),
+ source: "local_catalog",
+ });
+ }
+
const config =
provider in PROVIDER_MODELS_CONFIG
? PROVIDER_MODELS_CONFIG[provider as keyof typeof PROVIDER_MODELS_CONFIG]
diff --git a/src/lib/oauth/providers/qwen.ts b/src/lib/oauth/providers/qwen.ts
index 0a31c09212..8a3503bffe 100644
--- a/src/lib/oauth/providers/qwen.ts
+++ b/src/lib/oauth/providers/qwen.ts
@@ -59,6 +59,16 @@ export const qwen = {
}
}
+ if (!email && tokens.access_token) {
+ try {
+ const decodedToken = decodeJwt(tokens.access_token);
+ email = decodedToken.email || decodedToken.preferred_username || decodedToken.sub || null;
+ displayName = decodedToken.name || email;
+ } catch (e) {
+ // Ignore
+ }
+ }
+
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,