refactor(qoder): dedupe PAT exchange logic, validate PAT keys properly
PAT-to-job-token exchange was duplicated between the executor and the model service, each with its own cache. Consolidate into qoderModels.js and have the executor import it. Also add a qoder case to the API-key validate route - the generic OpenAI-compat probe cannot validate a PAT (needs job-token exchange + COSY signing first), so bulk-add always reported unknown for qoder keys.
This commit is contained in:
@@ -34,13 +34,9 @@ import {
|
|||||||
QODER_CHAT_URL_ENCODED,
|
QODER_CHAT_URL_ENCODED,
|
||||||
QODER_CHAT_BASE_ALT,
|
QODER_CHAT_BASE_ALT,
|
||||||
QODER_CHAT_SIG_PATH,
|
QODER_CHAT_SIG_PATH,
|
||||||
QODER_JOB_TOKEN_EXCHANGE_URL,
|
|
||||||
QODER_USERINFO_URL,
|
|
||||||
QODER_MODEL_MAP,
|
QODER_MODEL_MAP,
|
||||||
QODER_IDE_VERSION,
|
|
||||||
QODER_CLIENT_TYPE,
|
|
||||||
} from "../shared/qoder/constants.js";
|
} 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
|
* 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 {
|
export class QoderExecutor extends BaseExecutor {
|
||||||
constructor() {
|
constructor() {
|
||||||
super("qoder", PROVIDERS.qoder);
|
super("qoder", PROVIDERS.qoder);
|
||||||
@@ -457,18 +367,7 @@ export class QoderExecutor extends BaseExecutor {
|
|||||||
const rawToken = credentials?.apiKey || credentials?.accessToken;
|
const rawToken = credentials?.apiKey || credentials?.accessToken;
|
||||||
if (isQoderPat(rawToken)) {
|
if (isQoderPat(rawToken)) {
|
||||||
try {
|
try {
|
||||||
const resolved = await resolvePatCredential(rawToken, proxyOptions, signal);
|
credentials = await resolveQoderCredentials(credentials, proxyOptions, signal);
|
||||||
credentials = {
|
|
||||||
...credentials,
|
|
||||||
accessToken: resolved.accessToken,
|
|
||||||
apiKey: undefined,
|
|
||||||
providerSpecificData: {
|
|
||||||
authMethod: "pat",
|
|
||||||
...(credentials?.providerSpecificData || {}),
|
|
||||||
userId: resolved.userId || credentials?.providerSpecificData?.userId || "",
|
|
||||||
machineId: credentials?.providerSpecificData?.machineId || "",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log?.error?.("QODER", `PAT exchange failed: ${err.message}`);
|
log?.error?.("QODER", `PAT exchange failed: ${err.message}`);
|
||||||
const fakeResp = new Response(
|
const fakeResp = new Response(
|
||||||
@@ -597,6 +496,4 @@ export const __test__ = {
|
|||||||
normalizeMessages,
|
normalizeMessages,
|
||||||
wrapQoderSSE,
|
wrapQoderSSE,
|
||||||
buildQoderRequestBody,
|
buildQoderRequestBody,
|
||||||
isQoderPat,
|
|
||||||
resolvePatCredential,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,11 +34,17 @@ import {
|
|||||||
const FETCH_TIMEOUT_MS = 15_000;
|
const FETCH_TIMEOUT_MS = 15_000;
|
||||||
const CACHE_TTL_MS = 60 * 60 * 1000; // 1h, same as the Kiro catalog
|
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 → 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.
|
// PAT and re-exchange once it is within 5 minutes of expiry.
|
||||||
const PAT_REFRESH_BUFFER_MS = 5 * 60 * 1000;
|
const PAT_REFRESH_BUFFER_MS = 5 * 60 * 1000;
|
||||||
const PAT_DEFAULT_TTL_MS = 24 * 60 * 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<string, { accessToken: string, userId: string, expiresAt: number }>} */
|
/** @type {Map<string, { accessToken: string, userId: string, expiresAt: number }>} */
|
||||||
const patJobCache = new 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) {
|
export async function resolveQoderCredentials(credentials, proxyOptions = null, signal = null) {
|
||||||
const raw = credentials?.apiKey || credentials?.accessToken;
|
const raw = credentials?.apiKey || credentials?.accessToken;
|
||||||
if (typeof raw === "string" && raw.startsWith("pt-")) {
|
if (isQoderPat(raw)) {
|
||||||
const resolved = await resolvePatCredential(raw, proxyOptions, signal);
|
const resolved = await resolvePatCredential(raw, proxyOptions, signal);
|
||||||
return {
|
return {
|
||||||
...credentials,
|
...credentials,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider, isCustomEmbe
|
|||||||
import { getDefaultModel } from "open-sse/config/providerModels.js";
|
import { getDefaultModel } from "open-sse/config/providerModels.js";
|
||||||
import { resolveOllamaLocalHost, resolveXiaomiTokenplanBaseUrl, PROVIDERS } from "open-sse/config/providers.js";
|
import { resolveOllamaLocalHost, resolveXiaomiTokenplanBaseUrl, PROVIDERS } from "open-sse/config/providers.js";
|
||||||
import { openaiToCommandCodeRequest } from "open-sse/translator/request/openai-to-commandcode.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";
|
import { normalizeProviderId } from "@/lib/providerNormalization";
|
||||||
|
|
||||||
// Probe a webSearch/webFetch provider using its searchConfig/fetchConfig.
|
// Probe a webSearch/webFetch provider using its searchConfig/fetchConfig.
|
||||||
@@ -581,6 +582,20 @@ export async function POST(request) {
|
|||||||
break;
|
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: {
|
default: {
|
||||||
// Generic probe for OpenAI-compatible providers (config-driven from PROVIDERS)
|
// Generic probe for OpenAI-compatible providers (config-driven from PROVIDERS)
|
||||||
const cfg = PROVIDERS[provider];
|
const cfg = PROVIDERS[provider];
|
||||||
|
|||||||
Reference in New Issue
Block a user