From 2133ad620c088721de65a9ff68c699642e79cc84 Mon Sep 17 00:00:00 2001 From: Xiangzhe Date: Wed, 26 Aug 2026 19:12:13 -0300 Subject: [PATCH] fix(providers): retire Qwen Web pending provenance review --- config/quality/eslint-suppressions.json | 15 - config/quality/test-masking-allowlist.json | 24 + open-sse/config/freeModelCatalog.data.ts | 4 - open-sse/config/freeTierCatalog.ts | 1 - open-sse/config/providers/index.ts | 2 - .../providers/registry/minimax/web/index.ts | 4 +- .../providers/registry/qwen/web/index.ts | 56 -- open-sse/executors/gitlab.ts | 4 +- open-sse/executors/index.ts | 5 +- open-sse/executors/qwen-web.ts | 498 ----------------- open-sse/handlers/chatCore/executorProxy.ts | 9 +- open-sse/services/autoCombo/virtualFactory.ts | 6 +- open-sse/services/combo/autoStrategy.ts | 6 +- open-sse/services/modelDeprecation.ts | 2 +- open-sse/services/tokenExtractionConfig.ts | 22 - .../models/discovery/providerModelsConfig.ts | 19 - src/lib/db/migrations/163_retire_qwen_web.sql | 166 ++++++ src/lib/db/providers.ts | 17 +- src/lib/providers/validation.ts | 2 - src/lib/providers/validation/transport.ts | 8 +- src/lib/providers/validation/webProvidersA.ts | 119 +--- src/lib/providers/webCookieAuth.ts | 35 -- src/shared/components/lobeProviderIcons.ts | 1 - src/shared/constants/providerRetirement.ts | 38 ++ src/shared/constants/providers/web-cookie.ts | 18 +- .../constants/reservedProviderPrefixes.ts | 22 +- src/shared/providers/webSessionCredentials.ts | 8 - .../utils/providerCredentialRequirement.ts | 1 - src/shared/validation/schemas/provider.ts | 22 +- src/sse/handlers/chat.ts | 9 +- src/sse/handlers/chatHelpers.ts | 16 +- src/sse/services/auth.ts | 7 + src/sse/services/model.ts | 6 + stryker.conf.json | 1 - tests/snapshots/executors/executor-map.json | 12 +- tests/snapshots/provider/translate-path.json | 23 - ...na-cookie-validation-redirect-7542.test.ts | 2 +- .../catalog-updates-v3829-kimi-qwen.test.ts | 120 +--- tests/unit/chatcore-executor-proxy.test.ts | 31 +- .../combo-auto-candidate-expansion.test.ts | 50 ++ tests/unit/executor-qwen-web.test.ts | 370 ------------- tests/unit/executor-web-cookie-sweep.test.ts | 1 - tests/unit/free-model-catalog.test.ts | 3 +- .../migration-163-retire-qwen-web.test.ts | 517 ++++++++++++++++++ tests/unit/provider-alias-uniqueness.test.ts | 4 +- .../provider-models-discovery-split.test.ts | 3 +- ...ider-models-qwen-web-redirect-6267.test.ts | 103 ---- .../provider-node-reserved-prefix.test.ts | 44 +- .../provider-validation-qwen-web-5855.test.ts | 95 ---- .../provider-validation-specialty.test.ts | 4 +- ...en-web-content-array-serialization.test.ts | 83 --- .../qwen-web-cookie-validation-3958.test.ts | 73 --- .../qwen-web-models-discovery-3931.test.ts | 127 ----- tests/unit/qwen-web-retirement.test.ts | 101 ++++ tests/unit/qwen-web-runtime-block.test.ts | 287 ++++++++++ tests/unit/qwen38-max-bare-id-alias.test.ts | 14 +- tests/unit/t28-model-catalog-updates.test.ts | 6 +- .../ts7-executor-override-signatures.test.ts | 5 +- .../validation-web-providers-split.test.ts | 3 +- tests/unit/virtual-auto-combo.test.ts | 98 +++- tests/unit/web-cookie-auth.test.ts | 42 +- tests/unit/web-cookie-providers-new.test.ts | 22 +- tests/unit/web-session-credentials.test.ts | 6 +- .../web-session-provider-link-6316.test.ts | 7 +- tests/unit/web-tools-translation.test.ts | 5 +- 65 files changed, 1504 insertions(+), 1930 deletions(-) delete mode 100644 open-sse/config/providers/registry/qwen/web/index.ts delete mode 100644 open-sse/executors/qwen-web.ts create mode 100644 src/lib/db/migrations/163_retire_qwen_web.sql create mode 100644 src/shared/constants/providerRetirement.ts delete mode 100644 tests/unit/executor-qwen-web.test.ts create mode 100644 tests/unit/migration-163-retire-qwen-web.test.ts delete mode 100644 tests/unit/provider-models-qwen-web-redirect-6267.test.ts delete mode 100644 tests/unit/provider-validation-qwen-web-5855.test.ts delete mode 100644 tests/unit/qwen-web-content-array-serialization.test.ts delete mode 100644 tests/unit/qwen-web-cookie-validation-3958.test.ts delete mode 100644 tests/unit/qwen-web-models-discovery-3931.test.ts create mode 100644 tests/unit/qwen-web-retirement.test.ts create mode 100644 tests/unit/qwen-web-runtime-block.test.ts diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 86fc92a447..2e577ff4f3 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -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 diff --git a/config/quality/test-masking-allowlist.json b/config/quality/test-masking-allowlist.json index fd828b6d5f..9e077686cb 100644 --- a/config/quality/test-masking-allowlist.json +++ b/config/quality/test-masking-allowlist.json @@ -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.", diff --git a/open-sse/config/freeModelCatalog.data.ts b/open-sse/config/freeModelCatalog.data.ts index 2a0bd5b4c0..36f31ea4d4 100644 --- a/open-sse/config/freeModelCatalog.data.ts +++ b/open-sse/config/freeModelCatalog.data.ts @@ -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" }, diff --git a/open-sse/config/freeTierCatalog.ts b/open-sse/config/freeTierCatalog.ts index cfc2836b98..e212d4474d 100644 --- a/open-sse/config/freeTierCatalog.ts +++ b/open-sse/config/freeTierCatalog.ts @@ -46,7 +46,6 @@ export const FREE_TIER_TOS: Record = { "amazon-q": "avoid", "muse-spark-web": "avoid", "t3-web": "avoid", - "qwen-web": "avoid", modal: "avoid", nlpcloud: "avoid", blackbox: "avoid", diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index c54ea48c60..61d849668f 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -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 = { 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, diff --git a/open-sse/config/providers/registry/minimax/web/index.ts b/open-sse/config/providers/registry/minimax/web/index.ts index 6c2addc043..1414d47c79 100644 --- a/open-sse/config/providers/registry/minimax/web/index.ts +++ b/open-sse/config/providers/registry/minimax/web/index.ts @@ -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", diff --git a/open-sse/config/providers/registry/qwen/web/index.ts b/open-sse/config/providers/registry/qwen/web/index.ts deleted file mode 100644 index 531c4bf1e1..0000000000 --- a/open-sse/config/providers/registry/qwen/web/index.ts +++ /dev/null @@ -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, - }, - ], -}; diff --git a/open-sse/executors/gitlab.ts b/open-sse/executors/gitlab.ts index b82d5c47aa..1d985a16da 100644 --- a/open-sse/executors/gitlab.ts +++ b/open-sse/executors/gitlab.ts @@ -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 `{...}` 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 }> diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 20155214ba..3ad061b640 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -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 Promise> = { ), // 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 Promise> = { 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 { + assertRuntimeProviderAvailable(provider); + const loaded = await loadRegisteredExecutor(provider); if (loaded) return loaded; if (CHAT_UNSUPPORTED_CLOUD_AGENT_PROVIDERS.has(provider)) { diff --git a/open-sse/executors/qwen-web.ts b/open-sse/executors/qwen-web.ts deleted file mode 100644 index 4a312d7003..0000000000 --- a/open-sse/executors/qwen-web.ts +++ /dev/null @@ -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 = { - // 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| { - const headers: Record = { - "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; - - 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 = { 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 { - const fid = uuid(); - const enableThinking = - REQUIRED_THINKING_MODELS.has(modelId) || /think|reason|r1/i.test(requestedModel); - const featureConfig: Record = { - 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, 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, - 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, - 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; -} diff --git a/open-sse/handlers/chatCore/executorProxy.ts b/open-sse/handlers/chatCore/executorProxy.ts index 3d7e8917d4..ccc0e0b564 100644 --- a/open-sse/handlers/chatCore/executorProxy.ts +++ b/open-sse/handlers/chatCore/executorProxy.ts @@ -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 | 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) { diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts index 2865906c5e..710b79b665 100644 --- a/open-sse/services/autoCombo/virtualFactory.ts +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -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(); diff --git a/open-sse/services/combo/autoStrategy.ts b/open-sse/services/combo/autoStrategy.ts index 78964c4c7a..3ed0b09861 100644 --- a/open-sse/services/combo/autoStrategy.ts +++ b/open-sse/services/combo/autoStrategy.ts @@ -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 diff --git a/open-sse/services/modelDeprecation.ts b/open-sse/services/modelDeprecation.ts index 8a120d6de0..961b968e7e 100644 --- a/open-sse/services/modelDeprecation.ts +++ b/open-sse/services/modelDeprecation.ts @@ -49,7 +49,7 @@ const BUILT_IN_ALIASES: Record = { "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. diff --git a/open-sse/services/tokenExtractionConfig.ts b/open-sse/services/tokenExtractionConfig.ts index 9aefedf706..08609f11ca 100644 --- a/open-sse/services/tokenExtractionConfig.ts +++ b/open-sse/services/tokenExtractionConfig.ts @@ -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", diff --git a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts index 0939b59b55..cc45e6b727 100644 --- a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts +++ b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts @@ -408,25 +408,6 @@ export const PROVIDER_MODELS_CONFIG: Record = 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], diff --git a/src/lib/db/migrations/163_retire_qwen_web.sql b/src/lib/db/migrations/163_retire_qwen_web.sql new file mode 100644 index 0000000000..be351a9dc2 --- /dev/null +++ b/src/lib/db/migrations/163_retire_qwen_web.sql @@ -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; diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index ae79516db2..849d6d1f2e 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -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 { diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index e95c2c9fc3..516cb3cce1 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -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, diff --git a/src/lib/providers/validation/transport.ts b/src/lib/providers/validation/transport.ts index c486ff41fc..2f5a3cc79a 100644 --- a/src/lib/providers/validation/transport.ts +++ b/src/lib/providers/validation/transport.ts @@ -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; diff --git a/src/lib/providers/validation/webProvidersA.ts b/src/lib/providers/validation/webProvidersA.ts index 7c9746ccd7..c4ebeab0fd 100644 --- a/src/lib/providers/validation/webProvidersA.ts +++ b/src/lib/providers/validation/webProvidersA.ts @@ -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 = { - 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). diff --git a/src/lib/providers/webCookieAuth.ts b/src/lib/providers/webCookieAuth.ts index 2035ed444f..325b5e20f5 100644 --- a/src/lib/providers/webCookieAuth.ts +++ b/src/lib/providers/webCookieAuth.ts @@ -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(); diff --git a/src/shared/components/lobeProviderIcons.ts b/src/shared/components/lobeProviderIcons.ts index c80a7476d0..cc3a9d4401 100644 --- a/src/shared/components/lobeProviderIcons.ts +++ b/src/shared/components/lobeProviderIcons.ts @@ -430,7 +430,6 @@ const LOBE_PROVIDER_ALIASES = { pollinations: "Pollinations", qoder: "Qoder", qwen: "Qwen", - "qwen-web": "Qwen", recraft: "Recraft", replicate: "Replicate", roo: "RooCode", diff --git a/src/shared/constants/providerRetirement.ts b/src/shared/constants/providerRetirement.ts new file mode 100644 index 0000000000..6c437883bd --- /dev/null +++ b/src/shared/constants/providerRetirement.ts @@ -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 = 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; +} diff --git a/src/shared/constants/providers/web-cookie.ts b/src/shared/constants/providers/web-cookie.ts index 7f653b6ebc..9c38890e9d 100644 --- a/src/shared/constants/providers/web-cookie.ts +++ b/src/shared/constants/providers/web-cookie.ts @@ -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", diff --git a/src/shared/constants/reservedProviderPrefixes.ts b/src/shared/constants/reservedProviderPrefixes.ts index fb6471b760..5af2867b9f 100644 --- a/src/shared/constants/reservedProviderPrefixes.ts +++ b/src/shared/constants/reservedProviderPrefixes.ts @@ -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 | null = null; function buildReservedProviderPrefixes(): Set { @@ -29,13 +34,15 @@ function buildReservedProviderPrefixes(): Set { 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 { return buildReservedProviderPrefixes(); @@ -58,7 +65,10 @@ export const RESERVED_PROVIDER_PREFIXES: ReadonlySet = 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)) + ); } /** diff --git a/src/shared/providers/webSessionCredentials.ts b/src/shared/providers/webSessionCredentials.ts index 7528ea4df3..bcf4ce6349 100644 --- a/src/shared/providers/webSessionCredentials.ts +++ b/src/shared/providers/webSessionCredentials.ts @@ -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", diff --git a/src/shared/utils/providerCredentialRequirement.ts b/src/shared/utils/providerCredentialRequirement.ts index 2160b5c4dd..1c28c2cbe7 100644 --- a/src/shared/utils/providerCredentialRequirement.ts +++ b/src/shared/utils/providerCredentialRequirement.ts @@ -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 ]; diff --git a/src/shared/validation/schemas/provider.ts b/src/shared/validation/schemas/provider.ts index a99ba7c947..696db7de9c 100644 --- a/src/shared/validation/schemas/provider.ts +++ b/src/shared/validation/schemas/provider.ts @@ -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({ diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index aadf7d29d3..b78924023b 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -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. diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index bcf9a51c62..f0c9fbf2cb 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -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 | 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 ( diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 5a3c046efa..ab5a98c99f 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -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)); diff --git a/src/sse/services/model.ts b/src/sse/services/model.ts index aa317dcb96..0aa8743594 100644 --- a/src/sse/services/model.ts +++ b/src/sse/services/model.ts @@ -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) => { diff --git a/stryker.conf.json b/stryker.conf.json index ee0e522b8d..1d74601af0 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -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", diff --git a/tests/snapshots/executors/executor-map.json b/tests/snapshots/executors/executor-map.json index 23d0e01b8b..da0a9cd5eb 100644 --- a/tests/snapshots/executors/executor-map.json +++ b/tests/snapshots/executors/executor-map.json @@ -555,16 +555,6 @@ "configSource": "qoder", "provider": "qoder" }, - "qw": { - "className": "QwenWebExecutor", - "configSource": "", - "provider": "qwen-web" - }, - "qwen-web": { - "className": "QwenWebExecutor", - "configSource": "", - "provider": "qwen-web" - }, "raycast": { "className": "RaycastExecutor", "configSource": "raycast", @@ -721,6 +711,6 @@ "provider": "zai-web" } }, - "keyCount": 144, + "keyCount": 142, "sharedInstances": [] } diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index f9e96d0909..d42f254e2d 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -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 ", - "Content-Type": "application/json" - }, - "nonStream": { - "Authorization": "Bearer ", - "Content-Type": "application/json" - }, - "oauth": { - "Accept": "text/event-stream", - "Authorization": "Bearer ", - "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": { diff --git a/tests/unit/arena-cookie-validation-redirect-7542.test.ts b/tests/unit/arena-cookie-validation-redirect-7542.test.ts index fd30aa9882..8a1e77817b 100644 --- a/tests/unit/arena-cookie-validation-redirect-7542.test.ts +++ b/tests/unit/arena-cookie-validation-redirect-7542.test.ts @@ -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; diff --git a/tests/unit/catalog-updates-v3829-kimi-qwen.test.ts b/tests/unit/catalog-updates-v3829-kimi-qwen.test.ts index 84d01f46b9..4a8ae3b20c 100644 --- a/tests/unit/catalog-updates-v3829-kimi-qwen.test.ts +++ b/tests/unit/catalog-updates-v3829-kimi-qwen.test.ts @@ -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> = - (mockResponse?.data?.data as Array>) || - (mockResponse?.data as unknown as Array>) || - []; - 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> = - (mockResponse?.data as unknown as { data?: Array> })?.data || - (mockResponse?.data as unknown as Array>) || - []; - 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"); -}); diff --git a/tests/unit/chatcore-executor-proxy.test.ts b/tests/unit/chatcore-executor-proxy.test.ts index dd4cc6e2fa..9082fcb9b3 100644 --- a/tests/unit/chatcore-executor-proxy.test.ts +++ b/tests/unit/chatcore-executor-proxy.test.ts @@ -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")); +}); diff --git a/tests/unit/combo-auto-candidate-expansion.test.ts b/tests/unit/combo-auto-candidate-expansion.test.ts index fdfeb062ab..f51f32ed35 100644 --- a/tests/unit/combo-auto-candidate-expansion.test.ts +++ b/tests/unit/combo-auto-candidate-expansion.test.ts @@ -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", diff --git a/tests/unit/executor-qwen-web.test.ts b/tests/unit/executor-qwen-web.test.ts deleted file mode 100644 index 5c8c709872..0000000000 --- a/tests/unit/executor-qwen-web.test.ts +++ /dev/null @@ -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>): 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( - "\n504 Gateway Time-out\n\n" + - "

504 Gateway Time-out

\n
alibaba-ga
\n" + - '\n\n', - { 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; - 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; - 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; - 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(" { - 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; - 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"); - }); -}); diff --git a/tests/unit/executor-web-cookie-sweep.test.ts b/tests/unit/executor-web-cookie-sweep.test.ts index 1f67c92fca..bd6bb28b75 100644 --- a/tests/unit/executor-web-cookie-sweep.test.ts +++ b/tests/unit/executor-web-cookie-sweep.test.ts @@ -62,7 +62,6 @@ const FAKE_CREDS: Record = { "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": "", }; diff --git a/tests/unit/free-model-catalog.test.ts b/tests/unit/free-model-catalog.test.ts index 07b4da72bd..c50442b92c 100644 --- a/tests/unit/free-model-catalog.test.ts +++ b/tests/unit/free-model-catalog.test.ts @@ -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`); diff --git a/tests/unit/migration-163-retire-qwen-web.test.ts b/tests/unit/migration-163-retire-qwen-web.test.ts new file mode 100644 index 0000000000..7dd20f6aa5 --- /dev/null +++ b/tests/unit/migration-163-retire-qwen-web.test.ts @@ -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(); + 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); +}); diff --git a/tests/unit/provider-alias-uniqueness.test.ts b/tests/unit/provider-alias-uniqueness.test.ts index 8ed41a601e..f3d845feec 100644 --- a/tests/unit/provider-alias-uniqueness.test.ts +++ b/tests/unit/provider-alias-uniqueness.test.ts @@ -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 diff --git a/tests/unit/provider-models-discovery-split.test.ts b/tests/unit/provider-models-discovery-split.test.ts index a98019ca64..72184e0169 100644 --- a/tests/unit/provider-models-discovery-split.test.ts +++ b/tests/unit/provider-models-discovery-split.test.ts @@ -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", () => { diff --git a/tests/unit/provider-models-qwen-web-redirect-6267.test.ts b/tests/unit/provider-models-qwen-web-redirect-6267.test.ts deleted file mode 100644 index 0618c8ab9f..0000000000 --- a/tests/unit/provider-models-qwen-web-redirect-6267.test.ts +++ /dev/null @@ -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; -} - -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(", ")}` - ); -}); diff --git a/tests/unit/provider-node-reserved-prefix.test.ts b/tests/unit/provider-node-reserved-prefix.test.ts index d7514541b7..e1619ecf2d 100644 --- a/tests/unit/provider-node-reserved-prefix.test.ts +++ b/tests/unit/provider-node-reserved-prefix.test.ts @@ -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", diff --git a/tests/unit/provider-validation-qwen-web-5855.test.ts b/tests/unit/provider-validation-qwen-web-5855.test.ts deleted file mode 100644 index 16c3aba6c7..0000000000 --- a/tests/unit/provider-validation-qwen-web-5855.test.ts +++ /dev/null @@ -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 = {}; - 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("aliyun_waf", { - 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); -}); diff --git a/tests/unit/provider-validation-specialty.test.ts b/tests/unit/provider-validation-specialty.test.ts index 515810fd25..71b0ec24c1 100644 --- a/tests/unit/provider-validation-specialty.test.ts +++ b/tests/unit/provider-validation-specialty.test.ts @@ -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); diff --git a/tests/unit/qwen-web-content-array-serialization.test.ts b/tests/unit/qwen-web-content-array-serialization.test.ts deleted file mode 100644 index 3949f6484b..0000000000 --- a/tests/unit/qwen-web-content-array-serialization.test.ts +++ /dev/null @@ -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>): 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[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"); - }); -}); diff --git a/tests/unit/qwen-web-cookie-validation-3958.test.ts b/tests/unit/qwen-web-cookie-validation-3958.test.ts deleted file mode 100644 index 0e11df5b48..0000000000 --- a/tests/unit/qwen-web-cookie-validation-3958.test.ts +++ /dev/null @@ -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("<>")) 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); -}); diff --git a/tests/unit/qwen-web-models-discovery-3931.test.ts b/tests/unit/qwen-web-models-discovery-3931.test.ts deleted file mode 100644 index 0a714b65e5..0000000000 --- a/tests/unit/qwen-web-models-discovery-3931.test.ts +++ /dev/null @@ -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; - } -}); diff --git a/tests/unit/qwen-web-retirement.test.ts b/tests/unit/qwen-web-retirement.test.ts new file mode 100644 index 0000000000..c7ef4f100a --- /dev/null +++ b/tests/unit/qwen-web-retirement.test.ts @@ -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` + ); + } +}); diff --git a/tests/unit/qwen-web-runtime-block.test.ts b/tests/unit/qwen-web-runtime-block.test.ts new file mode 100644 index 0000000000..71630ede72 --- /dev/null +++ b/tests/unit/qwen-web-runtime-block.test.ts @@ -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", + }); + } +}); diff --git a/tests/unit/qwen38-max-bare-id-alias.test.ts b/tests/unit/qwen38-max-bare-id-alias.test.ts index 57da646044..8b874a9ff7 100644 --- a/tests/unit/qwen38-max-bare-id-alias.test.ts +++ b/tests/unit/qwen38-max-bare-id-alias.test.ts @@ -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 diff --git a/tests/unit/t28-model-catalog-updates.test.ts b/tests/unit/t28-model-catalog-updates.test.ts index 600cd44229..5a3c8c129b 100644 --- a/tests/unit/t28-model-catalog-updates.test.ts +++ b/tests/unit/t28-model-catalog-updates.test.ts @@ -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 () => { diff --git a/tests/unit/ts7-executor-override-signatures.test.ts b/tests/unit/ts7-executor-override-signatures.test.ts index 515c44f324..5f092fa03f 100644 --- a/tests/unit/ts7-executor-override-signatures.test.ts +++ b/tests/unit/ts7-executor-override-signatures.test.ts @@ -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) { diff --git a/tests/unit/validation-web-providers-split.test.ts b/tests/unit/validation-web-providers-split.test.ts index 0b321c69ed..b39c9b2d91 100644 --- a/tests/unit/validation-web-providers-split.test.ts +++ b/tests/unit/validation-web-providers-split.test.ts @@ -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", diff --git a/tests/unit/virtual-auto-combo.test.ts b/tests/unit/virtual-auto-combo.test.ts index 4bb7e1fb49..1802a08369 100644 --- a/tests/unit/virtual-auto-combo.test.ts +++ b/tests/unit/virtual-auto-combo.test.ts @@ -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", diff --git a/tests/unit/web-cookie-auth.test.ts b/tests/unit/web-cookie-auth.test.ts index eab8acf780..2d8724046e 100644 --- a/tests/unit/web-cookie-auth.test.ts +++ b/tests/unit/web-cookie-auth.test.ts @@ -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"), ""); -}); diff --git a/tests/unit/web-cookie-providers-new.test.ts b/tests/unit/web-cookie-providers-new.test.ts index 14e3a2860a..813d382b7f 100644 --- a/tests/unit/web-cookie-providers-new.test.ts +++ b/tests/unit/web-cookie-providers-new.test.ts @@ -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 () => { diff --git a/tests/unit/web-session-credentials.test.ts b/tests/unit/web-session-credentials.test.ts index 91e90a7881..c6af96ca1c 100644 --- a/tests/unit/web-session-credentials.test.ts +++ b/tests/unit/web-session-credentials.test.ts @@ -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( diff --git a/tests/unit/web-session-provider-link-6316.test.ts b/tests/unit/web-session-provider-link-6316.test.ts index d8d39e849b..ce485c05b6 100644 --- a/tests/unit/web-session-provider-link-6316.test.ts +++ b/tests/unit/web-session-provider-link-6316.test.ts @@ -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"); }); diff --git a/tests/unit/web-tools-translation.test.ts b/tests/unit/web-tools-translation.test.ts index 8ed43fcd4b..a3bc7cb5f5 100644 --- a/tests/unit/web-tools-translation.test.ts +++ b/tests/unit/web-tools-translation.test.ts @@ -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 or // envelopes with nonce binding are accepted.