feat(providers): add qoder-cn support for Qoder CN (qoder.com.cn)
This commit is contained in:
1 parent
f67d5a0c93
commit
402745dc1f
30 files changed
+487
-234
No files matched your search
@@ -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
|
||||
<p className="text-xs text-text-muted">
|
||||
{isCloudflareAi
|
||||
? <>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 key per line. Format: <code>name|apiKey</code> or just <code>apiKey</code> (auto-named by index).</>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
</button>
|
||||
|
||||
{/* 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) && (
|
||||
<button
|
||||
onClick={handleImportQoderModels}
|
||||
disabled={importingQoderModels}
|
||||
|
||||
@@ -45,6 +45,7 @@ export default function ProviderLimitCard({
|
||||
codex: "#10A37F",
|
||||
kiro: "#FF9900",
|
||||
qoder: "#EC4899",
|
||||
"qoder-cn": "#EC4899",
|
||||
claude: "#D97757",
|
||||
};
|
||||
return colors[provider?.toLowerCase()] || "#6B7280";
|
||||
|
||||
@@ -551,6 +551,7 @@ export function parseQuotaData(provider, data) {
|
||||
break;
|
||||
|
||||
case "qoder":
|
||||
case "qoder-cn":
|
||||
// Qoder ships a `user` quota and (optionally) an `organization`
|
||||
// quota, both with same shape: {total, used, remaining, unit, resetAt}.
|
||||
// Skip an organization bucket when its total is 0 — most personal
|
||||
|
||||
@@ -263,6 +263,7 @@ export async function GET(request, { params }) {
|
||||
"codebuddy-cn",
|
||||
"codebuddy-intl",
|
||||
"qoder",
|
||||
"qoder-cn",
|
||||
"grok-cli",
|
||||
];
|
||||
let deviceData;
|
||||
@@ -505,7 +506,7 @@ export async function POST(request, { params }) {
|
||||
} else if (provider === "kiro") {
|
||||
// Kiro needs extraData (clientId, clientSecret) from device code response
|
||||
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
|
||||
// captured at device-code time (extraData._qoderMachineId) so
|
||||
// mapTokens can persist it for COSY signing.
|
||||
|
||||
@@ -127,6 +127,49 @@ const buildOAuthResolver = ({ refreshFn, fetchFn, parseFn, errorLabel }) => asyn
|
||||
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
|
||||
const PROVIDER_MODELS_CONFIG = {
|
||||
claude: {
|
||||
@@ -403,42 +446,8 @@ const PROVIDER_MODELS_CONFIG = {
|
||||
return { models: [], warning };
|
||||
}
|
||||
},
|
||||
qoder: {
|
||||
customResolver: async (connection) => {
|
||||
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 };
|
||||
},
|
||||
},
|
||||
qoder: buildQoderModelsResolver("qoder"),
|
||||
"qoder-cn": buildQoderModelsResolver("qoder-cn"),
|
||||
"gemini-cli": {
|
||||
customResolver: buildOAuthResolver({
|
||||
refreshFn: (conn) => refreshGoogleToken(conn.refreshToken, GEMINI_CONFIG.clientId, GEMINI_CONFIG.clientSecret),
|
||||
|
||||
@@ -74,6 +74,14 @@ const OAUTH_TEST_CONFIG = {
|
||||
authPrefix: "Bearer ",
|
||||
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-coding": { checkExpiry: true, refreshable: true },
|
||||
cursor: { tokenExists: true },
|
||||
@@ -781,12 +789,16 @@ async function testApiKeyConnection(connection, effectiveProxy = null) {
|
||||
}, effectiveProxy);
|
||||
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.
|
||||
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 pat = raw.startsWith("pt-") ? raw : `pt-${raw}`;
|
||||
const exRes = await fetchWithConnectionProxy(
|
||||
"https://openapi.qoder.sh/api/v1/jobToken/exchange",
|
||||
exchangeUrl,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
||||
@@ -582,11 +582,12 @@ export async function POST(request) {
|
||||
break;
|
||||
}
|
||||
|
||||
case "qoder": {
|
||||
case "qoder":
|
||||
case "qoder-cn": {
|
||||
// PAT (pt-...) needs the job-token exchange before it can sign
|
||||
// anything — the generic OpenAI-compat probe below can't validate it.
|
||||
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 });
|
||||
isValid = !!result?.models?.length;
|
||||
} catch (err) {
|
||||
|
||||
@@ -19,6 +19,27 @@ import { updateProviderCredentials } from "@/sse/services/tokenRefresh";
|
||||
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
|
||||
import { capabilitiesFromServiceKind, getCapabilitiesForModel, aggregateComboCapabilities } from "open-sse/providers/capabilities.js";
|
||||
|
||||
// Qoder shares one live resolver across intl (qoder) and CN (qoder-cn); the
|
||||
// credentials carry the provider id so qoderModels picks the right region's
|
||||
// catalog endpoint.
|
||||
async function resolveQoderLiveModels(conn, provider) {
|
||||
const result = await resolveQoderModels({
|
||||
provider,
|
||||
accessToken: conn.accessToken,
|
||||
// PAT (pt-...) connections keep the token in apiKey; without it the live
|
||||
// catalog silently fails and /v1/models falls back to the static list.
|
||||
apiKey: conn.apiKey,
|
||||
refreshToken: conn.refreshToken,
|
||||
email: conn.email,
|
||||
displayName: conn.displayName,
|
||||
providerSpecificData: conn.providerSpecificData || {}
|
||||
});
|
||||
// Visible + hidden (enable:false) catalog keys — chat routes all of them.
|
||||
const models = routableQoderModels(result);
|
||||
if (!models.length) return null;
|
||||
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.
|
||||
@@ -31,22 +52,8 @@ const LIVE_MODEL_RESOLVERS = {
|
||||
}, { log: console });
|
||||
return result?.models?.length ? { models: result.models } : null;
|
||||
},
|
||||
qoder: async (conn) => {
|
||||
const result = await resolveQoderModels({
|
||||
accessToken: conn.accessToken,
|
||||
// PAT (pt-...) connections keep the token in apiKey; without it the live
|
||||
// catalog silently fails and /v1/models falls back to the static list.
|
||||
apiKey: conn.apiKey,
|
||||
refreshToken: conn.refreshToken,
|
||||
email: conn.email,
|
||||
displayName: conn.displayName,
|
||||
providerSpecificData: conn.providerSpecificData || {}
|
||||
});
|
||||
// Visible + hidden (enable:false) catalog keys — chat routes all of them.
|
||||
const models = routableQoderModels(result);
|
||||
if (!models.length) return null;
|
||||
return { models: models.map((m) => ({ id: m.id, name: m.name })) };
|
||||
},
|
||||
qoder: async (conn) => resolveQoderLiveModels(conn, "qoder"),
|
||||
"qoder-cn": async (conn) => resolveQoderLiveModels(conn, "qoder-cn"),
|
||||
kimchi: async (conn) => {
|
||||
const result = await resolveKimchiModels({
|
||||
accessToken: conn.accessToken,
|
||||
|
||||
Reference in new issue
Block a user