feat(providers): add qoder-cn support for Qoder CN (qoder.com.cn)

This commit is contained in:
Liang.Xu
2026-09-21 20:44:26 +07:00
parent f67d5a0c93
commit 402745dc1f
30 changed files with 487 additions and 234 deletions

View File

@@ -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) # v0.5.81 (2026-09-18)
## Features ## Features

View File

@@ -35,6 +35,7 @@ const executors = {
github: new GithubExecutor(), github: new GithubExecutor(),
iflow: new IFlowExecutor(), iflow: new IFlowExecutor(),
qoder: new QoderExecutor(), qoder: new QoderExecutor(),
"qoder-cn": new QoderExecutor("qoder-cn"),
kiro: new KiroExecutor(), kiro: new KiroExecutor(),
kimchi: new KimchiExecutor(), kimchi: new KimchiExecutor(),
codex: new CodexExecutor(), codex: new CodexExecutor(),

View File

@@ -207,16 +207,16 @@ function truncate(s, n) {
/** /**
* Map the OpenAI-style request body into the exact shape Qoder expects. * 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\//, ""); const qoderKey = String(model || "").replace(/^qoder\//, "");
// Fetch model config from dynamic API instead of relying on static QODER_MODEL_MAP. // 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. // 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) { if (!modelConfig) {
// Try a forced refresh once before giving up — the cache may simply // Try a forced refresh once before giving up — the cache may simply
// not be populated yet on first ever call for this credential. // 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); const retried = refreshed?.rawConfigs.get(qoderKey);
if (!retried) { if (!retried) {
throw new Error( throw new Error(
@@ -584,12 +584,13 @@ async function wrapQoderSSE(response, model, log = null) {
} }
export class QoderExecutor extends BaseExecutor { export class QoderExecutor extends BaseExecutor {
constructor() { constructor(provider = "qoder") {
super("qoder", PROVIDERS.qoder); super(provider, PROVIDERS[provider]);
this.region = provider === "qoder-cn" ? "cn" : "intl";
} }
buildUrl(credentials) { 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: // Override execute entirely — Qoder needs:
@@ -604,7 +605,7 @@ export class QoderExecutor extends BaseExecutor {
const rawToken = credentials?.apiKey || credentials?.accessToken; const rawToken = credentials?.apiKey || credentials?.accessToken;
if (isQoderPat(rawToken)) { if (isQoderPat(rawToken)) {
try { try {
credentials = await resolveQoderCredentials(credentials, proxyOptions, signal); credentials = await resolveQoderCredentials(credentials, proxyOptions, signal, this.region);
} catch (err) { } catch (err) {
log?.error?.("QODER", `PAT exchange failed: ${err.message}`); log?.error?.("QODER", `PAT exchange failed: ${err.message}`);
const fakeResp = new Response( const fakeResp = new Response(
@@ -639,7 +640,7 @@ export class QoderExecutor extends BaseExecutor {
let qoderKey; let qoderKey;
let payload; let payload;
try { 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) { } catch (err) {
const fakeResp = new Response( const fakeResp = new Response(
JSON.stringify({ error: { message: err.message } }), JSON.stringify({ error: { message: err.message } }),
@@ -709,7 +710,7 @@ export class QoderExecutor extends BaseExecutor {
return { response, url, headers, transformedBody: payload }; 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 }; return { response: wrapped, url, headers, transformedBody: payload };
} }

View File

@@ -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 * Pattern fallback — glob (* = wildcard), matched case-insensitively and
* anchored (^...$) so a pattern must match the full model id. ORDER MATTERS: * anchored (^...$) so a pattern must match the full model id. ORDER MATTERS:

View File

@@ -77,6 +77,7 @@ 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 p124 from "./qoder-cn.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";
@@ -193,6 +194,7 @@ export default [
p65, p65,
p66, p66,
p123, p123,
p124,
p67, p67,
p68, p68,
p68z, p68z,

View File

@@ -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,
},
};

View File

@@ -13,9 +13,13 @@
* *
* PAT (Personal Access Token, pt-...) connections: a PAT cannot sign COSY * PAT (Personal Access Token, pt-...) connections: a PAT cannot sign COSY
* requests directly, so we exchange it for a short-lived job token (jt-...) * 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 * via the region's jobToken/exchange endpoint (plain JSON POST), then use
* that job token for signing. Job-token traffic must hit api2.qoder.sh — * that job token for signing. On intl, job-token traffic must hit api2.qoder.sh —
* api3 rejects jt- with "Login expired" (403). * 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"; import { createHash } from "crypto";
@@ -23,12 +27,12 @@ import { createHash } from "crypto";
import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { buildCosyHeaders } from "../shared/qoder/cosy.js"; import { buildCosyHeaders } from "../shared/qoder/cosy.js";
import { import {
QODER_MODEL_LIST_URL,
QODER_CHAT_BASE_ALT,
QODER_JOB_TOKEN_EXCHANGE_URL,
QODER_USERINFO_URL,
QODER_IDE_VERSION, QODER_IDE_VERSION,
QODER_CLIENT_TYPE, QODER_CLIENT_TYPE,
qoderRegionOf,
qoderJobTokenExchangeUrl,
qoderUserInfoUrl,
qoderInferenceBase,
} from "../shared/qoder/constants.js"; } from "../shared/qoder/constants.js";
const FETCH_TIMEOUT_MS = 15_000; 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-...). * Exchange a Qoder PAT (pt-...) for a short-lived job token (jt-...).
* This endpoint is plain JSON POST — NOT COSY-signed. * 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( const res = await proxyAwareFetch(
QODER_JOB_TOKEN_EXCHANGE_URL, qoderJobTokenExchangeUrl(region),
{ {
method: "POST", method: "POST",
headers: { 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). * Resolve the Qoder userId for a job token (needed for COSY signing).
* Returns "" on any failure — callers fall back to the stored userId. * 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 { try {
const res = await proxyAwareFetch( const res = await proxyAwareFetch(
QODER_USERINFO_URL, qoderUserInfoUrl(region),
{ {
method: "GET", method: "GET",
headers: { 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) { async function resolvePatCredential(pat, proxyOptions = null, signal = null, region = "intl") {
const cached = patJobCache.get(pat); const cacheKey = `${region}:${pat}`;
const cached = patJobCache.get(cacheKey);
if (cached && cached.expiresAt - Date.now() > PAT_REFRESH_BUFFER_MS) return cached; if (cached && cached.expiresAt - Date.now() > PAT_REFRESH_BUFFER_MS) return cached;
const { jobToken, expiresAt } = await exchangeJobToken(pat, proxyOptions, signal); const { jobToken, expiresAt } = await exchangeJobToken(pat, proxyOptions, signal, region);
const userId = await fetchUserIdForJobToken(jobToken, proxyOptions, signal); const userId = await fetchUserIdForJobToken(jobToken, proxyOptions, signal, region);
const resolved = { accessToken: jobToken, userId, expiresAt }; const resolved = { accessToken: jobToken, userId, expiresAt };
patJobCache.set(pat, resolved); patJobCache.set(cacheKey, resolved);
return resolved; return resolved;
} }
@@ -142,11 +147,14 @@ async function resolvePatCredential(pat, proxyOptions = null, signal = null) {
* Resolve connection credentials to COSY-signable form: * Resolve connection credentials to COSY-signable form:
* - PAT (pt-...) connections → exchanged to a job token (jt-...) + userId * - PAT (pt-...) connections → exchanged to a job token (jt-...) + userId
* - everything else → passed through unchanged * - 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; const raw = credentials?.apiKey || credentials?.accessToken;
if (isQoderPat(raw)) { 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 { return {
...credentials, ...credentials,
accessToken: resolved.accessToken, 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 * Stable cache key per credential+region (so different login sessions for the
* account share an entry). * same account share an entry, and the same PAT on both sites stays apart).
*/ */
function cacheKey(credentials) { function cacheKey(credentials) {
const psd = credentials?.providerSpecificData || {}; const psd = credentials?.providerSpecificData || {};
const seed = psd.userId || credentials?.refreshToken || credentials?.accessToken || "anonymous"; 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<modelKey, modelConfigObject> } * rawConfigs: Map<modelKey, modelConfigObject> }
* or `null` on any error. * 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); const creds = cosyCredsFromConnection(credentials);
if (!creds.userId || !creds.authToken) return null; if (!creds.userId || !creds.authToken) return null;
// Job-token traffic is rejected by api3 ("Login expired" 403) — the // Intl job-token traffic is rejected by api3 ("Login expired" 403) — the
// official qodercli serves it from api2 instead. // official qodercli serves it from api2 instead; CN uses the single gateway.
const modelListUrl = String(creds.authToken).startsWith("jt-") const modelListUrl = `${qoderInferenceBase(credentials, region)}/algo/api/v2/model/list`;
? `${QODER_CHAT_BASE_ALT}/algo/api/v2/model/list`
: QODER_MODEL_LIST_URL;
const headers = { const headers = {
Accept: "application/json", Accept: "application/json",
@@ -293,14 +300,20 @@ export async function getQoderModelConfig(credentials, modelKey, options = {}) {
* one upstream request per credential. * one upstream request per credential.
*/ */
export async function resolveQoderModels(credentials, options = {}) { export async function resolveQoderModels(credentials, options = {}) {
const region = options.region || qoderRegionOf(credentials?.provider);
let resolved; let resolved;
try { try {
resolved = await resolveQoderCredentials(credentials, options.proxyOptions, options.signal); resolved = await resolveQoderCredentials(credentials, options.proxyOptions, options.signal, region);
} catch (error) { } catch (error) {
options.log?.warn?.("QODER", `PAT exchange failed: ${error.message}`); options.log?.warn?.("QODER", `PAT exchange failed: ${error.message}`);
return null; return null;
} }
if (!resolved?.accessToken || !(resolved.providerSpecificData || {}).userId) 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 key = cacheKey(resolved);
const now = Date.now(); const now = Date.now();
@@ -319,7 +332,7 @@ export async function resolveQoderModels(credentials, options = {}) {
} }
const fetchPromise = (async () => { 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; if (!fetched) return null;
const entry = { const entry = {
expiresAt: Date.now() + CACHE_TTL_MS, expiresAt: Date.now() + CACHE_TTL_MS,

View File

@@ -42,12 +42,8 @@ const USAGE_HANDLERS = {
claude: (c) => getClaudeUsage(c.accessToken, c.proxyOptions, { force: c.force }), claude: (c) => getClaudeUsage(c.accessToken, c.proxyOptions, { force: c.force }),
codex: (c) => getCodexUsage(c.accessToken, c.proxyOptions), codex: (c) => getCodexUsage(c.accessToken, c.proxyOptions),
kiro: (c) => getKiroUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), kiro: (c) => getKiroUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
qoder: async (c) => { qoder: (c) => getQoderUsageFor(c),
// PAT (pt-...) connections must be exchanged to a job token before the "qoder-cn": (c) => getQoderUsageFor(c),
// quota endpoint accepts them.
const resolved = await resolveQoderCredentials(c, c.proxyOptions).catch(() => null);
return getQoderUsage(resolved?.accessToken || c.accessToken, c.proxyOptions);
},
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),
@@ -68,6 +64,14 @@ const USAGE_HANDLERS = {
commandcode: (c) => getCommandCodeUsage(c.apiKey, c.proxyOptions), 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 = {}) { export async function getUsageForProvider(connection, proxyOptions = null, options = {}) {
const { provider, accessToken, apiKey, providerSpecificData, projectId } = connection; const { provider, accessToken, apiKey, providerSpecificData, projectId } = connection;
const providerDataWithProjectId = { const providerDataWithProjectId = {

View File

@@ -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) { if (!accessToken) {
return { message: "Qoder usage unavailable: no access token" }; return { message: "Qoder usage unavailable: no access token" };
} }
try { try {
const response = await proxyAwareFetch( const response = await proxyAwareFetch(
U("qoder").url, U(providerId).url,
{ {
method: "GET", method: "GET",
headers: { headers: {

View File

@@ -1,38 +1,106 @@
/** /**
* Qoder API constants ported from CLIProxyAPIPlus qoder-provider branch. * Qoder API constants ported from CLIProxyAPIPlus qoder-provider branch.
* *
* Endpoint set: * Qoder runs two regional sites with parallel endpoint shapes:
* openapi.qoder.sh - device flow + userinfo + quota usage * intl (qoder) CN (qoder-cn)
* center.qoder.sh - token refresh (best-effort, currently 403 for device tokens) * openapi.qoder.sh openapi.qoder.com.cn - device flow + userinfo + quota usage
* api3.qoder.sh - inference (chat) + model list, requires COSY signing * center.qoder.sh gateway.qoder.com.cn - token refresh (best-effort, 403 for device tokens)
* qoder.com/device - browser landing page for device authorization * 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_REGION_INTL = "intl";
export const QODER_CENTER_BASE = "https://center.qoder.sh"; export const QODER_REGION_CN = "cn";
export const QODER_CHAT_BASE = "https://api3.qoder.sh";
// 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); // Job-token (jt-...) traffic is rejected by api3 with "Login expired" (403);
// the official qodercli serves it from api2 instead. // the official qodercli serves it from api2 instead (intl only).
export const QODER_CHAT_BASE_ALT = "https://api2.qoder.sh"; 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 // Device flow endpoints (region-aware variants; these are the intl defaults)
export const QODER_DEVICE_TOKEN_URL = `${QODER_OPENAPI_BASE}/api/v1/deviceToken/poll`; export function qoderOpenApiBase(region) {
export const QODER_USERINFO_URL = `${QODER_OPENAPI_BASE}/api/v1/userinfo`; return qoderRegionBases(region).openApi;
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`; 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. // PAT (Personal Access Token, pt-...) → short-lived job token (jt-...) exchange.
// PATs cannot sign COSY requests directly — they must be exchanged first. // PATs cannot sign COSY requests directly — they must be exchanged first.
// This endpoint is NOT COSY-signed (plain JSON POST). // 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_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 = `${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_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") // Official qodercli uploads images here (COSY-signed PUT multipart, field "file")
// instead of inlining base64 into agent_chat_generation. // instead of inlining base64 into agent_chat_generation.
export const QODER_IMAGE_UPLOAD_SIG_PATH = "/api/v2/image/upload"; 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-...) * "Login expired" (403). Device tokens (dt-...) stay on api3. PATs (pt-...)
* are exchanged for jt- before this is consulted. * 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; const raw = credentials?.apiKey || credentials?.accessToken;
if ( if (
typeof raw === "string" && typeof raw === "string" &&

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

@@ -13,10 +13,10 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
const isOllamaLocal = provider === "ollama-local"; const isOllamaLocal = provider === "ollama-local";
const isCookie = authType === "cookie"; const isCookie = authType === "cookie";
const isXaiApiKey = provider === "xai" && !isCookie; 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 const credentialPlaceholder = isCookie
? (provider === "grok-web" ? "sso=xxxxx... or just the raw value" : "eyJhbGciOi...") ? (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 isAzure = provider === "azure";
const isCloudflareAi = provider === "cloudflare-ai"; const isCloudflareAi = provider === "cloudflare-ai";
@@ -44,7 +44,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const bulkPlaceholder = isCloudflareAi const bulkPlaceholder = isCloudflareAi
? `name1|sk-key1|acc123456\nname2|sk-key2|def789012\nsk-key-only-auto-named` ? `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` ? `name1|pt-xxxxx\nname2|pt-yyyyy\npt-only-auto-named`
: BULK_PLACEHOLDER; : BULK_PLACEHOLDER;
@@ -201,7 +201,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
<p className="text-xs text-text-muted"> <p className="text-xs text-text-muted">
{isCloudflareAi {isCloudflareAi
? <>One key per line. Format: <code>name|apiKey|accountId</code> or just <code>apiKey</code> (auto-named by index).</> ? <>One key per line. Format: <code>name|apiKey|accountId</code> or just <code>apiKey</code> (auto-named by index).</>
: provider === "qoder" : provider === "qoder" || provider === "qoder-cn"
? <>One PAT per line. Format: <code>name|pt-...</code> or just <code>pt-...</code> (auto-named by index).</> ? <>One PAT per line. Format: <code>name|pt-...</code> or just <code>pt-...</code> (auto-named by index).</>
: <>One key per line. Format: <code>name|apiKey</code> or just <code>apiKey</code> (auto-named by index).</> : <>One key per line. Format: <code>name|apiKey</code> or just <code>apiKey</code> (auto-named by index).</>
} }

View File

@@ -172,7 +172,7 @@ export default function ProviderDetailPage() {
const apiKeyConnectionLabel = const apiKeyConnectionLabel =
providerId === "xai" ? "xAI API Key" providerId === "xai" ? "xAI API Key"
: providerId === "kimi" ? "Kimi API Key" : providerId === "kimi" ? "Kimi API Key"
: providerId === "qoder" ? "PAT" : (providerId === "qoder" || providerId === "qoder-cn") ? "PAT"
: "API Key"; : "API Key";
// Resolve suffix "(level)" for a model when a thinking level is picked and the model supports it. // Resolve suffix "(level)" for a model when a thinking level is picked and the model supports it.
const resolveThinkingSuffix = (modelId) => { const resolveThinkingSuffix = (modelId) => {
@@ -612,8 +612,9 @@ export default function ProviderDetailPage() {
const modelId = model.id || model.name; const modelId = model.id || model.name;
if (!modelId) continue; if (!modelId) continue;
// Qoder model ID format may be "qoder/auto" or "auto", need to remove prefix // Qoder model ID format may be "qoder/auto", "qoder-cn/auto" or "auto",
const cleanModelId = modelId.replace(/^qoder\//, ""); // need to remove the provider prefix before storing.
const cleanModelId = modelId.replace(/^(qoder-cn|qoder)\//, "");
const alreadyExists = customModels.some( const alreadyExists = customModels.some(
(entry) => entry.providerAlias === providerStorageAlias && entry.id === cleanModelId && (entry.kind || entry.type || "llm") === "llm" (entry) => entry.providerAlias === providerStorageAlias && entry.id === cleanModelId && (entry.kind || entry.type || "llm") === "llm"
) || Object.values(modelAliases).includes(`${providerStorageAlias}/${cleanModelId}`); ) || Object.values(modelAliases).includes(`${providerStorageAlias}/${cleanModelId}`);
@@ -1245,8 +1246,8 @@ export default function ProviderDetailPage() {
Add Model Add Model
</button> </button>
{/* Import Qoder models button — only show for qoder provider */} {/* Import Qoder models button — only show for qoder/qoder-cn provider */}
{providerId === "qoder" && connections.some((conn) => conn.isActive !== false) && ( {(providerId === "qoder" || providerId === "qoder-cn") && connections.some((conn) => conn.isActive !== false) && (
<button <button
onClick={handleImportQoderModels} onClick={handleImportQoderModels}
disabled={importingQoderModels} disabled={importingQoderModels}

View File

@@ -45,6 +45,7 @@ export default function ProviderLimitCard({
codex: "#10A37F", codex: "#10A37F",
kiro: "#FF9900", kiro: "#FF9900",
qoder: "#EC4899", qoder: "#EC4899",
"qoder-cn": "#EC4899",
claude: "#D97757", claude: "#D97757",
}; };
return colors[provider?.toLowerCase()] || "#6B7280"; return colors[provider?.toLowerCase()] || "#6B7280";

View File

@@ -551,6 +551,7 @@ export function parseQuotaData(provider, data) {
break; break;
case "qoder": case "qoder":
case "qoder-cn":
// Qoder ships a `user` quota and (optionally) an `organization` // Qoder ships a `user` quota and (optionally) an `organization`
// quota, both with same shape: {total, used, remaining, unit, resetAt}. // quota, both with same shape: {total, used, remaining, unit, resetAt}.
// Skip an organization bucket when its total is 0 — most personal // Skip an organization bucket when its total is 0 — most personal

View File

@@ -263,6 +263,7 @@ export async function GET(request, { params }) {
"codebuddy-cn", "codebuddy-cn",
"codebuddy-intl", "codebuddy-intl",
"qoder", "qoder",
"qoder-cn",
"grok-cli", "grok-cli",
]; ];
let deviceData; let deviceData;
@@ -505,7 +506,7 @@ export async function POST(request, { params }) {
} else if (provider === "kiro") { } else if (provider === "kiro") {
// Kiro needs extraData (clientId, clientSecret) from device code response // Kiro needs extraData (clientId, clientSecret) from device code response
result = await pollForToken(provider, deviceCode, null, extraData); result = await pollForToken(provider, deviceCode, null, extraData);
} else if (provider === "qoder") { } else if (provider === "qoder" || provider === "qoder-cn") {
// Qoder needs both the PKCE verifier (codeVerifier) and the machineId // Qoder needs both the PKCE verifier (codeVerifier) and the machineId
// captured at device-code time (extraData._qoderMachineId) so // captured at device-code time (extraData._qoderMachineId) so
// mapTokens can persist it for COSY signing. // mapTokens can persist it for COSY signing.

View File

@@ -127,6 +127,49 @@ const buildOAuthResolver = ({ refreshFn, fetchFn, parseFn, errorLabel }) => asyn
return { models: [], warning }; return { models: [], warning };
}; };
// Qoder shares one resolver across intl (qoder) and CN (qoder-cn); the
// credentials carry the connection's provider so qoderModels picks the right
// region's catalog endpoint, and the ids keep the provider prefix.
function buildQoderModelsResolver(providerId) {
return {
customResolver: async (connection) => {
const credentials = {
provider: providerId,
accessToken: connection.accessToken,
apiKey: connection.apiKey,
refreshToken: connection.refreshToken,
email: connection.email,
displayName: connection.displayName,
providerSpecificData: connection.providerSpecificData || {},
};
let warning;
try {
const result = await resolveQoderModels(credentials, { forceRefresh: true });
if (result?.models?.length) {
return {
models: result.models.map((m) => ({
// Use the canonical "<providerId>/<key>" id so the dashboard
// surfaces the same identifier the chat router expects.
id: `${providerId}/${m.id}`,
name: m.name,
contextLength: m.contextLength,
isVL: m.isVL,
isReasoning: m.isReasoning,
maxOutputTokens: m.maxOutputTokens,
description: m.description,
})),
};
}
warning = "Qoder returned no models; falling back to static catalog.";
} catch (error) {
warning = `Failed to fetch Qoder models: ${error.message}`;
console.log("Failed to fetch Qoder models dynamically, falling back to static:", error.message);
}
return { models: [], warning };
},
};
}
// Provider models endpoints configuration // Provider models endpoints configuration
const PROVIDER_MODELS_CONFIG = { const PROVIDER_MODELS_CONFIG = {
claude: { claude: {
@@ -403,42 +446,8 @@ const PROVIDER_MODELS_CONFIG = {
return { models: [], warning }; return { models: [], warning };
} }
}, },
qoder: { qoder: buildQoderModelsResolver("qoder"),
customResolver: async (connection) => { "qoder-cn": buildQoderModelsResolver("qoder-cn"),
const credentials = {
accessToken: connection.accessToken,
apiKey: connection.apiKey,
refreshToken: connection.refreshToken,
email: connection.email,
displayName: connection.displayName,
providerSpecificData: connection.providerSpecificData || {},
};
let warning;
try {
const result = await resolveQoderModels(credentials, { forceRefresh: true });
if (result?.models?.length) {
return {
models: result.models.map((m) => ({
// Use the canonical "qoder/<key>" id so the dashboard
// surfaces the same identifier the chat router expects.
id: `qoder/${m.id}`,
name: m.name,
contextLength: m.contextLength,
isVL: m.isVL,
isReasoning: m.isReasoning,
maxOutputTokens: m.maxOutputTokens,
description: m.description,
})),
};
}
warning = "Qoder returned no models; falling back to static catalog.";
} catch (error) {
warning = `Failed to fetch Qoder models: ${error.message}`;
console.log("Failed to fetch Qoder models dynamically, falling back to static:", error.message);
}
return { models: [], warning };
},
},
"gemini-cli": { "gemini-cli": {
customResolver: buildOAuthResolver({ customResolver: buildOAuthResolver({
refreshFn: (conn) => refreshGoogleToken(conn.refreshToken, GEMINI_CONFIG.clientId, GEMINI_CONFIG.clientSecret), refreshFn: (conn) => refreshGoogleToken(conn.refreshToken, GEMINI_CONFIG.clientId, GEMINI_CONFIG.clientSecret),

View File

@@ -74,6 +74,14 @@ const OAUTH_TEST_CONFIG = {
authPrefix: "Bearer ", authPrefix: "Bearer ",
refreshable: false, refreshable: false,
}, },
"qoder-cn": {
// Same shape as intl qoder, CN host.
url: "https://openapi.qoder.com.cn/api/v1/userinfo",
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
refreshable: false,
},
kimi: { checkExpiry: true, refreshable: true }, kimi: { checkExpiry: true, refreshable: true },
"kimi-coding": { checkExpiry: true, refreshable: true }, "kimi-coding": { checkExpiry: true, refreshable: true },
cursor: { tokenExists: true }, cursor: { tokenExists: true },
@@ -781,12 +789,16 @@ async function testApiKeyConnection(connection, effectiveProxy = null) {
}, effectiveProxy); }, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
} }
case "qoder": { case "qoder":
case "qoder-cn": {
// PAT (pt-...) exchange → job token. A successful exchange proves the PAT. // PAT (pt-...) exchange → job token. A successful exchange proves the PAT.
const exchangeUrl = provider === "qoder-cn"
? "https://openapi.qoder.com.cn/api/v1/jobToken/exchange"
: "https://openapi.qoder.sh/api/v1/jobToken/exchange";
const raw = connection.apiKey || ""; const raw = connection.apiKey || "";
const pat = raw.startsWith("pt-") ? raw : `pt-${raw}`; const pat = raw.startsWith("pt-") ? raw : `pt-${raw}`;
const exRes = await fetchWithConnectionProxy( const exRes = await fetchWithConnectionProxy(
"https://openapi.qoder.sh/api/v1/jobToken/exchange", exchangeUrl,
{ {
method: "POST", method: "POST",
headers: { headers: {

View File

@@ -582,11 +582,12 @@ export async function POST(request) {
break; break;
} }
case "qoder": { case "qoder":
case "qoder-cn": {
// PAT (pt-...) needs the job-token exchange before it can sign // PAT (pt-...) needs the job-token exchange before it can sign
// anything — the generic OpenAI-compat probe below can't validate it. // anything — the generic OpenAI-compat probe below can't validate it.
try { try {
const resolved = await resolveQoderCredentials({ apiKey, providerSpecificData }, null, AbortSignal.timeout(8000)); const resolved = await resolveQoderCredentials({ provider, apiKey, providerSpecificData }, null, AbortSignal.timeout(8000));
const result = await resolveQoderModels(resolved, { forceRefresh: true }); const result = await resolveQoderModels(resolved, { forceRefresh: true });
isValid = !!result?.models?.length; isValid = !!result?.models?.length;
} catch (err) { } catch (err) {

View File

@@ -19,20 +19,12 @@ import { updateProviderCredentials } from "@/sse/services/tokenRefresh";
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
import { capabilitiesFromServiceKind, getCapabilitiesForModel, aggregateComboCapabilities } from "open-sse/providers/capabilities.js"; import { capabilitiesFromServiceKind, getCapabilitiesForModel, aggregateComboCapabilities } from "open-sse/providers/capabilities.js";
// Per-provider live model resolvers. Each receives a connection record and // Qoder shares one live resolver across intl (qoder) and CN (qoder-cn); the
// returns { models: [{ id, name? }, ...] } | null on failure. // credentials carry the provider id so qoderModels picks the right region's
// Adding a provider here makes /v1/models prefer the live catalog for it. // catalog endpoint.
const LIVE_MODEL_RESOLVERS = { async function resolveQoderLiveModels(conn, provider) {
kiro: async (conn) => {
const result = await resolveKiroModels({
accessToken: conn.accessToken,
refreshToken: conn.refreshToken,
providerSpecificData: conn.providerSpecificData || {}
}, { log: console });
return result?.models?.length ? { models: result.models } : null;
},
qoder: async (conn) => {
const result = await resolveQoderModels({ const result = await resolveQoderModels({
provider,
accessToken: conn.accessToken, accessToken: conn.accessToken,
// PAT (pt-...) connections keep the token in apiKey; without it the live // PAT (pt-...) connections keep the token in apiKey; without it the live
// catalog silently fails and /v1/models falls back to the static list. // catalog silently fails and /v1/models falls back to the static list.
@@ -46,7 +38,22 @@ const LIVE_MODEL_RESOLVERS = {
const models = routableQoderModels(result); const models = routableQoderModels(result);
if (!models.length) return null; if (!models.length) return null;
return { models: models.map((m) => ({ id: m.id, name: m.name })) }; return { models: models.map((m) => ({ id: m.id, name: m.name })) };
}
// Per-provider live model resolvers. Each receives a connection record and
// returns { models: [{ id, name? }, ...] } | null on failure.
// Adding a provider here makes /v1/models prefer the live catalog for it.
const LIVE_MODEL_RESOLVERS = {
kiro: async (conn) => {
const result = await resolveKiroModels({
accessToken: conn.accessToken,
refreshToken: conn.refreshToken,
providerSpecificData: conn.providerSpecificData || {}
}, { log: console });
return result?.models?.length ? { models: result.models } : null;
}, },
qoder: async (conn) => resolveQoderLiveModels(conn, "qoder"),
"qoder-cn": async (conn) => resolveQoderLiveModels(conn, "qoder-cn"),
kimchi: async (conn) => { kimchi: async (conn) => {
const result = await resolveKimchiModels({ const result = await resolveKimchiModels({
accessToken: conn.accessToken, accessToken: conn.accessToken,

View File

@@ -35,6 +35,9 @@ export const GEMINI_CONFIG = { ...GOOGLE_OAUTH_CLIENT, ...PROVIDER_OAUTH["gemini
// of attempting to silently rotate. // of attempting to silently rotate.
export const QODER_CONFIG = { ...PROVIDER_OAUTH["qoder"] }; export const QODER_CONFIG = { ...PROVIDER_OAUTH["qoder"] };
// Qoder CN (qoder.com.cn) — same device flow as intl Qoder, CN endpoints.
export const QODER_CN_CONFIG = { ...PROVIDER_OAUTH["qoder-cn"] };
// iFlow OAuth Configuration (Authorization Code) // iFlow OAuth Configuration (Authorization Code)
export const IFLOW_CONFIG = { ...PROVIDER_OAUTH["iflow"] }; export const IFLOW_CONFIG = { ...PROVIDER_OAUTH["iflow"] };
@@ -219,6 +222,7 @@ export const PROVIDERS = {
CODEX: "codex", CODEX: "codex",
GEMINI: "gemini-cli", GEMINI: "gemini-cli",
QODER: "qoder", QODER: "qoder",
QODER_CN: "qoder-cn",
IFLOW: "iflow", IFLOW: "iflow",
ANTIGRAVITY: "antigravity", ANTIGRAVITY: "antigravity",
OPENAI: "openai", OPENAI: "openai",

View File

@@ -12,6 +12,7 @@ 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 qoderCn from "./qoder-cn.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";
@@ -37,6 +38,7 @@ const PROVIDERS = {
antigravity, antigravity,
iflow, iflow,
qoder, qoder,
"qoder-cn": qoderCn,
github, github,
kiro, kiro,
cursor, cursor,

View File

@@ -0,0 +1,5 @@
import { QODER_CN_CONFIG } from "../constants/oauth.js";
import { createQoderProvider } from "./qoder.js";
// Qoder CN (qoder.com.cn) — same device flow as intl Qoder, CN endpoints.
export default createQoderProvider(QODER_CN_CONFIG);

View File

@@ -1,14 +1,22 @@
import { QODER_CONFIG } from "../constants/oauth.js"; import { QODER_CONFIG } from "../constants/oauth.js";
const qoder = { /**
config: QODER_CONFIG, * Build a Qoder device-code provider for a region. `config` is the registry
* oauth block (see open-sse/providers/registry/qoder.js / qoder-cn.js), which
* carries the region's login/deviceToken/userInfo URLs. The device flow is
* identical across regions — only the hosts differ.
*/
export function createQoderProvider(config) {
return {
config,
flowType: "device_code", flowType: "device_code",
// Qoder uses a custom device flow: PKCE + nonce + machine_id are generated // Qoder uses a custom device flow: PKCE + nonce + machine_id are generated
// locally, the user lands on qoder.com/device/selectAccounts in the // locally, the user lands on qoder.com[-cn]/device/selectAccounts in the
// browser, and we poll openapi.qoder.sh until a `dt-...` token appears. // browser, and we poll the region's deviceToken endpoint until a `dt-...`
requestDeviceCode: async (config) => { // token appears.
requestDeviceCode: async (cfg) => {
const { QoderService } = await import("@/lib/oauth/services/qoder"); const { QoderService } = await import("@/lib/oauth/services/qoder");
const flow = new QoderService().initiateDeviceFlow(); const flow = new QoderService(cfg).initiateDeviceFlow();
// Match the device_code shape the rest of the OAuthModal expects // Match the device_code shape the rest of the OAuthModal expects
// (device_code, user_code, verification_uri[_complete], interval). // (device_code, user_code, verification_uri[_complete], interval).
// The poll endpoint identifies us by nonce+verifier, not by a // The poll endpoint identifies us by nonce+verifier, not by a
@@ -18,7 +26,7 @@ const qoder = {
return { return {
device_code: flow.nonce, device_code: flow.nonce,
user_code: flow.nonce.slice(0, 8).toUpperCase(), user_code: flow.nonce.slice(0, 8).toUpperCase(),
verification_uri: config.loginUrl, verification_uri: cfg.loginUrl,
verification_uri_complete: flow.verificationUriComplete, verification_uri_complete: flow.verificationUriComplete,
expires_in: 300, expires_in: 300,
interval: 2, interval: 2,
@@ -27,9 +35,9 @@ const qoder = {
_qoderMachineId: flow.machineId, _qoderMachineId: flow.machineId,
}; };
}, },
pollToken: async (config, deviceCode, codeVerifier, extraData) => { pollToken: async (cfg, deviceCode, codeVerifier, extraData) => {
const { QoderService } = await import("@/lib/oauth/services/qoder"); const { QoderService } = await import("@/lib/oauth/services/qoder");
const svc = new QoderService(); const svc = new QoderService(cfg);
const nonce = deviceCode || extraData?._qoderNonce; const nonce = deviceCode || extraData?._qoderNonce;
const verifier = codeVerifier || extraData?._qoderVerifier; const verifier = codeVerifier || extraData?._qoderVerifier;
if (!nonce || !verifier) { if (!nonce || !verifier) {
@@ -97,6 +105,7 @@ const qoder = {
}, },
}; };
}, },
}; };
}
export default qoder; export default createQoderProvider(QODER_CONFIG);

View File

@@ -52,6 +52,27 @@ async function fetchWithTimeout(url, init = {}) {
} }
export class QoderService { export class QoderService {
/**
* Region-aware device flow. Pass an oauth config block (registry oauth →
* PROVIDER_OAUTH) to hit the CN site; without one, the intl endpoints are
* used. Only the hostnames differ between regions — the flow is identical.
*/
constructor(config = {}) {
this.config = config;
}
loginUrl() {
return this.config.loginUrl || QODER_LOGIN_URL;
}
deviceTokenUrl() {
return this.config.deviceTokenUrl || QODER_DEVICE_TOKEN_URL;
}
userInfoUrl() {
return this.config.userInfoUrl || QODER_USERINFO_URL;
}
/** /**
* Generate a PKCE verifier + S256 challenge pair. * Generate a PKCE verifier + S256 challenge pair.
* Uses 32 random bytes (matches qodercli/Veria). * Uses 32 random bytes (matches qodercli/Veria).
@@ -79,7 +100,7 @@ export class QoderService {
}); });
return { return {
verificationUriComplete: `${QODER_LOGIN_URL}?${params.toString()}`, verificationUriComplete: `${this.loginUrl()}?${params.toString()}`,
codeVerifier: verifier, codeVerifier: verifier,
nonce, nonce,
machineId, machineId,
@@ -98,7 +119,7 @@ export class QoderService {
if (!nonce || !codeVerifier) { if (!nonce || !codeVerifier) {
throw new Error("pollDeviceToken: missing nonce or code verifier"); throw new Error("pollDeviceToken: missing nonce or code verifier");
} }
const url = `${QODER_DEVICE_TOKEN_URL}?nonce=${encodeURIComponent(nonce)}&verifier=${encodeURIComponent(codeVerifier)}&challenge_method=S256`; const url = `${this.deviceTokenUrl()}?nonce=${encodeURIComponent(nonce)}&verifier=${encodeURIComponent(codeVerifier)}&challenge_method=S256`;
const response = await fetchWithTimeout(url, { const response = await fetchWithTimeout(url, {
method: "GET", method: "GET",
@@ -155,7 +176,7 @@ export class QoderService {
*/ */
async fetchUserInfo(accessToken) { async fetchUserInfo(accessToken) {
try { try {
const response = await fetchWithTimeout(QODER_USERINFO_URL, { const response = await fetchWithTimeout(this.userInfoUrl(), {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,

View File

@@ -289,6 +289,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
"codebuddy-cn", "codebuddy-cn",
"codebuddy-intl", "codebuddy-intl",
"qoder", "qoder",
"qoder-cn",
"grok-cli", "grok-cli",
]; ];
if (deviceCodeProviders.includes(provider)) { if (deviceCodeProviders.includes(provider)) {
@@ -324,7 +325,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
_authMethod: data._authMethod, _authMethod: data._authMethod,
_startUrl: data._startUrl, _startUrl: data._startUrl,
} }
: provider === "qoder" : (provider === "qoder" || provider === "qoder-cn")
? { ? {
_qoderNonce: data._qoderNonce, _qoderNonce: data._qoderNonce,
_qoderMachineId: data._qoderMachineId, _qoderMachineId: data._qoderMachineId,

View File

@@ -187,6 +187,7 @@
"perplexity-web": "perplexity-web", "perplexity-web": "perplexity-web",
"poolside": "poolside", "poolside": "poolside",
"qoder": "qd", "qoder": "qd",
"qoder-cn": "qdcn",
"sambanova": "samba", "sambanova": "samba",
"siliconflow": "siliconflow", "siliconflow": "siliconflow",
"tencent": "hunyuan", "tencent": "hunyuan",
@@ -280,6 +281,7 @@
"perplexity-web", "perplexity-web",
"poolside", "poolside",
"qd", "qd",
"qdcn",
"qianfan", "qianfan",
"recraft", "recraft",
"runwayml", "runwayml",

View File

@@ -719,6 +719,16 @@
"validateUrl": "https://ollama.com/api/tags", "validateUrl": "https://ollama.com/api/tags",
"format": "ollama" "format": "ollama"
}, },
"qoder-cn": {
"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"
},
"format": "openai"
},
"openai": { "openai": {
"baseUrl": "https://api.openai.com/v1/chat/completions", "baseUrl": "https://api.openai.com/v1/chat/completions",
"forceStream": true, "forceStream": true,

View File

@@ -651,6 +651,11 @@ describe("qoderInferenceBase", () => {
expect(qoderInferenceBase({ accessToken: "jt-abc" })).toContain("api2.qoder.sh"); expect(qoderInferenceBase({ accessToken: "jt-abc" })).toContain("api2.qoder.sh");
expect(qoderInferenceBase({ accessToken: "dt-abc" })).toContain("api3.qoder.sh"); expect(qoderInferenceBase({ accessToken: "dt-abc" })).toContain("api3.qoder.sh");
}); });
it("serves every token kind from the CN gateway for the qoder-cn region", () => {
expect(qoderInferenceBase({ accessToken: "jt-abc" }, "cn")).toContain("gateway.qoder.com.cn");
expect(qoderInferenceBase({ accessToken: "dt-abc" }, "cn")).toContain("gateway.qoder.com.cn");
});
}); });
describe("rewriteQoderMessageAttachments", () => { describe("rewriteQoderMessageAttachments", () => {

View File

@@ -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", "iflow", "ollama", "glm", "glm-cn", "qoder", "qoder-cn", "iflow", "ollama", "glm", "glm-cn",
"minimax", "minimax-cn", "vercel-ai-gateway", "grok-cli", "kimi", "minimax", "minimax-cn", "vercel-ai-gateway", "grok-cli", "kimi",
"deepseek", "opencode-go", "zed", "commandcode", "deepseek", "opencode-go", "zed", "commandcode",
]; ];