diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 0ea46aa9a2..405f7ac46e 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -3940,6 +3940,26 @@ export const REGISTRY: Record = { ], }, + "qwen-web": { + id: "qwen-web", + alias: "qw", + format: "openai", + executor: "qwen-web", + baseUrl: "https://chat.qwen.ai/api/chat/completions", + authType: "apikey", + authHeader: "bearer", + models: [ + { id: "qwen-plus", name: "Qwen Plus" }, + { id: "qwen-max", name: "Qwen Max" }, + { id: "qwen-turbo", name: "Qwen Turbo" }, + { id: "qwen3-plus", name: "Qwen3 Plus" }, + { id: "qwen3-max", name: "Qwen3 Max" }, + { id: "qwen3-flash", name: "Qwen3 Flash" }, + { id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" }, + { id: "qwen3-coder-flash", name: "Qwen3 Coder Flash" }, + ], + }, + codestral: { id: "codestral", alias: "codestral", diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index d5428950ac..adf2f4e208 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -45,6 +45,7 @@ import { VeniceWebExecutor } from "./venice-web.ts"; import { V0VercelWebExecutor } from "./v0-vercel-web.ts"; import { KimiWebExecutor } from "./kimi-web.ts"; import { DoubaoWebExecutor } from "./doubao-web.ts"; +import { QwenWebExecutor } from "./qwen-web.ts"; const executors = { antigravity: new AntigravityExecutor(), @@ -127,6 +128,8 @@ const executors = { kimi: new KimiWebExecutor(), // Alias "doubao-web": new DoubaoWebExecutor(), db: new DoubaoWebExecutor(), // Alias + "qwen-web": new QwenWebExecutor(), + qw: new QwenWebExecutor(), // Alias }; const defaultCache = new Map(); @@ -182,3 +185,4 @@ export { DeepSeekWebWithAutoRefreshExecutor } from "./deepseek-web-with-auto-ref export { AdaptaWebExecutor } from "./adapta-web.ts"; export { T3ChatWebExecutor } from "./t3-chat-web.ts"; export { InnerAiExecutor } from "./inner-ai.ts"; +export { QwenWebExecutor } from "./qwen-web.ts"; diff --git a/open-sse/executors/qwen-web.ts b/open-sse/executors/qwen-web.ts new file mode 100644 index 0000000000..66f8aec0db --- /dev/null +++ b/open-sse/executors/qwen-web.ts @@ -0,0 +1,159 @@ +/** + * QwenWebExecutor — Alibaba Tongyi Qwen Chat via chat.qwen.ai + * + * Routes requests through Qwen's consumer chat API. + * Chinese market provider with strong vision, coding, and reasoning models. + * + * Auth: Token from chat.qwen.ai Local Storage or tongyi_sso_ticket cookie + * Endpoint: POST https://chat.qwen.ai/api/chat/completions + * Format: OpenAI-compatible + */ +import { BaseExecutor, type ExecuteInput } from "./base.ts"; +import { makeExecutorErrorResult as makeErrorResult } from "../utils/error.ts"; + +const BASE_URL = "https://chat.qwen.ai"; +const CHAT_URL = `${BASE_URL}/api/chat/completions`; +const USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + +export class QwenWebExecutor extends BaseExecutor { + constructor() { + super("qwen-web", { id: "qwen-web", baseUrl: BASE_URL }); + } + + async execute(input: ExecuteInput) { + const { body, credentials, signal, stream: wantStream } = input; + const bodyObj = (body || {}) as Record; + const rawToken = String(credentials?.apiKey ?? credentials?.accessToken ?? "").trim(); + + const messages = (bodyObj.messages as Array<{ role: string; content: string }>) || []; + const modelId = (bodyObj.model as string) || "qwen-plus"; + + const reqBody = { + messages: messages.map((m) => ({ role: m.role, content: m.content })), + model: modelId, + stream: wantStream, + max_tokens: (bodyObj.max_tokens as number) || 4096, + }; + + const reqHeaders: Record = { + "Content-Type": "application/json", + "User-Agent": USER_AGENT, + Accept: wantStream ? "text/event-stream" : "application/json", + Referer: `${BASE_URL}/`, + Origin: BASE_URL, + }; + if (rawToken) { + reqHeaders["Authorization"] = `Bearer ${rawToken}`; + } + + let upstream: Response; + try { + upstream = await fetch(CHAT_URL, { + method: "POST", + headers: reqHeaders, + body: JSON.stringify(reqBody), + signal, + }); + } catch (err) { + return makeErrorResult( + 502, + `Qwen fetch failed: ${err instanceof Error ? err.message : "unknown"}`, + body, + CHAT_URL + ); + } + + if (!upstream.ok) { + const errText = await upstream.text().catch(() => ""); + if (upstream.status === 401) { + return makeErrorResult( + 401, + "Qwen authentication failed. Your token may have expired. " + + "Get a fresh token from chat.qwen.ai (DevTools → Application → Local Storage → token)", + body, + CHAT_URL + ); + } + return makeErrorResult(upstream.status, `Qwen error: ${errText}`, body, CHAT_URL); + } + + if (!wantStream) { + const data = (await upstream.json()) as Record; + const content = + (data?.choices as Array<{ message?: { content?: string } }>)?.[0]?.message?.content || + (data?.content as string) || + ""; + 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: { role: "assistant", content }, finish_reason: "stop" }], + }), + { headers: { "Content-Type": "application/json" } } + ), + url: CHAT_URL, + headers: reqHeaders, + transformedBody: reqBody, + }; + } + + // Streaming + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const stream = new ReadableStream({ + async start(controller) { + const reader = upstream.body?.getReader(); + if (!reader) { controller.close(); return; } + 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) { + if (!line.startsWith("data:")) continue; + const data = line.slice(5).trim(); + if (data === "[DONE]") { controller.enqueue(encoder.encode("data: [DONE]\n\n")); continue; } + try { + const parsed = JSON.parse(data); + const text = parsed.choices?.[0]?.delta?.content || parsed.choices?.[0]?.text || ""; + if (text) { + const chunk = { + id: `chatcmpl-qwen-${Date.now()}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: modelId, + choices: [{ index: 0, delta: { content: text }, finish_reason: null }], + }; + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); + } + } catch { + /* skip unparseable chunks */ + } + } + } + } catch (err) { + if (!signal?.aborted) controller.error(err); + } finally { + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + } + }, + }); + + return { + response: new Response(stream, { + headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" }, + }), + url: CHAT_URL, + headers: reqHeaders, + transformedBody: reqBody, + }; + } +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/webSessionCredentials.ts b/src/app/(dashboard)/dashboard/providers/[id]/webSessionCredentials.ts index d755a22ca7..d479c1235f 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/webSessionCredentials.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/webSessionCredentials.ts @@ -141,6 +141,12 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = { placeholder: "session=... or full Cookie header from doubao.com", acceptsFullCookieHeader: true, }, + "qwen-web": { + kind: "token", + credentialName: "token", + placeholder: "Paste your Qwen token from chat.qwen.ai (Local Storage → token)", + acceptsFullCookieHeader: false, + }, } satisfies Record; export function getWebSessionCredentialRequirement( diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 0bca6d0785..8c4ca6ce3e 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -516,6 +516,20 @@ export const WEB_COOKIE_PROVIDERS = { subscriptionRisk: true, riskNoticeVariant: "webCookie", }, + "qwen-web": { + id: "qwen-web", + alias: "qw", + 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).', + }, }; // API Key Providers diff --git a/tests/unit/web-cookie-providers-new.test.ts b/tests/unit/web-cookie-providers-new.test.ts index e30f003aab..b6ff83b790 100644 --- a/tests/unit/web-cookie-providers-new.test.ts +++ b/tests/unit/web-cookie-providers-new.test.ts @@ -8,6 +8,7 @@ const { VeniceWebExecutor } = await import("../../open-sse/executors/venice-web. const { V0VercelWebExecutor } = await import("../../open-sse/executors/v0-vercel-web.ts"); const { KimiWebExecutor } = await import("../../open-sse/executors/kimi-web.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 ────────────────────────────────────────────────────────────────── @@ -175,6 +176,22 @@ test("Doubao Web sets correct provider", () => { assert.equal(executor.getProvider(), "doubao-web"); }); +// ── Registration Tests (Qwen Web) ──────────────────────────────────────────── + +test("Qwen Web executor is registered", () => { + assert.ok(hasSpecializedExecutor("qwen-web")); + assert.ok(hasSpecializedExecutor("qw")); + const executor = 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 () => {