diff --git a/open-sse/executors/qoder.js b/open-sse/executors/qoder.js index 912e9995..f8691ef5 100644 --- a/open-sse/executors/qoder.js +++ b/open-sse/executors/qoder.js @@ -34,13 +34,9 @@ import { QODER_CHAT_URL_ENCODED, QODER_CHAT_BASE_ALT, QODER_CHAT_SIG_PATH, - QODER_JOB_TOKEN_EXCHANGE_URL, - QODER_USERINFO_URL, QODER_MODEL_MAP, - QODER_IDE_VERSION, - QODER_CLIENT_TYPE, } from "../shared/qoder/constants.js"; -import { getQoderModelConfig, resolveQoderModels } from "../services/qoderModels.js"; +import { getQoderModelConfig, resolveQoderModels, isQoderPat, resolveQoderCredentials } from "../services/qoderModels.js"; /** * Hoist role:"system" messages out of the messages array (Qoder rejects @@ -344,92 +340,6 @@ function wrapQoderSSE(response, model) { }); } -// ── PAT (Personal Access Token) → job-token exchange ─────────────────────── -// PATs (pt-...) cannot sign COSY requests directly. Exchange them for a -// short-lived job token (jt-...) via /api/v1/jobToken/exchange (plain JSON, -// not COSY-signed), then resolve the userId from userinfo. Mirrors the -// official qodercli flow. Cached per-PAT until near-expiry. -const PAT_PREFIX = "pt-"; -const PAT_REFRESH_BUFFER_MS = 5 * 60 * 1000; -const patJobCache = new Map(); - -export function isQoderPat(token) { - return typeof token === "string" && token.startsWith(PAT_PREFIX); -} - -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() + 24 * 60 * 60 * 1000; - 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 }; -} - -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 info = await res.json().catch(() => ({})); - return info.id || info.userId || info.user_id || ""; - } catch { - return ""; - } -} - -/** - * Exchange a PAT for a job token + userId, caching until near-expiry so repeat - * chat requests don't re-exchange. Returns { accessToken, userId }. - */ -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 entry = { accessToken: jobToken, userId, expiresAt }; - patJobCache.set(pat, entry); - return entry; -} - export class QoderExecutor extends BaseExecutor { constructor() { super("qoder", PROVIDERS.qoder); @@ -457,18 +367,7 @@ export class QoderExecutor extends BaseExecutor { const rawToken = credentials?.apiKey || credentials?.accessToken; if (isQoderPat(rawToken)) { try { - const resolved = await resolvePatCredential(rawToken, proxyOptions, signal); - credentials = { - ...credentials, - accessToken: resolved.accessToken, - apiKey: undefined, - providerSpecificData: { - authMethod: "pat", - ...(credentials?.providerSpecificData || {}), - userId: resolved.userId || credentials?.providerSpecificData?.userId || "", - machineId: credentials?.providerSpecificData?.machineId || "", - }, - }; + credentials = await resolveQoderCredentials(credentials, proxyOptions, signal); } catch (err) { log?.error?.("QODER", `PAT exchange failed: ${err.message}`); const fakeResp = new Response( @@ -597,6 +496,4 @@ export const __test__ = { normalizeMessages, wrapQoderSSE, buildQoderRequestBody, - isQoderPat, - resolvePatCredential, }; diff --git a/open-sse/services/qoderModels.js b/open-sse/services/qoderModels.js index 930461af..572931e5 100644 --- a/open-sse/services/qoderModels.js +++ b/open-sse/services/qoderModels.js @@ -34,11 +34,17 @@ import { const FETCH_TIMEOUT_MS = 15_000; const CACHE_TTL_MS = 60 * 60 * 1000; // 1h, same as the Kiro catalog +const PAT_PREFIX = "pt-"; + // 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; +export function isQoderPat(token) { + return typeof token === "string" && token.startsWith(PAT_PREFIX); +} + /** @type {Map} */ const patJobCache = new Map(); @@ -139,7 +145,7 @@ async function resolvePatCredential(pat, proxyOptions = null, signal = null) { */ export async function resolveQoderCredentials(credentials, proxyOptions = null, signal = null) { const raw = credentials?.apiKey || credentials?.accessToken; - if (typeof raw === "string" && raw.startsWith("pt-")) { + if (isQoderPat(raw)) { const resolved = await resolvePatCredential(raw, proxyOptions, signal); return { ...credentials, diff --git a/src/app/api/providers/validate/route.js b/src/app/api/providers/validate/route.js index 0be11b1e..e9f8f400 100644 --- a/src/app/api/providers/validate/route.js +++ b/src/app/api/providers/validate/route.js @@ -4,6 +4,7 @@ import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider, isCustomEmbe import { getDefaultModel } from "open-sse/config/providerModels.js"; import { resolveOllamaLocalHost, resolveXiaomiTokenplanBaseUrl, PROVIDERS } from "open-sse/config/providers.js"; import { openaiToCommandCodeRequest } from "open-sse/translator/request/openai-to-commandcode.js"; +import { resolveQoderCredentials, resolveQoderModels } from "open-sse/services/qoderModels.js"; import { normalizeProviderId } from "@/lib/providerNormalization"; // Probe a webSearch/webFetch provider using its searchConfig/fetchConfig. @@ -581,6 +582,20 @@ export async function POST(request) { break; } + case "qoder": { + // 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 result = await resolveQoderModels(resolved, { forceRefresh: true }); + isValid = !!result?.models?.length; + } catch (err) { + isValid = false; + error = err.message; + } + break; + } + default: { // Generic probe for OpenAI-compatible providers (config-driven from PROVIDERS) const cfg = PROVIDERS[provider];