fix(providers): retire Qwen Web pending provenance review

This commit is contained in:
Xiangzhe
2026-08-26 19:12:13 -03:00
committed by Markus Hartung
parent 825f8fe425
commit 2133ad620c
65 changed files with 1504 additions and 1930 deletions

View File

@@ -4945,11 +4945,6 @@
"count": 4
}
},
"tests/unit/executor-qwen-web.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 40
}
},
"tests/unit/fetch-timeout.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 7
@@ -5567,11 +5562,6 @@
"count": 6
}
},
"tests/unit/provider-validation-qwen-web-5855.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"tests/unit/provider-validation-specialty.test.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 4
@@ -5726,11 +5716,6 @@
"count": 1
}
},
"tests/unit/qwen-web-cookie-validation-3958.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"tests/unit/radar-api-routes.test.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 2

View File

@@ -80,6 +80,30 @@
"tests/unit/gemini-3-5-flash-thinking.test.ts": {
"replacement": "tests/unit/model-capabilities-registry.test.ts",
"reason": "v3.8.50 back-merge f95b03d7: the provider-neutral Gemini 3.5 Flash tier catalog was RETIRED (MODEL_SPECS entries removed; the thinking tiers now live under antigravity/gemini-3.7-flash-*), so the deleted file's thinking-capable/routing-policy fixtures pinned a retired surface and red-fail against HEAD. The replacement guards the retirement itself (retired ids have no provider-neutral specs) plus resolvable capability floors for the surviving gemini-3-flash-agent id. Verified legitimate, not masking."
},
"tests/unit/executor-qwen-web.test.ts": {
"replacement": "tests/unit/qwen-web-retirement.test.ts",
"reason": "v3.8.51 #11713 Qwen Web provenance HOLD: the specialized executor and its positive request/stream contract were retired. The replacement proves the exact qwen-web/qw identities have no registry/executor/admission surface and fail closed with 410 instead of falling through to DefaultExecutor; proxy-bypass coverage lives in tests/unit/chatcore-executor-proxy.test.ts. Feature retirement, not masking."
},
"tests/unit/provider-models-qwen-web-redirect-6267.test.ts": {
"replacement": "tests/unit/qwen-web-retirement.test.ts",
"reason": "v3.8.51 #11713 Qwen Web provenance HOLD: the chat.qwen.ai discovery endpoint was removed with the retired provider. The replacement asserts PROVIDER_MODELS_CONFIG and registry admission remain absent while official qwen-cloud providers remain available. Feature retirement, not masking."
},
"tests/unit/provider-validation-qwen-web-5855.test.ts": {
"replacement": "tests/unit/qwen-web-retirement.test.ts",
"reason": "v3.8.51 #11713 Qwen Web provenance HOLD: provider-specific validation was removed together with the integration. The replacement locks the new contract that qwen-web/qw cannot be admitted or dispatched and that similarly named legitimate Qwen providers are not tombstoned. Feature retirement, not masking."
},
"tests/unit/qwen-web-content-array-serialization.test.ts": {
"replacement": "tests/unit/qwen-web-retirement.test.ts",
"reason": "v3.8.51 #11713 Qwen Web provenance HOLD: content-array serialization belonged exclusively to the removed executor. The replacement proves the executor implementation file does not ship and exact retired IDs fail closed before any generic serialization/dispatch path. Feature retirement, not masking."
},
"tests/unit/qwen-web-cookie-validation-3958.test.ts": {
"replacement": "tests/unit/qwen-web-retirement.test.ts",
"reason": "v3.8.51 #11713 Qwen Web provenance HOLD: cookie validation and web-session credential admission were removed with the provider. The replacement asserts there is no web-session requirement, token extraction config, provider metadata or runtime dispatch for qwen-web/qw. Feature retirement, not masking."
},
"tests/unit/qwen-web-models-discovery-3931.test.ts": {
"replacement": "tests/unit/provider-models-discovery-split.test.ts",
"reason": "v3.8.51 #11713 Qwen Web provenance HOLD: the retired chat.qwen.ai discovery integration no longer has a positive model-discovery contract. The replacement asserts qwen-web is absent from PROVIDER_MODELS_CONFIG while the remaining discovery providers retain their mappings; tests/unit/qwen-web-retirement.test.ts supplies the end-to-end retirement guard. Feature retirement, not masking."
}
},
"tests/unit/catalog-updates-v3x.test.ts": "v3.8.45 #6248: fix(providers) remove deprecated MiMo V2 entries — os 5 asserts removidos pinavam specs de modelos mimo-v2-* que deixaram de existir no catálogo (54→49). Asserts seguem a remoção dos modelos, não enfraquecimento. Verificado legítimo. Prune após v3.8.45 mergear para main.",

View File

@@ -363,10 +363,6 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
{ provider: "qoder", modelId: "deepseek-v4-pro", displayName: "DeepSeek-V4-Pro", monthlyTokens: 0, creditTokens: 1000000, freeType: "one-time-initial", poolKey: "qoder", tos: "caution" },
{ provider: "qoder", modelId: "deepseek-v4-flash", displayName: "DeepSeek-V4-Flash", monthlyTokens: 0, creditTokens: 1000000, freeType: "one-time-initial", poolKey: "qoder", tos: "caution" },
{ provider: "qoder", modelId: "minimax-m3", displayName: "MiniMax-M3", monthlyTokens: 0, creditTokens: 1000000, freeType: "one-time-initial", poolKey: "qoder", tos: "caution" },
{ provider: "qwen-web", modelId: "qwen3.8-max", displayName: "Qwen3.8 Max", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "qwen-web", tos: "avoid" },
{ provider: "qwen-web", modelId: "qwen3.7-max", displayName: "Qwen3.7 Max", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "qwen-web", tos: "avoid" },
{ provider: "qwen-web", modelId: "qwen3.7-plus", displayName: "Qwen3.7 Plus", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "qwen-web", tos: "avoid" },
{ provider: "qwen-web", modelId: "qwen3.6-plus", displayName: "Qwen3.6 Plus", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "qwen-web", tos: "avoid" },
{ provider: "reka", modelId: "reka-flash-3", displayName: "Reka Flash 3", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-monthly", poolKey: "reka", tos: "caution" },
{ provider: "reka", modelId: "reka-edge-2603", displayName: "Reka Edge 2603", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-monthly", poolKey: "reka", tos: "caution" },
{ provider: "sambanova", modelId: "MiniMax-M2.7", displayName: "MiniMax-M2.7", monthlyTokens: 6000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "sambanova", tos: "caution" },

View File

@@ -46,7 +46,6 @@ export const FREE_TIER_TOS: Record<string, TosVerdict> = {
"amazon-q": "avoid",
"muse-spark-web": "avoid",
"t3-web": "avoid",
"qwen-web": "avoid",
modal: "avoid",
nlpcloud: "avoid",
blackbox: "avoid",

View File

@@ -91,7 +91,6 @@ import { snowflakeProvider } from "./registry/snowflake/index.ts";
import { huggingfaceProvider } from "./registry/huggingface/index.ts";
import { chipotleProvider } from "./registry/chipotle/index.ts";
import { freeaiapikeyProvider } from "./registry/freeaiapikey/index.ts";
import { qwen_webProvider } from "./registry/qwen/web/index.ts";
import { qwen_cloudProvider } from "./registry/qwen-cloud/index.ts";
import { qwen_cloud_token_planProvider } from "./registry/qwen-cloud-token-plan/index.ts";
import { zai_webProvider } from "./registry/zai-web/index.ts";
@@ -361,7 +360,6 @@ export const REGISTRY: Record<string, RegistryEntry> = {
huggingface: huggingfaceProvider,
chipotle: chipotleProvider,
freeaiapikey: freeaiapikeyProvider,
"qwen-web": qwen_webProvider,
"qwen-cloud": qwen_cloudProvider,
"qwen-cloud-token-plan": qwen_cloud_token_planProvider,
"zai-web": zai_webProvider,

View File

@@ -11,8 +11,8 @@ export const hailuo_webProvider: RegistryEntry = {
id: "hailuo-web",
// Distinct alias: the paid API-key "minimax"/"minimax-cn" providers
// (../../minimax/index.ts) keep their own short alias; this free web/cookie
// variant is addressed by its own id, per the established kimi-web/qwen-web
// secondary-variant convention (tests/unit/provider-alias-uniqueness.test.ts).
// variant is addressed by its own id, following the web-only secondary-provider
// convention guarded by tests/unit/provider-alias-uniqueness.test.ts.
alias: "hailuo-web",
format: "openai",
executor: "hailuo-web",

View File

@@ -1,56 +0,0 @@
import type { RegistryEntry } from "../../../shared.ts";
export const qwen_webProvider: RegistryEntry = {
id: "qwen-web",
// The web/cookie variant is addressed by its own id.
alias: "qwen-web",
format: "openai",
executor: "qwen-web",
// v2 API (the legacy /api/chat/completions endpoint was retired upstream).
// Restored after the registry modularization (#3993) regressed this to v1 with
// a retired catalog. Source of truth: pre-#3993 providerRegistry.ts (commit 1ed01dd90^).
baseUrl: "https://chat.qwen.ai/api/v2/chat/completions",
authType: "apikey",
authHeader: "bearer",
// Current upstream catalog (GET https://chat.qwen.ai/api/models). Legacy
// ids (qwen-plus, qwen3-max, ...) still resolve via the executor's
// MODEL_ALIASES map for backward compatibility.
models: [
{
id: "qwen3.8-max",
name: "Qwen3.8 Max",
toolCalling: false,
supportsReasoning: true,
supportsVision: true,
contextLength: 1_000_000,
maxOutputTokens: 131_072,
},
{
id: "qwen3.7-max",
name: "Qwen3.7 Max",
toolCalling: false,
supportsReasoning: true,
supportsVision: false,
contextLength: 1_000_000,
maxOutputTokens: 65_536,
},
{
id: "qwen3.7-plus",
name: "Qwen3.7 Plus",
toolCalling: false,
supportsReasoning: true,
supportsVision: true,
contextLength: 1_000_000,
maxOutputTokens: 65_536,
},
{
id: "qwen3.6-plus",
name: "Qwen3.6 Plus",
toolCalling: false,
supportsReasoning: true,
supportsVision: true,
contextLength: 1_000_000,
maxOutputTokens: 65_536,
},
],
};

View File

@@ -656,8 +656,8 @@ export class GitlabExecutor extends BaseExecutor {
// Emulate OpenAI tool calling for GitLab Duo (which has no native function
// calling). When `tools` are present we serialize the tool contract into the
// prompt and parse `<tool>{...}</tool>` blocks back out of the completion text
// into OpenAI `tool_calls` — the same web-tool-emulation idiom used by the
// qwen-web / duckduckgo-web executors (#6051).
// into OpenAI `tool_calls` — the same web-tool-emulation idiom used by other
// pure-API web executors such as duckduckgo-web (#6051).
const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages(
bodyObj,
rawMessages as Array<{ role: string; content: unknown }>

View File

@@ -1,4 +1,5 @@
import { SEARCH_PROVIDERS } from "../config/searchRegistry.ts";
import { assertRuntimeProviderAvailable } from "@/shared/constants/providerRetirement";
import {
registerLazyExecutor,
loadRegisteredExecutor,
@@ -175,7 +176,6 @@ const lazyExecutors: Record<string, () => Promise<BaseExecutor>> = {
), // Alias
"doubao-web": () => import("./doubao-web.ts").then((m) => new m.DoubaoWebExecutor()),
db: () => import("./doubao-web.ts").then((m) => new m.DoubaoWebExecutor()), // Alias
"qwen-web": () => import("./qwen-web.ts").then((m) => new m.QwenWebExecutor()),
raycast: () => import("./raycast.ts").then((m) => new m.RaycastExecutor()),
rc: () => import("./raycast.ts").then((m) => new m.RaycastExecutor()), // Alias
"hailuo-web": () => import("./hailuo-web.ts").then((m) => new m.HailuoWebExecutor()),
@@ -205,7 +205,6 @@ const lazyExecutors: Record<string, () => Promise<BaseExecutor>> = {
xai: () => import("./xai.ts").then((m) => new m.XaiExecutor()),
"xai-oauth": () => import("./xai.ts").then((m) => new m.XaiExecutor("xai-oauth")),
xao: () => import("./xai.ts").then((m) => new m.XaiExecutor("xai-oauth")),
qw: () => import("./qwen-web.ts").then((m) => new m.QwenWebExecutor()), // Alias
"conol-web": () => import("./conol-web.ts").then((m) => new m.ConolWebExecutor()),
cnl: () => import("./conol-web.ts").then((m) => new m.ConolWebExecutor()), // Alias
};
@@ -242,6 +241,8 @@ const CHAT_UNSUPPORTED_CLOUD_AGENT_PROVIDERS = new Set(["jules"]);
const CHAT_UNSUPPORTED_SEARCH_PROVIDERS = new Set(Object.keys(SEARCH_PROVIDERS));
export async function getExecutor(provider: string): Promise<BaseExecutor> {
assertRuntimeProviderAvailable(provider);
const loaded = await loadRegisteredExecutor(provider);
if (loaded) return loaded;
if (CHAT_UNSUPPORTED_CLOUD_AGENT_PROVIDERS.has(provider)) {

View File

@@ -1,498 +0,0 @@
/**
* QwenWebExecutor — Alibaba Tongyi Qwen Chat via chat.qwen.ai (v2 API)
*
* Routes requests through Qwen's consumer chat API. The legacy v1 endpoint
* (`/api/chat/completions`) was retired upstream in 2026 and now answers 504
* HTML from Alibaba's gateway for every request, regardless of credentials
* (#3288 / discussion #2768). The current contract is a two-step v2 flow:
*
* 1. POST /api/v2/chats/new → create a chat, returns chat_id
* 2. POST /api/v2/chat/completions?chat_id= → phase-based SSE stream
*
* The v2 endpoints sit behind Alibaba's "baxia" WAF, which requires the full
* browser cookie jar from a real logged-in session (cna, ssxmod_itna,
* ssxmod_itna2, token, ...). We therefore replay the captured/pasted Cookie
* header verbatim plus the bearer token, mirroring how grok-web replays its
* anti-bot cookies.
*
* SSE chunks carry `choices[0].delta` with a `phase` field: `think` /
* `thinking_summary` map to reasoning, `answer` (or a null phase) carries the
* assistant content.
*
* Reference implementations: gpt4free `g4f/Provider/Qwen.py`,
* Chat2API `proxy/adapters/qwen-ai.ts`.
*
* Auth: full Cookie header from chat.qwen.ai + bearer token (localStorage
* `token`, also mirrored to a `token` cookie).
* Format: OpenAI-compatible (translated from Qwen's phase protocol).
*/
import { BaseExecutor, type ExecuteInput } from "./base.ts";
import { makeExecutorErrorResult as makeErrorResult } from "../utils/error.ts";
import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts";
import { buildQwenCookieHeader, extractQwenToken } from "@/lib/providers/webCookieAuth";
const BASE_URL = "https://chat.qwen.ai";
const CHATS_NEW_URL = `${BASE_URL}/api/v2/chats/new`;
const CHAT_COMPLETIONS_URL = `${BASE_URL}/api/v2/chat/completions`;
const USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
// Anti-bot headers the v2 endpoint expects. `bx-umidtoken` is normally minted
// per-session from sg-wum.alibaba.com; a captured value travels with the cookie
// jar, but we also send a static fallback so the header is always present.
const BX_VERSION = "2.5.36";
const BX_UMIDTOKEN_FALLBACK = "T2gA0000000000000000000000000000000000000000";
// Qwen SPA version — required by the v2 chat completion endpoint. Without this
// header the upstream returns HTTP 200 with `{"success":false,"data":{"code":"Bad_Request"}}`
// for every completion request, even with a valid session. The version string is
// the SPA build identifier shipped in the React client's `version` request header.
// Pinned from a live capture (2026-08); bump if Qwen ships a breaking change.
const QWEN_SPA_VERSION = "0.2.81";
const MODEL_ALIASES: Record<string, string> = {
// Legacy OmniRoute ids → current upstream catalog (GET /api/models).
"qwen-plus": "qwen3.7-plus",
"qwen-max": "qwen3.7-max",
"qwen-turbo": "qwen3.6-plus",
"qwen3-plus": "qwen3.7-plus",
"qwen3-max": "qwen3.7-max",
"qwen3-flash": "qwen3.6-plus",
"qwen3.8-max-preview": "qwen3.8-max",
// Note: `qwen3-coder-plus` is a real upstream model id (Qwen3-Coder) and
// must NOT be aliased — the previous `"qwen3-coder-plus": "qwen3.7-max"`
// entry silently rewrote valid coder requests to the wrong model.
"qwen3-coder-flash": "qwen3.6-plus",
qwen: "qwen3.7-max",
qwen3: "qwen3.7-max",
};
const DEFAULT_MODEL = "qwen3.7-max";
const REQUIRED_THINKING_MODELS = new Set(["qwen3.8-max"]);
function mapModel(modelId: string): string {
return MODEL_ALIASES[modelId] || modelId;
}
function uuid(): string {
return crypto.randomUUID();
}
/** Detect Alibaba's WAF / retired-v1 gateway page so we never surface raw HTML. */
function isWafResponse(status: number, contentType: string, bodyText: string): boolean {
if (contentType.includes("text/html")) return true;
if (status === 504) return true;
return /aliyun_waf|baxia|<html/i.test(bodyText);
}
const WAF_ERROR_MESSAGE =
"Qwen session expired or blocked by Alibaba's WAF. Re-login at https://chat.qwen.ai and " +
"paste a fresh full Cookie header (must include cna, ssxmod_itna and token) — a bearer token " +
"alone is no longer accepted by the v2 endpoint.";
export class QwenWebExecutor extends BaseExecutor {
constructor() {
super("qwen-web", { id: "qwen-web", baseUrl: BASE_URL });
}
private buildApiHeaders(
token: string,
cookieHeader: string,
chatId?: string
): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "*/*",
"User-Agent": USER_AGENT,
Origin: BASE_URL,
Referer: chatId ? `${BASE_URL}/c/${chatId}` : `${BASE_URL}/`,
source: "web",
version: QWEN_SPA_VERSION,
"x-request-id": uuid(),
"bx-v": BX_VERSION,
"bx-umidtoken": BX_UMIDTOKEN_FALLBACK,
};
if (token) headers["Authorization"] = `Bearer ${token}`;
if (cookieHeader) headers["Cookie"] = cookieHeader;
return headers;
}
async execute(input: ExecuteInput) {
const { body, credentials, signal, stream: wantStream } = input;
const bodyObj = (body || {}) as Record<string, unknown>;
const rawCred = String(credentials?.apiKey ?? "").trim();
const cookieHeader = buildQwenCookieHeader(rawCred);
let token = extractQwenToken(rawCred);
if (!token && credentials?.accessToken) token = String(credentials.accessToken).trim();
const messages = (bodyObj.messages as Array<{ role: string; content: string }>) || [];
const requestedModel = (bodyObj.model as string) || DEFAULT_MODEL;
const modelId = mapModel(requestedModel);
const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages(bodyObj, messages);
// Qwen Web is single-turn: fold the conversation into one user prompt.
const prompt = this.foldMessages(effectiveMessages);
// ── Step 1: create a chat ────────────────────────────────────────────────
let chatId: string;
try {
const newChatRes = await fetch(CHATS_NEW_URL, {
method: "POST",
headers: this.buildApiHeaders(token, cookieHeader),
body: JSON.stringify({
title: "New Chat",
models: [modelId],
chat_mode: "normal",
chat_type: "t2t",
timestamp: Date.now(),
}),
signal,
});
const ct = newChatRes.headers.get("content-type") || "";
if (!newChatRes.ok || ct.includes("text/html")) {
const text = await newChatRes.text().catch(() => "");
if (isWafResponse(newChatRes.status, ct, text)) {
return makeErrorResult(401, WAF_ERROR_MESSAGE, body, CHATS_NEW_URL);
}
return makeErrorResult(
newChatRes.status || 502,
`Qwen create-chat failed: ${text.slice(0, 300)}`,
body,
CHATS_NEW_URL
);
}
const data = (await newChatRes.json()) as { data?: { id?: string } };
chatId = data?.data?.id ?? "";
if (!chatId) {
return makeErrorResult(502, "Qwen create-chat returned no chat id", body, CHATS_NEW_URL);
}
} catch (err) {
return makeErrorResult(
502,
`Qwen create-chat error: ${err instanceof Error ? err.message : "unknown"}`,
body,
CHATS_NEW_URL
);
}
// ── Step 2: send the message ─────────────────────────────────────────────
const completionUrl = `${CHAT_COMPLETIONS_URL}?chat_id=${chatId}`;
const msgPayload = this.buildMessagePayload(chatId, modelId, prompt, requestedModel);
let upstream: Response;
try {
upstream = await fetch(completionUrl, {
method: "POST",
headers: this.buildApiHeaders(token, cookieHeader, chatId),
body: JSON.stringify(msgPayload),
signal,
});
} catch (err) {
return makeErrorResult(
502,
`Qwen completion fetch failed: ${err instanceof Error ? err.message : "unknown"}`,
body,
completionUrl
);
}
const ct = upstream.headers.get("content-type") || "";
if (!upstream.ok || ct.includes("text/html")) {
const errText = await upstream.text().catch(() => "");
if (isWafResponse(upstream.status, ct, errText)) {
return makeErrorResult(401, WAF_ERROR_MESSAGE, body, completionUrl);
}
return makeErrorResult(
upstream.status || 502,
`Qwen error: ${errText.slice(0, 300)}`,
body,
completionUrl
);
}
if (!wantStream) {
const { content } = await this.collectStream(upstream);
const finalText = content;
if (hasTools) {
const {
content: toolContent,
toolCalls,
finishReason,
} = buildToolAwareResult(finalText, requestedTools, "qwen");
const message: Record<string, unknown> = { role: "assistant", content: toolContent };
if (toolCalls) {
message.tool_calls = toolCalls;
message.content = null;
}
return this.jsonResponse(modelId, message, finishReason, completionUrl, msgPayload);
}
return this.jsonResponse(
modelId,
{ role: "assistant", content: finalText },
"stop",
completionUrl,
msgPayload
);
}
// Streaming: transform Qwen phase SSE → OpenAI chat.completion.chunk SSE.
const stream = this.buildClientStream(upstream, modelId, hasTools, requestedTools, signal);
return {
response: new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
}),
url: completionUrl,
headers: this.buildApiHeaders(token, cookieHeader, chatId),
transformedBody: msgPayload,
};
}
/** Flatten OpenAI-style content (string | Array<{type,text}>) into plain text.
* A bare String() on an array of content parts yields "[object Object]" — the
* serialization bug reported on the support mesh. */
private contentToText(content: unknown): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((part) => {
if (typeof part === "string") return part;
if (part && typeof part === "object") {
const p = part as { type?: unknown; text?: unknown };
if (typeof p.text === "string") return p.text;
}
return "";
})
.filter(Boolean)
.join("\n");
}
return content == null ? "" : String(content);
}
private foldMessages(messages: Array<{ role: string; content: unknown }>): string {
let systemContent = "";
let userContent = "";
for (const m of messages) {
const text = this.contentToText(m.content);
if (m.role === "system") {
systemContent += (systemContent ? "\n\n" : "") + text;
} else if (m.role === "user") {
userContent = text;
}
}
return systemContent ? `${systemContent}\n\nUser: ${userContent}` : userContent;
}
private buildMessagePayload(
chatId: string,
modelId: string,
prompt: string,
requestedModel: string
): Record<string, unknown> {
const fid = uuid();
const enableThinking =
REQUIRED_THINKING_MODELS.has(modelId) || /think|reason|r1/i.test(requestedModel);
const featureConfig: Record<string, unknown> = {
thinking_enabled: enableThinking,
output_schema: "phase",
auto_thinking: enableThinking,
research_mode: "normal",
auto_search: false,
};
return {
stream: true,
incremental_output: true,
chat_id: chatId,
chat_mode: "normal",
model: modelId,
parent_id: null,
messages: [
{
fid,
parentId: null,
childrenIds: [],
role: "user",
content: prompt,
user_action: "chat",
files: [],
timestamp: Math.floor(Date.now() / 1000),
models: [modelId],
chat_type: "t2t",
feature_config: featureConfig,
sub_chat_type: "t2t",
parent_id: null,
},
],
};
}
/** Read the whole upstream SSE stream, returning the joined answer + reasoning. */
private async collectStream(upstream: Response): Promise<{ content: string; reasoning: string }> {
const reader = upstream.body?.getReader();
const decoder = new TextDecoder();
let content = "";
let reasoning = "";
if (!reader) return { content, reasoning };
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
const delta = parseSseDelta(line);
if (!delta) continue;
if (delta.kind === "answer") content += delta.text;
else if (delta.kind === "think") reasoning += delta.text;
}
}
} catch {
/* upstream closed mid-stream — return what we have */
}
return { content, reasoning };
}
/** Transform the Qwen phase SSE into OpenAI chat.completion.chunk SSE. */
private buildClientStream(
upstream: Response,
modelId: string,
hasTools: boolean,
requestedTools: unknown,
signal: AbortSignal | null | undefined
): ReadableStream {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const id = `chatcmpl-qwen-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
const emitChunk = (delta: Record<string, unknown>, finishReason: string | null) =>
`data: ${JSON.stringify({
id,
object: "chat.completion.chunk",
created,
model: modelId,
choices: [{ index: 0, delta, finish_reason: finishReason }],
})}\n\n`;
return new ReadableStream({
async start(controller) {
const reader = upstream.body?.getReader();
if (!reader) {
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
return;
}
let buffer = "";
let fullContent = "";
controller.enqueue(encoder.encode(emitChunk({ role: "assistant", content: "" }, null)));
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
const delta = parseSseDelta(line);
if (!delta || !delta.text) continue;
if (delta.kind === "answer") {
fullContent += delta.text;
if (!hasTools) {
controller.enqueue(encoder.encode(emitChunk({ content: delta.text }, null)));
}
} else if (delta.kind === "think" && !hasTools) {
controller.enqueue(
encoder.encode(emitChunk({ reasoning_content: delta.text }, null))
);
}
}
}
} catch (err) {
if (!signal?.aborted) {
controller.error(err);
return;
}
}
if (hasTools) {
const { content, toolCalls, finishReason } = buildToolAwareResult(
fullContent,
requestedTools,
"qwen"
);
const delta = toolCalls
? { role: "assistant", content: null, tool_calls: toolCalls }
: { role: "assistant", content };
controller.enqueue(encoder.encode(emitChunk(delta, null)));
controller.enqueue(encoder.encode(emitChunk({}, finishReason)));
} else {
controller.enqueue(encoder.encode(emitChunk({}, "stop")));
}
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
},
});
}
private jsonResponse(
modelId: string,
message: Record<string, unknown>,
finishReason: string,
url: string,
transformedBody: unknown
) {
return {
response: new Response(
JSON.stringify({
id: `chatcmpl-qwen-${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: modelId,
choices: [{ index: 0, message, finish_reason: finishReason }],
}),
{ headers: { "Content-Type": "application/json" } }
),
url,
headers: {} as Record<string, string>,
transformedBody,
};
}
}
/** Parse one SSE line into a typed delta, or null if it carries no content. */
function parseSseDelta(line: string): { kind: "answer" | "think"; text: string } | null {
if (!line.startsWith("data:")) return null;
const payload = line.slice(5).trim();
if (!payload || payload === "[DONE]") return null;
let parsed: {
choices?: Array<{ delta?: { phase?: string | null; content?: unknown } }>;
};
try {
parsed = JSON.parse(payload);
} catch {
return null;
}
const delta = parsed?.choices?.[0]?.delta;
if (!delta) return null;
const phase = delta.phase;
const content = typeof delta.content === "string" ? delta.content : "";
if (phase === "think" || phase === "thinking_summary") {
return { kind: "think", text: content };
}
// `answer` phase or a null/absent phase both carry assistant content.
if (phase === "answer" || phase === null || phase === undefined) {
return { kind: "answer", text: content };
}
return null;
}

