fix(providers): remove Qwen provider support
Qwen OAuth flow (portal.qwen.ai) stopped working reliably; drop the executor, registry entry, OAuth provider/service, token refresh profile, usage handler, and related test coverage and baselines.
This commit is contained in:
parent
6498b3122f
commit
dcdd4628b3
27 changed files with 8 additions and 525 deletions
|
|
@ -176,7 +176,6 @@ export const OAUTH_ENDPOINTS = {
|
||||||
google: { token: "https://oauth2.googleapis.com/token", auth: "https://accounts.google.com/o/oauth2/auth" },
|
google: { token: "https://oauth2.googleapis.com/token", auth: "https://accounts.google.com/o/oauth2/auth" },
|
||||||
openai: { token: PROVIDER_OAUTH["codex"]?.tokenUrl, auth: PROVIDER_OAUTH["codex"]?.authorizeUrl },
|
openai: { token: PROVIDER_OAUTH["codex"]?.tokenUrl, auth: PROVIDER_OAUTH["codex"]?.authorizeUrl },
|
||||||
anthropic: { token: PROVIDER_OAUTH["claude"]?.tokenUrl, auth: "https://api.anthropic.com/v1/oauth/authorize" }, // ≠ claude.authorizeUrl (claude.ai login) — keep
|
anthropic: { token: PROVIDER_OAUTH["claude"]?.tokenUrl, auth: "https://api.anthropic.com/v1/oauth/authorize" }, // ≠ claude.authorizeUrl (claude.ai login) — keep
|
||||||
qwen: { token: PROVIDER_OAUTH["qwen"]?.tokenUrl, auth: PROVIDER_OAUTH["qwen"]?.deviceCodeUrl },
|
|
||||||
iflow: { token: PROVIDER_OAUTH["iflow"]?.tokenUrl, auth: PROVIDER_OAUTH["iflow"]?.authorizeUrl },
|
iflow: { token: PROVIDER_OAUTH["iflow"]?.tokenUrl, auth: PROVIDER_OAUTH["iflow"]?.authorizeUrl },
|
||||||
github: { token: PROVIDER_OAUTH["github"]?.tokenUrl, auth: PROVIDER_OAUTH["github"]?.authorizeUrl, deviceCode: PROVIDER_OAUTH["github"]?.deviceCodeUrl },
|
github: { token: PROVIDER_OAUTH["github"]?.tokenUrl, auth: PROVIDER_OAUTH["github"]?.authorizeUrl, deviceCode: PROVIDER_OAUTH["github"]?.deviceCodeUrl },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -211,7 +211,6 @@ export class DefaultExecutor extends BaseExecutor {
|
||||||
const refreshers = {
|
const refreshers = {
|
||||||
claude: () => this.refreshFromGrant(credentials, proxyOptions),
|
claude: () => this.refreshFromGrant(credentials, proxyOptions),
|
||||||
codex: () => this.refreshFromGrant(credentials, proxyOptions),
|
codex: () => this.refreshFromGrant(credentials, proxyOptions),
|
||||||
qwen: () => this.refreshWithForm(OAUTH_ENDPOINTS.qwen.token, { grant_type: "refresh_token", refresh_token: credentials.refreshToken, client_id: PROVIDERS.qwen.clientId }, proxyOptions),
|
|
||||||
iflow: () => this.refreshIflow(credentials.refreshToken, proxyOptions),
|
iflow: () => this.refreshIflow(credentials.refreshToken, proxyOptions),
|
||||||
gemini: () => this.refreshFromGrant(credentials, proxyOptions),
|
gemini: () => this.refreshFromGrant(credentials, proxyOptions),
|
||||||
kiro: () => this.refreshKiro(credentials.refreshToken, proxyOptions),
|
kiro: () => this.refreshKiro(credentials.refreshToken, proxyOptions),
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import { KimchiExecutor } from "./kimchi.js";
|
||||||
import { CodexExecutor } from "./codex.js";
|
import { CodexExecutor } from "./codex.js";
|
||||||
import { CursorExecutor } from "./cursor.js";
|
import { CursorExecutor } from "./cursor.js";
|
||||||
import { VertexExecutor } from "./vertex.js";
|
import { VertexExecutor } from "./vertex.js";
|
||||||
import { QwenExecutor } from "./qwen.js";
|
|
||||||
import { OpenCodeExecutor } from "./opencode.js";
|
import { OpenCodeExecutor } from "./opencode.js";
|
||||||
import { OpenCodeGoExecutor } from "./opencode-go.js";
|
import { OpenCodeGoExecutor } from "./opencode-go.js";
|
||||||
import { GrokWebExecutor } from "./grok-web.js";
|
import { GrokWebExecutor } from "./grok-web.js";
|
||||||
|
|
@ -41,7 +40,6 @@ const executors = {
|
||||||
cu: new CursorExecutor(), // Alias for cursor
|
cu: new CursorExecutor(), // Alias for cursor
|
||||||
vertex: new VertexExecutor("vertex"),
|
vertex: new VertexExecutor("vertex"),
|
||||||
"vertex-partner": new VertexExecutor("vertex-partner"),
|
"vertex-partner": new VertexExecutor("vertex-partner"),
|
||||||
qwen: new QwenExecutor(),
|
|
||||||
opencode: new OpenCodeExecutor(),
|
opencode: new OpenCodeExecutor(),
|
||||||
"opencode-go": new OpenCodeGoExecutor(),
|
"opencode-go": new OpenCodeGoExecutor(),
|
||||||
"grok-web": new GrokWebExecutor(),
|
"grok-web": new GrokWebExecutor(),
|
||||||
|
|
@ -87,7 +85,6 @@ export { CodexExecutor } from "./codex.js";
|
||||||
export { CursorExecutor } from "./cursor.js";
|
export { CursorExecutor } from "./cursor.js";
|
||||||
export { VertexExecutor } from "./vertex.js";
|
export { VertexExecutor } from "./vertex.js";
|
||||||
export { DefaultExecutor } from "./default.js";
|
export { DefaultExecutor } from "./default.js";
|
||||||
export { QwenExecutor } from "./qwen.js";
|
|
||||||
export { OpenCodeExecutor } from "./opencode.js";
|
export { OpenCodeExecutor } from "./opencode.js";
|
||||||
export { OpenCodeGoExecutor } from "./opencode-go.js";
|
export { OpenCodeGoExecutor } from "./opencode-go.js";
|
||||||
export { GrokWebExecutor } from "./grok-web.js";
|
export { GrokWebExecutor } from "./grok-web.js";
|
||||||
|
|
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
import { DefaultExecutor } from "./default.js";
|
|
||||||
import { PROVIDERS } from "../config/providers.js";
|
|
||||||
import { OAUTH_ENDPOINTS } from "../config/appConstants.js";
|
|
||||||
|
|
||||||
/** portal.qwen.ai — static fingerprint matching stable Qwen Code release */
|
|
||||||
const QWEN_USER_AGENT = "QwenCode/0.12.3 (linux; x64)";
|
|
||||||
const QWEN_STAINLESS = {
|
|
||||||
os: "Linux",
|
|
||||||
arch: "x64",
|
|
||||||
lang: "js",
|
|
||||||
runtime: "node",
|
|
||||||
runtimeVersion: "v18.19.1",
|
|
||||||
packageVersion: "5.11.0",
|
|
||||||
retryCount: "1"
|
|
||||||
};
|
|
||||||
const QWEN_DEFAULT_SYSTEM_MESSAGE = {
|
|
||||||
role: "system",
|
|
||||||
content: [{ type: "text", text: "", cache_control: { type: "ephemeral" } }]
|
|
||||||
};
|
|
||||||
|
|
||||||
function ensureQwenSystemMessage(body) {
|
|
||||||
if (!body || typeof body !== "object") return body;
|
|
||||||
const next = { ...body };
|
|
||||||
if (Array.isArray(next.messages)) {
|
|
||||||
next.messages = [QWEN_DEFAULT_SYSTEM_MESSAGE, ...next.messages];
|
|
||||||
} else {
|
|
||||||
next.messages = [QWEN_DEFAULT_SYSTEM_MESSAGE];
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isQwenThinkingActive(body) {
|
|
||||||
const thinking = body?.thinking;
|
|
||||||
if (thinking === true || body?.enable_thinking === true) return true;
|
|
||||||
return typeof thinking === "object" && thinking !== null && !Array.isArray(thinking) && thinking.type === "enabled";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Qwen rejects tool_choice="required" or object forms when thinking is active; neutralize to "auto".
|
|
||||||
function sanitizeQwenThinkingToolChoice(body) {
|
|
||||||
if (!isQwenThinkingActive(body)) return body;
|
|
||||||
const tc = body.tool_choice;
|
|
||||||
const incompatible = tc === "required" || (typeof tc === "object" && tc !== null);
|
|
||||||
if (!incompatible) return body;
|
|
||||||
return { ...body, tool_choice: "auto" };
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildQwenUpstreamHeaders(credentials, stream = true) {
|
|
||||||
const token = credentials?.apiKey || credentials?.accessToken || "";
|
|
||||||
const headers = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
"User-Agent": QWEN_USER_AGENT,
|
|
||||||
"X-DashScope-AuthType": "qwen-oauth",
|
|
||||||
"X-DashScope-CacheControl": "enable",
|
|
||||||
"X-DashScope-UserAgent": QWEN_USER_AGENT,
|
|
||||||
"X-Stainless-Arch": QWEN_STAINLESS.arch,
|
|
||||||
"X-Stainless-Lang": QWEN_STAINLESS.lang,
|
|
||||||
"X-Stainless-Os": QWEN_STAINLESS.os,
|
|
||||||
"X-Stainless-Package-Version": QWEN_STAINLESS.packageVersion,
|
|
||||||
"X-Stainless-Retry-Count": QWEN_STAINLESS.retryCount,
|
|
||||||
"X-Stainless-Runtime": QWEN_STAINLESS.runtime,
|
|
||||||
"X-Stainless-Runtime-Version": QWEN_STAINLESS.runtimeVersion,
|
|
||||||
Connection: "keep-alive",
|
|
||||||
"Accept-Language": "*",
|
|
||||||
"Sec-Fetch-Mode": "cors"
|
|
||||||
};
|
|
||||||
headers.Accept = stream ? "text/event-stream" : "application/json";
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class QwenExecutor extends DefaultExecutor {
|
|
||||||
constructor() {
|
|
||||||
super("qwen");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Qwen tokens are bound to a resource_url returned at OAuth time.
|
|
||||||
// Using portal.qwen.ai when the token is issued for another shard returns 401/403.
|
|
||||||
buildUrl(model, stream, urlIndex = 0, credentials = null) {
|
|
||||||
const resourceUrl = credentials?.providerSpecificData?.resourceUrl;
|
|
||||||
const host = resourceUrl ? resourceUrl.replace(/^https?:\/\//, "").replace(/\/$/, "") : "portal.qwen.ai";
|
|
||||||
return `https://${host}/v1/chat/completions`;
|
|
||||||
}
|
|
||||||
|
|
||||||
buildHeaders(credentials, stream = true) {
|
|
||||||
return buildQwenUpstreamHeaders(credentials, stream);
|
|
||||||
}
|
|
||||||
|
|
||||||
transformRequest(model, body, stream, credentials) {
|
|
||||||
let next = body && typeof body === "object" ? { ...body } : body;
|
|
||||||
if (stream && next?.messages && !next.stream_options && !next.thinking && !next.enable_thinking && next.stream !== false) {
|
|
||||||
next.stream_options = { include_usage: true };
|
|
||||||
}
|
|
||||||
next = sanitizeQwenThinkingToolChoice(next);
|
|
||||||
return ensureQwenSystemMessage(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override to capture resource_url from refresh response (required for buildUrl).
|
|
||||||
async refreshCredentials(credentials, log) {
|
|
||||||
if (!credentials?.refreshToken) return null;
|
|
||||||
try {
|
|
||||||
const response = await fetch(OAUTH_ENDPOINTS.qwen.token, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
||||||
body: new URLSearchParams({
|
|
||||||
grant_type: "refresh_token",
|
|
||||||
refresh_token: credentials.refreshToken,
|
|
||||||
client_id: PROVIDERS.qwen.clientId
|
|
||||||
})
|
|
||||||
});
|
|
||||||
if (!response.ok) return null;
|
|
||||||
const tokens = await response.json();
|
|
||||||
log?.info?.("TOKEN", "qwen refreshed");
|
|
||||||
return {
|
|
||||||
accessToken: tokens.access_token,
|
|
||||||
refreshToken: tokens.refresh_token || credentials.refreshToken,
|
|
||||||
expiresIn: tokens.expires_in,
|
|
||||||
providerSpecificData: {
|
|
||||||
...(credentials.providerSpecificData || {}),
|
|
||||||
...(tokens.resource_url ? { resourceUrl: tokens.resource_url } : {})
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
log?.error?.("TOKEN", `qwen refresh error: ${error.message}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default QwenExecutor;
|
|
||||||
|
|
@ -47,7 +47,6 @@ export {
|
||||||
refreshAccessToken,
|
refreshAccessToken,
|
||||||
refreshClaudeOAuthToken,
|
refreshClaudeOAuthToken,
|
||||||
refreshGoogleToken,
|
refreshGoogleToken,
|
||||||
refreshQwenToken,
|
|
||||||
refreshCodexToken,
|
refreshCodexToken,
|
||||||
refreshIflowToken,
|
refreshIflowToken,
|
||||||
refreshGitHubToken,
|
refreshGitHubToken,
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,6 @@ import p72 from "./perplexity.js";
|
||||||
import p73 from "./perplexity-agent.js";
|
import p73 from "./perplexity-agent.js";
|
||||||
import p74 from "./playht.js";
|
import p74 from "./playht.js";
|
||||||
import p75 from "./qoder.js";
|
import p75 from "./qoder.js";
|
||||||
import p76 from "./qwen.js";
|
|
||||||
import p77 from "./recraft.js";
|
import p77 from "./recraft.js";
|
||||||
import p78 from "./runwayml.js";
|
import p78 from "./runwayml.js";
|
||||||
import p79 from "./sdwebui.js";
|
import p79 from "./sdwebui.js";
|
||||||
|
|
@ -198,7 +197,6 @@ export default [
|
||||||
p73,
|
p73,
|
||||||
p74,
|
p74,
|
||||||
p75,
|
p75,
|
||||||
p76,
|
|
||||||
p77,
|
p77,
|
||||||
p78,
|
p78,
|
||||||
p79,
|
p79,
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
export default {
|
|
||||||
id: "qwen",
|
|
||||||
hidden: true,
|
|
||||||
priority: 130,
|
|
||||||
alias: "qw",
|
|
||||||
display: {
|
|
||||||
name: "Qwen Code",
|
|
||||||
icon: "psychology",
|
|
||||||
color: "#10B981",
|
|
||||||
website: "https://chat.qwen.ai",
|
|
||||||
notice: {
|
|
||||||
signupUrl: "https://chat.qwen.ai",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
category: "oauth",
|
|
||||||
transport: {
|
|
||||||
baseUrl: "https://portal.qwen.ai/v1/chat/completions",
|
|
||||||
},
|
|
||||||
models: [
|
|
||||||
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
|
|
||||||
{ id: "qwen3-coder-flash", name: "Qwen3 Coder Flash" },
|
|
||||||
{ id: "vision-model", name: "Qwen3 Vision Model" },
|
|
||||||
{ id: "coder-model", name: "Qwen3.6 Coder Model" },
|
|
||||||
],
|
|
||||||
oauth: {
|
|
||||||
clientId: "f0304373b74a44d2b584a3fb70ca9e56",
|
|
||||||
deviceCodeUrl: "https://chat.qwen.ai/api/v1/oauth2/device/code",
|
|
||||||
tokenUrl: "https://chat.qwen.ai/api/v1/oauth2/token",
|
|
||||||
scope: "openid profile email model.completion",
|
|
||||||
codeChallengeMethod: "S256",
|
|
||||||
refreshLeadMs: 1200000,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -6,7 +6,6 @@ import {
|
||||||
refreshKimiToken,
|
refreshKimiToken,
|
||||||
refreshClaudeOAuthToken,
|
refreshClaudeOAuthToken,
|
||||||
refreshGoogleToken,
|
refreshGoogleToken,
|
||||||
refreshQwenToken,
|
|
||||||
refreshCodexToken,
|
refreshCodexToken,
|
||||||
refreshKiroToken,
|
refreshKiroToken,
|
||||||
refreshIflowToken,
|
refreshIflowToken,
|
||||||
|
|
@ -26,7 +25,6 @@ export {
|
||||||
refreshKimiToken,
|
refreshKimiToken,
|
||||||
refreshClaudeOAuthToken,
|
refreshClaudeOAuthToken,
|
||||||
refreshGoogleToken,
|
refreshGoogleToken,
|
||||||
refreshQwenToken,
|
|
||||||
refreshCodexToken,
|
refreshCodexToken,
|
||||||
refreshKiroToken,
|
refreshKiroToken,
|
||||||
refreshIflowToken,
|
refreshIflowToken,
|
||||||
|
|
@ -137,7 +135,6 @@ const REFRESH_HANDLERS = {
|
||||||
antigravity: (c, log) => refreshGoogleToken(c.refreshToken, PROVIDERS.antigravity.clientId, PROVIDERS.antigravity.clientSecret, log),
|
antigravity: (c, log) => refreshGoogleToken(c.refreshToken, PROVIDERS.antigravity.clientId, PROVIDERS.antigravity.clientSecret, log),
|
||||||
claude: (c, log) => refreshClaudeOAuthToken(c.refreshToken, log),
|
claude: (c, log) => refreshClaudeOAuthToken(c.refreshToken, log),
|
||||||
codex: (c, log) => refreshCodexToken(c.refreshToken, log),
|
codex: (c, log) => refreshCodexToken(c.refreshToken, log),
|
||||||
qwen: (c, log) => refreshQwenToken(c.refreshToken, log),
|
|
||||||
iflow: (c, log) => refreshIflowToken(c.refreshToken, log),
|
iflow: (c, log) => refreshIflowToken(c.refreshToken, log),
|
||||||
github: (c, log) => refreshGitHubToken(c.refreshToken, log),
|
github: (c, log) => refreshGitHubToken(c.refreshToken, log),
|
||||||
kiro: (c, log) => refreshKiroToken(c.refreshToken, c.providerSpecificData, log),
|
kiro: (c, log) => refreshKiroToken(c.refreshToken, c.providerSpecificData, log),
|
||||||
|
|
@ -205,7 +202,6 @@ export function formatProviderCredentials(provider, credentials, log) {
|
||||||
};
|
};
|
||||||
|
|
||||||
case "codex":
|
case "codex":
|
||||||
case "qwen":
|
|
||||||
case "iflow":
|
case "iflow":
|
||||||
case "openai":
|
case "openai":
|
||||||
case "openrouter":
|
case "openrouter":
|
||||||
|
|
|
||||||
|
|
@ -40,11 +40,6 @@ const REFRESH_PROFILES = {
|
||||||
url: () => OAUTH_ENDPOINTS.anthropic.token,
|
url: () => OAUTH_ENDPOINTS.anthropic.token,
|
||||||
dedupKey: "claude",
|
dedupKey: "claude",
|
||||||
},
|
},
|
||||||
qwen: {
|
|
||||||
url: () => OAUTH_ENDPOINTS.qwen.token,
|
|
||||||
dedupKey: "qwen",
|
|
||||||
parse: (tokens) => tokens.resource_url ? { providerSpecificData: { resourceUrl: tokens.resource_url } } : {},
|
|
||||||
},
|
|
||||||
iflow: {
|
iflow: {
|
||||||
url: () => OAUTH_ENDPOINTS.iflow.token,
|
url: () => OAUTH_ENDPOINTS.iflow.token,
|
||||||
dedupKey: "iflow",
|
dedupKey: "iflow",
|
||||||
|
|
@ -191,11 +186,6 @@ export async function refreshGoogleToken(refreshToken, clientId, clientSecret, l
|
||||||
}, log);
|
}, log);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Qwen: form body + clientId, surfaces resource_url. Delegate to refreshAccessToken("qwen", ...).
|
|
||||||
export async function refreshQwenToken(refreshToken, log) {
|
|
||||||
return refreshAccessToken("qwen", refreshToken, {}, log);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function classifyOAuthRefreshError(errorText = "", status = 0) {
|
export function classifyOAuthRefreshError(errorText = "", status = 0) {
|
||||||
let parsed = null;
|
let parsed = null;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import { getKimiUsage } from "./usage/kimi.js";
|
||||||
import { getDeepseekUsage } from "./usage/deepseek.js";
|
import { getDeepseekUsage } from "./usage/deepseek.js";
|
||||||
import { resolveQoderCredentials } from "./qoderModels.js";
|
import { resolveQoderCredentials } from "./qoderModels.js";
|
||||||
import {
|
import {
|
||||||
getQwenUsage,
|
|
||||||
getIflowUsage,
|
getIflowUsage,
|
||||||
getOllamaUsage,
|
getOllamaUsage,
|
||||||
getGlmUsage,
|
getGlmUsage,
|
||||||
|
|
@ -43,7 +42,6 @@ const USAGE_HANDLERS = {
|
||||||
const resolved = await resolveQoderCredentials(c, c.proxyOptions).catch(() => null);
|
const resolved = await resolveQoderCredentials(c, c.proxyOptions).catch(() => null);
|
||||||
return getQoderUsage(resolved?.accessToken || c.accessToken, c.proxyOptions);
|
return getQoderUsage(resolved?.accessToken || c.accessToken, c.proxyOptions);
|
||||||
},
|
},
|
||||||
qwen: (c) => getQwenUsage(c.accessToken, c.providerSpecificData),
|
|
||||||
iflow: (c) => getIflowUsage(c.accessToken),
|
iflow: (c) => getIflowUsage(c.accessToken),
|
||||||
ollama: (c) => getOllamaUsage(c.apiKey, c.providerSpecificData, c.proxyOptions),
|
ollama: (c) => getOllamaUsage(c.apiKey, c.providerSpecificData, c.proxyOptions),
|
||||||
glm: (c) => getGlmUsage(c.apiKey, c.provider, c.proxyOptions),
|
glm: (c) => getGlmUsage(c.apiKey, c.provider, c.proxyOptions),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* Misc usage handlers (Qwen, iFlow, Ollama, GLM, Vercel AI Gateway, Qoder)
|
* Misc usage handlers (iFlow, Ollama, GLM, Vercel AI Gateway, Qoder)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
|
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
|
||||||
|
|
@ -15,23 +15,6 @@ const GLM_QUOTA_URLS = {
|
||||||
// Returns { balance: "95.50", total_used: "4.50" } (USD as decimal strings).
|
// Returns { balance: "95.50", total_used: "4.50" } (USD as decimal strings).
|
||||||
const VERCEL_AI_GATEWAY_CREDITS_URL = U("vercel-ai-gateway").url;
|
const VERCEL_AI_GATEWAY_CREDITS_URL = U("vercel-ai-gateway").url;
|
||||||
|
|
||||||
/**
|
|
||||||
* Qwen Usage
|
|
||||||
*/
|
|
||||||
export async function getQwenUsage(accessToken, providerSpecificData) {
|
|
||||||
try {
|
|
||||||
const resourceUrl = providerSpecificData?.resourceUrl;
|
|
||||||
if (!resourceUrl) {
|
|
||||||
return { message: "Qwen connected. No resource URL available." };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Qwen may have usage endpoint at resource URL
|
|
||||||
return { message: "Qwen connected. Usage tracked per request." };
|
|
||||||
} catch (error) {
|
|
||||||
return { message: "Unable to fetch Qwen usage." };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* iFlow Usage
|
* iFlow Usage
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -79,18 +79,6 @@ const createOpenAIModelsConfig = (url) => ({
|
||||||
parseResponse: parseOpenAIStyleModels
|
parseResponse: parseOpenAIStyleModels
|
||||||
});
|
});
|
||||||
|
|
||||||
const resolveQwenModelsUrl = (connection) => {
|
|
||||||
const fallback = "https://portal.qwen.ai/v1/models";
|
|
||||||
const raw = connection?.providerSpecificData?.resourceUrl;
|
|
||||||
if (!raw || typeof raw !== "string") return fallback;
|
|
||||||
const value = raw.trim();
|
|
||||||
if (!value) return fallback;
|
|
||||||
if (value.startsWith("http://") || value.startsWith("https://")) {
|
|
||||||
return `${value.replace(/\/$/, "")}/models`;
|
|
||||||
}
|
|
||||||
return `https://${value.replace(/\/$/, "")}/v1/models`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStaticProviderModels = (providerId) =>
|
const getStaticProviderModels = (providerId) =>
|
||||||
getModelsByProviderId(providerId).map((model) => ({
|
getModelsByProviderId(providerId).map((model) => ({
|
||||||
...model,
|
...model,
|
||||||
|
|
@ -156,14 +144,6 @@ const PROVIDER_MODELS_CONFIG = {
|
||||||
authQuery: "key", // Use query param for API key
|
authQuery: "key", // Use query param for API key
|
||||||
parseResponse: (data) => data.models || []
|
parseResponse: (data) => data.models || []
|
||||||
},
|
},
|
||||||
qwen: {
|
|
||||||
url: "https://portal.qwen.ai/v1/models",
|
|
||||||
method: "GET",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
authHeader: "Authorization",
|
|
||||||
authPrefix: "Bearer ",
|
|
||||||
parseResponse: (data) => data.data || []
|
|
||||||
},
|
|
||||||
codex: {
|
codex: {
|
||||||
customResolver: buildOAuthResolver({
|
customResolver: buildOAuthResolver({
|
||||||
refreshFn: (conn) => refreshCodexToken(conn.refreshToken),
|
refreshFn: (conn) => refreshCodexToken(conn.refreshToken),
|
||||||
|
|
@ -572,9 +552,6 @@ export async function GET(request, { params }) {
|
||||||
|
|
||||||
// Build request URL
|
// Build request URL
|
||||||
let url = config.url;
|
let url = config.url;
|
||||||
if (connection.provider === "qwen") {
|
|
||||||
url = resolveQwenModelsUrl(connection);
|
|
||||||
}
|
|
||||||
if (config.authQuery) {
|
if (config.authQuery) {
|
||||||
url += `?${config.authQuery}=${token}`;
|
url += `?${config.authQuery}=${token}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import {
|
||||||
GEMINI_CONFIG,
|
GEMINI_CONFIG,
|
||||||
ANTIGRAVITY_CONFIG,
|
ANTIGRAVITY_CONFIG,
|
||||||
KIRO_CONFIG,
|
KIRO_CONFIG,
|
||||||
QWEN_CONFIG,
|
|
||||||
CLAUDE_CONFIG,
|
CLAUDE_CONFIG,
|
||||||
CLINE_CONFIG,
|
CLINE_CONFIG,
|
||||||
KILOCODE_CONFIG,
|
KILOCODE_CONFIG,
|
||||||
|
|
@ -62,7 +61,6 @@ const OAUTH_TEST_CONFIG = {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
noAuth: true,
|
noAuth: true,
|
||||||
},
|
},
|
||||||
qwen: { checkExpiry: true, refreshable: true },
|
|
||||||
kiro: { checkExpiry: true, refreshable: true },
|
kiro: { checkExpiry: true, refreshable: true },
|
||||||
qoder: {
|
qoder: {
|
||||||
// Test by hitting Qoder's userinfo endpoint with the device token.
|
// Test by hitting Qoder's userinfo endpoint with the device token.
|
||||||
|
|
@ -285,21 +283,6 @@ async function refreshOAuthToken(connection) {
|
||||||
return { accessToken: data.accessToken, expiresIn: data.expiresIn || 3600, refreshToken: data.refreshToken || refreshToken };
|
return { accessToken: data.accessToken, expiresIn: data.expiresIn || 3600, refreshToken: data.refreshToken || refreshToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider === "qwen") {
|
|
||||||
const response = await fetch(QWEN_CONFIG.tokenUrl, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json" },
|
|
||||||
body: new URLSearchParams({
|
|
||||||
grant_type: "refresh_token",
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
client_id: QWEN_CONFIG.clientId,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!response.ok) return null;
|
|
||||||
const data = await response.json();
|
|
||||||
return { accessToken: data.access_token, expiresIn: data.expires_in, refreshToken: data.refresh_token || refreshToken };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === "cline") {
|
if (provider === "cline") {
|
||||||
const response = await fetch(CLINE_CONFIG.refreshUrl, {
|
const response = await fetch(CLINE_CONFIG.refreshUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,6 @@ export const CODEX_CONFIG = { ...PROVIDER_OAUTH["codex"] };
|
||||||
// clientId/clientSecret from GOOGLE_OAUTH_CLIENT (shared.js) — not stored in registry
|
// clientId/clientSecret from GOOGLE_OAUTH_CLIENT (shared.js) — not stored in registry
|
||||||
export const GEMINI_CONFIG = { ...GOOGLE_OAUTH_CLIENT, ...PROVIDER_OAUTH["gemini-cli"] };
|
export const GEMINI_CONFIG = { ...GOOGLE_OAUTH_CLIENT, ...PROVIDER_OAUTH["gemini-cli"] };
|
||||||
|
|
||||||
// Qwen OAuth Configuration (Device Code Flow with PKCE)
|
|
||||||
export const QWEN_CONFIG = { ...PROVIDER_OAUTH["qwen"] };
|
|
||||||
|
|
||||||
// Qoder OAuth Configuration (Device Token Flow with PKCE).
|
// Qoder OAuth Configuration (Device Token Flow with PKCE).
|
||||||
// Device tokens are long-lived (~30 days for access, ~360 for refresh).
|
// Device tokens are long-lived (~30 days for access, ~360 for refresh).
|
||||||
// The upstream refresh endpoint at center.qoder.sh returns 403 for our
|
// The upstream refresh endpoint at center.qoder.sh returns 403 for our
|
||||||
|
|
@ -206,7 +203,6 @@ export const PROVIDERS = {
|
||||||
CLAUDE: "claude",
|
CLAUDE: "claude",
|
||||||
CODEX: "codex",
|
CODEX: "codex",
|
||||||
GEMINI: "gemini-cli",
|
GEMINI: "gemini-cli",
|
||||||
QWEN: "qwen",
|
|
||||||
QODER: "qoder",
|
QODER: "qoder",
|
||||||
IFLOW: "iflow",
|
IFLOW: "iflow",
|
||||||
ANTIGRAVITY: "antigravity",
|
ANTIGRAVITY: "antigravity",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import geminiCli from "./gemini-cli.js";
|
||||||
import antigravity from "./antigravity.js";
|
import antigravity from "./antigravity.js";
|
||||||
import iflow from "./iflow.js";
|
import iflow from "./iflow.js";
|
||||||
import qoder from "./qoder.js";
|
import qoder from "./qoder.js";
|
||||||
import qwen from "./qwen.js";
|
|
||||||
import github from "./github.js";
|
import github from "./github.js";
|
||||||
import kiro from "./kiro.js";
|
import kiro from "./kiro.js";
|
||||||
import cursor from "./cursor.js";
|
import cursor from "./cursor.js";
|
||||||
|
|
@ -38,7 +37,6 @@ const PROVIDERS = {
|
||||||
antigravity,
|
antigravity,
|
||||||
iflow,
|
iflow,
|
||||||
qoder,
|
qoder,
|
||||||
qwen,
|
|
||||||
github,
|
github,
|
||||||
kiro,
|
kiro,
|
||||||
cursor,
|
cursor,
|
||||||
|
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
import { QWEN_CONFIG } from "../constants/oauth.js";
|
|
||||||
|
|
||||||
const qwen = {
|
|
||||||
config: QWEN_CONFIG,
|
|
||||||
flowType: "device_code",
|
|
||||||
requestDeviceCode: async (config, codeChallenge) => {
|
|
||||||
const response = await fetch(config.deviceCodeUrl, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
|
||||||
client_id: config.clientId,
|
|
||||||
scope: config.scope,
|
|
||||||
code_challenge: codeChallenge,
|
|
||||||
code_challenge_method: config.codeChallengeMethod,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text();
|
|
||||||
throw new Error(`Device code request failed: ${error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json();
|
|
||||||
},
|
|
||||||
pollToken: async (config, deviceCode, codeVerifier) => {
|
|
||||||
const response = await fetch(config.tokenUrl, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
|
||||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
||||||
client_id: config.clientId,
|
|
||||||
device_code: deviceCode,
|
|
||||||
code_verifier: codeVerifier,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: response.ok,
|
|
||||||
data: await response.json(),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
mapTokens: (tokens) => ({
|
|
||||||
accessToken: tokens.access_token,
|
|
||||||
refreshToken: tokens.refresh_token,
|
|
||||||
expiresIn: tokens.expires_in,
|
|
||||||
providerSpecificData: { resourceUrl: tokens.resource_url },
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default qwen;
|
|
||||||
|
|
@ -6,7 +6,6 @@ export { OAuthService } from "./oauth.js";
|
||||||
export { ClaudeService } from "./claude.js";
|
export { ClaudeService } from "./claude.js";
|
||||||
export { CodexService } from "./codex.js";
|
export { CodexService } from "./codex.js";
|
||||||
export { GeminiCLIService } from "./gemini.js";
|
export { GeminiCLIService } from "./gemini.js";
|
||||||
export { QwenService } from "./qwen.js";
|
|
||||||
export { IFlowService } from "./iflow.js";
|
export { IFlowService } from "./iflow.js";
|
||||||
export { QoderService } from "./qoder.js";
|
export { QoderService } from "./qoder.js";
|
||||||
export { AntigravityService } from "./antigravity.js";
|
export { AntigravityService } from "./antigravity.js";
|
||||||
|
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
import open from "open";
|
|
||||||
import { QWEN_CONFIG } from "../constants/oauth.js";
|
|
||||||
import { getServerCredentials } from "../config/index.js";
|
|
||||||
import { generatePKCE } from "../utils/pkce.js";
|
|
||||||
import { spinner as createSpinner } from "../utils/ui.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Qwen OAuth Service
|
|
||||||
* Uses Device Code Flow with PKCE
|
|
||||||
*/
|
|
||||||
export class QwenService {
|
|
||||||
constructor() {
|
|
||||||
this.config = QWEN_CONFIG;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request device code
|
|
||||||
*/
|
|
||||||
async requestDeviceCode(codeChallenge) {
|
|
||||||
const response = await fetch(this.config.deviceCodeUrl, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
|
||||||
client_id: this.config.clientId,
|
|
||||||
scope: this.config.scope,
|
|
||||||
code_challenge: codeChallenge,
|
|
||||||
code_challenge_method: this.config.codeChallengeMethod,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.text();
|
|
||||||
throw new Error(`Device code request failed: ${error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Poll for token
|
|
||||||
*/
|
|
||||||
async pollForToken(deviceCode, codeVerifier, interval = 5) {
|
|
||||||
const maxAttempts = 60; // 5 minutes
|
|
||||||
const pollInterval = interval * 1000;
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
||||||
await new Promise((r) => setTimeout(r, pollInterval));
|
|
||||||
|
|
||||||
const response = await fetch(this.config.tokenUrl, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
|
||||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
||||||
client_id: this.config.clientId,
|
|
||||||
device_code: deviceCode,
|
|
||||||
code_verifier: codeVerifier,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
const error = await response.json();
|
|
||||||
|
|
||||||
if (error.error === "authorization_pending") {
|
|
||||||
continue;
|
|
||||||
} else if (error.error === "slow_down") {
|
|
||||||
await new Promise((r) => setTimeout(r, 5000));
|
|
||||||
continue;
|
|
||||||
} else if (error.error === "expired_token") {
|
|
||||||
throw new Error("Device code expired");
|
|
||||||
} else if (error.error === "access_denied") {
|
|
||||||
throw new Error("Access denied");
|
|
||||||
} else {
|
|
||||||
throw new Error(error.error_description || error.error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("Authorization timeout");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save Qwen tokens to server
|
|
||||||
*/
|
|
||||||
async saveTokens(tokens) {
|
|
||||||
const { server, token, userId } = getServerCredentials();
|
|
||||||
|
|
||||||
const response = await fetch(`${server}/api/cli/providers/qwen`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
"X-User-Id": userId,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
accessToken: tokens.access_token,
|
|
||||||
refreshToken: tokens.refresh_token,
|
|
||||||
expiresIn: tokens.expires_in,
|
|
||||||
resourceUrl: tokens.resource_url,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.error || "Failed to save tokens");
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Complete Qwen OAuth flow
|
|
||||||
*/
|
|
||||||
async connect() {
|
|
||||||
const spinner = createSpinner("Starting Qwen OAuth...").start();
|
|
||||||
|
|
||||||
try {
|
|
||||||
spinner.text = "Generating PKCE...";
|
|
||||||
|
|
||||||
// Generate PKCE
|
|
||||||
const { codeVerifier, codeChallenge } = generatePKCE();
|
|
||||||
|
|
||||||
spinner.text = "Requesting device code...";
|
|
||||||
|
|
||||||
// Request device code
|
|
||||||
const deviceData = await this.requestDeviceCode(codeChallenge);
|
|
||||||
|
|
||||||
spinner.stop();
|
|
||||||
|
|
||||||
console.log("\n📋 Please visit the following URL and enter the code:\n");
|
|
||||||
console.log(` ${deviceData.verification_uri}\n`);
|
|
||||||
console.log(` Code: ${deviceData.user_code}\n`);
|
|
||||||
|
|
||||||
// Open browser
|
|
||||||
if (deviceData.verification_uri_complete) {
|
|
||||||
await open(deviceData.verification_uri_complete);
|
|
||||||
} else {
|
|
||||||
await open(deviceData.verification_uri);
|
|
||||||
}
|
|
||||||
|
|
||||||
spinner.start("Waiting for authorization...");
|
|
||||||
|
|
||||||
// Poll for token
|
|
||||||
const tokens = await this.pollForToken(
|
|
||||||
deviceData.device_code,
|
|
||||||
codeVerifier,
|
|
||||||
deviceData.interval || 5
|
|
||||||
);
|
|
||||||
|
|
||||||
spinner.text = "Saving tokens to server...";
|
|
||||||
|
|
||||||
// Save tokens to server
|
|
||||||
await this.saveTokens(tokens);
|
|
||||||
|
|
||||||
spinner.succeed("Qwen connected successfully!");
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
spinner.fail(`Failed: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -226,7 +226,6 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||||
// Device code flow providers (must match oauth providers with flowType: "device_code")
|
// Device code flow providers (must match oauth providers with flowType: "device_code")
|
||||||
const deviceCodeProviders = [
|
const deviceCodeProviders = [
|
||||||
"github",
|
"github",
|
||||||
"qwen",
|
|
||||||
"kiro",
|
"kiro",
|
||||||
"kimi",
|
"kimi",
|
||||||
"kimi-coding",
|
"kimi-coding",
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ const navItems = [
|
||||||
{ href: "/dashboard/endpoint", label: "Endpoint & Key", icon: "api" },
|
{ href: "/dashboard/endpoint", label: "Endpoint & Key", icon: "api" },
|
||||||
{ href: "/dashboard/providers", label: "Providers", icon: "dns" },
|
{ href: "/dashboard/providers", label: "Providers", icon: "dns" },
|
||||||
// { href: "/dashboard/basic-chat", label: "Basic Chat", icon: "chat" }, // Hidden
|
// { href: "/dashboard/basic-chat", label: "Basic Chat", icon: "chat" }, // Hidden
|
||||||
{ href: "/dashboard/combos", label: "Combos", icon: "layers" },
|
{ href: "/dashboard/combos", label: "Combo & Vision Adapter", icon: "layers" },
|
||||||
{ href: "/dashboard/usage", label: "Usage", icon: "bar_chart" },
|
{ href: "/dashboard/usage", label: "Usage", icon: "bar_chart" },
|
||||||
{ href: "/dashboard/quota", label: "Quota Tracker", icon: "data_usage" },
|
{ href: "/dashboard/quota", label: "Quota Tracker", icon: "data_usage" },
|
||||||
{ href: "/dashboard/token-saver", label: "Token Saver", icon: "savings" },
|
{ href: "/dashboard/token-saver", label: "Token Saver", icon: "savings" },
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import {
|
||||||
refreshAccessToken as _refreshAccessToken,
|
refreshAccessToken as _refreshAccessToken,
|
||||||
refreshClaudeOAuthToken as _refreshClaudeOAuthToken,
|
refreshClaudeOAuthToken as _refreshClaudeOAuthToken,
|
||||||
refreshGoogleToken as _refreshGoogleToken,
|
refreshGoogleToken as _refreshGoogleToken,
|
||||||
refreshQwenToken as _refreshQwenToken,
|
|
||||||
refreshCodexToken as _refreshCodexToken,
|
refreshCodexToken as _refreshCodexToken,
|
||||||
refreshIflowToken as _refreshIflowToken,
|
refreshIflowToken as _refreshIflowToken,
|
||||||
refreshGitHubToken as _refreshGitHubToken,
|
refreshGitHubToken as _refreshGitHubToken,
|
||||||
|
|
@ -41,9 +40,6 @@ export const refreshClaudeOAuthToken = (refreshToken) =>
|
||||||
export const refreshGoogleToken = (refreshToken, clientId, clientSecret) =>
|
export const refreshGoogleToken = (refreshToken, clientId, clientSecret) =>
|
||||||
_refreshGoogleToken(refreshToken, clientId, clientSecret, log);
|
_refreshGoogleToken(refreshToken, clientId, clientSecret, log);
|
||||||
|
|
||||||
export const refreshQwenToken = (refreshToken) =>
|
|
||||||
_refreshQwenToken(refreshToken, log);
|
|
||||||
|
|
||||||
export const refreshCodexToken = (refreshToken) =>
|
export const refreshCodexToken = (refreshToken) =>
|
||||||
_refreshCodexToken(refreshToken, log);
|
_refreshCodexToken(refreshToken, log);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,6 @@
|
||||||
"token": "https://api.anthropic.com/v1/oauth/token",
|
"token": "https://api.anthropic.com/v1/oauth/token",
|
||||||
"auth": "https://api.anthropic.com/v1/oauth/authorize"
|
"auth": "https://api.anthropic.com/v1/oauth/authorize"
|
||||||
},
|
},
|
||||||
"qwen": {
|
|
||||||
"token": "https://chat.qwen.ai/api/v1/oauth2/token",
|
|
||||||
"auth": "https://chat.qwen.ai/api/v1/oauth2/device/code"
|
|
||||||
},
|
|
||||||
"iflow": {
|
"iflow": {
|
||||||
"token": "https://iflow.cn/oauth/token",
|
"token": "https://iflow.cn/oauth/token",
|
||||||
"auth": "https://iflow.cn/oauth"
|
"auth": "https://iflow.cn/oauth"
|
||||||
|
|
@ -29,7 +25,6 @@
|
||||||
"tokenUrls": {
|
"tokenUrls": {
|
||||||
"claude": "https://api.anthropic.com/v1/oauth/token",
|
"claude": "https://api.anthropic.com/v1/oauth/token",
|
||||||
"codex": "https://auth.openai.com/oauth/token",
|
"codex": "https://auth.openai.com/oauth/token",
|
||||||
"qwen": "https://chat.qwen.ai/api/v1/oauth2/token",
|
|
||||||
"iflow": "https://iflow.cn/oauth/token",
|
"iflow": "https://iflow.cn/oauth/token",
|
||||||
"kiro": "https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken",
|
"kiro": "https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken",
|
||||||
"xai": "https://auth.x.ai/oauth2/token",
|
"xai": "https://auth.x.ai/oauth2/token",
|
||||||
|
|
@ -49,7 +44,6 @@
|
||||||
"clientIds": {
|
"clientIds": {
|
||||||
"claude": "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
"claude": "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
||||||
"codex": "app_EMoamEEZ73f0CkXaXp7hrann",
|
"codex": "app_EMoamEEZ73f0CkXaXp7hrann",
|
||||||
"qwen": "f0304373b74a44d2b584a3fb70ca9e56",
|
|
||||||
"iflow": "10009311001",
|
"iflow": "10009311001",
|
||||||
"kimi": "17e5f671-d194-4dfb-9706-5516cb48c098",
|
"kimi": "17e5f671-d194-4dfb-9706-5516cb48c098",
|
||||||
"grok-cli": "b1a00492-073a-47ea-816f-4c329264a828"
|
"grok-cli": "b1a00492-073a-47ea-816f-4c329264a828"
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ const resolved = {
|
||||||
tokenUrls: {
|
tokenUrls: {
|
||||||
claude: PROVIDERS.claude?.tokenUrl,
|
claude: PROVIDERS.claude?.tokenUrl,
|
||||||
codex: PROVIDERS.codex?.tokenUrl,
|
codex: PROVIDERS.codex?.tokenUrl,
|
||||||
qwen: PROVIDERS.qwen?.tokenUrl,
|
|
||||||
iflow: PROVIDERS.iflow?.tokenUrl,
|
iflow: PROVIDERS.iflow?.tokenUrl,
|
||||||
kiro: PROVIDERS.kiro?.tokenUrl,
|
kiro: PROVIDERS.kiro?.tokenUrl,
|
||||||
xai: PROVIDERS.xai?.tokenUrl,
|
xai: PROVIDERS.xai?.tokenUrl,
|
||||||
|
|
@ -25,7 +24,6 @@ const resolved = {
|
||||||
kimi: PROVIDERS.kimi?.tokenUrl,
|
kimi: PROVIDERS.kimi?.tokenUrl,
|
||||||
},
|
},
|
||||||
authUrls: {
|
authUrls: {
|
||||||
qwen: PROVIDERS.qwen?.authUrl,
|
|
||||||
iflow: PROVIDERS.iflow?.authUrl,
|
iflow: PROVIDERS.iflow?.authUrl,
|
||||||
kiro: PROVIDERS.kiro?.authUrl,
|
kiro: PROVIDERS.kiro?.authUrl,
|
||||||
},
|
},
|
||||||
|
|
@ -38,7 +36,6 @@ const resolved = {
|
||||||
clientIds: {
|
clientIds: {
|
||||||
claude: PROVIDERS.claude?.clientId,
|
claude: PROVIDERS.claude?.clientId,
|
||||||
codex: PROVIDERS.codex?.clientId,
|
codex: PROVIDERS.codex?.clientId,
|
||||||
qwen: PROVIDERS.qwen?.clientId,
|
|
||||||
iflow: PROVIDERS.iflow?.clientId,
|
iflow: PROVIDERS.iflow?.clientId,
|
||||||
kimi: PROVIDERS.kimi?.clientId,
|
kimi: PROVIDERS.kimi?.clientId,
|
||||||
"grok-cli": PROVIDERS["grok-cli"]?.clientId,
|
"grok-cli": PROVIDERS["grok-cli"]?.clientId,
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ const SPECIAL_CRED = {
|
||||||
// Chúng được lock riêng ở 11-provider edge tests / unit test chuyên biệt.
|
// Chúng được lock riêng ở 11-provider edge tests / unit test chuyên biệt.
|
||||||
const SPECIALIZED = new Set([
|
const SPECIALIZED = new Set([
|
||||||
"antigravity", "azure", "gemini-cli", "github", "iflow", "qoder", "kiro",
|
"antigravity", "azure", "gemini-cli", "github", "iflow", "qoder", "kiro",
|
||||||
"codex", "cursor", "vertex", "vertex-partner", "qwen", "opencode",
|
"codex", "cursor", "vertex", "vertex-partner", "opencode",
|
||||||
"opencode-go", "grok-web", "perplexity-web", "ollama-local", "commandcode",
|
"opencode-go", "grok-web", "perplexity-web", "ollama-local", "commandcode",
|
||||||
"xiaomi-tokenplan", "mimo-free",
|
"xiaomi-tokenplan", "mimo-free",
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,6 @@ describe("Codex Refresh Token", () => {
|
||||||
expect(getRefreshLeadMs("codex")).toBe(5 * 24 * 60 * 60 * 1000); // 5 days
|
expect(getRefreshLeadMs("codex")).toBe(5 * 24 * 60 * 60 * 1000); // 5 days
|
||||||
expect(getRefreshLeadMs("claude")).toBe(4 * 60 * 60 * 1000); // 4 hours
|
expect(getRefreshLeadMs("claude")).toBe(4 * 60 * 60 * 1000); // 4 hours
|
||||||
expect(getRefreshLeadMs("iflow")).toBe(24 * 60 * 60 * 1000); // 24 hours
|
expect(getRefreshLeadMs("iflow")).toBe(24 * 60 * 60 * 1000); // 24 hours
|
||||||
expect(getRefreshLeadMs("qwen")).toBe(20 * 60 * 1000); // 20 minutes
|
|
||||||
expect(getRefreshLeadMs("kimi")).toBe(5 * 60 * 1000); // 5 minutes
|
expect(getRefreshLeadMs("kimi")).toBe(5 * 60 * 1000); // 5 minutes
|
||||||
expect(getRefreshLeadMs("kimi-coding")).toBe(5 * 60 * 1000); // legacy alias
|
expect(getRefreshLeadMs("kimi-coding")).toBe(5 * 60 * 1000); // legacy alias
|
||||||
expect(getRefreshLeadMs("antigravity")).toBe(5 * 60 * 1000); // 5 minutes
|
expect(getRefreshLeadMs("antigravity")).toBe(5 * 60 * 1000); // 5 minutes
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
/**
|
/**
|
||||||
* Generic OAuth2 token refresh — config-driven profiles.
|
* Generic OAuth2 token refresh — config-driven profiles.
|
||||||
*
|
*
|
||||||
* Verifies refreshAccessToken() handles the 5 foldable providers
|
* Verifies refreshAccessToken() handles the 4 foldable providers
|
||||||
* (qwen, iflow, github, kimi, claude) via a REFRESH_PROFILES table,
|
* (iflow, github, kimi, claude) via a REFRESH_PROFILES table,
|
||||||
* while preserving the legacy generic path for unknown providers.
|
* while preserving the legacy generic path for unknown providers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -25,32 +25,6 @@ describe("refreshAccessToken — config-driven profiles", () => {
|
||||||
beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); global.fetch = originalFetch; });
|
beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); global.fetch = originalFetch; });
|
||||||
afterEach(() => { global.fetch = originalFetch; });
|
afterEach(() => { global.fetch = originalFetch; });
|
||||||
|
|
||||||
it("qwen: form body + clientId, surfaces resource_url as providerSpecificData", async () => {
|
|
||||||
const fm = mockFetchOnce({
|
|
||||||
access_token: "qw-acc",
|
|
||||||
refresh_token: "qw-refresh-rotated",
|
|
||||||
expires_in: 7200,
|
|
||||||
resource_url: "https://dashscope.aliyuncs.com",
|
|
||||||
});
|
|
||||||
const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js");
|
|
||||||
|
|
||||||
const out = await refreshAccessToken("qwen", "qw-old-refresh", {}, console);
|
|
||||||
|
|
||||||
expect(out).toEqual({
|
|
||||||
accessToken: "qw-acc",
|
|
||||||
refreshToken: "qw-refresh-rotated",
|
|
||||||
expiresIn: 7200,
|
|
||||||
providerSpecificData: { resourceUrl: "https://dashscope.aliyuncs.com" },
|
|
||||||
});
|
|
||||||
const [url, init] = fm.mock.calls[0];
|
|
||||||
expect(init.method).toBe("POST");
|
|
||||||
expect(init.headers["Content-Type"]).toBe("application/x-www-form-urlencoded");
|
|
||||||
const body = new URLSearchParams(init.body);
|
|
||||||
expect(body.get("grant_type")).toBe("refresh_token");
|
|
||||||
expect(body.get("refresh_token")).toBe("qw-old-refresh");
|
|
||||||
expect(body.get("client_id")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("iflow: Basic Auth header from clientId:clientSecret, form body keeps client_secret", async () => {
|
it("iflow: Basic Auth header from clientId:clientSecret, form body keeps client_secret", async () => {
|
||||||
const fm = mockFetchOnce({ access_token: "if-acc", refresh_token: "if-rot", expires_in: 3600 });
|
const fm = mockFetchOnce({ access_token: "if-acc", refresh_token: "if-rot", expires_in: 3600 });
|
||||||
const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js");
|
const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js");
|
||||||
|
|
@ -107,13 +81,13 @@ describe("refreshAccessToken — config-driven profiles", () => {
|
||||||
it("returns null on non-ok response", async () => {
|
it("returns null on non-ok response", async () => {
|
||||||
mockFetchOnce({ error: "invalid_grant" }, { ok: false, status: 400 });
|
mockFetchOnce({ error: "invalid_grant" }, { ok: false, status: 400 });
|
||||||
const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js");
|
const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js");
|
||||||
const out = await refreshAccessToken("qwen", "dead", {}, console);
|
const out = await refreshAccessToken("iflow", "dead", {}, console);
|
||||||
expect(out).toBeNull();
|
expect(out).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when refreshToken missing", async () => {
|
it("returns null when refreshToken missing", async () => {
|
||||||
const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js");
|
const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js");
|
||||||
const out = await refreshAccessToken("qwen", "", {}, console);
|
const out = await refreshAccessToken("iflow", "", {}, console);
|
||||||
expect(out).toBeNull();
|
expect(out).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
|
||||||
const load = () => import("../../open-sse/services/usage.js");
|
const load = () => import("../../open-sse/services/usage.js");
|
||||||
const SUPPORTED = [
|
const SUPPORTED = [
|
||||||
"github", "gemini-cli", "antigravity", "claude", "codex", "kiro",
|
"github", "gemini-cli", "antigravity", "claude", "codex", "kiro",
|
||||||
"qoder", "qwen", "iflow", "ollama", "glm", "glm-cn",
|
"qoder", "iflow", "ollama", "glm", "glm-cn",
|
||||||
"minimax", "minimax-cn", "vercel-ai-gateway", "grok-cli", "kimi",
|
"minimax", "minimax-cn", "vercel-ai-gateway", "grok-cli", "kimi",
|
||||||
"deepseek",
|
"deepseek",
|
||||||
];
|
];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue