From 402745dc1f30786c09dbee900e0a7326aa0f988c Mon Sep 17 00:00:00 2001 From: "Liang.Xu" <17314823439@163.com> Date: Mon, 21 Sep 2026 20:44:26 +0700 Subject: [PATCH] feat(providers): add qoder-cn support for Qoder CN (qoder.com.cn) --- CHANGELOG.md | 5 + open-sse/executors/index.js | 1 + open-sse/executors/qoder.js | 21 +- open-sse/providers/capabilities.js | 4 + open-sse/providers/registry/index.js | 2 + open-sse/providers/registry/qoder-cn.js | 61 ++++++ open-sse/services/qoderModels.js | 73 ++++--- open-sse/services/usage.js | 16 +- open-sse/services/usage/misc.js | 4 +- open-sse/shared/qoder/constants.js | 110 ++++++++-- public/providers/qoder-cn.png | Bin 0 -> 8397 bytes .../providers/[id]/AddApiKeyModal.js | 8 +- .../dashboard/providers/[id]/page.js | 11 +- .../ProviderLimits/ProviderLimitCard.js | 1 + .../usage/components/ProviderLimits/utils.js | 1 + .../api/oauth/[provider]/[action]/route.js | 3 +- src/app/api/providers/[id]/models/route.js | 81 +++---- src/app/api/providers/[id]/test/testUtils.js | 16 +- src/app/api/providers/validate/route.js | 5 +- src/app/api/v1/models/route.js | 39 ++-- src/lib/oauth/constants/oauth.js | 4 + src/lib/oauth/providers/index.js | 2 + src/lib/oauth/providers/qoder-cn.js | 5 + src/lib/oauth/providers/qoder.js | 199 +++++++++--------- src/lib/oauth/services/qoder.js | 27 ++- src/shared/components/OAuthModal.js | 3 +- tests/__baseline__/alias-baseline.json | 2 + tests/__baseline__/providers-baseline.json | 10 + tests/unit/qoder.test.js | 5 + tests/unit/usage-dispatch.test.js | 2 +- 30 files changed, 487 insertions(+), 234 deletions(-) create mode 100644 open-sse/providers/registry/qoder-cn.js create mode 100644 public/providers/qoder-cn.png create mode 100644 src/lib/oauth/providers/qoder-cn.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bcc3390..c3ddfcb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# v0.5.82 (unreleased) + +## Features +- **Qoder CN**: add the `qoder-cn` provider for qoder.com.cn — same device/OAuth flow and COSY-signed chat protocol as intl Qoder, served from the CN gateway (`gateway.qoder.com.cn` / `openapi.qoder.com.cn`). Region resolution is derived from the provider id (`qoder-cn` → CN) across executor, model catalog, quota usage, validation, and the dashboard. + # v0.5.81 (2026-09-18) ## Features diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js index ed17d330..e8b06ea6 100644 --- a/open-sse/executors/index.js +++ b/open-sse/executors/index.js @@ -35,6 +35,7 @@ const executors = { github: new GithubExecutor(), iflow: new IFlowExecutor(), qoder: new QoderExecutor(), + "qoder-cn": new QoderExecutor("qoder-cn"), kiro: new KiroExecutor(), kimchi: new KimchiExecutor(), codex: new CodexExecutor(), diff --git a/open-sse/executors/qoder.js b/open-sse/executors/qoder.js index d1b0dbae..da6844f4 100644 --- a/open-sse/executors/qoder.js +++ b/open-sse/executors/qoder.js @@ -207,16 +207,16 @@ function truncate(s, n) { /** * Map the OpenAI-style request body into the exact shape Qoder expects. */ -async function buildQoderRequestBody({ model, body, credentials, log, proxyOptions, signal, uploadFn = null }) { +async function buildQoderRequestBody({ model, body, credentials, log, proxyOptions, signal, uploadFn = null, region = "intl" }) { const qoderKey = String(model || "").replace(/^qoder\//, ""); - + // Fetch model config from dynamic API instead of relying on static QODER_MODEL_MAP. // This allows support for new Qoder models (e.g., qmodel_latest) without code changes. - let modelConfig = await getQoderModelConfig(credentials, qoderKey, { log, proxyOptions, signal }); + let modelConfig = await getQoderModelConfig(credentials, qoderKey, { log, proxyOptions, signal, region }); if (!modelConfig) { // Try a forced refresh once before giving up — the cache may simply // not be populated yet on first ever call for this credential. - const refreshed = await resolveQoderModels(credentials, { forceRefresh: true, log, proxyOptions, signal }); + const refreshed = await resolveQoderModels(credentials, { forceRefresh: true, log, proxyOptions, signal, region }); const retried = refreshed?.rawConfigs.get(qoderKey); if (!retried) { throw new Error( @@ -584,12 +584,13 @@ async function wrapQoderSSE(response, model, log = null) { } export class QoderExecutor extends BaseExecutor { - constructor() { - super("qoder", PROVIDERS.qoder); + constructor(provider = "qoder") { + super(provider, PROVIDERS[provider]); + this.region = provider === "qoder-cn" ? "cn" : "intl"; } buildUrl(credentials) { - return `${qoderInferenceBase(credentials)}/algo${QODER_CHAT_SIG_PATH}?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1`; + return `${qoderInferenceBase(credentials, this.region)}/algo${QODER_CHAT_SIG_PATH}?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1`; } // Override execute entirely — Qoder needs: @@ -604,7 +605,7 @@ export class QoderExecutor extends BaseExecutor { const rawToken = credentials?.apiKey || credentials?.accessToken; if (isQoderPat(rawToken)) { try { - credentials = await resolveQoderCredentials(credentials, proxyOptions, signal); + credentials = await resolveQoderCredentials(credentials, proxyOptions, signal, this.region); } catch (err) { log?.error?.("QODER", `PAT exchange failed: ${err.message}`); const fakeResp = new Response( @@ -639,7 +640,7 @@ export class QoderExecutor extends BaseExecutor { let qoderKey; let payload; try { - ({ qoderKey, payload } = await buildQoderRequestBody({ model, body, credentials, log, proxyOptions, signal })); + ({ qoderKey, payload } = await buildQoderRequestBody({ model, body, credentials, log, proxyOptions, signal, region: this.region })); } catch (err) { const fakeResp = new Response( JSON.stringify({ error: { message: err.message } }), @@ -709,7 +710,7 @@ export class QoderExecutor extends BaseExecutor { return { response, url, headers, transformedBody: payload }; } - const wrapped = await wrapQoderSSE(response, `qoder/${qoderKey}`, log); + const wrapped = await wrapQoderSSE(response, `${this.provider}/${qoderKey}`, log); return { response: wrapped, url, headers, transformedBody: payload }; } diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index 221488cf..7ec47ce8 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -255,6 +255,10 @@ export const PROVIDER_CAPABILITIES = { }, }; +// Qoder CN serves the identical model catalog from the CN gateway, so it shares +// the intl Qoder capability table verbatim (vision/reasoning/contextWindow). +PROVIDER_CAPABILITIES["qoder-cn"] = PROVIDER_CAPABILITIES["qoder"]; + /** * Pattern fallback — glob (* = wildcard), matched case-insensitively and * anchored (^...$) so a pattern must match the full model id. ORDER MATTERS: diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index 56a8d41d..0e8dbe2e 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -77,6 +77,7 @@ import p72 from "./perplexity.js"; import p73 from "./perplexity-agent.js"; import p74 from "./playht.js"; import p75 from "./qoder.js"; +import p124 from "./qoder-cn.js"; import p77 from "./recraft.js"; import p78 from "./runwayml.js"; import p79 from "./sdwebui.js"; @@ -193,6 +194,7 @@ export default [ p65, p66, p123, + p124, p67, p68, p68z, diff --git a/open-sse/providers/registry/qoder-cn.js b/open-sse/providers/registry/qoder-cn.js new file mode 100644 index 00000000..a8809cfa --- /dev/null +++ b/open-sse/providers/registry/qoder-cn.js @@ -0,0 +1,61 @@ +export default { + id: "qoder-cn", + priority: 30, + alias: "qdcn", + uiAlias: "qdcn", + display: { + name: "Qoder CN", + icon: "water_drop", + color: "#EC4899", + website: "https://qoder.com.cn", + notice: { + signupUrl: "https://qoder.com.cn", + }, + }, + category: "oauth", + authModes: ["oauth", "apikey"], + hasOAuth: true, + authHint: "Personal Access Token (pt-...) from https://qoder.com.cn/account/integrations", + transport: { + baseUrl: "https://gateway.qoder.com.cn/algo/api/v2/service/pro/sse/agent_chat_generation", + headers: {}, + timeoutMs: 120000, + stallTimeoutMs: 120000, + usage: { + url: "https://openapi.qoder.com.cn/api/v2/quota/usage", + }, + }, + models: [ + { id: "ultimate", name: "Ultimate" }, + { id: "auto", name: "Auto" }, + { id: "performance", name: "Performance" }, + { id: "efficient", name: "Efficient" }, + { id: "lite", name: "Lite" }, + { id: "qmodel_38max", name: "Qwen3.8-Max" }, + { id: "qmodel_latest", name: "Qwen3.7-Max" }, + { id: "qmodel", name: "Qwen3.7-Plus" }, + { id: "qfmodel", name: "Qwen3.8-Flash" }, + { id: "kmodel_latest", name: "Kimi-K3" }, + { id: "kmodel", name: "Kimi-K2.7-Code" }, + { id: "gmodel", name: "GLM-5.3" }, + { id: "gfmodel", name: "GLM-5.3-Flash" }, + { id: "dmodel", name: "DeepSeek-V4-Pro" }, + { id: "dfmodel", name: "DeepSeek-V4-Flash" }, + { id: "mmodel", name: "MiniMax-M3" }, + ], + oauth: { + openApiBaseUrl: "https://openapi.qoder.com.cn", + centerBaseUrl: "https://gateway.qoder.com.cn", + chatBaseUrl: "https://gateway.qoder.com.cn", + deviceTokenUrl: "https://openapi.qoder.com.cn/api/v1/deviceToken/poll", + refreshUrl: "https://gateway.qoder.com.cn/algo/api/v3/user/refresh_token", + userInfoUrl: "https://openapi.qoder.com.cn/api/v1/userinfo", + quotaUsageUrl: "https://openapi.qoder.com.cn/api/v2/quota/usage", + loginUrl: "https://qoder.com.cn/device/selectAccounts", + }, + features: { + usage: true, + // PAT (apikey) connections also carry quota usage (via job-token exchange). + usageApikey: true, + }, +}; diff --git a/open-sse/services/qoderModels.js b/open-sse/services/qoderModels.js index e9a7879b..b5bd70fb 100644 --- a/open-sse/services/qoderModels.js +++ b/open-sse/services/qoderModels.js @@ -13,9 +13,13 @@ * * PAT (Personal Access Token, pt-...) connections: a PAT cannot sign COSY * requests directly, so we exchange it for a short-lived job token (jt-...) - * via openapi.qoder.sh/api/v1/jobToken/exchange (plain JSON POST), then use - * that job token for signing. Job-token traffic must hit api2.qoder.sh — - * api3 rejects jt- with "Login expired" (403). + * via the region's jobToken/exchange endpoint (plain JSON POST), then use + * that job token for signing. On intl, job-token traffic must hit api2.qoder.sh — + * api3 rejects jt- with "Login expired" (403); CN serves it from the same + * gateway host. + * + * The region (intl/cn) is derived from credentials.provider (or an explicit + * options.region override) so the same catalog logic works for both sites. */ import { createHash } from "crypto"; @@ -23,12 +27,12 @@ import { createHash } from "crypto"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { buildCosyHeaders } from "../shared/qoder/cosy.js"; import { - QODER_MODEL_LIST_URL, - QODER_CHAT_BASE_ALT, - QODER_JOB_TOKEN_EXCHANGE_URL, - QODER_USERINFO_URL, QODER_IDE_VERSION, QODER_CLIENT_TYPE, + qoderRegionOf, + qoderJobTokenExchangeUrl, + qoderUserInfoUrl, + qoderInferenceBase, } from "../shared/qoder/constants.js"; const FETCH_TIMEOUT_MS = 15_000; @@ -63,9 +67,9 @@ const inflight = new Map(); * Exchange a Qoder PAT (pt-...) for a short-lived job token (jt-...). * This endpoint is plain JSON POST — NOT COSY-signed. */ -async function exchangeJobToken(pat, proxyOptions = null, signal = null) { +async function exchangeJobToken(pat, proxyOptions = null, signal = null, region = "intl") { const res = await proxyAwareFetch( - QODER_JOB_TOKEN_EXCHANGE_URL, + qoderJobTokenExchangeUrl(region), { method: "POST", headers: { @@ -101,10 +105,10 @@ async function exchangeJobToken(pat, proxyOptions = null, signal = null) { * Resolve the Qoder userId for a job token (needed for COSY signing). * Returns "" on any failure — callers fall back to the stored userId. */ -async function fetchUserIdForJobToken(jobToken, proxyOptions = null, signal = null) { +async function fetchUserIdForJobToken(jobToken, proxyOptions = null, signal = null, region = "intl") { try { const res = await proxyAwareFetch( - QODER_USERINFO_URL, + qoderUserInfoUrl(region), { method: "GET", headers: { @@ -125,16 +129,17 @@ async function fetchUserIdForJobToken(jobToken, proxyOptions = null, signal = nu } /** - * Resolve a PAT to a job-token credential, cached per-PAT. + * Resolve a PAT to a job-token credential, cached per-PAT-per-region. */ -async function resolvePatCredential(pat, proxyOptions = null, signal = null) { - const cached = patJobCache.get(pat); +async function resolvePatCredential(pat, proxyOptions = null, signal = null, region = "intl") { + const cacheKey = `${region}:${pat}`; + const cached = patJobCache.get(cacheKey); if (cached && cached.expiresAt - Date.now() > PAT_REFRESH_BUFFER_MS) return cached; - const { jobToken, expiresAt } = await exchangeJobToken(pat, proxyOptions, signal); - const userId = await fetchUserIdForJobToken(jobToken, proxyOptions, signal); + const { jobToken, expiresAt } = await exchangeJobToken(pat, proxyOptions, signal, region); + const userId = await fetchUserIdForJobToken(jobToken, proxyOptions, signal, region); const resolved = { accessToken: jobToken, userId, expiresAt }; - patJobCache.set(pat, resolved); + patJobCache.set(cacheKey, resolved); return resolved; } @@ -142,11 +147,14 @@ async function resolvePatCredential(pat, proxyOptions = null, signal = null) { * Resolve connection credentials to COSY-signable form: * - PAT (pt-...) connections → exchanged to a job token (jt-...) + userId * - everything else → passed through unchanged + * + * Region defaults to the one implied by credentials.provider (qoder-cn → cn). */ -export async function resolveQoderCredentials(credentials, proxyOptions = null, signal = null) { +export async function resolveQoderCredentials(credentials, proxyOptions = null, signal = null, region) { const raw = credentials?.apiKey || credentials?.accessToken; if (isQoderPat(raw)) { - const resolved = await resolvePatCredential(raw, proxyOptions, signal); + const effRegion = region || qoderRegionOf(credentials?.provider); + const resolved = await resolvePatCredential(raw, proxyOptions, signal, effRegion); return { ...credentials, accessToken: resolved.accessToken, @@ -163,13 +171,14 @@ export async function resolveQoderCredentials(credentials, proxyOptions = null, } /** - * Stable cache key per credential (so different login sessions for the same - * account share an entry). + * Stable cache key per credential+region (so different login sessions for the + * same account share an entry, and the same PAT on both sites stays apart). */ function cacheKey(credentials) { const psd = credentials?.providerSpecificData || {}; const seed = psd.userId || credentials?.refreshToken || credentials?.accessToken || "anonymous"; - return createHash("sha256").update(`qoder:${seed}`).digest("hex"); + const region = qoderRegionOf(credentials?.provider); + return createHash("sha256").update(`qoder:${region}:${seed}`).digest("hex"); } /** @@ -192,15 +201,13 @@ function cosyCredsFromConnection(credentials) { * rawConfigs: Map } * or `null` on any error. */ -async function fetchQoderCatalogRaw(credentials, signal, proxyOptions = null) { +async function fetchQoderCatalogRaw(credentials, signal, proxyOptions = null, region = "intl") { const creds = cosyCredsFromConnection(credentials); if (!creds.userId || !creds.authToken) return null; - // Job-token traffic is rejected by api3 ("Login expired" 403) — the - // official qodercli serves it from api2 instead. - const modelListUrl = String(creds.authToken).startsWith("jt-") - ? `${QODER_CHAT_BASE_ALT}/algo/api/v2/model/list` - : QODER_MODEL_LIST_URL; + // Intl job-token traffic is rejected by api3 ("Login expired" 403) — the + // official qodercli serves it from api2 instead; CN uses the single gateway. + const modelListUrl = `${qoderInferenceBase(credentials, region)}/algo/api/v2/model/list`; const headers = { Accept: "application/json", @@ -293,14 +300,20 @@ export async function getQoderModelConfig(credentials, modelKey, options = {}) { * one upstream request per credential. */ export async function resolveQoderModels(credentials, options = {}) { + const region = options.region || qoderRegionOf(credentials?.provider); let resolved; try { - resolved = await resolveQoderCredentials(credentials, options.proxyOptions, options.signal); + resolved = await resolveQoderCredentials(credentials, options.proxyOptions, options.signal, region); } catch (error) { options.log?.warn?.("QODER", `PAT exchange failed: ${error.message}`); return null; } if (!resolved?.accessToken || !(resolved.providerSpecificData || {}).userId) return null; + // Stamp the provider so cacheKey/catalog derive the region even when the + // caller's credentials object didn't carry a provider id (e.g. /v1/models). + if (resolved && !resolved.provider) { + resolved.provider = region === "cn" ? "qoder-cn" : "qoder"; + } const key = cacheKey(resolved); const now = Date.now(); @@ -319,7 +332,7 @@ export async function resolveQoderModels(credentials, options = {}) { } const fetchPromise = (async () => { - const fetched = await fetchQoderCatalogRaw(resolved, options.signal, options.proxyOptions); + const fetched = await fetchQoderCatalogRaw(resolved, options.signal, options.proxyOptions, region); if (!fetched) return null; const entry = { expiresAt: Date.now() + CACHE_TTL_MS, diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 90ecb0e9..b431e21a 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -42,12 +42,8 @@ const USAGE_HANDLERS = { claude: (c) => getClaudeUsage(c.accessToken, c.proxyOptions, { force: c.force }), codex: (c) => getCodexUsage(c.accessToken, c.proxyOptions), kiro: (c) => getKiroUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), - qoder: async (c) => { - // PAT (pt-...) connections must be exchanged to a job token before the - // quota endpoint accepts them. - const resolved = await resolveQoderCredentials(c, c.proxyOptions).catch(() => null); - return getQoderUsage(resolved?.accessToken || c.accessToken, c.proxyOptions); - }, + qoder: (c) => getQoderUsageFor(c), + "qoder-cn": (c) => getQoderUsageFor(c), iflow: (c) => getIflowUsage(c.accessToken), ollama: (c) => getOllamaUsage(c.apiKey, c.providerSpecificData, c.proxyOptions), glm: (c) => getGlmUsage(c.apiKey, c.provider, c.proxyOptions), @@ -68,6 +64,14 @@ const USAGE_HANDLERS = { commandcode: (c) => getCommandCodeUsage(c.apiKey, c.proxyOptions), }; +// Qoder intl/CN share one usage path: PATs must be exchanged to a job token +// before the quota endpoint accepts them, and the quota URL comes from the +// provider's own registry usage block (region-correct via c.provider). +async function getQoderUsageFor(c) { + const resolved = await resolveQoderCredentials(c, c.proxyOptions).catch(() => null); + return getQoderUsage(resolved?.accessToken || c.accessToken, c.proxyOptions, c.provider || "qoder"); +} + export async function getUsageForProvider(connection, proxyOptions = null, options = {}) { const { provider, accessToken, apiKey, providerSpecificData, projectId } = connection; const providerDataWithProjectId = { diff --git a/open-sse/services/usage/misc.js b/open-sse/services/usage/misc.js index eac37698..567739e8 100644 --- a/open-sse/services/usage/misc.js +++ b/open-sse/services/usage/misc.js @@ -227,13 +227,13 @@ export async function getVercelAiGatewayUsage(apiKey, proxyOptions = null) { } } -export async function getQoderUsage(accessToken, proxyOptions = null) { +export async function getQoderUsage(accessToken, proxyOptions = null, providerId = "qoder") { if (!accessToken) { return { message: "Qoder usage unavailable: no access token" }; } try { const response = await proxyAwareFetch( - U("qoder").url, + U(providerId).url, { method: "GET", headers: { diff --git a/open-sse/shared/qoder/constants.js b/open-sse/shared/qoder/constants.js index 849f67ed..04244b44 100644 --- a/open-sse/shared/qoder/constants.js +++ b/open-sse/shared/qoder/constants.js @@ -1,38 +1,106 @@ /** * Qoder API constants ported from CLIProxyAPIPlus qoder-provider branch. * - * Endpoint set: - * openapi.qoder.sh - device flow + userinfo + quota usage - * center.qoder.sh - token refresh (best-effort, currently 403 for device tokens) - * api3.qoder.sh - inference (chat) + model list, requires COSY signing - * qoder.com/device - browser landing page for device authorization + * Qoder runs two regional sites with parallel endpoint shapes: + * intl (qoder) CN (qoder-cn) + * openapi.qoder.sh openapi.qoder.com.cn - device flow + userinfo + quota usage + * center.qoder.sh gateway.qoder.com.cn - token refresh (best-effort, 403 for device tokens) + * api3.qoder.sh gateway.qoder.com.cn - inference (chat) + model list, requires COSY signing + * qoder.com/device qoder.com.cn/device - browser landing page for device authorization + * + * All path suffixes are identical between regions — only the hosts differ. + * Region-aware consumers call qoder*Url(region) / qoderInferenceBase(creds, region) + * and derive the region from the provider id via qoderRegionOf(). The named + * QODER_* constants below keep the intl defaults for backward compatibility. */ -export const QODER_OPENAPI_BASE = "https://openapi.qoder.sh"; -export const QODER_CENTER_BASE = "https://center.qoder.sh"; -export const QODER_CHAT_BASE = "https://api3.qoder.sh"; +export const QODER_REGION_INTL = "intl"; +export const QODER_REGION_CN = "cn"; + +// Per-region base URLs. CN serves job tokens (jt-...) from the same gateway +// host — there is no api2-style split like intl's api2.qoder.sh. +const QODER_REGION_BASES = { + [QODER_REGION_INTL]: { + chat: "https://api3.qoder.sh", + chatAlt: "https://api2.qoder.sh", + openApi: "https://openapi.qoder.sh", + center: "https://center.qoder.sh", + login: "https://qoder.com/device/selectAccounts", + website: "https://qoder.com", + }, + [QODER_REGION_CN]: { + chat: "https://gateway.qoder.com.cn", + chatAlt: "https://gateway.qoder.com.cn", + openApi: "https://openapi.qoder.com.cn", + center: "https://gateway.qoder.com.cn", + login: "https://qoder.com.cn/device/selectAccounts", + website: "https://qoder.com.cn", + }, +}; + +/** Base URL set for a region; unknown regions fall back to intl. */ +export function qoderRegionBases(region) { + return QODER_REGION_BASES[region] || QODER_REGION_BASES[QODER_REGION_INTL]; +} + +/** Region for a provider id — "cn" for qoder-cn, "intl" otherwise. */ +export function qoderRegionOf(providerId) { + return providerId === "qoder-cn" ? QODER_REGION_CN : QODER_REGION_INTL; +} + +export const QODER_OPENAPI_BASE = QODER_REGION_BASES[QODER_REGION_INTL].openApi; +export const QODER_CENTER_BASE = QODER_REGION_BASES[QODER_REGION_INTL].center; +export const QODER_CHAT_BASE = QODER_REGION_BASES[QODER_REGION_INTL].chat; // Job-token (jt-...) traffic is rejected by api3 with "Login expired" (403); -// the official qodercli serves it from api2 instead. -export const QODER_CHAT_BASE_ALT = "https://api2.qoder.sh"; +// the official qodercli serves it from api2 instead (intl only). +export const QODER_CHAT_BASE_ALT = QODER_REGION_BASES[QODER_REGION_INTL].chatAlt; -export const QODER_LOGIN_URL = "https://qoder.com/device/selectAccounts"; +export const QODER_LOGIN_URL = QODER_REGION_BASES[QODER_REGION_INTL].login; -// Device flow endpoints -export const QODER_DEVICE_TOKEN_URL = `${QODER_OPENAPI_BASE}/api/v1/deviceToken/poll`; -export const QODER_USERINFO_URL = `${QODER_OPENAPI_BASE}/api/v1/userinfo`; -export const QODER_QUOTA_USAGE_URL = `${QODER_OPENAPI_BASE}/api/v2/quota/usage`; -export const QODER_REFRESH_TOKEN_URL = `${QODER_CENTER_BASE}/algo/api/v3/user/refresh_token`; +// Device flow endpoints (region-aware variants; these are the intl defaults) +export function qoderOpenApiBase(region) { + return qoderRegionBases(region).openApi; +} +export function qoderDeviceTokenUrl(region) { + return `${qoderOpenApiBase(region)}/api/v1/deviceToken/poll`; +} +export function qoderUserInfoUrl(region) { + return `${qoderOpenApiBase(region)}/api/v1/userinfo`; +} +export function qoderQuotaUsageUrl(region) { + return `${qoderOpenApiBase(region)}/api/v2/quota/usage`; +} +export function qoderRefreshTokenUrl(region) { + return `${qoderRegionBases(region).center}/algo/api/v3/user/refresh_token`; +} +export function qoderLoginUrl(region) { + return qoderRegionBases(region).login; +} +export function qoderWebsiteUrl(region) { + return qoderRegionBases(region).website; +} + +export const QODER_DEVICE_TOKEN_URL = qoderDeviceTokenUrl(QODER_REGION_INTL); +export const QODER_USERINFO_URL = qoderUserInfoUrl(QODER_REGION_INTL); +export const QODER_QUOTA_USAGE_URL = qoderQuotaUsageUrl(QODER_REGION_INTL); +export const QODER_REFRESH_TOKEN_URL = qoderRefreshTokenUrl(QODER_REGION_INTL); // PAT (Personal Access Token, pt-...) → short-lived job token (jt-...) exchange. // PATs cannot sign COSY requests directly — they must be exchanged first. // This endpoint is NOT COSY-signed (plain JSON POST). -export const QODER_JOB_TOKEN_EXCHANGE_URL = `${QODER_OPENAPI_BASE}/api/v1/jobToken/exchange`; +export function qoderJobTokenExchangeUrl(region) { + return `${qoderOpenApiBase(region)}/api/v1/jobToken/exchange`; +} +export const QODER_JOB_TOKEN_EXCHANGE_URL = qoderJobTokenExchangeUrl(QODER_REGION_INTL); -// Inference endpoints (under /algo on api3.qoder.sh, all COSY-signed) +// Inference endpoints (under /algo on the chat host, all COSY-signed) export const QODER_CHAT_SIG_PATH = "/api/v2/service/pro/sse/agent_chat_generation"; export const QODER_CHAT_URL = `${QODER_CHAT_BASE}/algo${QODER_CHAT_SIG_PATH}?FetchKeys=llm_model_result&AgentId=agent_common`; export const QODER_CHAT_URL_ENCODED = `${QODER_CHAT_URL}&Encode=1`; -export const QODER_MODEL_LIST_URL = `${QODER_CHAT_BASE}/algo/api/v2/model/list`; +export function qoderModelListUrl(region) { + return `${qoderRegionBases(region).chat}/algo/api/v2/model/list`; +} +export const QODER_MODEL_LIST_URL = qoderModelListUrl(QODER_REGION_INTL); // Official qodercli uploads images here (COSY-signed PUT multipart, field "file") // instead of inlining base64 into agent_chat_generation. export const QODER_IMAGE_UPLOAD_SIG_PATH = "/api/v2/image/upload"; @@ -55,7 +123,9 @@ export const QODER_CONTEXT_TIER_MODES = Object.freeze({ AUTO: "auto", MAX: "max" * "Login expired" (403). Device tokens (dt-...) stay on api3. PATs (pt-...) * are exchanged for jt- before this is consulted. */ -export function qoderInferenceBase(credentials) { +export function qoderInferenceBase(credentials, region = QODER_REGION_INTL) { + // CN serves every token kind from the single gateway host. + if (region === QODER_REGION_CN) return QODER_REGION_BASES[QODER_REGION_CN].chat; const raw = credentials?.apiKey || credentials?.accessToken; if ( typeof raw === "string" && diff --git a/public/providers/qoder-cn.png b/public/providers/qoder-cn.png new file mode 100644 index 0000000000000000000000000000000000000000..59da3da55109700ac4778a132cc0bec44366c2b6 GIT binary patch literal 8397 zcmYLPWmKF!)4mt?qJ`ouR-hDjTio5DKyenQxG(Nj+!iVBPFviixVyA;aVYNa^8ER} zACs9Rb8=4RoLrN+l1No$8Egzv3;+PII6|834}Mx zOvi-5#MJ%`iXn>!k;ld;&?!WFPffk?%nRTCI1C$e_~PD>btp733~%J;Jh>0})i#5# zh%_BZz<_bI(b=il;d{#jA5Pk-|1%iBaCd*w$*LqP`@ZZO^jId3aOxU;ZmslK#`REj zk7xHr{X&mX!2Y9ZUYE+xfcL9K%4iFySI27x$H^MXKh#o6s~l%>d-++jc^!>h4j0+K zx%h+}+}9=7q&x8Sc`A@i?Wp895*%qqD4$C;OkfZ(T}bflZ!-e!C+mh>0Z$K?S~oo6 zmu}+D&Z;=QbeGOysKa7i&^!RG@sDx9W0Q{Sr^1B|LD${M7|wA%vw#FWi<@)?Ai-Ny zBeX+j(2vQJjww%k@D1$kZLkmEEnoYAgjRNAkA!Y4L9@|%Y9r`Z_hZ=y(C3+ACFF6z zleR0S%_Q{`;&*#8@sw`Q`VY#fjMM@KW7GnUuhCGBR)4rC_D2!LoAdlpMBI!Qefq9$ z1A$_=66_0)-;CB%yPVttCi$a%GohHoMwuDe;wVs&RGrZiCanzvp8dw&@K;vw)>99m z0&sT_GG2#hAvR3#a#kZ;_Wd&s-9Z9S>$uT@Z+eHu@4=GbNh=Dg&qd&iu@0e;iHjjt zp=8poT!ejAAmReH{Zf-I;t9@e_W}+QknR(2T((MpDi&@cP`4@nW>qvXD~xo>vl=cCXX0{t#@;f0cL`Dt63X2x()gn! z%sbsb;FcL-^OiiBu~UxJL~2>RWCl#x!MF{Ptz#$>osZSz)e>Ia5uVp*El_eD?GHA`)y^2|X}XLRWb? zuYF&5sLZDvWwFUt2ai4AM}j)zauHRryu#9-@994sCny`t25}m=`IjDUdU?(dYC1C? z?_VuajvyePWz8rvn~n=#f6{3Fr1z~_L*u9G8$-ddD+|?yRRokq=zgmnb*cNjo)rav zR7MIORncs$`D!|h!&h*ehOs?!Iv9v(Ia3^Kd0TH`xz!_OdD&7isv`PmZhf_R(WMIh zO}j%rNyw}{o7`C&^y74ZOv%&~(M77sM*5OI4E84wSkzj;C{QSfL!V?wk^h2QyldHO z?eg@Q7R$7D_-B*tSR-U+bd|qhEDW1$?4MJAwe_>5M_E*S~5YL5d37W}^g;=LinAC0#V@HCpGVKeaYp zTO%4XPAFzfVabYk&2{{JeZCp|Duc}N=kOgK7UWkXI+dKzI;+32c7bFh)dyhvapr=I zf@PcvE0EvCa)PI(ooCgz&N^`punwx^SfkRQpk_O+77Ip!PYwRe+~>Agb2i|{3Z3RlAMkN5F5 z&wta56>BJkM^UL1Whn@#nDR}?tDv3y7ot0P6s>7GGO&Q(zs0ODh2zlBtaALCAs&uC zGj$pa8qIckg4;PKeKzu^h)vYIYy1J3X z!Gorz&70ee&)Uu_FHC2g&;0L?cxjZZ{MUcXZ0=Bltg}BSAqSl?8vblV77xdvz}f&S zn5Ru58MwFXf5D!16c?&pyTLjoxV3Mlx%!uuyBx$fz8W&`Q$-(<*?Dr>>Ny=#XV2aD2-Y$&}h z*k``W5oi?3;$b2I#F*&V@AZ|X5ba(AA7f~P#5R(Lla$g>k6T73WGMuhNFa_Y%{h>@ z;wJ1JaXSFdW+~CTFyLUm%5t<+jh+!wsrG(VASWN_zIEr8BB1i$#+#{CGqWC?T6=xH6=6vftYdMA5++7~I^kSXEdr#c`s&9*R%<)+5AH*etB1oS>foVg+q zP*Y1v+rM?&2zbtcxbNgxhG*AnxKh5~s?E2&zoh!?P;Dq^H^v5rYTv-K97ii8!ZXV> zN=U+bLP7S~x=4FuH3EV9 zE!@z9o8bID%S9ozk+Nmw+{h3$&o}D~o9d}NgWc~Z_&TkS{|1fr3JI zDn5g`=I4mb2Sw>dIz{jz2$)uFF{JXFjUm0%?kc9dBA2ON`4gXYEbOfx8PymZwS7M3 zbq>!=q>)Y!#eBWU0ZRBx0M4_}YSVR|xfmB5^~*WoO*8iQT5Qy<{VRX}NfLl!2RN?s z&HR13S6cUVPcU8nvgi_i%l5Qq1*=m;o9jN$a;E=?pbv%K!T8O>-Uzrx=rv#1CME3T z&P&$nsrxr(2pB15`5f1QOI7p5XOWV6Z~)rpL8<^v-}XRz{~ultvRLX15>s6zxknD@ zeNBhTIJp1NEhjQ9!a4;;Q7XkDEUKueXgjGwFBpQ2E3i`fZWH{+LIm>c z8ZBvSTi-`fUtv6 z8+WoC+buG=eh421;rW()*Z4*qFxYtwbNf;n(|M6*M}_#I_IZgL@y(?&!=#3aNWMQb z-mZ_85Z-Io6-?2hFX=W{POj~3a#v?Ih+G=%esMPSZLpHDFAXgjpoQmUk@rp)$!Gnl z`Q9^thtQss;sdhKH+)XPr;`)6yFz_y-B6oLm4W%}aAamwIoC zv=4ffV%09AcW$=a#>2WL9QGX@JL!WJgyOK9;gYf>UEdR*%g!C3E0r#m!i@58x}SUf zRgaJGb#{ixyajPviPTh^Wh{DA%jxN@v1a%jU}$Z{S}QKB6lSEX`;GMCMLABPqT$+%deJv|FE z;>R|=n*`H#`VEbSt1bEtP)wVG1SQujH1OLKGTqvQJI z2q6Pwu*-vmN5=t3=5)asGxO~5(3)&{faY^vnsKTz$1vf@=fxjcw8dpn3?;tNx0ouz zk1#mZ2X{wK|l721z?K_rT8i2KH;_TeVg&|^U6UFw0 z_g6Ap)h1tcKNMKSA8Yp)D6dWDON84~`T1e<4vNTdn81$P&Oee#p6%d@v(Ag$crPu$ zvOpeDA+n%|3N6MXwi6`L5b1I-huIws(an}mFlxJ<%%dsFDn5*VtX0I%xPGcj)&>{} z;jh&EQD3HiISnmNTnt=jJjVj^2*tmAm*Z=W8Ojw+Ze?d^SU82HeK4|PfZjyQ@ZG*U z!dfmQj@xOYuRYHJcLH&MKDLsa$9ZhtTIyKV*xUYhtGi^oWDeIa@%?5}XXbz`iP27? zxTXDOM_{9boRxX8r;>(u?p58yeBXpguMxKu%*(QwZG(}sQ|n{L&|E+Pj{6Z!XzC+G zcf(iwc7qczzfe=fiQ@06GEIECJ%=x)NUav;aySWHKb$kTeGh51b8r=BPMzM?XlWM7 zLm9nbVMFh)AY=zTwKikU0#!>dV+P*+!&<9)0rWb{Xx~s&mmAql7N;JfE(D;2n01SZ zdKU9s9_j0IyF5P*ke*Sbkha)O;SfjFM^8OxNRzNv=v?@8b7}_iBZ;6Kxwur#=u<2M zlS1bagcF?sq(Zx(HzjW^1GCi%J2gt~XS>z8n}YkN$bA}LfNG-4>ZKNZ{qG>79UTqQ zEZrk{m&z^!0U`$fxk$fjl@pDlQkrVMZswHX8lIH{pnLNX$GIx+Qj=R_5=c;-^UL)w~oj zX07sUoBN!7)8NjBGfbU56rd7QQ=K0A{IWVb0DcT;^=`sW{m$>acpDZ=TI6!(>7RCo z^*Z1(rGhx_q)WBdl=R{aDwwc`3SO<-A(Ue-C>09fbK5lfRO!#fNy(C(ppsXr40*mJ(0i=wYBwd81>N?}Ytt?L zH~>&&Q2c4X!lQFSMXx=>@g*eEkDxf%-<`hx`z=msKx%neUOUQ-=u6N*z^tg@R4YP^ z6hNE8TK<*v(j`6OT2#At@frmU$Yt2eL+3Ts5aSMp#M z48182pnY0#?BpgGh*v3jlP%=gQ8@p;yk?9}m-T%~Q3MDW$P43tU}t0wK?d&U{6)X? z<%pF&aZU(%eL(0UsjLfnMhbXH3M!_-R5xO&|m+Sems2v z9XzEF7IKG}_6_8##Yei8q$1*;(<3jUOOWtR54WMecILDz6?^~F;ssf(6zhK*25`-PfCZFWAB#lC!9zV7FkAxPNSE~K| zu-->_lAZmsQOTYs&c@q5^k*CbK)1^Cu~;eVla}N6h_RNzSRb8eF(HWPR19Q$A_p80fxqerAmb(?b-%R5G zizvyLe5V|ku_L`bR;8rS_PMgzHlvhrBD85-*_{EZkb%<4F>3>Gi8;u@5TQ`?NBDw~ zM^43SFn;{A-|F~6uAo&Mg$;$QK<_Zt@;y@cUB~Ri>Utxjy7s=~;({EI6`t-BX)E>N zWr5O!Ni2$ea}iBv>lTPhbj5m$@Kq)H?*{AB%16EM;PjQRI{*|~I=QkxTZc~+QOcHG zrffHJx7+(_>Cy{QfqFLGZi=hC#fV_t`Gh#uyF3)c(Hb~c;V5k;Vgdp}8twOB?T41p zxGOCDqm;tl$z?t(_VCd4i4}SSzCfVi%@*fbEN_#fAF*xu7n<`eIb8re;5kRsL?W5n z;a4STX1j*}IdCgI6S9y8_ zrt?=I{1vQF$pjgol2{YDot=R*QXF0r%M$Q~_IibEg~U)Tj2K}P9IV{>_x*KddjxfF za5IRe$9J+T+CSjcbSC~>t8fwJeGa1l;X?Du~&YZPvgbT z!II}wGE~btet})Q7zg~Yo#)0#+djHQp9JnFgj-t3#@ODD2`Ydjxv$na;eO&E9ZP8@ z(Z6UKFnT8AM4})w^CkBFWU-?i@^MAjK%*4 z%R2zd#zscJnr|pkqL^cOtQL-`kmJOnOZ`&bbou*_opc!3;ovlpl5^x_zH4p^Srs+akcG21><`@M z{lw9gKWhJAa1NoC4ZMPG5zxo9=!15qBAE;Qat@+9_3ai`&JBrZk6eBn2^sWvC*DcM z{>Uw5M=%?p&tO(}A3~c~Lqg+=`fv}CiegAG3zA^mk1oRIop00&p~0LgZd zHvbtKSo{!5tWW)Y=}L7Z>ZHrM(1J8=$fGACJMZ;|n8fLkb$*kE<4BTBNPPLYV>UN! zt`ZUD)cipMe3ZN0*mLUR?$B&aB>=(>EZZ%c>rT&wPHegby__4mbz?G-RiVO;Fos;u z)#^{;OFxKZHM|UA;M?wdq)@#it=%u(lDh~Nud97z@s+oDxVOGGYGK~vJywQhCsdB& ze=W9ZXwAyns%f{Rn{k$JP)8+E;XgGjR)pEzT^*TOQ3!e!fY3x+7DS}nxR*jw$%nElPP@b9Lz?mcfC7{e56NH27+Zu85~QF%%QkEy*q4?AAaO&RfXb~Rttl` z!dlkSwPV%RAas00&Qw-oOZy`(*Ftd21Dn$Ca=5S@pW!LwT&$eOnLosT1ye zM31<^m3OmBlM&fb>Zt#*h&II#QT#}kh?f_XP8#zW(R;fNIwmk0Pq+0UyQB%8T(fnB zZdpD5PIiOa5ux0Q+7a7b)~%TT&_FDi;+HS=6vIdjAdE~J=HYxF>dUwv5*lsXHjNBd z#v2bBgMZOiS{zvm6E2XK0>3xvF&>*&v)r^_3h@E!ePBor z+Wtc@nl7?-+|pv&|K)&BZBJLp)tuX5wU4&_JgzFJ1~Q_U!E~ zgE3;^L>2V`(DdTGe65_x(T_E!OigU)n%EB08f2V3D^Ox%A-^Hbkee-u8rgWE0@Yb- zZVG+jYLpcqLJ8K2QJr5XL~e&_mKbFAC!aN@(6}zw{dVln2M%fs?ql_a%GYo-byB}t zc({5qtUw-mZs{gC<>btozU|8kE^&R+M)2yj=Hg(^jMu*TS+36vFjk;vMhtdFZ=}f) zJXnLk#Tnssj2xssL^y}ypX1u@G*nT=A&gB z`~0(J3oz1Gtx>(i82CFI3Tky8gm_ekdDOlcH>%1O`mSx8~Jk;FC{JVBD|e4 zKoyh~P5r0+4mU%_AZY0pb9-Tg+F&eT-FHTD_Fp~4@;)W~Ce1%GJ8^M}a6FWHG`HA& zZpEaPtaJgl{Y)2baA@3z!9%_$^l|cF-E|+2?99YyLYbQ-fgInovng8hm76OH0h>4r z`@lcBhW$Y-I?@v$@6+$0htEhbCFHJZ!`3J?3TCMYjJjpgq0+N)KRd>7eXwOY0&5?x zZVU9-JRJjfTAm(DX5=@B!wqoJ?^l=#Mx5%{6U^DT$~1S>3ky7w{IVrTa5v*F->Ekejen_{Lx#gyQ9?9+u08k!1wpB zJVK53DQw7dJpM5Ld=I>`xXvX|NxNmvFb4kR&GxI7#iC) z*KV_rX({Bzm~*lG-;8a9(1A()?wl(1Ys0=^?+gnOy0J!-e^v0O$NPz7Ico+<_gQB= zoJb5SpE=*$=;vI1j7h`{{HbpqU%sV3Q&E`BRa_guzdURhz<{n{<&_#DB7uX0wv@)6 zOD0$JE@)()fmWzHW$ZIb9)Km&&S@>>YK}A^GIfeMbz+D {isCloudflareAi ? <>One key per line. Format: name|apiKey|accountId or just apiKey (auto-named by index). - : provider === "qoder" + : provider === "qoder" || provider === "qoder-cn" ? <>One PAT per line. Format: name|pt-... or just pt-... (auto-named by index). : <>One key per line. Format: name|apiKey or just apiKey (auto-named by index). } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index bae6ff05..a9722d7f 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -172,7 +172,7 @@ export default function ProviderDetailPage() { const apiKeyConnectionLabel = providerId === "xai" ? "xAI API Key" : providerId === "kimi" ? "Kimi API Key" - : providerId === "qoder" ? "PAT" + : (providerId === "qoder" || providerId === "qoder-cn") ? "PAT" : "API Key"; // Resolve suffix "(level)" for a model when a thinking level is picked and the model supports it. const resolveThinkingSuffix = (modelId) => { @@ -612,8 +612,9 @@ export default function ProviderDetailPage() { const modelId = model.id || model.name; if (!modelId) continue; - // Qoder model ID format may be "qoder/auto" or "auto", need to remove prefix - const cleanModelId = modelId.replace(/^qoder\//, ""); + // Qoder model ID format may be "qoder/auto", "qoder-cn/auto" or "auto", + // need to remove the provider prefix before storing. + const cleanModelId = modelId.replace(/^(qoder-cn|qoder)\//, ""); const alreadyExists = customModels.some( (entry) => entry.providerAlias === providerStorageAlias && entry.id === cleanModelId && (entry.kind || entry.type || "llm") === "llm" ) || Object.values(modelAliases).includes(`${providerStorageAlias}/${cleanModelId}`); @@ -1245,8 +1246,8 @@ export default function ProviderDetailPage() { Add Model - {/* Import Qoder models button — only show for qoder provider */} - {providerId === "qoder" && connections.some((conn) => conn.isActive !== false) && ( + {/* Import Qoder models button — only show for qoder/qoder-cn provider */} + {(providerId === "qoder" || providerId === "qoder-cn") && connections.some((conn) => conn.isActive !== false) && (