diff --git a/open-sse/executors/qoder.js b/open-sse/executors/qoder.js index d062b139..912e9995 100644 --- a/open-sse/executors/qoder.js +++ b/open-sse/executors/qoder.js @@ -32,6 +32,8 @@ import { SSE_DONE } from "../utils/sseConstants.js"; import { FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js"; import { QODER_CHAT_URL_ENCODED, + QODER_CHAT_BASE_ALT, + QODER_CHAT_SIG_PATH, QODER_JOB_TOKEN_EXCHANGE_URL, QODER_USERINFO_URL, QODER_MODEL_MAP, @@ -433,7 +435,13 @@ export class QoderExecutor extends BaseExecutor { super("qoder", PROVIDERS.qoder); } - buildUrl() { + buildUrl(credentials) { + // Job-token (jt-...) traffic must hit api2.qoder.sh — api3 rejects jt- + // with "Login expired" (403). Device tokens (dt-...) stay on api3. + const raw = credentials?.apiKey || credentials?.accessToken; + if (typeof raw === "string" && !raw.startsWith("pt-") && (raw.startsWith("jt-") || (credentials?.accessToken || "").startsWith("jt-"))) { + return `${QODER_CHAT_BASE_ALT}/algo${QODER_CHAT_SIG_PATH}?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1`; + } return QODER_CHAT_URL_ENCODED; } @@ -443,8 +451,6 @@ export class QoderExecutor extends BaseExecutor { // - COSY headers built from the *encoded* body bytes // - response stream re-wrapped from {statusCodeValue, body} to OpenAI SSE async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { - const url = this.buildUrl(); - // PAT (pt-...) → exchange for short-lived job token + resolve userId so // downstream COSY signing + catalog fetch work. Device tokens (dt-...) and // job tokens (jt-...) skip this and are used directly. @@ -469,10 +475,11 @@ export class QoderExecutor extends BaseExecutor { JSON.stringify({ error: { message: `qoder PAT exchange failed: ${err.message}` } }), { status: 401, headers: { "Content-Type": "application/json" } }, ); - return { response: fakeResp, url, headers: {}, transformedBody: body }; + return { response: fakeResp, url: this.buildUrl(credentials), headers: {}, transformedBody: body }; } } + const url = this.buildUrl(credentials); const psd = credentials?.providerSpecificData || {}; if (!psd.userId) { // No user id → no way to sign. Surface a 401 so the dashboard nudges diff --git a/open-sse/providers/registry/qoder.js b/open-sse/providers/registry/qoder.js index 2b6c93ed..fe76fd72 100644 --- a/open-sse/providers/registry/qoder.js +++ b/open-sse/providers/registry/qoder.js @@ -52,5 +52,7 @@ export default { }, features: { usage: true, + // PAT (apikey) connections also carry quota usage (via job-token exchange). + usageApikey: true, }, }; diff --git a/open-sse/services/qoderModels.js b/open-sse/services/qoderModels.js index 01e6fb13..930461af 100644 --- a/open-sse/services/qoderModels.js +++ b/open-sse/services/qoderModels.js @@ -10,6 +10,12 @@ * * On any error the live cache stays empty and chatExecuteCall surfaces the * problem to the user as "model config not yet fetched, retry shortly". + * + * 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). */ import { createHash } from "crypto"; @@ -18,11 +24,24 @@ 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, } from "../shared/qoder/constants.js"; const FETCH_TIMEOUT_MS = 15_000; const CACHE_TTL_MS = 60 * 60 * 1000; // 1h, same as the Kiro catalog +// PAT → job-token cache: a job token is short-lived (24h), so we keep it per +// PAT and re-exchange once it is within 5 minutes of expiry. +const PAT_REFRESH_BUFFER_MS = 5 * 60 * 1000; +const PAT_DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; + +/** @type {Map} */ +const patJobCache = new Map(); + /** @type {Map, fetched: boolean }>} */ const catalogCache = new Map(); @@ -34,6 +53,109 @@ const catalogCache = new Map(); */ 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) { + const res = await proxyAwareFetch( + QODER_JOB_TOKEN_EXCHANGE_URL, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": "qodercli/1.0.0", + "Cosy-Version": QODER_IDE_VERSION, + "Cosy-ClientType": QODER_CLIENT_TYPE, + }, + body: JSON.stringify({ personal_token: pat }), + signal, + }, + proxyOptions, + ); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`qoder PAT exchange failed: ${res.status} ${text.slice(0, 200)}`); + } + const data = await res.json(); + if (!data.token) throw new Error("qoder PAT exchange returned no job token"); + + let expiresAt = Date.now() + PAT_DEFAULT_TTL_MS; + if (data.expires_at) { + const parsed = Date.parse(data.expires_at); + if (!Number.isNaN(parsed)) expiresAt = parsed; + } else if (typeof data.expires_in === "number" && data.expires_in > 0) { + expiresAt = Date.now() + data.expires_in; + } + return { jobToken: data.token, jobRefreshToken: data.refresh_token || "", expiresAt }; +} + +/** + * 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) { + try { + const res = await proxyAwareFetch( + QODER_USERINFO_URL, + { + method: "GET", + headers: { + Authorization: `Bearer ${jobToken}`, + Accept: "application/json", + "User-Agent": "qodercli/1.0.0", + }, + signal, + }, + proxyOptions, + ); + if (!res.ok) return ""; + const data = await res.json().catch(() => ({})); + return data.id || data.userId || data.user_id || ""; + } catch { + return ""; + } +} + +/** + * Resolve a PAT to a job-token credential, cached per-PAT. + */ +async function resolvePatCredential(pat, proxyOptions = null, signal = null) { + const cached = patJobCache.get(pat); + 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 resolved = { accessToken: jobToken, userId, expiresAt }; + patJobCache.set(pat, resolved); + return resolved; +} + +/** + * Resolve connection credentials to COSY-signable form: + * - PAT (pt-...) connections → exchanged to a job token (jt-...) + userId + * - everything else → passed through unchanged + */ +export async function resolveQoderCredentials(credentials, proxyOptions = null, signal = null) { + const raw = credentials?.apiKey || credentials?.accessToken; + if (typeof raw === "string" && raw.startsWith("pt-")) { + const resolved = await resolvePatCredential(raw, proxyOptions, signal); + return { + ...credentials, + accessToken: resolved.accessToken, + apiKey: undefined, + providerSpecificData: { + authMethod: "pat", + ...(credentials?.providerSpecificData || {}), + userId: resolved.userId || credentials?.providerSpecificData?.userId || "", + machineId: credentials?.providerSpecificData?.machineId || "", + }, + }; + } + return credentials; +} + /** * Stable cache key per credential (so different login sessions for the same * account share an entry). @@ -68,10 +190,16 @@ async function fetchQoderCatalogRaw(credentials, signal, proxyOptions = null) { 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; + const headers = { Accept: "application/json", "Accept-Encoding": "identity", - ...buildCosyHeaders(Buffer.alloc(0), QODER_MODEL_LIST_URL, creds), + ...buildCosyHeaders(Buffer.alloc(0), modelListUrl, creds), }; const controller = new AbortController(); @@ -92,7 +220,7 @@ async function fetchQoderCatalogRaw(credentials, signal, proxyOptions = null) { } } response = await proxyAwareFetch( - QODER_MODEL_LIST_URL, + modelListUrl, { method: "GET", headers, @@ -159,11 +287,16 @@ export async function getQoderModelConfig(credentials, modelKey, options = {}) { * one upstream request per credential. */ export async function resolveQoderModels(credentials, options = {}) { - if (!credentials?.accessToken) return null; - const psd = credentials.providerSpecificData || {}; - if (!psd.userId) return null; + let resolved; + try { + resolved = await resolveQoderCredentials(credentials, options.proxyOptions, options.signal); + } catch (error) { + options.log?.warn?.("QODER", `PAT exchange failed: ${error.message}`); + return null; + } + if (!resolved?.accessToken || !(resolved.providerSpecificData || {}).userId) return null; - const key = cacheKey(credentials); + const key = cacheKey(resolved); const now = Date.now(); if (!options.forceRefresh) { const cached = catalogCache.get(key); @@ -180,7 +313,7 @@ export async function resolveQoderModels(credentials, options = {}) { } const fetchPromise = (async () => { - const fetched = await fetchQoderCatalogRaw(credentials, options.signal, options.proxyOptions); + const fetched = await fetchQoderCatalogRaw(resolved, options.signal, options.proxyOptions); if (!fetched) return null; const entry = { expiresAt: Date.now() + CACHE_TTL_MS, diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index cfcc05da..93bfeca6 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -14,6 +14,7 @@ import { getCodeBuddyCnUsage, getCodeBuddyIntlUsage } from "./usage/codebuddy-cn import { getGrokCliUsage } from "./usage/grok-cli.js"; import { getKimiUsage } from "./usage/kimi.js"; import { getDeepseekUsage } from "./usage/deepseek.js"; +import { resolveQoderCredentials } from "./qoderModels.js"; import { getQwenUsage, getIflowUsage, @@ -36,7 +37,12 @@ const USAGE_HANDLERS = { claude: (c) => getClaudeUsage(c.accessToken, c.proxyOptions), codex: (c) => getCodexUsage(c.accessToken, c.proxyOptions), kiro: (c) => getKiroUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), - qoder: (c) => getQoderUsage(c.accessToken, 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); + }, qwen: (c) => getQwenUsage(c.accessToken, c.providerSpecificData), iflow: (c) => getIflowUsage(c.accessToken), ollama: (c) => getOllamaUsage(c.apiKey, c.providerSpecificData, c.proxyOptions), diff --git a/open-sse/shared/qoder/constants.js b/open-sse/shared/qoder/constants.js index 184c35d6..e2635f40 100644 --- a/open-sse/shared/qoder/constants.js +++ b/open-sse/shared/qoder/constants.js @@ -11,6 +11,9 @@ 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"; +// 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"; export const QODER_LOGIN_URL = "https://qoder.com/device/selectAccounts"; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js b/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js index 5d19bd10..e9a628db 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js @@ -13,10 +13,10 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa const isOllamaLocal = provider === "ollama-local"; const isCookie = authType === "cookie"; const isXaiApiKey = provider === "xai" && !isCookie; - const credentialLabel = isCookie ? "Cookie Value" : "API Key"; + const credentialLabel = isCookie ? "Cookie Value" : provider === "qoder" ? "Personal Access Token (PAT)" : "API Key"; const credentialPlaceholder = isCookie ? (provider === "grok-web" ? "sso=xxxxx... or just the raw value" : "eyJhbGciOi...") - : (isXaiApiKey ? "xai-..." : ""); + : (isXaiApiKey ? "xai-..." : provider === "qoder" ? "pt-..." : ""); const isAzure = provider === "azure"; const isCloudflareAi = provider === "cloudflare-ai"; @@ -44,7 +44,9 @@ 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` - : BULK_PLACEHOLDER; + : provider === "qoder" + ? `name1|pt-xxxxx\nname2|pt-yyyyy\npt-only-auto-named` + : BULK_PLACEHOLDER; const [mode, setMode] = useState("single"); // "single" | "bulk" const [bulkText, setBulkText] = useState(""); @@ -145,6 +147,21 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa let failed = 0; for (const entry of plan) { try { + // Validate each key before saving so bulk-added connections get a + // real status (active/unknown) like single adds, instead of a + // hardcoded "unknown" that never flips until a manual test. + let isValid = false; + try { + const vres = await fetch("/api/providers/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider, apiKey: entry.apiKey }), + }); + const vdata = await vres.json().catch(() => ({})); + isValid = !!vdata.valid; + } catch { + isValid = false; + } const res = await fetch("/api/providers", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -153,7 +170,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa apiKey: entry.apiKey, name: entry.name, priority: 1, - testStatus: "unknown", + testStatus: isValid ? "active" : "unknown", ...(entry.providerSpecificData ? { providerSpecificData: entry.providerSpecificData } : {}), }), }); @@ -184,7 +201,9 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa

{isCloudflareAi ? <>One key per line. Format: name|apiKey|accountId or just apiKey (auto-named by index). - : <>One key per line. Format: name|apiKey or just apiKey (auto-named by index). + : provider === "qoder" + ? <>One PAT per line. Format: name|pt-... or just pt-... (auto-named by index). + : <>One key per line. Format: name|apiKey or just apiKey (auto-named by index). }