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

@@ -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(),

View File

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

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
* 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 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,

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
* 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<modelKey, modelConfigObject> }
* 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,

View File

@@ -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 = {

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

View File

@@ -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" &&