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 00000000..59da3da5 Binary files /dev/null and b/public/providers/qoder-cn.png differ diff --git a/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js b/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js index e9a628db..b5e746d4 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js @@ -13,10 +13,10 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa const isOllamaLocal = provider === "ollama-local"; const isCookie = authType === "cookie"; const isXaiApiKey = provider === "xai" && !isCookie; - const credentialLabel = isCookie ? "Cookie Value" : provider === "qoder" ? "Personal Access Token (PAT)" : "API Key"; + const credentialLabel = isCookie ? "Cookie Value" : provider === "qoder" || provider === "qoder-cn" ? "Personal Access Token (PAT)" : "API Key"; const credentialPlaceholder = isCookie ? (provider === "grok-web" ? "sso=xxxxx... or just the raw value" : "eyJhbGciOi...") - : (isXaiApiKey ? "xai-..." : provider === "qoder" ? "pt-..." : ""); + : (isXaiApiKey ? "xai-..." : provider === "qoder" || provider === "qoder-cn" ? "pt-..." : ""); const isAzure = provider === "azure"; const isCloudflareAi = provider === "cloudflare-ai"; @@ -44,7 +44,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa const [saving, setSaving] = useState(false); const bulkPlaceholder = isCloudflareAi ? `name1|sk-key1|acc123456\nname2|sk-key2|def789012\nsk-key-only-auto-named` - : provider === "qoder" + : provider === "qoder" || provider === "qoder-cn" ? `name1|pt-xxxxx\nname2|pt-yyyyy\npt-only-auto-named` : BULK_PLACEHOLDER; @@ -201,7 +201,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa

{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) && (