mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-16 20:02:45 +03:00
fix(providers): retire Qwen Web pending provenance review
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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 }>
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
|
||||
166
src/lib/db/migrations/163_retire_qwen_web.sql
Normal file
166
src/lib/db/migrations/163_retire_qwen_web.sql
Normal 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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -430,7 +430,6 @@ const LOBE_PROVIDER_ALIASES = {
|
||||
pollinations: "Pollinations",
|
||||
qoder: "Qoder",
|
||||
qwen: "Qwen",
|
||||
"qwen-web": "Qwen",
|
||||
recraft: "Recraft",
|
||||
replicate: "Replicate",
|
||||
roo: "RooCode",
|
||||
|
||||
38
src/shared/constants/providerRetirement.ts
Normal file
38
src/shared/constants/providerRetirement.ts
Normal 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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
];
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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": "",
|
||||
};
|
||||
|
||||
@@ -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`);
|
||||
|
||||
517
tests/unit/migration-163-retire-qwen-web.test.ts
Normal file
517
tests/unit/migration-163-retire-qwen-web.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(", ")}`
|
||||
);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
101
tests/unit/qwen-web-retirement.test.ts
Normal file
101
tests/unit/qwen-web-retirement.test.ts
Normal 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`
|
||||
);
|
||||
}
|
||||
});
|
||||
287
tests/unit/qwen-web-runtime-block.test.ts
Normal file
287
tests/unit/qwen-web-runtime-block.test.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"), "");
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user