feat(qoder): support PAT (Personal Access Token) connections end-to-end
Adds pt-... token auth as an alternative to OAuth device flow. A PAT can't sign COSY requests directly, so it's exchanged for a short-lived job token (jt-...) plus userId via openapi.qoder.sh, then used for signing. Also fixes job-token traffic (jt-...) being rejected by api3.qoder.sh with 403 "Login expired" — the official qodercli serves jt- traffic from api2.qoder.sh instead, so buildUrl/model-list routing now branches on it. Quota usage and the dashboard add-key modal are updated to resolve PAT credentials and label the field correctly, and bulk-add now validates each key so it gets a real testStatus instead of a hardcoded "unknown".
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -52,5 +52,7 @@ export default {
|
||||
},
|
||||
features: {
|
||||
usage: true,
|
||||
// PAT (apikey) connections also carry quota usage (via job-token exchange).
|
||||
usageApikey: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<string, { accessToken: string, userId: string, expiresAt: number }>} */
|
||||
const patJobCache = new Map();
|
||||
|
||||
/** @type {Map<string, { expiresAt: number, models: any[], rawConfigs: Map<string, object>, 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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
<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).</>
|
||||
: <>One key per line. Format: <code>name|apiKey</code> or just <code>apiKey</code> (auto-named by index).</>
|
||||
: provider === "qoder"
|
||||
? <>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).</>
|
||||
}
|
||||
</p>
|
||||
<textarea
|
||||
|
||||
@@ -161,6 +161,7 @@ export default function ProviderDetailPage() {
|
||||
const apiKeyConnectionLabel =
|
||||
providerId === "xai" ? "xAI API Key"
|
||||
: providerId === "kimi" ? "Kimi API Key"
|
||||
: providerId === "qoder" ? "PAT"
|
||||
: "API Key";
|
||||
// Resolve suffix "(level)" for a model when a thinking level is picked and the model supports it.
|
||||
const resolveThinkingSuffix = (modelId) => {
|
||||
|
||||
@@ -356,6 +356,7 @@ const PROVIDER_MODELS_CONFIG = {
|
||||
customResolver: async (connection) => {
|
||||
const credentials = {
|
||||
accessToken: connection.accessToken,
|
||||
apiKey: connection.apiKey,
|
||||
refreshToken: connection.refreshToken,
|
||||
email: connection.email,
|
||||
displayName: connection.displayName,
|
||||
|
||||
Reference in New Issue
Block a user