View File

@@ -16,6 +16,8 @@
* account pool (not a configured bearer key) and has its own server-side model-alias mechanism.
*/
import { assertRuntimeProviderAvailable } from "@/shared/constants/providerRetirement";
import { getExecutor } from "../../executors/index.ts";
import { isCliproxyapiDeepModeEnabled } from "../../executors/cliproxyapi.ts";
import { isDarioDeepModeEnabled } from "../../executors/dario.ts";
@@ -94,6 +96,8 @@ export async function resolveExecutorWithProxy(
log?: LoggerLike,
providerSpecificData?: Record<string, unknown> | null
) {
assertRuntimeProviderAvailable(prov);
// Per-connection routing override (#6339): the resolved connection can opt itself
// into the CLIProxyAPI passthrough executor via providerSpecificData.cliproxyapiMode
// === "claude-native" (UI toggle). This takes precedence over the provider-level
@@ -173,7 +177,10 @@ export async function resolveExecutorWithProxy(
result = await nativeExec.execute(input);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
log?.info?.("UPSTREAM_PROXY", `${prov} native error (${errMsg}), retrying via ${backendLabel}`);
log?.info?.(
"UPSTREAM_PROXY",
`${prov} native error (${errMsg}), retrying via ${backendLabel}`
);
try {
return await proxyExec.execute(input);
} catch (proxyErr) {

View File

@@ -6,6 +6,7 @@ import { getSettings } from "@/lib/db/settings";
import { getProviderRegistry } from "./providerRegistryAccessor";
import type { ConnectionFields } from "@/lib/db/encryption";
import { NOAUTH_PROVIDERS } from "@/shared/constants/providers";
import { isRuntimeRetiredProviderId } from "@/shared/constants/providerRetirement";
import { hasUsableWebSessionCredential } from "@/shared/providers/webSessionCredentials";
import { toNumber } from "@/shared/utils/numeric";
import { isCompatibleProviderConnectionId } from "@/shared/utils/compatibleProviderId";
@@ -612,7 +613,10 @@ export async function prepareVirtualAutoComboInputs(
}
}
const validConnections = connections.filter(hasUsableConnectionCredential);
const validConnections = connections.filter(
(connection) =>
!isRuntimeRetiredProviderId(connection.provider) && hasUsableConnectionCredential(connection)
);
const candidatePool: VirtualAutoComboCandidate[] = [];
const registry = getProviderRegistry();

View File

@@ -28,6 +28,7 @@ import type {
ResolvedComboTarget,
} from "./types.ts";
import { extractSessionAffinityKey } from "@/sse/services/auth";
import { isRuntimeRetiredProviderId } from "@/shared/constants/providerRetirement";
import { filterChatSelectableModels } from "../modelEndpointPolicy.ts";
import { DEFAULT_INTENT_CONFIG, type IntentClassifierConfig } from "../intentClassifier.ts";
import { getTaskFitness } from "../autoCombo/taskFitness.ts";
@@ -457,7 +458,10 @@ export async function expandAutoComboCandidatePool(
...new Set(
(allConnections as Array<{ provider?: unknown }>)
.map((c) => c.provider)
.filter((p): p is string => typeof p === "string" && p.length > 0)
.filter(
(p): p is string =>
typeof p === "string" && p.length > 0 && !isRuntimeRetiredProviderId(p)
)
),
];
// Pre-build a Set of already-present modelStr values so candidate-pool

View File

@@ -49,7 +49,7 @@ const BUILT_IN_ALIASES: Record<string, string> = {
"kimi-k2": "moonshotai/Kimi-K2",
// Qwen — the model ships only under the `-preview` id (bailian-coding-plan, qoder,
// qwen-cloud-token-plan, qwen-web). Without this, the bare id missed MODEL_SPECS and
// qwen-cloud-token-plan). Without this, the bare id missed MODEL_SPECS and
// the context preflight fell back to contextManager's `default: 128000`, rejecting
// prompts the model's real 1M window accepts. Drop this line if Alibaba ever ships a
// distinct GA `qwen3.8-max` — it would no longer be the same model.

View File

@@ -163,28 +163,6 @@ const RAW_CONFIGS: TokenExtractionConfig[] = [
{ cookieDomain: ".deepseek.com" }
),
// ── Qwen Web ──────────────────────────────────────────────
// The v2 API sits behind Alibaba's "baxia" WAF, which needs the full browser
// cookie jar (cna + ssxmod_itna/itna2 + token), not just the bearer token.
// Capture the WAF cookies alongside the localStorage token (#3288).
config(
"qwen-web",
"Qwen Web (Tongyi)",
"https://chat.qwen.ai/",
"https://chat.qwen.ai",
[
{ type: "localStorage", key: "token" },
{ type: "cookie", name: "token", domain: ".chat.qwen.ai" },
{ type: "cookie", name: "cna", domain: ".chat.qwen.ai" },
{ type: "cookie", name: "ssxmod_itna", domain: ".chat.qwen.ai" },
{ type: "cookie", name: "ssxmod_itna2", domain: ".chat.qwen.ai" },
{ type: "cookie", name: "XSRF_TOKEN", domain: ".chat.qwen.ai" },
],
"Log in to Qwen at chat.qwen.ai using your Alibaba account. The session token and the " +
"Alibaba WAF cookies (cna, ssxmod_itna) will be extracted — all are required by the v2 API.",
{ cookieDomain: ".chat.qwen.ai" }
),
// ── Volcano Engine Ark Console ───────────────────────────
config(
"volcengine-console",

View File

@@ -408,25 +408,6 @@ export const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> =
authPrefix: "Bearer ",
parseResponse: (data) => normalizeOpenAiLikeModelsResponse(data, "huggingface"),
},
// #3931: qwen-web (cookie provider) was missing here, so its discovery page
// showed nothing.
// `chat.qwen.ai/api/v2/models/` is public (no auth header configured/sent);
// shape `{ data: { data: [{ id, name, owned_by }] } }`, flatter `{ data: [] }` fallback.
"qwen-web": {
url: "https://chat.qwen.ai/api/v2/models/",
method: "GET",
headers: { "Content-Type": "application/json" },
parseResponse: (data) => {
const innerData = data?.data?.data || data?.data || [];
return (Array.isArray(innerData) ? innerData : [])
.map((item: any) => ({
id: item.id || item.name,
name: item.name || item.id,
owned_by: item.owned_by || "qwen",
}))
.filter((m: any) => m.id);
},
},
"qwen-cloud": QWEN_CLOUD_TEXT_MODELS_CONFIG,
antigravity: {
url: getAntigravityModelsDiscoveryUrls()[0],

View File

@@ -0,0 +1,166 @@
-- Retire the Qwen Web integration while its GPL-derived provenance remains on hold.
--
-- Match the complete ECMAScript trim whitespace set so database tombstones and
-- the TypeScript runtime agree even for restored provider ids wrapped in Unicode
-- spaces (NBSP, OGHAM, U+2000..U+200A, line/paragraph separators and BOM).
--
-- Keep connection rows and historical records for auditability. Disabling the
-- connections is deliberately fail-closed: API-key allowed_connections entries
-- continue to reference the same connection ids instead of becoming an empty
-- allowlist, which would mean unrestricted access in the policy layer.
UPDATE exclusive_connection_leases
SET state = 'INVALIDATED',
ended_at = COALESCE(ended_at, datetime('now')),
end_reason = 'CONNECTION_INELIGIBLE'
WHERE state = 'ACTIVE'
AND (
lower(trim(provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
IN ('qwen-web', 'qw')
OR connection_id IN (
SELECT id
FROM provider_connections
WHERE lower(trim(provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
IN ('qwen-web', 'qw')
)
);
UPDATE provider_connections
SET is_active = 0,
test_status = 'unavailable',
error_code = 'PROVIDER_REMOVED',
last_error = 'Provider integration retired from OmniRoute v3.8.50',
last_error_type = 'provider_removed',
last_error_source = 'migration:retire-qwen-web',
last_error_at = datetime('now'),
updated_at = datetime('now')
WHERE lower(trim(provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
IN ('qwen-web', 'qw')
AND (
is_active IS NOT 0
OR test_status IS NOT 'unavailable'
OR error_code IS NOT 'PROVIDER_REMOVED'
OR last_error IS NOT 'Provider integration retired from OmniRoute v3.8.50'
OR last_error_type IS NOT 'provider_removed'
OR last_error_source IS NOT 'migration:retire-qwen-web'
OR last_error_at IS NULL
);
-- Migrations run before settings imports. Keep the tombstone durable when an
-- old db.json snapshot or an admin PATCH later attempts to reactivate either
-- retired id. The WHEN predicates are null-safe and prevent timestamp churn
-- when an already-normalized row is written again.
CREATE TRIGGER IF NOT EXISTS provider_connections_retire_qwen_web_insert
AFTER INSERT ON provider_connections
WHEN lower(trim(NEW.provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
IN ('qwen-web', 'qw')
BEGIN
UPDATE provider_connections
SET is_active = 0,
test_status = 'unavailable',
error_code = 'PROVIDER_REMOVED',
last_error = 'Provider integration retired from OmniRoute v3.8.50',
last_error_type = 'provider_removed',
last_error_source = 'migration:retire-qwen-web',
last_error_at = datetime('now'),
updated_at = datetime('now')
WHERE id = NEW.id
AND (
is_active IS NOT 0
OR test_status IS NOT 'unavailable'
OR error_code IS NOT 'PROVIDER_REMOVED'
OR last_error IS NOT 'Provider integration retired from OmniRoute v3.8.50'
OR last_error_type IS NOT 'provider_removed'
OR last_error_source IS NOT 'migration:retire-qwen-web'
OR last_error_at IS NULL
);
UPDATE exclusive_connection_leases
SET state = 'INVALIDATED',
ended_at = COALESCE(ended_at, datetime('now')),
end_reason = 'CONNECTION_INELIGIBLE'
WHERE state = 'ACTIVE'
AND connection_id = NEW.id;
END;
CREATE TRIGGER IF NOT EXISTS provider_connections_retire_qwen_web_update
AFTER UPDATE OF provider, is_active, test_status, error_code, last_error,
last_error_type, last_error_source, last_error_at ON provider_connections
WHEN lower(trim(NEW.provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
IN ('qwen-web', 'qw')
BEGIN
UPDATE provider_connections
SET is_active = 0,
test_status = 'unavailable',
error_code = 'PROVIDER_REMOVED',
last_error = 'Provider integration retired from OmniRoute v3.8.50',
last_error_type = 'provider_removed',
last_error_source = 'migration:retire-qwen-web',
last_error_at = datetime('now'),
updated_at = datetime('now')
WHERE id = NEW.id
AND (
is_active IS NOT 0
OR test_status IS NOT 'unavailable'
OR error_code IS NOT 'PROVIDER_REMOVED'
OR last_error IS NOT 'Provider integration retired from OmniRoute v3.8.50'
OR last_error_type IS NOT 'provider_removed'
OR last_error_source IS NOT 'migration:retire-qwen-web'
OR last_error_at IS NULL
);
UPDATE exclusive_connection_leases
SET state = 'INVALIDATED',
ended_at = COALESCE(ended_at, datetime('now')),
end_reason = 'CONNECTION_INELIGIBLE'
WHERE state = 'ACTIVE'
AND connection_id = NEW.id;
END;
-- A restore can also insert lease rows after migrations have completed. Keep
-- lease state fail-closed independently of request-time auth selection.
CREATE TRIGGER IF NOT EXISTS exclusive_connection_leases_retire_qwen_web_insert
AFTER INSERT ON exclusive_connection_leases
WHEN NEW.state = 'ACTIVE'
AND (
lower(trim(NEW.provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
IN ('qwen-web', 'qw')
OR EXISTS (
SELECT 1
FROM provider_connections
WHERE id = NEW.connection_id
AND lower(trim(provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
IN ('qwen-web', 'qw')
)
)
BEGIN
UPDATE exclusive_connection_leases
SET state = 'INVALIDATED',
ended_at = COALESCE(ended_at, datetime('now')),
end_reason = 'CONNECTION_INELIGIBLE'
WHERE id = NEW.id
AND state = 'ACTIVE';
END;
CREATE TRIGGER IF NOT EXISTS exclusive_connection_leases_retire_qwen_web_update
AFTER UPDATE OF provider, connection_id, state ON exclusive_connection_leases
WHEN NEW.state = 'ACTIVE'
AND (
lower(trim(NEW.provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
IN ('qwen-web', 'qw')
OR EXISTS (
SELECT 1
FROM provider_connections
WHERE id = NEW.connection_id
AND lower(trim(provider, char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)))
IN ('qwen-web', 'qw')
)
)
BEGIN
UPDATE exclusive_connection_leases
SET state = 'INVALIDATED',
ended_at = COALESCE(ended_at, datetime('now')),
end_reason = 'CONNECTION_INELIGIBLE'
WHERE id = NEW.id
AND state = 'ACTIVE';
END;

View File

@@ -35,6 +35,7 @@ import {
} from "./webSessionDedup";
import { pickCodexConnectionForUser } from "@/lib/oauth/utils/codexConnectionSelection";
import { reconcileCodexUsageHistory } from "./providers/usageIdentityReconciliation";
import { isRuntimeRetiredProviderId } from "@/shared/constants/providerRetirement";
/**
* normalizeProviderSpecificData + the Codex fingerprint-seed invariant: Codex
@@ -722,13 +723,19 @@ export async function createProviderConnection(data: JsonRecord) {
backupDbFile("pre-write");
invalidateDbCache("connections"); // Bust connections read cache
return withNullableRateLimitOverrides(
const returnedConnection = withNullableRateLimitOverrides(
withNullableQuotaWindowThresholds(
withNullableMaxConcurrent(cleanNulls(connection), connection),
connection
),
connection
);
if (isRuntimeRetiredProviderId(providerId)) {
return (await getProviderConnectionById(String(connection.id))) ?? returnedConnection;
}
return returnedConnection;
}
function _insertConnectionRow(db: DbLike, conn: JsonRecord) {
@@ -967,13 +974,19 @@ export async function updateProviderConnection(id: string, data: JsonRecord) {
reorderConnections(db, providerId);
}
return withNullableRateLimitOverrides(
const returnedConnection = withNullableRateLimitOverrides(
withNullableQuotaWindowThresholds(
withNullableMaxConcurrent(cleanNulls(merged), merged),
merged
),
merged
);
if (isRuntimeRetiredProviderId(merged.provider)) {
return (await getProviderConnectionById(id)) ?? returnedConnection;
}
return returnedConnection;
}
export {

View File

@@ -29,7 +29,6 @@ import {
import { toValidationErrorResult } from "./validation/transport";
import {
validateDeepSeekWebProvider,
validateQwenWebProvider,
validateGrokWebProvider,
validateChatGptWebProvider,
validatePerplexityWebProvider,
@@ -305,7 +304,6 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
"deepseek-web": validateDeepSeekWebProvider,
"zai-web": validateZaiWebProvider,
"grok-web": validateGrokWebProvider,
"qwen-web": validateQwenWebProvider,
"kimi-web": validateKimiWebProvider,
"chatgpt-web": validateChatGptWebProvider,
"chatgpt-web-codex": validateChatGptWebCodexProvider,

View File

@@ -75,10 +75,10 @@ export async function validationWrite(url: string, init: RequestInit, isLocal: b
// surfaces as a `provider.validation.ssrf_blocked` audit event + a security warning in
// the UI) when it is a GENUINE SSRF/guard block — not for every outbound-guard 503.
// A blocked redirect (REDIRECT_BLOCKED) to a PUBLIC host is benign: the redirect was
// never followed, so no SSRF occurred. Web-cookie providers like qwen-web answer their
// probe with a 307 to a public host, which used to be mislabeled as an SSRF block
// (#3288 / #3758). Only treat a blocked redirect as a security event when its target is
// a private/internal host.
// never followed, so no SSRF occurred. Some web-cookie providers answer their probe
// with a 307 to a public host, which used to be mislabeled as an SSRF block (#3288 /
// #3758). Only treat a blocked redirect as a security event when its target is a
// private/internal host.
export function isSecurityBlockError(error: unknown): boolean {
if (!(error instanceof SafeOutboundFetchError)) return false;
if (error.code === "URL_GUARD_BLOCKED" || error.code === "INVALID_URL") return true;

View File

@@ -1,15 +1,12 @@
// Web-cookie provider key validators (part A): deepseek-web, qwen-web, grok-web, chatgpt-web,
// Web-cookie provider key validators (part A): deepseek-web, grok-web, chatgpt-web,
// perplexity-web, blackbox-web. Extracted from validation.ts (god-file decomposition) — top-level
// functions with no dispatcher-state captures; behavior is byte-identical to the original inline defs.
import { addModelsSuffix } from "./urlHelpers";
import { applyCustomUserAgent } from "./headers";
import { toValidationErrorResult, validationRead, validationWrite } from "./transport";
import {
buildGrokCookieHeader,
buildQwenCookieHeader,
extractCookieValue,
extractKimiAccessToken,
extractQwenToken,
normalizeSessionCookieHeader,
} from "@/lib/providers/webCookieAuth";
@@ -153,120 +150,6 @@ export async function validateDeepSeekWebProvider({ apiKey }: any) {
}
}
// qwen-web has no `modelsUrl` in its registry entry, so the generic OpenAI-compatible
// validator used to derive a probe URL of `https://chat.qwen.ai/api/v2/models/` (via
// addModelsSuffix) — a non-existent path that answers with a 307 redirect, which the
// outbound guard blocked and the route then mislabeled as an SSRF block (#3288/#3758).
//
// History of the session probe:
// - Originally `GET /api/v2/user` (Chat2API-derived). Upstream retired the path
// in mid-2026: it now returns `{"success":false,"data":{"code":"not found"}}`
// regardless of credentials, so the body-shape check (#3958) always fails.
// - Current probe: `GET /api/v1/auths/` (note the trailing slash — without it
// the path returns 401). This is the endpoint Qwen's own SPA hits right after
// login to fetch the user profile. It returns the user object directly at the
// top level: `{ id, email, name, role, ... }`.
//
// The validator mirrors the executor's anti-bot headers + cookie-jar replay and uses
// plain fetch (like the other web-cookie validators) so it never hits the
// addModelsSuffix/redirect path.
export async function validateQwenWebProvider({ apiKey }: any) {
const rawCred = String(apiKey ?? "").trim();
if (!rawCred) {
return {
valid: false,
error:
"Missing Qwen session — paste the full chat.qwen.ai Cookie header (must include token, cna and ssxmod_itna)",
};
}
const token = extractQwenToken(rawCred);
const cookieHeader = buildQwenCookieHeader(rawCred);
if (!token && !cookieHeader) {
return {
valid: false,
error: "Could not find a Qwen token/cookie in the pasted value",
};
}
try {
const headers: Record<string, string> = {
Accept: "*/*",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
Origin: "https://chat.qwen.ai",
Referer: "https://chat.qwen.ai/",
source: "web",
"bx-v": "2.5.36",
// The Qwen SPA's `version` header is required by the v2 chat completion
// endpoint; the validator sends it too so the probe matches a real
// browser request as closely as possible. (The session probe endpoint
// doesn't enforce it, but consistency with the executor avoids surprises
// if Qwen ever tightens its WAF rules.)
version: "0.2.66",
};
if (token) headers["Authorization"] = `Bearer ${token}`;
if (cookieHeader) headers["Cookie"] = cookieHeader;
// The trailing slash is significant: `/api/v1/auths` (no slash) answers 401,
// `/api/v1/auths/` returns the user profile.
const resp = await fetch("https://chat.qwen.ai/api/v1/auths/", { headers });
const contentType = resp.headers.get("content-type") || "";
if (resp.status === 401 || resp.status === 403) {
return {
valid: false,
error:
"Qwen session is invalid or expired — re-login at https://chat.qwen.ai and paste a fresh full Cookie header",
};
}
// Alibaba's WAF / retired-v1 gateway answers with an HTML challenge page (or 504)
// instead of JSON. A bearer token alone is no longer enough for the v2 endpoint.
if (contentType.includes("text/html") || resp.status === 504) {
return {
valid: false,
error:
"Qwen blocked the request with its anti-bot WAF. Re-login at https://chat.qwen.ai and paste a fresh full Cookie header (must include cna, ssxmod_itna and token) — a bearer token alone is not accepted.",
};
}
if (!resp.ok) {
return { valid: false, error: `Qwen returned HTTP ${resp.status}` };
}
// Parse JSON response and verify we have a real user object.
// /api/v1/auths/ returns the user at the top level: {id, email, name, role, ...}.
// We require `id` to be a non-empty string AND look like a real identifier
// (uuid-ish or otherwise ≥8 chars) to avoid false-positives from upstream
// error envelopes that happen to ship a top-level `id: "not_found"` style
// field. Keep the legacy nested checks (data.user, user) for robustness in
// case the upstream shape changes again.
try {
const data = await resp.json();
const hasTopLevelUser =
typeof data?.id === "string" && data.id.length >= 8 && typeof data?.email === "string";
const hasNestedUser =
(typeof data?.user?.id === "string" && data.user.id.length > 0) ||
(typeof data?.data?.user?.id === "string" && data.data.user.id.length > 0);
if (!hasTopLevelUser && !hasNestedUser) {
return {
valid: false,
error:
"Qwen session token is invalid or expired — re-login at https://chat.qwen.ai and paste a fresh full Cookie header",
};
}
} catch (parseError) {
return {
valid: false,
error: "Qwen returned invalid JSON response",
};
}
return { valid: true, error: null };
} catch (error) {
return toValidationErrorResult(error);
}
}
/**
* Heuristic for a Grok 403 that is an anti-bot / IP-reputation block rather than
* a genuine upstream API error (issue #3474).

View File

@@ -130,41 +130,6 @@ export function buildGrokCookieHeader(rawValue: string): string {
return parts.join("; ");
}
/**
* Build the `Cookie` header value for chat.qwen.ai (Qwen Web / Tongyi).
*
* The Qwen v2 API sits behind Alibaba's "baxia" WAF, which requires the full
* browser cookie jar from a real logged-in session (`cna`, `ssxmod_itna`,
* `ssxmod_itna2`, `token`, `_bl_uid`, `x-ap`, ...). Unlike grok we cannot
* reconstruct a canonical subset, so we forward the whole pasted/captured blob
* verbatim (minus a leading `Cookie:`/`bearer ` prefix).
*
* A bare token (no cookie pairs, i.e. no `=`) yields "" — there is no jar to
* replay, only a bearer credential (handled by {@link extractQwenToken}).
*/
export function buildQwenCookieHeader(rawValue: string): string {
const trimmed = stripCookieInputPrefix(rawValue);
if (!trimmed || !trimmed.includes("=")) return "";
return trimmed;
}
/**
* Extract the Qwen bearer token from whatever the user pasted/captured.
*
* Qwen stores its auth JWT in localStorage as `token`, and chat.qwen.ai also
* mirrors it into a `token` cookie. So:
* - full cookie blob with `token=...` → that value
* - bare token (no cookie pairs) → the value itself
* - cookie blob without a `token` pair → "" (token must come from elsewhere)
*/
export function extractQwenToken(rawValue: string): string {
const trimmed = stripCookieInputPrefix(rawValue);
if (!trimmed) return "";
if (!trimmed.includes("=")) return trimmed;
const match = trimmed.match(/(?:^|;\s*)token=([^;\s]+)/);
return match ? match[1] : "";
}
/** Extract Kimi Web's current localStorage access token, with legacy cookie compatibility. */
export function extractKimiAccessToken(rawValue: string): string {
const raw = String(rawValue ?? "").trim();

View File

@@ -430,7 +430,6 @@ const LOBE_PROVIDER_ALIASES = {
pollinations: "Pollinations",
qoder: "Qoder",
qwen: "Qwen",
"qwen-web": "Qwen",
recraft: "Recraft",
replicate: "Replicate",
roo: "RooCode",

View File

@@ -0,0 +1,38 @@
/**
* Provider ids that must remain unavailable even when stale rows are restored
* after migrations have already run. Keep canonical ids and operational legacy
* bypasses together so neither executor dispatch nor credential selection can
* fall back to an unrelated provider.
*/
export const RUNTIME_RETIRED_PROVIDER_IDS: ReadonlySet<string> = new Set(["qwen-web", "qw"]);
export const RUNTIME_PROVIDER_RETIRED_ERROR_CODE = "PROVIDER_RETIRED";
export const RUNTIME_PROVIDER_RETIRED_MESSAGE = "Provider is retired and unavailable.";
type RuntimeProviderRetirementError = Error & {
code: typeof RUNTIME_PROVIDER_RETIRED_ERROR_CODE;
status: 410;
};
export function isRuntimeRetiredProviderId(providerId: unknown): providerId is string {
return (
typeof providerId === "string" &&
RUNTIME_RETIRED_PROVIDER_IDS.has(providerId.trim().toLowerCase())
);
}
export function assertRuntimeProviderAvailable(providerId: unknown): void {
if (!isRuntimeRetiredProviderId(providerId)) return;
const error = new Error(RUNTIME_PROVIDER_RETIRED_MESSAGE) as RuntimeProviderRetirementError;
error.code = RUNTIME_PROVIDER_RETIRED_ERROR_CODE;
error.status = 410;
throw error;
}
export function isRuntimeProviderRetirementError(
error: unknown
): error is RuntimeProviderRetirementError {
if (!(error instanceof Error)) return false;
const typed = error as Error & { code?: unknown; status?: unknown };
return typed.code === RUNTIME_PROVIDER_RETIRED_ERROR_CODE && typed.status === 410;
}

View File

@@ -305,7 +305,7 @@ export const WEB_COOKIE_PROVIDERS = {
// alias. Aliases resolve 1:1 to a provider id, so the dashboard's model-string
// routing always picked v0-vercel, silently hiding this provider's own
// credentials. Follows the established secondary-web-variant convention (see
// kimi-web / qwen-web / huggingchat in tests/unit/provider-alias-uniqueness.test.ts):
// kimi-web / huggingchat in tests/unit/provider-alias-uniqueness.test.ts):
// the web/secondary variant uses its own id as alias instead of a short prefix.
alias: "v0-vercel-web",
name: "v0 Vercel Web (Code Gen)",
@@ -360,22 +360,6 @@ export const WEB_COOKIE_PROVIDERS = {
subscriptionRisk: true,
riskNoticeVariant: "webCookie",
},
"qwen-web": {
id: "qwen-web",
// The web variant uses its own id; the retired `qw` alias is not reassigned.
alias: "qwen-web",
name: "Qwen Web (Free)",
icon: "auto_awesome",
color: "#10B981",
textIcon: "QW",
website: "https://chat.qwen.ai",
hasFree: true,
freeNote: "Free — Qwen models via chat.qwen.ai with login token. No subscription required.",
authHint:
"Open chat.qwen.ai, log in, then open DevTools → Application → Local Storage → " +
'copy the "token" value (or use tongyi_sso_ticket cookie as Bearer token).',
toolCalling: "emulated",
},
"gemini-business": {
id: "gemini-business",
alias: "gembiz",

View File

@@ -12,14 +12,19 @@
// tokenrouter" despite a fully configured compatible node).
//
// Semantics (mirror the original inline runtime guard exactly):
// - REGISTRY entry ids + aliases only. Manual alias ids outside REGISTRY
// - REGISTRY entry ids + aliases, plus permanent tombstones for retired
// provider ids. Manual alias ids outside REGISTRY
// (xiaomi/llamacpp/aq) do NOT intercept nodes at runtime and are therefore
// deliberately NOT reserved — including them would cause false-positive
// rejections.
// - Case-sensitive: mixed-case input like "TokenRouter" does not collide with
// the runtime lookup (`Set.has` is exact-match), so it stays allowed.
// - Active registry ids remain case-sensitive: mixed-case input like
// "TokenRouter" does not collide with the runtime lookup (`Set.has` is
// exact-match), so it stays allowed. Retired ids use the tombstone's
// trim/lowercase normalization so casing cannot bypass retirement.
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
import { isRuntimeRetiredProviderId, RUNTIME_RETIRED_PROVIDER_IDS } from "./providerRetirement";
let _reserved: Set<string> | null = null;
function buildReservedProviderPrefixes(): Set<string> {
@@ -29,13 +34,15 @@ function buildReservedProviderPrefixes(): Set<string> {
if (entry?.id) reserved.add(entry.id);
if (entry?.alias) reserved.add(entry.alias);
}
for (const providerId of RUNTIME_RETIRED_PROVIDER_IDS) reserved.add(providerId);
_reserved = reserved;
return reserved;
}
/**
* All reserved provider prefixes (REGISTRY ids + aliases). Built lazily so the
* registry is only walked once per process.
* All exact reserved provider prefixes (REGISTRY ids + aliases and retired
* provider tombstones). Built lazily so the registry is only walked once per
* process.
*/
export function getReservedProviderPrefixes(): ReadonlySet<string> {
return buildReservedProviderPrefixes();
@@ -58,7 +65,10 @@ export const RESERVED_PROVIDER_PREFIXES: ReadonlySet<string> = getReservedProvid
* reserved (mirrors the runtime guard's typeof check).
*/
export function isReservedProviderPrefix(value: unknown): boolean {
return typeof value === "string" && buildReservedProviderPrefixes().has(value);
return (
typeof value === "string" &&
(buildReservedProviderPrefixes().has(value) || isRuntimeRetiredProviderId(value))
);
}
/**

View File

@@ -253,14 +253,6 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = {
acceptsFullCookieHeader: true,
storageKeys: ["cookie", "sessionid", "ttwid", "s_v_web_id", "fp"],
},
"qwen-web": {
kind: "cookie",
credentialName: "full Cookie header (must include cna, ssxmod_itna, token)",
placeholder:
"cna=...; token=...; ssxmod_itna=...; ssxmod_itna2=... (full Cookie header from chat.qwen.ai)",
acceptsFullCookieHeader: true,
storageKeys: ["cookie", "token", "ssxmod_itna", "ssxmod_itna2", "cna", "tongyi_sso_ticket"],
},
"duckduckgo-web": {
kind: "cookie",
credentialName: "duckai",

View File

@@ -85,7 +85,6 @@ export const NOT_TOKEN_QUANTIFIABLE_BUT_CREDENTIALED: readonly string[] = [
"iflytek", // probed -> 401 Unauthorized
"liquid", // probed -> 404: endpoint moved; config needs a separate audit
"muse-spark-web", // probed -> 403; authHeader is a session cookie, not a key
"qwen-web", // probed -> 200 but serves the WAF HTML page, not the API
"sparkdesk", // probed -> 401 Unauthorized
];

View File

@@ -339,6 +339,17 @@ export const createProviderNodeSchema = z
})
.superRefine((value, ctx) => {
const nodeType = value.type || "openai-compatible";
const normalizedPrefix = value.prefix?.trim();
if (normalizedPrefix && isReservedProviderPrefix(normalizedPrefix)) {
// Validate caller-supplied prefixes before preset handling. Presets may
// provide a default, but the route preserves an explicit prefix; an early
// return here used to let retired identities create unreachable nodes.
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: reservedProviderPrefixMessage(normalizedPrefix),
path: ["prefix"],
});
}
if (value.preset === "vibeproxy-openai") {
// Preset supplies name/prefix/apiType — but baseUrl is still mandatory
// (a local proxy's host/port is operator-specific, unlike the generic
@@ -365,17 +376,6 @@ export const createProviderNodeSchema = z
message: "Prefix is required",
path: ["prefix"],
});
} else if (isReservedProviderPrefix(value.prefix.trim())) {
// Reserved-prefix guard (tokenrouter bug): the runtime model resolver skips
// compatible-node lookup for built-in registry ids/aliases, so a node
// created with such a prefix could never be reached by it and silently
// routed requests to the built-in provider instead. Reject at the write
// path. Case-sensitive to match the runtime guard exactly.
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: reservedProviderPrefixMessage(value.prefix.trim()),
path: ["prefix"],
});
}
if (nodeType === "openai-compatible" && !value.apiType) {
ctx.addIssue({

View File

@@ -63,6 +63,7 @@ import * as log from "../utils/logger";
import { checkAndRefreshToken } from "../services/tokenRefresh";
import { createHookContext, runHooks, initPreRequestRegistry } from "@/lib/middleware/registry";
import { rejectPeerRequest } from "@/shared/resilience/peerRouting";
import { isRuntimeProviderRetirementError } from "@/shared/constants/providerRetirement";
import { deleteHandoff, getHandoff } from "@/lib/db/contextHandoffs";
import { getComboByName, updateCombo } from "@/lib/db/combos";
import { isModelAllowedForKey } from "@/lib/db/apiKeys";
@@ -948,7 +949,13 @@ async function handleChatImplementation(
// prefix may differ from the credential provider ID (e.g. model
// "xiaomi/mimo-v2-flash" resolves to provider "xiaomi" but the combo
// target specifies providerId: "opengate" for credential lookup).
const modelInfo = await getModelInfo(modelString);
let modelInfo;
try {
modelInfo = await getModelInfo(modelString);
} catch (error) {
if (isRuntimeProviderRetirementError(error)) return false;
throw error;
}
// Apply the same prefix-override guard as handleSingleModelChat:
// if providerId is just the prefix already in the model string, use
// the fully-resolved modelInfo.provider for a precise credential check.

View File

@@ -28,6 +28,10 @@ import {
} from "@omniroute/open-sse/utils/proxyFetch.ts";
import { resolveProxyForConnection } from "@/lib/localDb";
import { hasBlockingProxyAssignment } from "@/lib/db/proxies";
import {
isRuntimeProviderRetirementError,
RUNTIME_PROVIDER_RETIRED_MESSAGE,
} from "@/shared/constants/providerRetirement";
import {
CircuitBreakerOpenError,
getCircuitBreaker,
@@ -120,7 +124,17 @@ export async function resolveModelOrError(
endpointPath: string = "",
requestHeaders: Record<string, unknown> | null | undefined = null
) {
const modelInfo = await getModelInfo(modelStr);
let modelInfo;
try {
modelInfo = await getModelInfo(modelStr);
} catch (error) {
if (isRuntimeProviderRetirementError(error)) {
return {
error: errorResponse(HTTP_STATUS.GONE, RUNTIME_PROVIDER_RETIRED_MESSAGE),
};
}
throw error;
}
const sourceFormat = detectFormatFromEndpoint(body, endpointPath);
if (

View File

@@ -31,6 +31,7 @@ import {
} from "@/lib/providers/peakHourProtection";
import { buildJinaEnvCredentials } from "@/lib/providers/jina";
import { buildGeminiEnvCredentials } from "@/lib/providers/gemini";
import { isRuntimeRetiredProviderId } from "@/shared/constants/providerRetirement";
import { toNumber } from "@/shared/utils/numeric";
import {
createLazyConnectionView,
@@ -1283,6 +1284,12 @@ export async function getProviderCredentials(
requestedModel: string | null = null,
options: CredentialSelectionOptions = {}
) {
if (isRuntimeRetiredProviderId(provider)) {
invalidateManagedLease(options, "CONNECTION_INELIGIBLE");
log.warn("AUTH", "Retired provider rejected before credential selection");
return null;
}
const selectionLock = options._leaseRetryWithLockHeld
? null
: createSelectionLock(getSelectionMutexKey(provider, options));

View File

@@ -21,6 +21,7 @@ import { getLearnedReasoningEffortForModel } from "@omniroute/open-sse/services/
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
import { getRegisteredProviderEffortBaseModelId } from "@omniroute/open-sse/utils/registeredEffortVariants.ts";
import { getReservedProviderPrefixes } from "@/shared/constants/reservedProviderPrefixes";
import { assertRuntimeProviderAvailable } from "@/shared/constants/providerRetirement";
export { parseModel, stripContextWindowSuffix };
@@ -423,6 +424,11 @@ function stripRedundantNodeRoutingSegments(model: string, routingIds: unknown[])
*/
export async function getModelInfo(modelStr) {
const parsed = parseModel(modelStr);
// Fail before compatible-node lookup and stripModelPrefix can erase or remap
// a retired provider identity. Executor/auth tombstones are later defenses;
// they cannot see the original prefix after either remapping path.
assertRuntimeProviderAvailable(parsed.providerAlias);
assertRuntimeProviderAvailable(parsed.provider);
const { extendedContext } = parsed;
const attachRuntimeModelMeta = async (info: any) => {

View File

@@ -319,7 +319,6 @@
"tests/unit/quota-pool-log-route.test.ts",
"tests/unit/quota-scoring-alias-lookup-10877.test.ts",
"tests/unit/quota-streaming-consumption-usd.test.ts",
"tests/unit/qwen-web-content-array-serialization.test.ts",
"tests/unit/rate-limit-enhanced.test.ts",
"tests/unit/rate-limit-execution-timeout-message-4165.test.ts",
"tests/unit/rate-limit-local-capacity-classification.test.ts",

View File

@@ -555,16 +555,6 @@
"configSource": "qoder",
"provider": "qoder"
},
"qw": {
"className": "QwenWebExecutor",
"configSource": "<custom-config>",
"provider": "qwen-web"
},
"qwen-web": {
"className": "QwenWebExecutor",
"configSource": "<custom-config>",
"provider": "qwen-web"
},
"raycast": {
"className": "RaycastExecutor",
"configSource": "raycast",
@@ -721,6 +711,6 @@
"provider": "zai-web"
}
},
"keyCount": 144,
"keyCount": 142,
"sharedInstances": []
}

View File

@@ -5078,29 +5078,6 @@
"stream": "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/chat/completions"
}
},
"qwen-web": {
"format": "openai",
"headers": {
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
}
},
"url": {
"nonStream": "https://chat.qwen.ai/api/v2/chat/completions",
"stream": "https://chat.qwen.ai/api/v2/chat/completions"
}
},
"raycast": {
"format": "openai",
"headers": {

View File

@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
// Import BEFORE mocking global.fetch — open-sse/utils/proxyFetch.ts overwrites
// globalThis.fetch as a module-load side effect, so a mock installed before the
// import gets clobbered (same pattern as tests/unit/provider-models-qwen-web-redirect-6267.test.ts).
// import gets clobbered (the same pattern used by other proxy-fetch regression tests).
const { validateWebCookieProvider } = await import("../../src/lib/providers/validation.ts");
const originalFetch = globalThis.fetch;

View File

@@ -1,18 +1,13 @@
// Regression guard for two catalog fixes shipped in v3.8.29:
//
// 1. Kimi Code's fallback catalog uses the public stable model ids,
// while account-specific metadata comes from /coding/v1/models.
//
// 2. Bug #3 (issue #3931) — qwen-web missing from PROVIDER_MODELS_CONFIG in
// src/app/api/providers/[id]/models/route.ts, so the model discovery page for
// the web-cookie provider returned nothing. Identified by @thezukiru in #3895.
// Regression guard for Kimi Code's stable fallback catalog and the Qwen provider split.
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { getModelsByProviderId } from "../../open-sse/config/providerModels.ts";
import {
PROVIDER_ID_TO_ALIAS,
getModelsByProviderId,
} from "../../open-sse/config/providerModels.ts";
import { PROVIDER_MODELS_CONFIG } from "../../src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts";
import { getResolvedModelCapabilities } from "../../src/lib/modelCapabilities.ts";
const providerPageUtils =
@@ -92,88 +87,29 @@ test("Kimi API-key connections fold into the Code provider card", () => {
}
});
// ── Bug #3 / issue #3931: qwen-web in PROVIDER_MODELS_CONFIG ──────────────────
// ── Qwen Web removal and official Qwen provider preservation ─────────────────
// PROVIDER_MODELS_CONFIG was extracted from the discovery route into the
// discovery/ leaf (refactor: split provider-models discovery route). The
// source-guard follows the config to its new home.
const CONFIG_FILE = path.join(
"src",
"app",
"api",
"providers",
"[id]",
"models",
"discovery",
"providerModelsConfig.ts"
);
test("retired Qwen Web surfaces stay absent while official Qwen providers remain", () => {
assert.equal(PROVIDER_ID_TO_ALIAS["qwen-web"], undefined);
assert.deepEqual(getModelsByProviderId("qwen-web"), []);
assert.equal(Object.hasOwn(providers.WEB_COOKIE_PROVIDERS, "qwen-web"), false);
assert.equal(PROVIDER_MODELS_CONFIG["qwen-web"], undefined);
test("PROVIDER_MODELS_CONFIG contains a qwen-web entry (issue #3931 bug #3)", () => {
const src = fs.readFileSync(CONFIG_FILE, "utf-8");
assert.match(
src,
/"qwen-web"\s*:/,
'"qwen-web" key missing from PROVIDER_MODELS_CONFIG in discovery/providerModelsConfig.ts'
assert.equal(PROVIDER_ID_TO_ALIAS["qwen-cloud"], "qwc");
assert.ok(
getModelsByProviderId("qwen-cloud").some((model) => model.id === "qwen3.8-max"),
"official Qwen Cloud models must remain routable"
);
assert.equal(PROVIDER_ID_TO_ALIAS["qwen-cloud-token-plan"], "qct");
assert.ok(
getModelsByProviderId("qwen-cloud-token-plan").some((model) => model.id === "qwen3.8-max"),
"official Qwen Cloud Token Plan models must remain routable"
);
assert.deepEqual(
[
providers.APIKEY_PROVIDERS["qwen-cloud"].name,
providers.APIKEY_PROVIDERS["qwen-cloud-token-plan"].name,
],
["Qwen Cloud", "Qwen Cloud Token Plan"]
);
});
test("qwen-web PROVIDER_MODELS_CONFIG entry targets chat.qwen.ai/api/v2/models/", () => {
const src = fs.readFileSync(CONFIG_FILE, "utf-8");
assert.match(
src,
/chat\.qwen\.ai\/api\/v2\/models\//,
"qwen-web discovery URL must be https://chat.qwen.ai/api/v2/models/"
);
});
test("qwen-web parseResponse handles Qwen nested data.data structure", () => {
const mockResponse = {
data: {
data: [
{ id: "qwen3.7-plus", name: "Qwen3.7-Plus", owned_by: "qwen" },
{ id: "qwen3-235b-a22b", name: "Qwen3-235B-A22B", owned_by: "qwen" },
{ id: "qwen3-coder-480b", name: "Qwen3-Coder-480B" },
],
},
};
// parseResponse logic matches PROVIDER_MODELS_CONFIG["qwen-web"].parseResponse
const innerData: Array<Record<string, unknown>> =
(mockResponse?.data?.data as Array<Record<string, unknown>>) ||
(mockResponse?.data as unknown as Array<Record<string, unknown>>) ||
[];
const models = innerData
.map((item) => ({
id: (item.id || item.name) as string,
name: (item.name || item.id) as string,
owned_by: (item.owned_by || "qwen") as string,
}))
.filter((m) => m.id);
assert.equal(models.length, 3);
assert.equal(models[0].id, "qwen3.7-plus");
assert.equal(models[0].name, "Qwen3.7-Plus");
assert.equal(models[0].owned_by, "qwen");
assert.equal(models[2].owned_by, "qwen", "owned_by defaults to 'qwen' when absent");
});
test("qwen-web parseResponse handles flat data array fallback", () => {
const mockResponse = {
data: [{ id: "qwen3.7-plus", name: "Qwen3.7-Plus" }],
};
const innerData: Array<Record<string, unknown>> =
(mockResponse?.data as unknown as { data?: Array<Record<string, unknown>> })?.data ||
(mockResponse?.data as unknown as Array<Record<string, unknown>>) ||
[];
const models = innerData
.map((item) => ({
id: (item.id || item.name) as string,
name: (item.name || item.id) as string,
owned_by: (item.owned_by || "qwen") as string,
}))
.filter((m) => m.id);
assert.equal(models.length, 1);
assert.equal(models[0].id, "qwen3.7-plus");
});

View File

@@ -17,13 +17,11 @@ process.env.DATA_DIR = testDataDir;
// Dynamic imports AFTER DATA_DIR is set so core.ts picks up the temp path.
const coreDb = await import("../../src/lib/db/core.ts");
const upstreamProxyDb = await import("../../src/lib/db/upstreamProxy.ts");
const { resolveExecutorWithProxy } = await import(
"../../open-sse/handlers/chatCore/executorProxy.ts"
);
const { resolveExecutorWithProxy } =
await import("../../open-sse/handlers/chatCore/executorProxy.ts");
const { getExecutor } = await import("../../open-sse/executors/index.ts");
const { clearUpstreamProxyConfigCache } = await import(
"../../open-sse/handlers/chatCore/comboContextCache.ts"
);
const { clearUpstreamProxyConfigCache } =
await import("../../open-sse/handlers/chatCore/comboContextCache.ts");
before(async () => {
await coreDb.ensureDbInitialized();
@@ -137,3 +135,24 @@ test("connection override wins over provider mode 'fallback'", async () => {
// Connection override short-circuits to the passthrough executor, not the fallback wrapper.
assert.equal(exec, await getExecutor("cliproxyapi"));
});
test("retired Qwen Web ids cannot bypass the tombstone through a connection proxy", async () => {
for (const providerId of ["qwen-web", "qw", " QwEn-WeB ", "\tQW\n"]) {
await assert.rejects(
resolveExecutorWithProxy(providerId, undefined, {
cliproxyapiMode: "claude-native",
}),
(error: unknown) => {
const typed = error as Error & { status?: number };
assert.equal(typed.status, 410);
assert.match(typed.message, /retired/i);
return true;
}
);
}
const qwenCloud = await resolveExecutorWithProxy("qwen-cloud", undefined, {
cliproxyapiMode: "claude-native",
});
assert.equal(qwenCloud, getExecutor("cliproxyapi"));
});

View File

@@ -14,6 +14,7 @@ process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const modelsDb = await import("../../src/lib/db/models.ts");
const combo = await import("../../open-sse/services/combo.ts");
const providerModels = await import("../../open-sse/config/providerModels.ts");
@@ -63,6 +64,55 @@ test("expandAutoComboCandidatePool adds every model of an active provider when n
}
});
test("expandAutoComboCandidatePool excludes retired Qwen rows with synced models", async () => {
const db = core.getDbInstance();
db.exec(`
DROP TRIGGER provider_connections_retire_qwen_web_insert;
DROP TRIGGER provider_connections_retire_qwen_web_update;
`);
const qwenWeb = await providersDb.createProviderConnection({
provider: "qwen-web",
authType: "apikey",
name: "Retired Qwen Web",
apiKey: "retired-qwen-web-key",
});
const legacyAlias = await providersDb.createProviderConnection({
provider: "qw",
authType: "apikey",
name: "Retired Qwen Web Alias",
apiKey: "retired-qw-key",
});
const qwenCloud = await providersDb.createProviderConnection({
provider: "qwen-cloud",
authType: "apikey",
name: "Qwen Cloud Control",
apiKey: "qwen-cloud-key",
});
await modelsDb.replaceSyncedAvailableModelsForConnection("qwen-web", qwenWeb.id, [
{ id: "retired-web-model", name: "Retired Web Model" },
]);
await modelsDb.replaceSyncedAvailableModelsForConnection("qw", legacyAlias.id, [
{ id: "retired-alias-model", name: "Retired Alias Model" },
]);
await modelsDb.replaceSyncedAvailableModelsForConnection("qwen-cloud", qwenCloud.id, [
{ id: "qwen3.8-max", name: "Qwen3.8 Max" },
]);
const expanded = await combo.expandAutoComboCandidatePool([], { config: {} });
assert.equal(
expanded.some((target) => target.provider === "qwen-web"),
false
);
assert.equal(
expanded.some((target) => target.provider === "qw"),
false
);
assert.ok(expanded.some((target) => target.modelStr === "qwen-cloud/qwen3.8-max"));
});
test("expandAutoComboCandidatePool is a no-op when an explicit candidatePool exists", async () => {
await providersDb.createProviderConnection({
provider: "openai",

View File

@@ -1,370 +0,0 @@
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import type { RegistryModel } from "../../open-sse/config/providers/shared.ts";
const mod = await import("../../open-sse/executors/qwen-web.ts");
const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts");
const { FREE_MODEL_BUDGETS } = await import("../../open-sse/config/freeModelCatalog.data.ts");
type FetchCall = { url: string; init: any };
const realFetch = globalThis.fetch;
let calls: FetchCall[] = [];
/** Build an SSE Response from an array of v2 "phase" delta events. */
function sseResponse(events: Array<Record<string, unknown>>): Response {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
for (const ev of events) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(ev)}\n\n`));
}
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
},
});
return new Response(stream, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}
function chatCreatedResponse(id = "chat-abc"): Response {
return new Response(JSON.stringify({ success: true, data: { id } }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
/** The 504 + HTML page Alibaba's gateway returns for the retired v1 endpoint
* and for WAF-blocked requests. */
function wafHtmlResponse(status = 504): Response {
return new Response(
"<html>\n<head><title>504 Gateway Time-out</title></head>\n<body>\n" +
"<center><h1>504 Gateway Time-out</h1></center>\n<hr><center>alibaba-ga</center>\n" +
'<meta name="aliyun_waf_aa" content="ff926c7f07e45e2e487a29a6197d3460">\n</body>\n</html>',
{ status, headers: { "content-type": "text/html; charset=utf-8" } }
);
}
beforeEach(() => {
calls = [];
});
afterEach(() => {
globalThis.fetch = realFetch;
});
describe("QwenWebExecutor (v2 migration)", () => {
it("can be instantiated", () => {
assert.ok(new mod.QwenWebExecutor());
});
it("uses the v2 two-step flow: chats/new then chat/completions?chat_id=", async () => {
globalThis.fetch = (async (url: any, init: any = {}) => {
calls.push({ url: String(url), init });
if (String(url).includes("/api/v2/chats/new")) return chatCreatedResponse("chat-xyz");
return sseResponse([
{ choices: [{ delta: { phase: "answer", content: "Hello", status: "typing" } }] },
{ choices: [{ delta: { phase: "answer", content: " world", status: "finished" } }] },
]);
}) as any;
const executor = new mod.QwenWebExecutor();
const result = await executor.execute({
model: "qwen3.7-max",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "token=jwt-tok; cna=abc; ssxmod_itna=1-xyz" },
signal: null,
} as any);
assert.equal(calls.length, 2, "should make exactly two upstream calls");
assert.match(calls[0].url, /\/api\/v2\/chats\/new$/);
assert.equal(calls[0].init.method, "POST");
assert.match(calls[1].url, /\/api\/v2\/chat\/completions\?chat_id=chat-xyz/);
assert.equal(calls[1].init.method, "POST");
// chats/new payload shape
const newBody = JSON.parse(calls[0].init.body);
assert.deepEqual(newBody.models, ["qwen3.7-max"]);
assert.equal(newBody.chat_type, "t2t");
assert.equal(newBody.chat_mode, "normal");
// completion payload references the created chat_id
const compBody = JSON.parse(calls[1].init.body);
assert.equal(compBody.chat_id, "chat-xyz");
assert.equal(compBody.model, "qwen3.7-max");
assert.equal(compBody.messages[0].role, "user");
assert.equal(compBody.messages[0].content, "hi");
assert.equal(compBody.messages[0].feature_config.thinking_enabled, false);
const json = (await result.response.json()) as any;
assert.equal(json.choices[0].message.content, "Hello world");
});
it("replays the full cookie jar and the extracted bearer token on every call", async () => {
globalThis.fetch = (async (url: any, init: any = {}) => {
calls.push({ url: String(url), init });
if (String(url).includes("/api/v2/chats/new")) return chatCreatedResponse();
return sseResponse([
{ choices: [{ delta: { phase: "answer", content: "ok", status: "finished" } }] },
]);
}) as any;
const cookieBlob = "token=jwt-secret; cna=CNA1; ssxmod_itna=1-AAA; ssxmod_itna2=1-BBB";
const executor = new mod.QwenWebExecutor();
await executor.execute({
model: "qwen3.7-plus",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: cookieBlob },
signal: null,
} as any);
for (const call of calls) {
const headers = call.init.headers as Record<string, string>;
const cookie = headers.Cookie || headers.cookie || "";
assert.match(cookie, /cna=CNA1/, "full cookie jar must be replayed");
assert.match(cookie, /ssxmod_itna=1-AAA/, "WAF cookies must be replayed");
const auth = headers.Authorization || headers.authorization || "";
assert.equal(auth, "Bearer jwt-secret", "bearer token extracted from token= cookie");
}
});
it("sends the anti-bot headers required by the v2 endpoint", async () => {
globalThis.fetch = (async (url: any, init: any = {}) => {
calls.push({ url: String(url), init });
if (String(url).includes("/api/v2/chats/new")) return chatCreatedResponse();
return sseResponse([
{ choices: [{ delta: { phase: "answer", content: "ok", status: "finished" } }] },
]);
}) as any;
const executor = new mod.QwenWebExecutor();
await executor.execute({
model: "qwen3.7-plus",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "token=t; cna=c" },
signal: null,
} as any);
const headers = calls[0].init.headers as Record<string, string>;
assert.ok(headers["bx-v"], "bx-v header present");
assert.ok(headers["bx-umidtoken"], "bx-umidtoken header present");
assert.equal(headers.source || headers.Source, "web", "source: web header present");
});
it("sends the Qwen SPA build 'version' header on the v2 chat completion request", async () => {
globalThis.fetch = (async (url: any, init: any = {}) => {
calls.push({ url: String(url), init });
if (String(url).includes("/api/v2/chats/new")) return chatCreatedResponse();
return sseResponse([
{ choices: [{ delta: { phase: "answer", content: "ok", status: "finished" } }] },
]);
}) as any;
const executor = new mod.QwenWebExecutor();
await executor.execute({
model: "qwen3.7-plus",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "token=t; cna=c" },
signal: null,
} as any);
// Without the `version` header the v2 endpoint short-circuits with a
// Bad_Request envelope before ever reaching the model router — see
// open-sse/executors/qwen-web.ts::QWEN_SPA_VERSION.
const completionCall = calls.find((call) => call.url.includes("/api/v2/chat/completions"));
assert.ok(completionCall, "chat/completions call must have been made");
const headers = completionCall!.init.headers as Record<string, string>;
assert.equal(headers.version, "0.2.81", "SPA build version header present");
});
it("maps the thinking phase to reasoning_content, not the answer content", async () => {
globalThis.fetch = (async (url: any) => {
if (String(url).includes("/api/v2/chats/new")) return chatCreatedResponse();
return sseResponse([
{ choices: [{ delta: { phase: "think", content: "let me think", status: "typing" } }] },
{ choices: [{ delta: { phase: "think", content: "...", status: "finished" } }] },
{ choices: [{ delta: { phase: "answer", content: "Final answer", status: "finished" } }] },
]);
}) as any;
const executor = new mod.QwenWebExecutor();
const result = await executor.execute({
model: "qwen3.7-max",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "token=t; cna=c" },
signal: null,
} as any);
const json = (await result.response.json()) as any;
assert.equal(json.choices[0].message.content, "Final answer");
assert.ok(
!String(json.choices[0].message.content).includes("let me think"),
"thinking content must not leak into the answer"
);
});
it("classifies the retired-v1 / WAF 504 HTML page as a clear auth error (not raw HTML)", async () => {
globalThis.fetch = (async (url: any) => {
if (String(url).includes("/api/v2/chats/new")) return wafHtmlResponse(504);
return chatCreatedResponse();
}) as any;
const executor = new mod.QwenWebExecutor();
const result = await executor.execute({
model: "qwen3.7-max",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "token=stale; cna=c" },
signal: null,
} as any);
assert.ok([401, 403].includes(result.response.status), "should map to an auth status");
const json = (await result.response.json()) as any;
const msg = String(json.error?.message || "");
assert.ok(!msg.includes("<html"), "raw HTML must not be returned to the client");
assert.match(msg, /session|expired|WAF|re-?login|cookie/i, "actionable error message");
});
it("streams answer-phase content as OpenAI chat.completion.chunk deltas", async () => {
globalThis.fetch = (async (url: any) => {
if (String(url).includes("/api/v2/chats/new")) return chatCreatedResponse();
return sseResponse([
{ choices: [{ delta: { phase: "answer", content: "Hi", status: "typing" } }] },
{ choices: [{ delta: { phase: "answer", content: " there", status: "finished" } }] },
]);
}) as any;
const executor = new mod.QwenWebExecutor();
const result = await executor.execute({
model: "qwen3.7-max",
body: { messages: [{ role: "user", content: "hi" }] },
stream: true,
credentials: { apiKey: "token=t; cna=c" },
signal: null,
} as any);
const text = await result.response.text();
assert.match(text, /chat\.completion\.chunk/);
assert.match(text, /"content":"Hi"/);
assert.match(text, /"content":" there"/);
assert.match(text, /data: \[DONE\]/);
});
it("accepts a bare token (back-compat) without a cookie jar", async () => {
globalThis.fetch = (async (url: any, init: any = {}) => {
calls.push({ url: String(url), init });
if (String(url).includes("/api/v2/chats/new")) return chatCreatedResponse();
return sseResponse([
{ choices: [{ delta: { phase: "answer", content: "ok", status: "finished" } }] },
]);
}) as any;
const executor = new mod.QwenWebExecutor();
await executor.execute({
model: "qwen3.7-plus",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "barejwttoken" },
signal: null,
} as any);
const headers = calls[0].init.headers as Record<string, string>;
assert.equal(headers.Authorization || headers.authorization, "Bearer barejwttoken");
});
it("registry points at the v2 endpoint and the current model catalog", () => {
const provider = (REGISTRY as any)["qwen-web"];
assert.ok(provider, "qwen-web must be registered");
assert.match(
provider.baseUrl,
/\/api\/v2\/chat\/completions$/,
"registry must use v2 endpoint"
);
const ids = provider.models.map((m: any) => m.id);
assert.deepEqual(ids.sort(), ["qwen3.6-plus", "qwen3.7-max", "qwen3.7-plus", "qwen3.8-max"]);
const qwen38 = provider.models.find((model: RegistryModel) => model.id === "qwen3.8-max");
assert.deepEqual(qwen38, {
id: "qwen3.8-max",
name: "Qwen3.8 Max",
toolCalling: false,
supportsReasoning: true,
supportsVision: true,
contextLength: 1_000_000,
maxOutputTokens: 131_072,
});
const qwen37Max = provider.models.find((model: RegistryModel) => model.id === "qwen3.7-max");
assert.equal(qwen37Max.supportsVision, false);
});
it("free-model catalog lists the current qwen-web ids (not the retired ones)", () => {
const qwenModels = (FREE_MODEL_BUDGETS as any[]).filter((m) => m.provider === "qwen-web");
const ids = qwenModels.map((m) => m.modelId);
assert.ok(ids.includes("qwen3.8-max"), "catalog must list qwen3.8-max");
assert.ok(ids.includes("qwen3.7-max"), "catalog must list qwen3.7-max");
assert.ok(!ids.includes("qwen-plus"), "retired qwen-plus must be gone");
assert.ok(
qwenModels.every((m) => m.freeType !== "discontinued"),
"qwen-web is no longer discontinued after the v2 migration"
);
});
it("uses qwen3.8-max and maps its preview id for compatibility", async () => {
globalThis.fetch = (async (url: string | URL | Request, init: RequestInit = {}) => {
calls.push({ url: String(url), init });
if (String(url).includes("/api/v2/chats/new")) return chatCreatedResponse();
return sseResponse([
{ choices: [{ delta: { phase: "answer", content: "ok", status: "finished" } }] },
]);
}) as typeof globalThis.fetch;
const executor = new mod.QwenWebExecutor();
await executor.execute({
model: "qwen3.8-max-preview",
body: {
model: "qwen3.8-max-preview",
messages: [{ role: "user", content: "hi" }],
},
stream: false,
credentials: { apiKey: "token=t; cna=c" },
signal: null,
});
const newBody = JSON.parse(calls[0].init.body);
const completionBody = JSON.parse(calls[1].init.body);
assert.deepEqual(newBody.models, ["qwen3.8-max"]);
assert.equal(completionBody.model, "qwen3.8-max");
assert.equal(completionBody.messages[0].feature_config.thinking_enabled, true);
assert.equal(completionBody.messages[0].feature_config.auto_thinking, true);
});
it("maps legacy model ids to the current upstream catalog", async () => {
globalThis.fetch = (async (url: any, init: any = {}) => {
calls.push({ url: String(url), init });
if (String(url).includes("/api/v2/chats/new")) return chatCreatedResponse();
return sseResponse([
{ choices: [{ delta: { phase: "answer", content: "ok", status: "finished" } }] },
]);
}) as any;
const executor = new mod.QwenWebExecutor();
await executor.execute({
model: "qwen3-max",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "token=t; cna=c" },
signal: null,
} as any);
const newBody = JSON.parse(calls[0].init.body);
assert.match(newBody.models[0], /^qwen3\.[67]-/, "legacy qwen3-max maps to a current model id");
});
});

View File

@@ -62,7 +62,6 @@ const FAKE_CREDS: Record<string, string> = {
"v0-vercel-web": "fake-audit-sweep",
"kimi-web": "fake-audit-sweep",
"doubao-web": "sessionid=fake-audit-sweep; ttwid=fake-audit-sweep; s_v_web_id=verify_fake",
"qwen-web": "fake-audit-sweep",
"duckduckgo-web": "",
"veoaifree-web": "",
};

View File

@@ -85,8 +85,7 @@ test("2026-06-17 refresh: discontinued providers dropped, new free providers add
for (const dead of ["chutes", "phind", "kluster", "gitlawb", "aimlapi", "theoldllm"]) {
assert.ok(!providers.has(dead), `${dead} should be removed (discontinued)`);
}
// qwen-web is kept because it uses its own cookie/web path.
assert.ok(providers.has("qwen-web"), "qwen-web must stay (cookie path still free)");
assert.equal(providers.has("qwen-web"), false, "retired qwen-web must stay out of routing");
// discovered in the refresh — must be present
for (const fresh of ["kilo-gateway", "opencode-zen", "glm-cn"]) {
assert.ok(providers.has(fresh), `${fresh} should be added`);

View File

@@ -0,0 +1,517 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-qwen-web-retirement-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const RETIRED_PROVIDER_IDS = ["qwen-web", "qw"] as const;
const CONTROL_PROVIDER = "qwen-cloud";
const CONTROL_PROVIDER_IDS = [
"qwen",
"qwc",
"qct",
CONTROL_PROVIDER,
"qwen-cloud-token-plan",
"qwen-web-other",
] as const;
const ECMASCRIPT_UNICODE_TRIM_WHITESPACE = [
"\u00a0",
"\u1680",
"\u2000",
"\u2001",
"\u2002",
"\u2003",
"\u2004",
"\u2005",
"\u2006",
"\u2007",
"\u2008",
"\u2009",
"\u200a",
"\u2028",
"\u2029",
"\u202f",
"\u205f",
"\u3000",
"\ufeff",
] as const;
type ConnectionState = {
id: string;
is_active: number;
test_status: string;
error_code: string;
last_error: string;
last_error_type: string;
last_error_source: string;
last_error_at: string;
api_key: string | null;
provider_specific_data: string | null;
created_at: string;
updated_at: string;
};
type LeaseState = {
id: number;
generation: number;
state: string;
ended_at: string | null;
end_reason: string | null;
};
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("migration 163 retires every Qwen Web id fail-closed and preserves audit history", async () => {
const db = core.getDbInstance();
const applied = db
.prepare("SELECT version FROM _omniroute_migrations WHERE version = 163")
.get() as { version: number } | undefined;
assert.ok(applied, "migration 163 must be recorded as applied");
// Recreate a pre-migration fixture even though a fresh test database already
// applied migration 163 during startup.
db.exec(`
DROP TRIGGER IF EXISTS provider_connections_retire_qwen_web_insert;
DROP TRIGGER IF EXISTS provider_connections_retire_qwen_web_update;
DROP TRIGGER IF EXISTS exclusive_connection_leases_retire_qwen_web_insert;
DROP TRIGGER IF EXISTS exclusive_connection_leases_retire_qwen_web_update;
`);
// The domain module reconciles API-key policy columns on a fresh database.
// Production upgrades already carry these columns from normal API-key use.
await apiKeysDb.getApiKeys();
for (const provider of [...RETIRED_PROVIDER_IDS, ...CONTROL_PROVIDER_IDS]) {
db.prepare(
"INSERT INTO provider_connections " +
"(id, provider, auth_type, name, is_active, api_key, provider_specific_data, " +
"created_at, updated_at) VALUES (?, ?, 'apikey', ?, 1, ?, ?, " +
"'1999-01-01T00:00:00.000Z', datetime('now'))"
).run(
`${provider}-connection`,
provider,
`${provider}-fixture`,
`${provider}-secret`,
JSON.stringify({ fixture: provider })
);
}
for (const provider of RETIRED_PROVIDER_IDS) {
db.prepare(
"UPDATE provider_connections SET test_status = 'active', last_error = 'legacy error', " +
"last_error_type = 'legacy', last_error_source = 'legacy:test', " +
"last_error_at = '2000-01-01T00:00:00.000Z', updated_at = '2000-01-01T00:00:00.000Z' " +
"WHERE provider = ?"
).run(provider);
}
const normalizedProviderVariants = [
{ id: "mixed-case-qwen-web-connection", provider: " QwEn-Web " },
{ id: "mixed-case-qw-alias-connection", provider: "\tQW\n" },
{ id: "vertical-tab-qw-alias-connection", provider: "\u000bqw\u000b" },
{ id: "form-feed-qwen-web-connection", provider: "\fQWEN-WEB\f" },
{ id: "carriage-return-qw-alias-connection", provider: "\rQw\r" },
...ECMASCRIPT_UNICODE_TRIM_WHITESPACE.map((whitespace, index) => ({
id: `unicode-trim-${whitespace.codePointAt(0)?.toString(16)}-connection`,
provider: `${whitespace}${index % 2 === 0 ? "qwen-web" : "qw"}${whitespace}`,
})),
];
for (const { id, provider } of normalizedProviderVariants) {
db.prepare(
"INSERT INTO provider_connections " +
"(id, provider, auth_type, name, is_active, test_status, last_error, " +
"last_error_type, last_error_source, last_error_at, created_at, updated_at) " +
"VALUES (?, ?, 'apikey', ?, 1, 'active', 'legacy error', 'legacy', " +
"'legacy:test', '2000-01-01T00:00:00.000Z', datetime('now'), " +
"'2000-01-01T00:00:00.000Z')"
).run(id, provider, `${id}-fixture`);
}
const retiredConnectionIds = RETIRED_PROVIDER_IDS.map((provider) => `${provider}-connection`);
db.prepare(
"INSERT INTO api_keys " +
"(id, name, key, key_hash, key_prefix, allowed_connections, is_active, created_at) " +
"VALUES ('restricted-key', 'restricted-key', 'restricted-secret', " +
"'restricted-hash', 'restrict', ?, 1, datetime('now'))"
).run(JSON.stringify(retiredConnectionIds));
const mixedConnectionIds = [...retiredConnectionIds, `${CONTROL_PROVIDER}-connection`];
const mixedAllowedConnectionsRaw =
' [ "qwen-web-connection" , "qw-connection" , "qwen-cloud-connection" ] ';
db.prepare(
"INSERT INTO api_keys " +
"(id, name, key, key_hash, key_prefix, allowed_connections, is_active, created_at) " +
"VALUES ('mixed-key', 'mixed-key', 'mixed-secret', " +
"'mixed-hash', 'mixed', ?, 1, datetime('now'))"
).run(mixedAllowedConnectionsRaw);
const leaseIds = new Map<string, number>();
for (const provider of RETIRED_PROVIDER_IDS) {
const connectionId = `${provider}-connection`;
const leaseProvider = provider === "qwen-web" ? "legacy-imported-provider" : provider;
const insertedLease = db
.prepare(
"INSERT INTO exclusive_connection_leases " +
"(lease_owner_hash, api_key_id, provider, connection_id, generation, state, " +
"acquired_at, renewed_at, expires_at) VALUES (?, 'restricted-key', ?, ?, 7, " +
"'ACTIVE', datetime('now'), datetime('now'), datetime('now', '+1 hour'))"
)
.run(provider.padEnd(64, "0"), leaseProvider, connectionId);
leaseIds.set(provider, Number(insertedLease.lastInsertRowid));
db.prepare(
"INSERT INTO usage_history (provider, model, timestamp) " +
"VALUES (?, 'qwen3.8-max', datetime('now'))"
).run(provider);
db.prepare(
"INSERT INTO call_logs (id, timestamp, provider, model, status) " +
"VALUES (?, datetime('now'), ?, 'qwen3.8-max', 200)"
).run(`${provider}-call`, provider);
db.prepare(
"INSERT INTO quota_snapshots " +
"(provider, connection_id, window_key, remaining_percentage, is_exhausted, created_at) " +
"VALUES (?, ?, 'monthly', 50, 0, ?)"
).run(provider, connectionId, new Date().toISOString());
db.prepare(
"INSERT INTO proxy_logs (id, timestamp, status, provider, connection_id) " +
"VALUES (?, datetime('now'), 'success', ?, ?)"
).run(`${provider}-proxy`, provider, connectionId);
}
const controlLeaseId = Number(
db
.prepare(
"INSERT INTO exclusive_connection_leases " +
"(lease_owner_hash, api_key_id, provider, connection_id, generation, state, " +
"acquired_at, renewed_at, expires_at) VALUES (?, 'mixed-key', ?, ?, 11, " +
"'ACTIVE', datetime('now'), datetime('now'), datetime('now', '+1 hour'))"
)
.run("qwen-cloud".padEnd(64, "0"), CONTROL_PROVIDER, `${CONTROL_PROVIDER}-connection`)
.lastInsertRowid
);
const readConnection = (provider: string) =>
db
.prepare(
"SELECT id, is_active, test_status, error_code, last_error, last_error_type, " +
"last_error_source, last_error_at, api_key, provider_specific_data, created_at, " +
"updated_at FROM provider_connections " +
"WHERE provider = ?"
)
.get(provider) as ConnectionState;
const readConnectionById = (id: string) =>
db
.prepare(
"SELECT id, is_active, test_status, error_code, last_error, last_error_type, " +
"last_error_source, last_error_at, api_key, provider_specific_data, created_at, " +
"updated_at FROM provider_connections " +
"WHERE id = ?"
)
.get(id) as ConnectionState;
const readLease = (id: number) =>
db
.prepare(
"SELECT id, generation, state, ended_at, end_reason FROM exclusive_connection_leases " +
"WHERE id = ?"
)
.get(id) as LeaseState;
const readTotalChanges = () =>
(db.prepare("SELECT total_changes() AS changes").get() as { changes: number }).changes;
const sql = fs.readFileSync(
path.join(process.cwd(), "src/lib/db/migrations/163_retire_qwen_web.sql"),
"utf8"
);
db.exec(sql);
const firstConnections = new Map(
RETIRED_PROVIDER_IDS.map((provider) => [provider, readConnection(provider)])
);
const firstLeases = new Map(
RETIRED_PROVIDER_IDS.map((provider) => [provider, readLease(leaseIds.get(provider)!)])
);
const changesBeforeSecondExecution = readTotalChanges();
db.exec(sql);
assert.equal(
readTotalChanges() - changesBeforeSecondExecution,
0,
"a second execution must not rewrite any retired connection or lease row"
);
for (const provider of RETIRED_PROVIDER_IDS) {
const connection = firstConnections.get(provider)!;
const lease = firstLeases.get(provider)!;
assert.deepEqual(readConnection(provider), connection, "timestamps must remain stable");
assert.deepEqual(
readLease(leaseIds.get(provider)!),
lease,
"the invalidated lease must remain stable"
);
assert.equal(connection.id, `${provider}-connection`);
assert.equal(connection.is_active, 0);
assert.equal(connection.test_status, "unavailable");
assert.equal(connection.error_code, "PROVIDER_REMOVED");
assert.equal(connection.last_error, "Provider integration retired from OmniRoute v3.8.50");
assert.equal(connection.last_error_type, "provider_removed");
assert.equal(connection.last_error_source, "migration:retire-qwen-web");
assert.notEqual(connection.last_error_at, "2000-01-01T00:00:00.000Z");
assert.equal(connection.api_key, `${provider}-secret`);
assert.equal(connection.provider_specific_data, JSON.stringify({ fixture: provider }));
assert.equal(connection.created_at, "1999-01-01T00:00:00.000Z");
assert.notEqual(connection.updated_at, "2000-01-01T00:00:00.000Z");
assert.equal(lease.id, leaseIds.get(provider));
assert.equal(lease.generation, 7);
assert.equal(lease.state, "INVALIDATED");
assert.ok(lease.ended_at);
assert.equal(lease.end_reason, "CONNECTION_INELIGIBLE");
assert.ok(db.prepare("SELECT id FROM usage_history WHERE provider = ?").get(provider));
assert.ok(db.prepare("SELECT id FROM call_logs WHERE provider = ?").get(provider));
assert.ok(db.prepare("SELECT id FROM quota_snapshots WHERE provider = ?").get(provider));
assert.ok(db.prepare("SELECT id FROM proxy_logs WHERE provider = ?").get(provider));
}
for (const { id } of normalizedProviderVariants) {
const connection = db
.prepare(
"SELECT is_active, test_status, error_code, last_error_type, last_error_source " +
"FROM provider_connections WHERE id = ?"
)
.get(id) as {
is_active: number;
test_status: string;
error_code: string;
last_error_type: string;
last_error_source: string;
};
assert.deepEqual(connection, {
is_active: 0,
test_status: "unavailable",
error_code: "PROVIDER_REMOVED",
last_error_type: "provider_removed",
last_error_source: "migration:retire-qwen-web",
});
}
for (const provider of CONTROL_PROVIDER_IDS) {
const control = db
.prepare("SELECT is_active FROM provider_connections WHERE id = ?")
.get(`${provider}-connection`) as { is_active: number };
assert.equal(control.is_active, 1, `${provider} must remain active`);
}
assert.deepEqual(
readLease(controlLeaseId),
{
id: controlLeaseId,
generation: 11,
state: "ACTIVE",
ended_at: null,
end_reason: null,
},
"an unrelated active lease must not be invalidated"
);
const apiKey = db
.prepare("SELECT is_active, allowed_connections FROM api_keys WHERE id = 'restricted-key'")
.get() as { is_active: number; allowed_connections: string };
assert.equal(apiKey.is_active, 1);
assert.deepEqual(
JSON.parse(apiKey.allowed_connections),
retiredConnectionIds,
"an allowlist containing only Qwen Web ids must remain non-empty and fail closed"
);
const mixedApiKey = db
.prepare("SELECT is_active, allowed_connections FROM api_keys WHERE id = 'mixed-key'")
.get() as { is_active: number; allowed_connections: string };
assert.equal(mixedApiKey.is_active, 1);
assert.equal(
mixedApiKey.allowed_connections,
mixedAllowedConnectionsRaw,
"the migration must preserve a mixed allowlist byte-for-byte"
);
assert.deepEqual(
JSON.parse(mixedApiKey.allowed_connections),
mixedConnectionIds,
"a mixed allowlist must preserve both retired ids and its unrelated connection"
);
db.prepare(
"INSERT INTO provider_connections " +
"(id, provider, auth_type, name, is_active, test_status, created_at, updated_at) " +
"VALUES ('post-migration-qw', 'qwen-web', 'apikey', 'post migration import', " +
"1, 'active', datetime('now'), datetime('now'))"
).run();
const postMigrationConnection = db
.prepare(
"SELECT id, is_active, test_status, error_code, last_error, last_error_type, " +
"last_error_source, last_error_at, updated_at FROM provider_connections " +
"WHERE id = 'post-migration-qw'"
)
.get() as ConnectionState;
assert.equal(postMigrationConnection.is_active, 0);
assert.equal(postMigrationConnection.test_status, "unavailable");
assert.equal(postMigrationConnection.error_code, "PROVIDER_REMOVED");
assert.equal(postMigrationConnection.last_error_type, "provider_removed");
assert.equal(postMigrationConnection.last_error_source, "migration:retire-qwen-web");
db.prepare(
"INSERT OR REPLACE INTO provider_connections " +
"(id, provider, auth_type, name, is_active, test_status, created_at, updated_at) " +
"VALUES ('post-migration-replace-qw', '\fQW\r', 'apikey', 'replace import', " +
"1, 'active', datetime('now'), datetime('now'))"
).run();
const postMigrationReplace = readConnectionById("post-migration-replace-qw");
assert.equal(postMigrationReplace.is_active, 0);
assert.equal(postMigrationReplace.test_status, "unavailable");
assert.equal(postMigrationReplace.error_code, "PROVIDER_REMOVED");
assert.equal(postMigrationReplace.last_error_source, "migration:retire-qwen-web");
db.prepare(
"INSERT INTO provider_connections " +
"(id, provider, auth_type, name, is_active, test_status, created_at, updated_at) " +
"VALUES ('post-migration-qw-alias', ' QW ', 'apikey', 'post migration alias', " +
"1, 'active', datetime('now'), datetime('now'))"
).run();
const postMigrationAlias = db
.prepare(
"SELECT is_active, test_status, error_code, last_error_source " +
"FROM provider_connections WHERE id = 'post-migration-qw-alias'"
)
.get() as {
is_active: number;
test_status: string;
error_code: string;
last_error_source: string;
};
assert.deepEqual(postMigrationAlias, {
is_active: 0,
test_status: "unavailable",
error_code: "PROVIDER_REMOVED",
last_error_source: "migration:retire-qwen-web",
});
const insertActiveLease = (owner: string, provider: string, connectionId: string) =>
Number(
db
.prepare(
"INSERT INTO exclusive_connection_leases " +
"(lease_owner_hash, api_key_id, provider, connection_id, generation, state, " +
"acquired_at, renewed_at, expires_at) VALUES (?, ?, ?, ?, 1, 'ACTIVE', " +
"datetime('now'), datetime('now'), datetime('now', '+1 hour'))"
)
.run(owner.padEnd(64, "0"), `${owner}-key`, provider, connectionId).lastInsertRowid
);
const alreadyTombstonedInsertLeaseId = insertActiveLease(
"already-tombstoned-insert",
"legacy-imported-provider",
"already-tombstoned-insert-connection"
);
assert.equal(readLease(alreadyTombstonedInsertLeaseId).state, "ACTIVE");
db.prepare(
"INSERT INTO provider_connections " +
"(id, provider, auth_type, name, is_active, test_status, error_code, last_error, " +
"last_error_type, last_error_source, last_error_at, created_at, updated_at) " +
"VALUES ('already-tombstoned-insert-connection', '\u00a0qwen-web\ufeff', " +
"'apikey', 'already tombstoned restore', 0, 'unavailable', 'PROVIDER_REMOVED', " +
"'Provider integration retired from OmniRoute v3.8.50', 'provider_removed', " +
"'migration:retire-qwen-web', '2001-01-01T00:00:00.000Z', datetime('now'), " +
"datetime('now'))"
).run();
assert.equal(readLease(alreadyTombstonedInsertLeaseId).state, "INVALIDATED");
db.prepare(
"INSERT INTO provider_connections " +
"(id, provider, auth_type, name, is_active, created_at, updated_at) " +
"VALUES ('already-tombstoned-update-connection', 'legacy-provider', 'apikey', " +
"'update to retired', 1, datetime('now'), datetime('now'))"
).run();
const alreadyTombstonedUpdateLeaseId = insertActiveLease(
"already-tombstoned-update",
"legacy-imported-provider",
"already-tombstoned-update-connection"
);
assert.equal(readLease(alreadyTombstonedUpdateLeaseId).state, "ACTIVE");
db.prepare(
"UPDATE provider_connections SET provider = '\u2003QW\u2029', is_active = 0, " +
"test_status = 'unavailable', error_code = 'PROVIDER_REMOVED', " +
"last_error = 'Provider integration retired from OmniRoute v3.8.50', " +
"last_error_type = 'provider_removed', last_error_source = 'migration:retire-qwen-web', " +
"last_error_at = '2001-01-01T00:00:00.000Z' " +
"WHERE id = 'already-tombstoned-update-connection'"
).run();
assert.equal(readLease(alreadyTombstonedUpdateLeaseId).state, "INVALIDATED");
const directRetiredLeaseId = insertActiveLease(
"post-qwen-web",
" QwEn-Web ",
"direct-retired-provider-connection"
);
assert.equal(readLease(directRetiredLeaseId).state, "INVALIDATED");
const retiredConnectionLeaseId = insertActiveLease(
"post-retired-connection",
"legacy-imported-provider",
"post-migration-qw"
);
assert.equal(readLease(retiredConnectionLeaseId).state, "INVALIDATED");
const restoredBeforeConnectionLeaseId = insertActiveLease(
"restored-before-connection",
"legacy-imported-provider",
"restored-qwen-web-connection"
);
assert.equal(readLease(restoredBeforeConnectionLeaseId).state, "ACTIVE");
db.prepare(
"INSERT INTO provider_connections " +
"(id, provider, auth_type, name, is_active, created_at, updated_at) " +
"VALUES ('restored-qwen-web-connection', 'qwen-web', 'apikey', " +
"'restored after lease', 1, datetime('now'), datetime('now'))"
).run();
assert.equal(readLease(restoredBeforeConnectionLeaseId).state, "INVALIDATED");
const qwenCloudLeaseId = insertActiveLease(
"post-qwen-cloud",
"qwen-cloud",
"post-qwen-cloud-connection"
);
assert.deepEqual(readLease(qwenCloudLeaseId), {
id: qwenCloudLeaseId,
generation: 1,
state: "ACTIVE",
ended_at: null,
end_reason: null,
});
db.prepare(
"UPDATE provider_connections SET provider = ' QW ', is_active = 1, test_status = 'active', " +
"error_code = NULL, last_error = NULL, last_error_type = NULL, " +
"last_error_source = NULL, last_error_at = NULL WHERE provider = 'qw'"
).run();
const updateProtectedConnection = readConnectionById("qw-connection");
assert.equal(updateProtectedConnection.is_active, 0);
assert.equal(updateProtectedConnection.test_status, "unavailable");
assert.equal(updateProtectedConnection.error_code, "PROVIDER_REMOVED");
assert.equal(updateProtectedConnection.last_error_type, "provider_removed");
assert.equal(updateProtectedConnection.last_error_source, "migration:retire-qwen-web");
db.prepare("UPDATE provider_connections SET name = 'renamed' WHERE id = 'qw-connection'").run();
const unrelatedUpdate = readConnectionById("qw-connection");
assert.equal(unrelatedUpdate.last_error_at, updateProtectedConnection.last_error_at);
assert.equal(unrelatedUpdate.updated_at, updateProtectedConnection.updated_at);
});

View File

@@ -45,7 +45,6 @@ test("no two provider IDs share the same alias in the open-sse registry", () =>
test("primary providers keep the short alias; web variants use their own id", () => {
// open-sse registry (source of the startup warning + chat routing)
assert.equal(PROVIDER_ID_TO_ALIAS["qwen-web"], "qwen-web");
assert.equal(PROVIDER_ID_TO_ALIAS.kimi, "kimi");
assert.equal(PROVIDER_ID_TO_ALIAS["kimi-web"], "kimi-web");
assert.equal(PROVIDER_ID_TO_ALIAS.huggingchat, "huggingchat");
@@ -54,8 +53,7 @@ test("primary providers keep the short alias; web variants use their own id", ()
test("src/shared providers map resolves the same aliases unambiguously", () => {
// alias → id
assert.equal(resolveProviderId("kimi"), "kimi");
// id used as alias for the secondary variants
assert.equal(resolveProviderId("qwen-web"), "qwen-web");
// ids used as aliases for the supported secondary variants
assert.equal(resolveProviderId("kimi-web"), "kimi-web");
assert.equal(resolveProviderId("huggingchat"), "huggingchat");
// id → alias

View File

@@ -134,7 +134,8 @@ test("providerSets.isNamedOpenAIStyleProvider matches Set membership", () => {
test("providerModelsConfig.PROVIDER_MODELS_CONFIG keeps core provider entries", () => {
assert.equal(PROVIDER_MODELS_CONFIG.claude.url, "https://api.anthropic.com/v1/models");
assert.equal(PROVIDER_MODELS_CONFIG["qwen-web"].url, "https://chat.qwen.ai/api/v2/models/");
assert.equal(PROVIDER_MODELS_CONFIG["qwen-web"], undefined);
assert.ok(PROVIDER_MODELS_CONFIG["qwen-cloud"]);
});
test("providerModelsConfig keeps the aimlapi live catalog entry", () => {

View File

@@ -1,103 +0,0 @@
// #6267 regression guard — a config-driven provider whose /models endpoint 307s
// must degrade to the local catalog OmniRoute ships, not surface a raw 503.
//
// Root cause: safeOutboundFetch throws REDIRECT_BLOCKED on the 307 →
// getSafeOutboundFetchErrorStatus maps it to 503 → buildDiscoveryErrorFallbackResponse
// returned null for status 503 → re-throw → raw 503, hiding the non-empty
// getModelsByProviderId("qwen-web") catalog. Fix: treat REDIRECT_BLOCKED as a
// non-fixable-config error that degrades to the cached/local catalog.
//
// Harness copied (minimal) from tests/unit/provider-models-route.test.ts — the
// frozen file's own note says the seedConnection/callRoute harness is not
// separately extractable, so a small local copy is acceptable.
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-qwen-web-redirect-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const providerModelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts");
const originalFetch = globalThis.fetch;
async function resetStorage() {
globalThis.fetch = originalFetch;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
interface SeedOverrides {
authType?: string;
name?: string;
apiKey?: string;
accessToken?: string;
isActive?: boolean;
testStatus?: string;
providerSpecificData?: Record<string, unknown>;
}
async function seedConnection(provider: string, overrides: SeedOverrides = {}) {
return providersDb.createProviderConnection({
provider,
authType: overrides.authType || "apikey",
name: overrides.name || `${provider}-${Math.random().toString(16).slice(2, 8)}`,
apiKey: overrides.apiKey,
accessToken: overrides.accessToken,
isActive: overrides.isActive ?? true,
testStatus: overrides.testStatus || "active",
providerSpecificData: overrides.providerSpecificData || {},
});
}
async function callRoute(connectionId: string, search = "") {
return providerModelsRoute.GET(
new Request(`http://localhost/api/providers/${connectionId}/models${search}`),
{ params: { id: connectionId } }
);
}
test.beforeEach(async () => {
await resetStorage();
});
test.after(async () => {
globalThis.fetch = originalFetch;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("qwen-web model import degrades to the local catalog when the /models endpoint 307s (#6267)", async () => {
// A configured apiKey ensures the token gate passes and the config-driven
// fetch is actually attempted (so we exercise the redirect path, not the
// no-token fallback).
const connection = await seedConnection("qwen-web", { apiKey: "qwen-web-cookie" });
// Upstream answers the models probe with a 307 to the login page — the exact
// shape safeOutboundFetch rejects with REDIRECT_BLOCKED.
globalThis.fetch = (async () =>
new Response(null, {
status: 307,
headers: { location: "https://chat.qwen.ai/login" },
})) as typeof fetch;
const response = await callRoute(connection.id);
const body = (await response.json()) as {
source?: string;
models?: Array<{ id: string }>;
};
// RED before the fix: raw 503 (Redirect blocked … (307)).
assert.equal(response.status, 200, "a redirect on the models endpoint must not surface a 503");
assert.equal(body.source, "local_catalog", "should fall back to the shipped catalog");
const ids = (body.models || []).map((m) => m.id);
assert.ok(
ids.includes("qwen3.7-max"),
`qwen-web catalog should be surfaced; got: ${ids.join(", ")}`
);
});

View File

@@ -90,6 +90,13 @@ test("shared set contains REGISTRY ids and aliases (tokenrouter + trk)", () => {
assert.equal(RESERVED_PROVIDER_PREFIXES.has("trk"), true);
});
test("shared guard keeps retired Qwen Web ids reserved after registry removal", () => {
assert.equal(RESERVED_PROVIDER_PREFIXES.has("qwen-web"), true);
assert.equal(RESERVED_PROVIDER_PREFIXES.has("qw"), true);
assert.equal(isReservedProviderPrefix(" QwEn-WeB "), true);
assert.equal(isReservedProviderPrefix("\u00a0QW\uFEFF"), true);
});
test("shared set is case-sensitive like the runtime guard", () => {
assert.equal(isReservedProviderPrefix("TokenRouter"), false);
assert.equal(isReservedProviderPrefix("TOKENROUTER"), false);
@@ -106,13 +113,13 @@ test("shared set excludes manual aliases that never intercept nodes at runtime",
assert.equal(RESERVED_PROVIDER_PREFIXES.has("aq"), false);
});
test("shared set size matches full REGISTRY scan (398 unique prefixes)", () => {
test("shared set size matches registry plus retired tombstones (399 unique prefixes)", () => {
// Count measured against release/v3.8.51 tip after #11629 (opper) and
// #11631 (1min.ai) boarded 398 unique ids/aliases walked from the
// provider REGISTRY on top of the 395 pinned post-#11333.
// the assertion pins that the set is a full REGISTRY walk, not a
// hand-maintained list.
assert.equal(RESERVED_PREFIX_COUNT, 398);
// #11631 (1min.ai) boarded (398 pinned post-#11333) — Qwen Web's live
// REGISTRY entry (id + alias) is removed but the same two strings are
// re-added as permanent tombstones. The assertion pins the actual
// computed set size, not a hand-derived sum.
assert.equal(RESERVED_PREFIX_COUNT, 399);
});
test("isReservedProviderPrefix rejects non-string input", () => {
@@ -148,6 +155,31 @@ test("createProviderNodeSchema rejects reserved alias 'trk'", () => {
assert.equal(result.success, false);
});
test("provider node schemas reject retired Qwen Web prefixes and normalized variants", () => {
for (const prefix of ["qwen-web", "qw", " QwEn-WeB ", "\u00a0QW\uFEFF"]) {
const created = createProviderNodeSchema.safeParse({
name: "Retired prefix",
prefix,
apiType: "chat",
});
assert.equal(created.success, false, `create must reject ${JSON.stringify(prefix)}`);
const updated = updateProviderNodeSchema.safeParse({
name: "Retired prefix",
prefix,
baseUrl: "https://retired.example.invalid/v1",
});
assert.equal(updated.success, false, `update must reject ${JSON.stringify(prefix)}`);
const preset = createProviderNodeSchema.safeParse({
preset: "vibeproxy-openai",
prefix,
baseUrl: "http://localhost:8317",
});
assert.equal(preset.success, false, `preset create must reject ${JSON.stringify(prefix)}`);
}
});
test("createProviderNodeSchema accepts mixed-case 'TokenRouter' (no runtime collision)", () => {
const result = createProviderNodeSchema.safeParse({
name: "Case Test",

View File

@@ -1,95 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
// Split out of tests/unit/provider-validation-specialty.test.ts (#5855) to keep that
// god-file under its frozen file-size cap — see config/quality/file-size-baseline.json.
const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts");
const originalFetch = globalThis.fetch;
test.afterEach(() => {
globalThis.fetch = originalFetch;
});
function toPlainHeaders(headers: any) {
if (headers instanceof Headers) return Object.fromEntries(headers.entries());
return Object.fromEntries(
Object.entries(headers || {}).map(([key, value]) => [key, String(value)])
);
}
// #3288 / #3758: qwen-web validation used to fall through to the generic
// OpenAI-compatible validator, which probed a non-existent `/api/v2/models` URL that
// answered with a 307 redirect — blocked by the outbound guard and mislabeled as an
// SSRF block. A specialty validator now probes the real session endpoint instead.
//
// History of the probe URL:
// - Originally `GET /api/v2/user` (returned `{ user: { ... } }`).
// - As of mid-2026, `/api/v2/user` is retired and answers `not found` regardless
// of credentials. The probe moved to `GET /api/v1/auths/` (trailing slash
// required), which returns the user object at the top level.
test("qwen-web validator probes /api/v1/auths/ (not /api/v2/models) and returns valid on 200", async () => {
let probedUrl = "";
let sentHeaders: Record<string, string> = {};
globalThis.fetch = async (url, init = {}) => {
probedUrl = String(url);
sentHeaders = toPlainHeaders(init.headers);
// /api/v1/auths/ returns the user object at the top level when the
// Authorization header is valid. The id must be >= 8 chars for the
// tightened top-level user-id check (#5855) to accept it.
return new Response(
JSON.stringify({ id: "u-1234567", email: "tester@example.com", name: "Tester", role: "user" }),
{
status: 200,
headers: { "content-type": "application/json" },
}
);
};
const result = await validateProviderApiKey({
provider: "qwen-web",
apiKey: "token=eyJqwen; cna=abc; ssxmod_itna=def",
});
assert.equal(probedUrl, "https://chat.qwen.ai/api/v1/auths/");
assert.ok(!probedUrl.includes("/api/v2/models"), "must not probe the bogus /api/v2/models URL");
assert.ok(!probedUrl.includes("/api/v2/user"), "must not probe the retired /api/v2/user URL");
assert.equal(sentHeaders.Authorization, "Bearer eyJqwen");
assert.equal(sentHeaders.source, "web");
assert.match(sentHeaders.Cookie, /token=eyJqwen/);
assert.equal(result.valid, true);
});
test("qwen-web validator reports an invalid session (401) without flagging a security block", async () => {
globalThis.fetch = async () =>
new Response(JSON.stringify({ error: "unauthorized" }), {
status: 401,
headers: { "content-type": "application/json" },
});
const result = await validateProviderApiKey({
provider: "qwen-web",
apiKey: "token=stale; cna=abc; ssxmod_itna=def",
});
assert.equal(result.valid, false);
assert.equal((result as { securityBlocked?: boolean }).securityBlocked ?? false, false);
assert.match(result.error ?? "", /invalid or expired/i);
});
test("qwen-web validator surfaces the WAF/anti-bot HTML challenge as a re-login hint", async () => {
globalThis.fetch = async () =>
new Response("<html>aliyun_waf</html>", {
status: 200,
headers: { "content-type": "text/html" },
});
const result = await validateProviderApiKey({
provider: "qwen-web",
apiKey: "token=eyJqwen; cna=abc; ssxmod_itna=def",
});
assert.equal(result.valid, false);
assert.match(result.error ?? "", /WAF|Cookie header/i);
});

View File

@@ -2980,11 +2980,11 @@ test("gitlawb-gmi validator: accepts custom baseUrl override", async () => {
test("isSecurityBlockError: public-host redirect block is NOT a security block", () => {
const publicRedirect = new SafeOutboundFetchError("Redirect blocked", {
code: "REDIRECT_BLOCKED",
url: "https://chat.qwen.ai/api/v2/models/",
url: "https://public-provider.example.com/api/v2/models/",
method: "GET",
attempts: 1,
status: 307,
location: "https://chat.qwen.ai/login",
location: "https://public-provider.example.com/login",
isRetryable: false,
});
assert.equal(isSecurityBlockError(publicRedirect), false);

View File

@@ -1,83 +0,0 @@
// Regression: Qwen Web executor folded structured (array) message content with a
// bare String(m.content), producing the literal "[object Object]" prompt instead of
// the real text (reported on the support mesh: "[[object][object]] serialisation error").
// The executor must flatten OpenAI-style content parts into their text before sending.
import { describe, it, afterEach } from "node:test";
import assert from "node:assert/strict";
const mod = await import("../../open-sse/executors/qwen-web.ts");
type FetchCall = { url: string; init: { method?: string; body?: string } };
const realFetch = globalThis.fetch;
function sseResponse(events: Array<Record<string, unknown>>): Response {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
for (const ev of events) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(ev)}\n\n`));
}
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
},
});
return new Response(stream, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}
function chatCreatedResponse(id = "chat-arr"): Response {
return new Response(JSON.stringify({ success: true, data: { id } }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
afterEach(() => {
globalThis.fetch = realFetch;
});
describe("QwenWebExecutor — structured (array) content serialization", () => {
it("flattens OpenAI-style content parts to text (no '[object Object]')", async () => {
const calls: FetchCall[] = [];
globalThis.fetch = (async (url: string | URL | Request, init: RequestInit = {}) => {
calls.push({ url: String(url), init: init as { method?: string; body?: string } });
if (String(url).includes("/api/v2/chats/new")) return chatCreatedResponse();
return sseResponse([
{ choices: [{ delta: { phase: "answer", content: "ok", status: "finished" } }] },
]);
}) as typeof fetch;
const executor = new mod.QwenWebExecutor();
await executor.execute({
model: "qwen3.7-max",
body: {
messages: [
{ role: "system", content: [{ type: "text", text: "You are helpful." }] },
{
role: "user",
content: [
{ type: "text", text: "First part." },
{ type: "text", text: "Second part." },
],
},
],
},
stream: false,
credentials: { apiKey: "token=jwt-tok; cna=abc" },
signal: null,
} as unknown as Parameters<typeof executor.execute>[0]);
const compBody = JSON.parse(calls[1].init.body);
const sent = String(compBody.messages[0].content);
assert.ok(
!sent.includes("[object Object]"),
`prompt must not contain '[object Object]', got: ${sent}`
);
assert.ok(sent.includes("First part."), "text of first content part must survive");
assert.ok(sent.includes("Second part."), "text of second content part must survive");
assert.ok(sent.includes("You are helpful."), "system content part must survive");
});
});

View File

@@ -1,73 +0,0 @@
// Regression for #3931 / #3958: Qwen's session probe endpoint must return a real
// user object for the validator to accept it.
//
// History of the probe URL:
// - Originally `GET /api/v2/user` returned `{ user: { ... } }` (nested).
// - As of mid-2026, `/api/v2/user` is retired and answers `not found` regardless
// of credentials. The probe moved to `GET /api/v1/auths/` (trailing slash
// required), which returns the user object at the top level:
// `{ id, email, name, role, ... }`.
//
// These tests mock `/api/v1/auths/` and assert the validator accepts a real user
// object (top-level `id`), and rejects bodies that lack one (was the original
// #3958 false-positive: HTTP 200 with no user).
import test from "node:test";
import assert from "node:assert/strict";
const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts");
const originalFetch = globalThis.fetch;
test.afterEach(() => {
globalThis.fetch = originalFetch;
});
function jsonResponse(body: string) {
return new Response(body, {
status: 200,
headers: { "content-type": "application/json" },
});
}
test("qwen-web validation is VALID when /api/v1/auths/ returns a top-level user object", async () => {
let probedUrl = "";
globalThis.fetch = (async (url: any) => {
probedUrl = String(url);
return jsonResponse(
JSON.stringify({ id: "u-1234567", email: "tester@example.com", name: "tester", role: "user" })
);
}) as typeof fetch;
const result = await validateProviderApiKey({ provider: "qwen-web", apiKey: "qwen-token-abc123" });
assert.strictEqual(result.valid, true);
assert.equal(probedUrl, "https://chat.qwen.ai/api/v1/auths/");
});
test("qwen-web validation rejects a 200 response with no user object (was false-positive)", async () => {
globalThis.fetch = (async () => jsonResponse(JSON.stringify({}))) as typeof fetch;
const result = await validateProviderApiKey({ provider: "qwen-web", apiKey: "qwen-token-abc123" });
assert.strictEqual(result.valid, false);
assert.match(result.error, /invalid or expired/i);
});
test("qwen-web validation still accepts legacy nested shapes for robustness", async () => {
globalThis.fetch = (async () =>
jsonResponse(JSON.stringify({ user: { id: "u-2" } }))) as typeof fetch;
const result = await validateProviderApiKey({ provider: "qwen-web", apiKey: "qwen-token-abc123" });
assert.strictEqual(result.valid, true);
globalThis.fetch = (async () =>
jsonResponse(JSON.stringify({ data: { user: { id: "u-3" } } }))) as typeof fetch;
const result2 = await validateProviderApiKey({ provider: "qwen-web", apiKey: "qwen-token-abc123" });
assert.strictEqual(result2.valid, true);
});
test("qwen-web validation rejects a 200 body that is not valid JSON", async () => {
globalThis.fetch = (async () => jsonResponse("<<not json>>")) as typeof fetch;
const result = await validateProviderApiKey({ provider: "qwen-web", apiKey: "qwen-token-abc123" });
assert.strictEqual(result.valid, false);
assert.match(result.error, /invalid JSON/i);
});

View File

@@ -1,127 +0,0 @@
/**
* TDD regression for #3931 (Problem #3, diagnosed by @thezukiru in discussion
* #3895): the `qwen-web` cookie provider had no entry in PROVIDER_MODELS_CONFIG
* (`src/app/api/providers/[id]/models/route.ts`), so the model-discovery page
* returned nothing for it, so qwen-web fell through to the no-config branch.
*
* (Problem #1 — the validator bare-token false-positive — was already fixed in
* the merged PR #3958; Problem #2 — empty stream from WAF bot-detection on the
* streaming endpoint — is a separate upstream/stealth concern, still open.)
*
* Fix: add a `qwen-web` PROVIDER_MODELS_CONFIG entry pointing at the public
* `https://chat.qwen.ai/api/v2/models/` endpoint, parsing the
* `{ data: { data: [{ id, name, owned_by }] } }` shape.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-3931-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const modelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
interface ModelsBody {
provider: string;
connectionId: string;
models: Array<{ id: string; name?: string; owned_by?: string }>;
source?: string;
}
const QWEN_WEB_MODELS_URL = "https://chat.qwen.ai/api/v2/models/";
test("#3931 qwen-web model discovery fetches the public /api/v2/models catalog", async () => {
await resetStorage();
const connection = await providersDb.createProviderConnection({
provider: "qwen-web",
authType: "apikey",
name: "qwen-web-discovery",
apiKey: "cna=abc; token=def; ssxmod_itna=xyz",
});
let fetchedUrl: string | null = null;
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url: string | URL | Request) => {
const u = String(url);
if (u.startsWith(QWEN_WEB_MODELS_URL)) {
fetchedUrl = u;
// Real qwen shape: { data: { data: [ { id, name, owned_by } ] } }
return Response.json({
data: {
data: [
{ id: "qwen3-max", name: "Qwen3 Max", owned_by: "qwen" },
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus", owned_by: "qwen" },
],
},
});
}
return new Response("not found", { status: 404 });
}) as typeof globalThis.fetch;
try {
const response = await modelsRoute.GET(
new Request(`http://localhost/api/providers/${connection.id}/models?refresh=true`),
{ params: { id: connection.id } }
);
assert.equal(response.status, 200);
const body = (await response.json()) as ModelsBody;
assert.equal(body.provider, "qwen-web");
assert.equal(
body.source,
"api",
"should serve the live qwen-web catalog, not local_catalog/empty"
);
assert.ok(fetchedUrl, `should have probed ${QWEN_WEB_MODELS_URL}`);
const ids = body.models.map((m) => m.id);
assert.ok(ids.includes("qwen3-max"), `live ids missing: ${ids.join(",")}`);
assert.ok(ids.includes("qwen3-coder-plus"), `live ids missing: ${ids.join(",")}`);
} finally {
globalThis.fetch = originalFetch;
}
});
test("#3931 qwen-web parseResponse tolerates the flatter { data: [...] } shape", async () => {
await resetStorage();
const connection = await providersDb.createProviderConnection({
provider: "qwen-web",
authType: "apikey",
name: "qwen-web-flat",
apiKey: "cna=abc; token=def",
});
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url: string | URL | Request) => {
if (String(url).startsWith(QWEN_WEB_MODELS_URL)) {
return Response.json({ data: [{ id: "qwen-plus", name: "Qwen Plus" }] });
}
return new Response("not found", { status: 404 });
}) as typeof globalThis.fetch;
try {
const response = await modelsRoute.GET(
new Request(`http://localhost/api/providers/${connection.id}/models?refresh=true`),
{ params: { id: connection.id } }
);
assert.equal(response.status, 200);
const body = (await response.json()) as ModelsBody;
assert.equal(body.source, "api");
assert.ok(body.models.map((m) => m.id).includes("qwen-plus"));
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -0,0 +1,101 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import { FREE_MODEL_BUDGETS } from "../../open-sse/config/freeModelCatalog.ts";
import { REGISTRY, getRegistryEntry } from "../../open-sse/config/providerRegistry.ts";
import { getExecutor, hasSpecializedExecutor } from "../../open-sse/executors/index.ts";
import { getExtractionConfig } from "../../open-sse/services/tokenExtractionConfig.ts";
import { PROVIDER_MODELS_CONFIG } from "../../src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts";
import { getLobeProviderIcon } from "../../src/shared/components/lobeProviderIcons.ts";
import { AI_PROVIDERS } from "../../src/shared/constants/providers.ts";
import {
assertRuntimeProviderAvailable,
isRuntimeRetiredProviderId,
} from "../../src/shared/constants/providerRetirement.ts";
import { getWebSessionCredentialRequirement } from "../../src/shared/providers/webSessionCredentials.ts";
test("Qwen Web provenance-hold integration is absent from runtime dispatch", () => {
assert.equal(REGISTRY["qwen-web"], undefined);
assert.equal(getRegistryEntry("qwen-web"), null);
assert.equal(getRegistryEntry("qw"), null);
assert.equal(AI_PROVIDERS["qwen-web"], undefined);
assert.equal(hasSpecializedExecutor("qwen-web"), false);
assert.equal(hasSpecializedExecutor("qw"), false);
});
test("retired Qwen Web ids fail closed instead of falling through to OpenAI", () => {
for (const providerId of [
"qwen-web",
"qw",
" QwEn-WeB ",
"\tQW\n",
"\u00a0QWEN-WEB\uFEFF",
"\u2003qw\u2029",
]) {
assert.throws(
() => getExecutor(providerId),
(error: unknown) => {
const typed = error as Error & { status?: number };
assert.equal(typed.status, 410);
assert.match(typed.message, /retired/i);
return true;
},
`${providerId} must never receive DefaultExecutor fallback`
);
}
const rawMixedCaseId = "\tQwEn-WeB\n";
assert.throws(
() => assertRuntimeProviderAvailable(rawMixedCaseId),
(error: unknown) => {
const typed = error as Error & { status?: number };
assert.equal(typed.status, 410);
assert.equal(typed.message.includes(rawMixedCaseId.trim()), false);
return true;
}
);
});
test("Qwen Web admission surfaces are absent from the shipped runtime", () => {
const qwenWebModels = FREE_MODEL_BUDGETS.filter(({ provider }) => provider === "qwen-web");
assert.deepEqual(qwenWebModels, []);
assert.equal(PROVIDER_MODELS_CONFIG["qwen-web"], undefined);
assert.equal(getExtractionConfig("qwen-web"), undefined);
assert.equal(getWebSessionCredentialRequirement("qwen-web"), null);
assert.equal(getLobeProviderIcon("qwen-web"), null);
});
test("official and local Qwen identities remain outside the retirement tombstone", () => {
for (const providerId of [
"qwen",
"qwc",
"qct",
"qwen-cloud",
"qwen-cloud-token-plan",
"qwen-web-other",
]) {
assert.equal(isRuntimeRetiredProviderId(providerId), false, providerId);
assert.doesNotThrow(() => assertRuntimeProviderAvailable(providerId), providerId);
}
assert.ok(REGISTRY["qwen-cloud"]);
assert.ok(REGISTRY["qwen-cloud-token-plan"]);
});
test("Qwen Web implementation files are absent from the shipped tree", () => {
const removedPaths = [
"open-sse/config/providers/registry/qwen/web/index.ts",
"open-sse/executors/qwen-web.ts",
];
for (const relativePath of removedPaths) {
assert.equal(
fs.existsSync(path.join(process.cwd(), relativePath)),
false,
`${relativePath} must not ship`
);
}
});

View File

@@ -0,0 +1,287 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-qw-runtime-block-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const combosDb = await import("../../src/lib/db/combos.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const providerNodesDb = await import("../../src/lib/db/providers/nodes.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
const { hashLeaseOwnerId } = await import("../../src/lib/db/exclusiveConnectionLeases.ts");
const { getProviderCredentials } = await import("../../src/sse/services/auth.ts");
const { getModelInfo } = await import("../../src/sse/services/model.ts");
const { resolveModelOrError } = await import("../../src/sse/handlers/chatHelpers.ts");
const chatRoute = await import("../../src/app/api/v1/chat/completions/route.ts");
const originalFetch = globalThis.fetch;
const RETIRED_PROVIDER_VARIANTS = [
"qwen-web",
"qw",
" QwEn-Web ",
"\tQW\n",
"\u00a0QWEN-WEB\uFEFF",
"\u2003qw\u2029",
"\u3000QWEN-WEB\u3000",
] as const;
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
core.getDbInstance();
}
test.beforeEach(async () => {
globalThis.fetch = originalFetch;
await resetStorage();
});
test.afterEach(async () => {
globalThis.fetch = originalFetch;
await new Promise((resolve) => setImmediate(resolve));
});
test.after(() => {
globalThis.fetch = originalFetch;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
function isRetiredError(error: unknown): boolean {
const typed = error as Error & { status?: number };
assert.equal(typed.status, 410);
assert.match(typed.message, /retired/i);
return true;
}
test("retired Qwen Web model prefixes cannot shadow custom compatible nodes", async () => {
for (const [index, providerId] of ["qwen-web", "qw", "QwEn-WeB", "QW"].entries()) {
const nodeId = `openai-compatible-chat-retired-prefix-${index}`;
await providerNodesDb.createProviderNode({
id: nodeId,
type: "openai-compatible",
name: `Retired prefix ${providerId}`,
prefix: providerId,
apiType: "chat",
baseUrl: "https://retired.example.invalid/v1",
});
await assert.rejects(() => getModelInfo(`${providerId}/gpt-4o`), isRetiredError);
}
});
test("stripModelPrefix cannot erase retired Qwen Web identities before dispatch", async () => {
await settingsDb.updateSettings({ stripModelPrefix: true });
try {
for (const providerId of ["qwen-web", "qw", "QwEn-WeB", "QW"]) {
await assert.rejects(() => getModelInfo(`${providerId}/gpt-4o`), isRetiredError);
}
} finally {
await settingsDb.updateSettings({ stripModelPrefix: false });
}
});
test("direct chat resolution converts retired provider failures into sanitized HTTP 410", async () => {
for (const providerId of ["qwen-web", "qw"]) {
const result = await resolveModelOrError(
`${providerId}/gpt-4o`,
{ model: `${providerId}/gpt-4o`, messages: [{ role: "user", content: "hello" }] },
"/v1/chat/completions"
);
assert.ok(result.error instanceof Response);
assert.equal(result.error.status, 410);
const body = (await result.error.json()) as { error?: { message?: string } };
assert.equal(body.error?.message, "Provider is retired and unavailable.");
assert.equal(JSON.stringify(body).includes(providerId), false);
}
});
test("priority combo skips retired Qwen Web target and falls back to a healthy target", async () => {
await providersDb.createProviderConnection({
provider: "openai",
authType: "apikey",
name: "Healthy combo fallback",
apiKey: "sk-healthy-combo-fallback",
isActive: true,
testStatus: "active",
});
await combosDb.createCombo({
name: "retired-qwen-fallback",
strategy: "priority",
models: [
{ provider: "qwen-web", model: "gpt-4o" },
{ provider: "openai", model: "gpt-4o" },
],
});
const fetchCalls: string[] = [];
globalThis.fetch = async (input: string | URL | Request) => {
fetchCalls.push(String(input));
return Response.json({
id: "chatcmpl-retired-qwen-fallback",
choices: [{ message: { role: "assistant", content: "healthy fallback" } }],
});
};
const response = await chatRoute.POST(
new Request("http://localhost/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-OmniRoute-No-Cache": "true",
},
body: JSON.stringify({
model: "retired-qwen-fallback",
messages: [{ role: "user", content: "hello" }],
stream: false,
}),
})
);
assert.equal(response.status, 200);
assert.equal(fetchCalls.length, 1);
const body = (await response.json()) as { choices?: Array<{ message?: { content?: string } }> };
assert.equal(body.choices?.[0]?.message?.content, "healthy fallback");
});
test("retired Qwen Web ids stay ineligible after imports, even if DB triggers are bypassed", async () => {
const db = core.getDbInstance();
const created = await providersDb.createProviderConnection({
provider: "qwen-web",
authType: "apikey",
name: "Retired create response",
apiKey: "retired-create-key",
isActive: true,
});
assert.equal(created.isActive, false, "create must report the tombstoned persisted state");
const updated = await providersDb.updateProviderConnection(created.id, {
isActive: true,
testStatus: "active",
errorCode: null,
lastError: null,
lastErrorType: null,
lastErrorSource: null,
lastErrorAt: null,
});
assert.equal(updated?.isActive, false, "update must report the tombstoned persisted state");
assert.equal(updated?.errorCode, "PROVIDER_REMOVED");
for (const [index, providerId] of RETIRED_PROVIDER_VARIANTS.entries()) {
const connectionId = `trigger-normalized-${index}`;
db.prepare(
"INSERT INTO provider_connections " +
"(id, provider, auth_type, name, is_active, test_status, created_at, updated_at) " +
"VALUES (?, ?, 'apikey', ?, 1, 'active', datetime('now'), datetime('now'))"
).run(connectionId, providerId, `${providerId}-post-migration-import`);
const persistedState = db
.prepare(
"SELECT is_active, test_status, error_code, last_error_type, last_error_source " +
"FROM provider_connections WHERE id = ?"
)
.get(connectionId) as {
is_active: number;
test_status: string;
error_code: string;
last_error_type: string;
last_error_source: string;
};
assert.deepEqual(persistedState, {
is_active: 0,
test_status: "unavailable",
error_code: "PROVIDER_REMOVED",
last_error_type: "provider_removed",
last_error_source: "migration:retire-qwen-web",
});
const credentials = await getProviderCredentials(
providerId,
null,
[connectionId],
"qwen3.8-max",
{ allowSuppressedConnections: true }
);
assert.equal(
credentials,
null,
`${providerId} must remain blocked after trigger normalization`
);
}
db.exec(`
DROP TRIGGER provider_connections_retire_qwen_web_insert;
DROP TRIGGER provider_connections_retire_qwen_web_update;
DROP TRIGGER exclusive_connection_leases_retire_qwen_web_insert;
DROP TRIGGER exclusive_connection_leases_retire_qwen_web_update;
`);
for (const [index, providerId] of RETIRED_PROVIDER_VARIANTS.entries()) {
const connectionId = `truly-active-${index}`;
const leaseOwnerId = `vlo_${String.fromCharCode(65 + index).repeat(43)}`;
const apiKeyId = `retired-key-${index}`;
const generation = index + 1;
const now = new Date().toISOString();
const expiresAt = new Date(Date.now() + 60_000).toISOString();
db.prepare(
"INSERT INTO provider_connections " +
"(id, provider, auth_type, name, is_active, test_status, created_at, updated_at) " +
"VALUES (?, ?, 'apikey', ?, 1, 'active', ?, ?)"
).run(connectionId, providerId, `${providerId}-trigger-bypass`, now, now);
db.prepare(
"INSERT INTO exclusive_connection_leases " +
"(lease_owner_hash, api_key_id, provider, connection_id, generation, state, " +
"acquired_at, renewed_at, expires_at) VALUES (?, ?, ?, ?, ?, 'ACTIVE', ?, ?, ?)"
).run(
hashLeaseOwnerId(leaseOwnerId),
apiKeyId,
providerId,
connectionId,
generation,
now,
now,
expiresAt
);
const activeBeforeSelection = db
.prepare("SELECT is_active, test_status FROM provider_connections WHERE id = ?")
.get(connectionId) as { is_active: number; test_status: string };
assert.deepEqual(
activeBeforeSelection,
{ is_active: 1, test_status: "active" },
"fixture must bypass the migration triggers so the auth tombstone is tested independently"
);
const credentials = await getProviderCredentials(
providerId,
null,
[connectionId],
"qwen3.8-max",
{
allowSuppressedConnections: true,
lease: {
apiKeyId,
context: { leaseOwnerId, generation },
mode: "request",
},
}
);
assert.equal(credentials, null, `${providerId} must be blocked even with a truly active row`);
const lease = db
.prepare("SELECT state, end_reason FROM exclusive_connection_leases WHERE connection_id = ?")
.get(connectionId) as { state: string; end_reason: string | null };
assert.deepEqual(lease, {
state: "INVALIDATED",
end_reason: "CONNECTION_INELIGIBLE",
});
}
});

View File

@@ -7,7 +7,7 @@ import { hasKnownProviderModel } from "../../open-sse/services/model.ts";
/**
* Bare `qwen3.8-max` was an unroutable id: the model ships everywhere as
* `qwen3.8-max-preview` (bailian-coding-plan, qoder, qwen-cloud-token-plan, qwen-web),
* `qwen3.8-max-preview` (bailian-coding-plan, qoder, qwen-cloud-token-plan),
* and nothing in the repo declared the short form. A client sending it therefore
*
* 1. missed MODEL_SPECS, so `getModelContextLimit()` fell through to the
@@ -52,12 +52,14 @@ test("the alias target carries the real 1M window, not the 128k fallback", () =>
assert.equal(MODEL_SPECS[BARE], undefined);
});
// The catalogs have since split. `qwen-cloud-token-plan` and `qwen-web` now list the
// BARE id and no longer carry `-preview`, so `resolveModelAlias`'s
// The catalogs have since split. `qwen-cloud-token-plan` now lists the BARE
// id and no longer carries `-preview`, so `resolveModelAlias`'s
// `hasKnownProviderModel` short-circuit deliberately leaves the id alone there —
// rewriting it to `-preview` would dispatch an id those two no longer serve. The
// rewrite still has to happen on the providers that only know `-preview`.
const SERVES_BARE = ["qwen-cloud-token-plan", "qwen-web"];
// rewriting it to `-preview` would dispatch an id that provider no longer
// serves. The rewrite still has to happen on the providers that only know
// `-preview`. `qwen-web` served the BARE id too before its retirement
// (provenance HOLD, #11713); it no longer exists as a provider at all.
const SERVES_BARE = ["qwen-cloud-token-plan"];
const SERVES_PREVIEW = ["qoder", "bailian-coding-plan"];
// Pin the premise, not just the outcome: if a catalog flips, this fails first and says

View File

@@ -79,9 +79,11 @@ test("T28: github registry exposes Gemini 3.1 Pro Preview and keeps legacy alias
assert.equal(legacy.model, "gemini-3.1-pro-preview");
});
test("T28: qwen OAuth registry entry is retired; qwen-web keeps the native chat.qwen.ai URL", () => {
test("T28: retired Qwen ids stay absent while official Qwen Cloud providers remain", () => {
assert.equal(REGISTRY.qwen, undefined);
assert.equal(REGISTRY["qwen-web"].baseUrl, "https://chat.qwen.ai/api/v2/chat/completions");
assert.equal(REGISTRY["qwen-web"], undefined);
assert.ok(REGISTRY["qwen-cloud"]);
assert.ok(REGISTRY["qwen-cloud-token-plan"]);
});
test("T28: lmarena registry seeds Direct-chat Text/search; image models in IMAGE_PROVIDERS", async () => {

View File

@@ -1,13 +1,12 @@
/**
* Guards the executor override signatures fixed for TS 7 readiness.
*
* Three executors declared a *private/protected* `buildHeaders()` helper whose signature
* Two executors declared a *private/protected* `buildHeaders()` helper whose signature
* has nothing to do with `BaseExecutor.buildHeaders(credentials, stream?, clientHeaders?,
* model?, health?)`:
*
* hailuo-web (token: string, yy: string)
* lmarena (_model: string, credentials: unknown, _body: unknown)
* qwen-web (token: string, cookieHeader: string, chatId?: string)
*
* They were name collisions, not overrides — each shadowed the inherited member with an
* incompatible signature (TS2416). `BaseExecutor` calls `this.buildHeaders(credentials,
@@ -25,12 +24,10 @@ import assert from "node:assert/strict";
import { BaseExecutor } from "../../open-sse/executors/base.ts";
import { HailuoWebExecutor } from "../../open-sse/executors/hailuo-web.ts";
import { LMArenaExecutor } from "../../open-sse/executors/lmarena.ts";
import { QwenWebExecutor } from "../../open-sse/executors/qwen-web.ts";
const CASES = [
{ name: "hailuo-web", make: () => new HailuoWebExecutor(), helper: "buildStreamHeaders" },
{ name: "lmarena", make: () => new LMArenaExecutor(), helper: "buildRequestHeaders" },
{ name: "qwen-web", make: () => new QwenWebExecutor(), helper: "buildApiHeaders" },
];
for (const { name, make, helper } of CASES) {

View File

@@ -12,10 +12,9 @@ const B = await import("../../src/lib/providers/validation/webProvidersB.ts");
const meta = await import("../../src/lib/providers/validation/metaAi.ts");
const HOST = await import("../../src/lib/providers/validation.ts");
test("webProvidersA exposes its six validators (deepseek/qwen/grok/chatgpt/perplexity/blackbox)", () => {
test("webProvidersA exposes its validators (deepseek/grok/chatgpt/perplexity/blackbox)", () => {
for (const name of [
"validateDeepSeekWebProvider",
"validateQwenWebProvider",
"validateGrokWebProvider",
"validateChatGptWebProvider",
"validatePerplexityWebProvider",

View File

@@ -83,39 +83,39 @@ test("createVirtualAutoCombo includes OAuth accessToken connections with real ex
test("createVirtualAutoCombo includes configured web-session providers without apiKey fields", async () => {
await providersDb.createProviderConnection({
provider: "qwen-web",
provider: "kimi-web",
authType: "apikey",
name: "Qwen Web Session",
providerSpecificData: { token: "qwen-web-session-token" },
defaultModel: "qwen3-coder-plus",
name: "Kimi Web Session",
providerSpecificData: { token: "kimi-web-session-token" },
defaultModel: "k3",
});
const combo: VirtualComboResult = await virtualFactory.createVirtualAutoCombo("coding");
const qwenWeb = combo.models.find(
(model) => model.providerId === "qwen-web" && model.model === "qwen-web/qwen3-coder-plus"
const kimiWeb = combo.models.find(
(model) => model.providerId === "kimi-web" && model.model === "kimi-web/k3"
);
assert.ok(qwenWeb, "the configured web-session model should be an auto candidate");
assert.ok(combo.autoConfig.candidatePool.includes("qwen-web"));
assert.ok(kimiWeb, "the configured web-session model should be an auto candidate");
assert.ok(combo.autoConfig.candidatePool.includes("kimi-web"));
});
test("createVirtualAutoCombo excludes web-session providers with empty required token data", async () => {
await providersDb.createProviderConnection({
provider: "qwen-web",
provider: "kimi-web",
authType: "apikey",
name: "Qwen Web Empty Session",
name: "Kimi Web Empty Session",
providerSpecificData: { token: " " },
defaultModel: "qwen3-coder-plus",
defaultModel: "k3",
});
const combo: VirtualComboResult = await virtualFactory.createVirtualAutoCombo("coding");
assert.equal(
combo.models.some((model) => model.providerId === "qwen-web"),
combo.models.some((model) => model.providerId === "kimi-web"),
false,
"web-session providers with empty required token data must not be auto-combo candidates"
);
assert.equal(combo.autoConfig.candidatePool.includes("qwen-web"), false);
assert.equal(combo.autoConfig.candidatePool.includes("kimi-web"), false);
});
test("createVirtualAutoCombo excludes web-session providers with irrelevant providerSpecificData", async () => {
@@ -139,39 +139,83 @@ test("createVirtualAutoCombo excludes web-session providers with irrelevant prov
test("createVirtualAutoCombo groups same-provider web sessions behind one logical model", async () => {
const connA = await providersDb.createProviderConnection({
provider: "qwen-web",
provider: "kimi-web",
authType: "apikey",
name: "Qwen Web Session A",
providerSpecificData: { token: "qwen-web-session-token-a" },
defaultModel: "qwen3-coder-plus",
name: "Kimi Web Session A",
providerSpecificData: { token: "kimi-web-session-token-a" },
defaultModel: "k3",
});
const connB = await providersDb.createProviderConnection({
provider: "qwen-web",
provider: "kimi-web",
authType: "apikey",
name: "Qwen Web Session B",
providerSpecificData: { token: "qwen-web-session-token-b" },
defaultModel: "qwen3-coder-plus",
name: "Kimi Web Session B",
providerSpecificData: { token: "kimi-web-session-token-b" },
defaultModel: "k3",
});
const combo: VirtualComboResult = await virtualFactory.createVirtualAutoCombo("coding");
const qwenWebModel = combo.models.find(
(model) => model.providerId === "qwen-web" && model.model === "qwen-web/qwen3-coder-plus"
const kimiWebModel = combo.models.find(
(model) => model.providerId === "kimi-web" && model.model === "kimi-web/k3"
);
assert.ok(qwenWebModel, "the provider model should remain in the candidate pool");
assert.equal(qwenWebModel.connectionId, null);
assert.ok(kimiWebModel, "the provider model should remain in the candidate pool");
assert.equal(kimiWebModel.connectionId, null);
assert.deepEqual(
new Set(qwenWebModel.allowedConnectionIds),
new Set(kimiWebModel.allowedConnectionIds),
new Set([connA.id, connB.id]),
"same-provider web sessions should remain available as account fallbacks"
);
assert.equal(
combo.autoConfig.candidatePool.filter((provider) => provider === "qwen-web").length,
combo.autoConfig.candidatePool.filter((provider) => provider === "kimi-web").length,
1,
"provider pool remains provider-scoped while model entries preserve connection identity"
);
});
test("createVirtualAutoCombo excludes trigger-bypassed retired Qwen rows", async () => {
const db = core.getDbInstance();
db.exec(`
DROP TRIGGER provider_connections_retire_qwen_web_insert;
DROP TRIGGER provider_connections_retire_qwen_web_update;
`);
await providersDb.createProviderConnection({
provider: "qwen-web",
authType: "apikey",
name: "Retired Qwen Web",
apiKey: "retired-qwen-web-key",
defaultModel: "qwen3.8-max",
});
await providersDb.createProviderConnection({
provider: "qw",
authType: "apikey",
name: "Retired Qwen Web Alias",
apiKey: "retired-qw-key",
defaultModel: "qwen3.8-max",
});
await providersDb.createProviderConnection({
provider: "qwen-cloud",
authType: "apikey",
name: "Qwen Cloud Control",
apiKey: "qwen-cloud-key",
defaultModel: "qwen3.8-max",
});
const combo: VirtualComboResult = await virtualFactory.createVirtualAutoCombo("coding");
assert.equal(
combo.models.some((model) => model.providerId === "qwen-web"),
false
);
assert.equal(
combo.models.some((model) => model.providerId === "qw"),
false
);
assert.equal(combo.autoConfig.candidatePool.includes("qwen-web"), false);
assert.equal(combo.autoConfig.candidatePool.includes("qw"), false);
assert.ok(combo.autoConfig.candidatePool.includes("qwen-cloud"));
});
test("createVirtualAutoCombo includes cookie web-session providers with required cookie data", async () => {
await providersDb.createProviderConnection({
provider: "chatgpt-web",

View File

@@ -1,14 +1,24 @@
import test from "node:test";
import assert from "node:assert/strict";
const webCookieAuth = await import("../../src/lib/providers/webCookieAuth.ts");
const {
extractCookieValue,
normalizeSessionCookieHeader,
stripCookieInputPrefix,
buildGrokCookieHeader,
buildQwenCookieHeader,
extractQwenToken,
} = await import("../../src/lib/providers/webCookieAuth.ts");
} = webCookieAuth;
test("Qwen Web cookie helpers are absent while supported cookie helpers remain", () => {
assert.equal("buildQwenCookieHeader" in webCookieAuth, false);
assert.equal("extractQwenToken" in webCookieAuth, false);
assert.equal(typeof webCookieAuth.stripCookieInputPrefix, "function");
assert.equal(typeof webCookieAuth.parseJsonCookiesToHeader, "function");
assert.equal(typeof webCookieAuth.normalizeSessionCookieHeader, "function");
assert.equal(typeof webCookieAuth.extractCookieValue, "function");
assert.equal(typeof webCookieAuth.buildGrokCookieHeader, "function");
});
test("stripCookieInputPrefix removes 'cookie:' and 'bearer ' prefixes", () => {
assert.equal(stripCookieInputPrefix("Cookie: sso=abc"), "sso=abc");
@@ -110,29 +120,3 @@ test("buildGrokCookieHeader: blob without sso returns empty string", () => {
assert.equal(buildGrokCookieHeader("foo=1; sso-rw=CCC.ddd; bar=2"), "");
assert.equal(buildGrokCookieHeader(""), "");
});
test("buildQwenCookieHeader: passes through a full DevTools cookie blob", () => {
const blob = "cna=ABC; token=jwt.tok; ssxmod_itna=1-XYZ; ssxmod_itna2=1-QRS";
assert.equal(buildQwenCookieHeader(blob), blob);
});
test("buildQwenCookieHeader: strips a leading 'Cookie:' prefix", () => {
assert.equal(buildQwenCookieHeader("Cookie: cna=ABC; token=jwt"), "cna=ABC; token=jwt");
});
test("buildQwenCookieHeader: a bare token (no cookie pairs) yields no cookie header", () => {
assert.equal(buildQwenCookieHeader("eyJ0eXAi.abc.def"), "");
assert.equal(buildQwenCookieHeader(""), "");
});
test("extractQwenToken: pulls the token= value out of a cookie blob", () => {
assert.equal(extractQwenToken("cna=ABC; token=jwt.tok; ssxmod_itna=1-XYZ"), "jwt.tok");
});
test("extractQwenToken: returns a bare token unchanged", () => {
assert.equal(extractQwenToken("eyJ0eXAi.abc.def"), "eyJ0eXAi.abc.def");
});
test("extractQwenToken: a cookie blob without a token cookie yields empty string", () => {
assert.equal(extractQwenToken("cna=ABC; ssxmod_itna=1-XYZ"), "");
});

View File

@@ -8,7 +8,6 @@ const { V0VercelWebExecutor } = await import("../../open-sse/executors/v0-vercel
const { KimiWebExecutor } = await import("../../open-sse/executors/kimi-web.ts");
const { MoonshotExecutor } = await import("../../open-sse/executors/moonshot.ts");
const { DoubaoWebExecutor } = await import("../../open-sse/executors/doubao-web.ts");
const { QwenWebExecutor } = await import("../../open-sse/executors/qwen-web.ts");
const { getExecutor, hasSpecializedExecutor } = await import("../../open-sse/executors/index.ts");
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -158,6 +157,12 @@ test("Doubao Web executor is registered", async () => {
assert.ok(executor instanceof DoubaoWebExecutor);
});
test("Qwen Web aliases stay removed while Qwen Cloud remains routable", () => {
assert.equal(hasSpecializedExecutor("qwen-web"), false);
assert.equal(hasSpecializedExecutor("qw"), false);
assert.equal(getExecutor("qwen-cloud").getProvider(), "qwen-cloud");
});
// ── Constructor Tests ────────────────────────────────────────────────────────
test("HuggingChat sets correct provider", () => {
@@ -190,21 +195,6 @@ test("Doubao Web sets correct provider", () => {
assert.equal(executor.getProvider(), "doubao-web");
});
// ── Registration Tests (Qwen Web) ────────────────────────────────────────────
test("Qwen Web executor is registered", async () => {
assert.ok(hasSpecializedExecutor("qwen-web"));
const executor = await getExecutor("qwen-web");
assert.ok(executor instanceof QwenWebExecutor);
});
// ── Constructor Tests (Qwen Web) ─────────────────────────────────────────────
test("Qwen Web sets correct provider", () => {
const executor = new QwenWebExecutor();
assert.equal(executor.getProvider(), "qwen-web");
});
// ── HuggingChat Execution Tests ──────────────────────────────────────────────
test("HuggingChat: streaming returns SSE chunks", async () => {

View File

@@ -106,15 +106,15 @@ test("web session credential metadata identifies cookie, token, and no-auth prov
test("web session credential validator requires provider-specific non-empty values", () => {
assert.equal(
webSessionCredentials.hasUsableWebSessionCredential("qwen-web", { token: "qwen-token" }),
webSessionCredentials.hasUsableWebSessionCredential("kimi-web", { token: "kimi-token" }),
true
);
assert.equal(
webSessionCredentials.hasUsableWebSessionCredential("qwen-web", { token: " " }),
webSessionCredentials.hasUsableWebSessionCredential("kimi-web", { token: " " }),
false
);
assert.equal(
webSessionCredentials.hasUsableWebSessionCredential("qwen-web", { unrelated: "value" }),
webSessionCredentials.hasUsableWebSessionCredential("kimi-web", { unrelated: "value" }),
false
);
assert.equal(

View File

@@ -5,12 +5,11 @@ import assert from "node:assert/strict";
// provider site. The host is derived from the provider's website URL via
// getProviderWebsiteHost — a full URL collapses to its host, a bare/invalid
// string falls back to itself, and an empty value yields null (no link).
const { getProviderWebsiteHost } = await import(
"../../src/app/(dashboard)/dashboard/providers/[id]/components/WebSessionCredentialGuide.tsx"
);
const { getProviderWebsiteHost } =
await import("../../src/app/(dashboard)/dashboard/providers/[id]/components/WebSessionCredentialGuide.tsx");
test("#6316: full URL collapses to host", () => {
assert.equal(getProviderWebsiteHost("https://chat.qwen.ai/path?x=1"), "chat.qwen.ai");
assert.equal(getProviderWebsiteHost("https://chat.example.com/path?x=1"), "chat.example.com");
assert.equal(getProviderWebsiteHost("https://www.kimi.com"), "www.kimi.com");
});

View File

@@ -9,9 +9,8 @@ import {
} from "../../open-sse/translator/webTools.ts";
// Regression coverage for the shared web-cookie tool-call translation helpers
// (#3259). These functions back tool-calling for the 8 pure-API web executors
// (adapta-web, blackbox-web, duckduckgo-web, inner-ai, muse-spark-web,
// perplexity-web, qwen-web, t3-chat-web), so the translation contract must hold.
// (#3259). These functions back tool-calling for multiple pure-API web executors,
// so the translation contract must hold independently of any one provider.
//
// #9343 — bare-JSON tools are disabled; only explicit <tool> or <tool_call>
// envelopes with nonce binding are accepted.