Files
2026-07-13 13:39:12 +08:00

988 lines
32 KiB
TypeScript

import { spawn } from "child_process";
import crypto from "crypto";
import fs from "fs";
import os from "os";
import path from "path";
import { getLookupEnv } from "@/shared/services/cliRuntime";
import { buildQoderCliNotFoundHint, resolveQoderCliInvocation } from "./qoderCliResolve";
export { getQoderCliCommand } from "./qoderCliResolve"; // #6263 public entry point
const DEFAULT_TIMEOUT_MS = 45_000;
const DEFAULT_MODELS_TIMEOUT_MS = 20_000;
const QODER_DEFAULT_MODEL = "qoder-rome-30ba3b";
export const QODER_STATIC_MODELS = [
{ id: "qoder-rome-30ba3b", name: "Qoder ROME" },
{ id: "glm-5.2", name: "GLM-5.2" },
{ id: "minimax-m3", name: "MiniMax M3" },
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "qwen3-max", name: "Qwen3 Max" },
{ id: "qwen3-vl-plus", name: "Qwen3 Vision Plus" },
{ id: "kimi-k2-0905", name: "Kimi K2 0905" },
{ id: "qwen3-max-preview", name: "Qwen3 Max Preview" },
{ id: "kimi-k2", name: "Kimi K2" },
{ id: "deepseek-v3.2", name: "DeepSeek V3.2" },
{ id: "deepseek-r1", name: "DeepSeek R1" },
{ id: "deepseek-v3", name: "DeepSeek V3" },
{ id: "qwen3-32b", name: "Qwen3 32B" },
{ id: "qwen3-235b-a22b-thinking-2507", name: "Qwen3 235B A22B Thinking 2507" },
{ id: "qwen3-235b-a22b-instruct", name: "Qwen3 235B A22B Instruct" },
{ id: "qwen3-235b", name: "Qwen3 235B" },
];
type JsonRecord = Record<string, unknown>;
type QoderCliRunOptions = {
token: string;
prompt: string;
stream: boolean;
model?: string | null;
workspace?: string | null;
command?: string | null;
signal?: AbortSignal | null;
timeoutMs?: number;
};
type QoderCliRunResult = {
ok: boolean;
code: number | null;
stdout: string;
stderr: string;
timedOut: boolean;
error: string | null;
};
type QoderCliFailure = {
status: number;
message: string;
code: string;
};
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
function getString(value: unknown): string {
return typeof value === "string" ? value : "";
}
export function getQoderCliWorkspace(): string {
const explicit = String(
process.env.QODER_CLI_WORKSPACE || process.env.OMNIROUTE_QODER_WORKSPACE || ""
).trim();
if (explicit) return explicit;
const home = String(process.env.HOME || "").trim();
return home || process.cwd();
}
/**
* Isolated `--config-dir` for OmniRoute-driven qodercli runs. Keeping it separate
* from the operator's own `~/.qoder` avoids polluting an interactive qodercli
* session and lets each PAT authenticate via `QODER_PERSONAL_ACCESS_TOKEN`
* without clobbering a browser login. Override with `QODER_CLI_CONFIG_DIR`.
*/
export function getQoderCliConfigDir(): string {
const explicit = String(process.env.QODER_CLI_CONFIG_DIR || "").trim();
if (explicit) return explicit;
const dataDir = String(process.env.DATA_DIR || "").trim();
const base = dataDir || path.join(os.homedir() || os.tmpdir(), ".omniroute");
return path.join(base, "qoder-cli");
}
// Memoized per resolved path so we don't hit synchronous disk I/O
// (fs.mkdirSync blocks the event loop) on every chat/quota request.
const ensuredQoderCliConfigDirs = new Set<string>();
/** Ensure the qodercli config dir exists so it is a valid spawn cwd + cache root. */
function ensureQoderCliConfigDir(): string {
const dir = getQoderCliConfigDir();
if (ensuredQoderCliConfigDirs.has(dir)) return dir;
try {
fs.mkdirSync(dir, { recursive: true });
ensuredQoderCliConfigDirs.add(dir);
} catch {
/* best-effort — spawn will surface a real failure */
}
return dir;
}
type SpawnQoderCliOptions = {
args: string[];
token?: string | null;
stdin?: string | null;
signal?: AbortSignal | null;
timeoutMs?: number;
command?: string | null;
cwd?: string | null;
};
/**
* Low-level qodercli spawn. The PAT (if any) is passed via the
* `QODER_PERSONAL_ACCESS_TOKEN` env var — the only env var the official CLI
* honors for headless PAT auth — and the prompt is piped through stdin so no
* untrusted value is ever interpolated into a shell command (Hard Rule #13).
*/
async function spawnQoderCli(options: SpawnQoderCliOptions): Promise<QoderCliRunResult> {
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
// #6263: resolve the real qodercli command (absolute .cmd/.exe on Windows) and
// whether it needs a shell, then spawn with the cliRuntime-enriched env (PATH +
// PATHEXT + APPDATA) so the npm `.cmd` wrapper under %APPDATA%\npm is found.
const { command, useShell } = await resolveQoderCliInvocation(options.command);
const env: NodeJS.ProcessEnv = { ...getLookupEnv() };
const token = String(options.token || "").trim();
if (token) env.QODER_PERSONAL_ACCESS_TOKEN = token;
return new Promise<QoderCliRunResult>((resolve) => {
let stdout = "";
let stderr = "";
let settled = false;
let timedOut = false;
let child: ReturnType<typeof spawn>;
try {
child = spawn(command, options.args, {
env,
cwd: options.cwd || undefined,
stdio: ["pipe", "pipe", "pipe"],
...(useShell ? { shell: true } : {}),
});
} catch (err) {
resolve({
ok: false,
code: null,
stdout: "",
stderr: "",
timedOut: false,
error: (err as Error).message,
});
return;
}
const timer = setTimeout(() => {
timedOut = true;
try {
child.kill("SIGKILL");
} catch {
/* already gone */
}
}, timeoutMs);
timer.unref?.();
const onAbort = () => {
try {
child.kill("SIGKILL");
} catch {
/* already gone */
}
};
if (options.signal) {
if (options.signal.aborted) onAbort();
else options.signal.addEventListener("abort", onAbort, { once: true });
}
const finish = (result: QoderCliRunResult) => {
if (settled) return;
settled = true;
clearTimeout(timer);
options.signal?.removeEventListener?.("abort", onAbort);
resolve(result);
};
child.on("error", (err: Error) => {
finish({ ok: false, code: null, stdout, stderr, timedOut, error: err.message });
});
// If qodercli exits or closes stdin before we finish writing the prompt, the
// write/end below can emit an ASYNC EPIPE/EINVAL on the stream (not caught by
// the surrounding try/catch). Without a listener that becomes an unhandled
// 'error' event that crashes the whole process — attach no-op handlers.
child.stdin?.on("error", () => {});
child.stdout?.on("error", () => {});
child.stderr?.on("error", () => {});
// Decode with a stateful UTF-8 reader so a multi-byte character (e.g. Chinese,
// common in Qoder output) split across two chunks is not corrupted — Buffer
// per-chunk toString() would mangle the boundary bytes.
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr?.on("data", (chunk: string) => {
stderr += chunk;
});
child.on("close", (code: number | null) => {
finish({
ok: code === 0 && !timedOut,
code,
stdout,
stderr,
timedOut,
error: timedOut ? "qodercli timed out" : null,
});
});
try {
if (options.stdin != null) child.stdin?.write(options.stdin);
child.stdin?.end();
} catch {
/* stdin closed early — the child will surface its own error */
}
});
}
/**
* Run a single non-interactive chat turn through qodercli. Returns the raw
* process result; use {@link parseQoderCliResult} to extract the reply text.
*/
export async function runQoderCli(options: QoderCliRunOptions): Promise<QoderCliRunResult> {
const level = await resolveQoderCliModel(options.model, options.token, {
command: options.command,
signal: options.signal,
});
const configDir = ensureQoderCliConfigDir();
const cwd = String(options.workspace || "").trim() || configDir;
const args = [
"--print",
"--output-format",
"json",
"--model",
level,
// Disable all built-in tools — OmniRoute only wants a plain LM reply, never
// file-system access or command execution from the proxied CLI.
"--tools",
"",
"--config-dir",
configDir,
];
return spawnQoderCli({
args,
token: options.token,
stdin: options.prompt,
signal: options.signal,
timeoutMs: options.timeoutMs,
command: options.command,
cwd,
});
}
/**
* List the models qodercli can reach for the given PAT. Used as a cheap
* connection/credential check (no chat tokens are consumed).
*/
export async function listQoderCliModels(
options: {
token?: string | null;
signal?: AbortSignal | null;
timeoutMs?: number;
command?: string | null;
} = {}
): Promise<QoderCliRunResult> {
const configDir = ensureQoderCliConfigDir();
return spawnQoderCli({
args: ["--list-models", "--config-dir", configDir],
token: options.token,
signal: options.signal,
timeoutMs: options.timeoutMs ?? DEFAULT_MODELS_TIMEOUT_MS,
command: options.command,
cwd: configDir,
});
}
/** Normalize a model id / display name so "glm-5.2" and "GLM-5.2" compare equal. */
export function normalizeQoderModelKey(value: unknown): string {
return String(value || "")
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
}
/** Extract the display names from a `qodercli --list-models` table. */
export function parseQoderCliModelNames(stdout: string): string[] {
return String(stdout || "")
.split("\n")
.map((line) => line.replace(/\[[0-9;]*m/g, "").trim()) // strip ANSI colors
.filter(
(line) =>
line.length > 0 &&
line.toLowerCase() !== "model" && // header row
!/invalid model|not logged in|please run|available model keys/i.test(line)
);
}
/**
* Resolve an OmniRoute model id to the exact value to pass to `qodercli -m`.
* Pure (no I/O) so it can be unit-tested against a captured model list.
*
* Preference order:
* 1. A live `--list-models` display name (case-insensitive, punctuation-insensitive)
* — qodercli accepts these directly and they track upstream renames of the
* opaque internal level keys.
* 2. The static family map (level keys) — used when the live list is unavailable
* or has no match.
* 3. "Auto".
*/
export function resolveQoderModelName(
requested: string | null | undefined,
availableNames: string[]
): string {
const normalized = normalizeQoderModelKey(requested);
if (!normalized) return "auto";
const match = (availableNames || []).find((name) => normalizeQoderModelKey(name) === normalized);
if (match) return match;
return mapQoderModelToLevel(requested) || "auto";
}
// Per-token cache of the `--list-models` display names (the catalog is stable and
// per-account); TTL keeps it fresh without a CLI spawn on every request.
const QODER_MODEL_LIST_TTL_MS = 10 * 60 * 1000;
type QoderModelNamesCacheEntry = { names: string[]; expiresAt: number };
const qoderModelNamesCache = new Map<string, QoderModelNamesCacheEntry>();
const qoderModelNamesPending = new Map<string, Promise<string[]>>();
async function getCachedQoderCliModelNames(
token?: string | null,
options: { command?: string | null; signal?: AbortSignal | null; now?: number } = {}
): Promise<string[]> {
const key = String(token || "").trim() || "default";
const now = options.now ?? Date.now();
const cached = qoderModelNamesCache.get(key);
if (cached && cached.expiresAt > now) return cached.names;
let pending = qoderModelNamesPending.get(key);
if (!pending) {
pending = listQoderCliModels({ token, command: options.command, signal: options.signal })
.then((run) => {
const names = run.ok ? parseQoderCliModelNames(run.stdout) : [];
// Only cache a non-empty success; a failed/empty list should retry next time.
if (names.length > 0) {
qoderModelNamesCache.set(key, { names, expiresAt: now + QODER_MODEL_LIST_TTL_MS });
}
return names;
})
.catch(() => [] as string[])
.finally(() => qoderModelNamesPending.delete(key));
qoderModelNamesPending.set(key, pending);
}
return pending;
}
/** Async resolver: matches the request against the live (cached) `--list-models`. */
export async function resolveQoderCliModel(
requested: string | null | undefined,
token?: string | null,
options: { command?: string | null; signal?: AbortSignal | null } = {}
): Promise<string> {
let names: string[] = [];
try {
names = await getCachedQoderCliModelNames(token, options);
} catch {
names = [];
}
return resolveQoderModelName(requested, names);
}
/** Test-only: drop the cached `--list-models` names so unit tests don't leak state. */
export function __clearQoderModelNamesCache(): void {
qoderModelNamesCache.clear();
qoderModelNamesPending.clear();
}
/**
* Parse the `--output-format json` envelope qodercli prints in print mode. The
* CLI may emit banner/log lines before the JSON, so we fall back to scanning for
* the last JSON object line. Returns the assistant text plus an error flag.
*/
export function parseQoderCliResult(stdout: string): {
text: string;
isError: boolean;
errorMessage: string;
} {
const trimmed = String(stdout || "").trim();
if (!trimmed) {
return { text: "", isError: true, errorMessage: "qodercli produced no output" };
}
let parsed: JsonRecord | null = null;
try {
const whole = JSON.parse(trimmed);
if (whole && typeof whole === "object") parsed = whole as JsonRecord;
} catch {
for (const line of trimmed.split("\n").reverse()) {
const candidate = line.trim();
if (!candidate.startsWith("{")) continue;
try {
const obj = JSON.parse(candidate);
if (obj && typeof obj === "object") {
parsed = obj as JsonRecord;
break;
}
} catch {
/* keep scanning earlier lines */
}
}
}
if (!parsed) {
return { text: "", isError: true, errorMessage: trimmed.slice(0, 300) };
}
const result = getString(parsed.result);
const isError =
parsed.is_error === true || getString(parsed.subtype).trim().toLowerCase() === "error";
return {
text: result,
isError,
errorMessage: isError ? result || "qodercli returned an error" : "",
};
}
export function normalizeQoderPatProviderData(providerSpecificData: JsonRecord = {}): JsonRecord {
return {
...providerSpecificData,
authMode: "pat",
transport: "qodercli",
};
}
export function isQoderCliTransport(providerSpecificData: unknown = {}): boolean {
const data = asRecord(providerSpecificData);
const transport = getString(data.transport).trim().toLowerCase();
const authMode = getString(data.authMode).trim().toLowerCase();
if (transport === "http-legacy") return false;
return transport === "qodercli" || authMode === "pat";
}
export function getStaticQoderModels() {
return QODER_STATIC_MODELS.map((model) => ({ ...model }));
}
/** qodercli's `-m` accepts these level keys (see `qodercli --list-models`). */
const QODER_LEVEL_KEYS = new Set([
"auto",
"ultimate",
"performance",
"efficient",
"lite",
"q35model_preview",
"qmodel_latest",
"qmodel",
"gm51model",
"kmodel",
"dmodel",
"dfmodel",
"mmodel",
]);
export function mapQoderModelToLevel(model: string | null | undefined): string | null {
const normalized = String(model || "")
.trim()
.toLowerCase();
if (!normalized) return null;
// A caller may pass a qodercli level key directly (e.g. "gm51model") — honor it.
if (QODER_LEVEL_KEYS.has(normalized)) return normalized;
if (normalized.includes("deepseek-r1")) return "ultimate";
if (normalized.includes("glm")) return "gm51model"; // GLM-5.2 (`qoder/glm-5.2`)
if (normalized.includes("minimax")) return "mmodel";
if (normalized.includes("qwen3-max")) return "performance";
if (normalized.includes("kimi-k2")) return "kmodel";
if (normalized.includes("qwen3-coder")) return "qmodel";
if (normalized.includes("qoder-rome")) return "qmodel";
return "auto";
}
function flattenMessageContent(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map((item) => {
if (typeof item === "string") return item;
if (!item || typeof item !== "object") return "";
const record = item as JsonRecord;
const itemType = getString(record.type);
if (itemType === "text" || itemType === "input_text") {
return getString(record.text);
}
if (itemType === "image_url" || itemType === "input_image") {
return "[Image omitted]";
}
return "";
})
.filter(Boolean)
.join("\n");
}
function formatMessage(message: unknown): string {
if (!message || typeof message !== "object") return "";
const record = message as JsonRecord;
const role = getString(record.role).trim().toUpperCase() || "UNKNOWN";
const base = flattenMessageContent(record.content);
if (role === "TOOL") {
const toolName = getString(record.name).trim();
return `TOOL${toolName ? ` (${toolName})` : ""}:\n${base}`.trim();
}
const toolCalls = Array.isArray(record.tool_calls) ? record.tool_calls : [];
if (toolCalls.length > 0) {
const toolLines = toolCalls
.map((toolCall) => {
const toolRecord = asRecord(toolCall);
const functionRecord = asRecord(toolRecord.function);
const toolName =
getString(functionRecord.name).trim() || getString(toolRecord.name).trim() || "tool";
const toolArgs =
getString(functionRecord.arguments).trim() || getString(toolRecord.arguments).trim();
return `TOOL_CALL ${toolName}: ${toolArgs}`.trim();
})
.filter(Boolean)
.join("\n");
return `${role}:\n${base}\n${toolLines}`.trim();
}
return `${role}:\n${base}`.trim();
}
export function buildQoderPrompt(body: unknown): string {
const requestBody = asRecord(body);
const lines = [
"You are answering an OmniRoute OpenAI-compatible request through the Qoder CLI transport.",
"Respond as a plain language model only.",
"Do not use your own tools, do not inspect files, and do not run commands.",
"Do not mention the adapter unless the user explicitly asks.",
];
const tools = Array.isArray(requestBody.tools) ? requestBody.tools : [];
if (tools.length > 0) {
const toolNames = tools
.map((tool) => {
const toolRecord = asRecord(tool);
const functionRecord =
toolRecord.type === "function" ? asRecord(toolRecord.function) : toolRecord;
return getString(functionRecord.name).trim();
})
.filter(Boolean)
.join(", ");
if (toolNames) {
lines.push(`Caller-side tools are available externally: ${toolNames}.`);
lines.push("Do not call those tools yourself. Answer in assistant text only.");
}
}
const responseFormat = asRecord(requestBody.response_format);
if (responseFormat.type === "json_object") {
lines.push("Return only valid JSON.");
} else if (
responseFormat.type === "json_schema" &&
responseFormat.json_schema &&
typeof responseFormat.json_schema === "object"
) {
const jsonSchema = asRecord(responseFormat.json_schema);
if (jsonSchema.schema && typeof jsonSchema.schema === "object") {
lines.push(
`Return only valid JSON matching this schema:\n${JSON.stringify(jsonSchema.schema, null, 2)}`
);
}
}
const messages = Array.isArray(requestBody.messages)
? requestBody.messages
: Array.isArray(requestBody.input)
? requestBody.input
: [];
if (messages.length > 0) {
lines.push("Conversation transcript:");
for (const message of messages) {
const formatted = formatMessage(message);
if (formatted) lines.push(formatted);
}
}
lines.push("Reply now with the assistant response only.");
return lines.filter(Boolean).join("\n\n");
}
export function extractTextFromQoderEnvelope(parsed: unknown): string {
const record = asRecord(parsed);
const messageRecord = asRecord(record.message);
const content = messageRecord.content ?? record.content ?? record.delta ?? record.text ?? null;
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map((item) => {
const itemRecord = asRecord(item);
const itemType = getString(itemRecord.type).trim();
if (itemType === "text" || !itemType) {
return getString(itemRecord.text);
}
return "";
})
.filter(Boolean)
.join("");
}
export function buildQoderCompletionPayload({
model,
text,
}: {
model?: string | null;
text: string;
}) {
const created = Math.floor(Date.now() / 1000);
return {
id: `chatcmpl-${crypto.randomUUID()}`,
object: "chat.completion",
created,
model: model || QODER_DEFAULT_MODEL,
choices: [
{
index: 0,
message: {
role: "assistant",
content: text,
},
finish_reason: "stop",
},
],
usage: {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
},
};
}
export function buildQoderChunk({
id,
model,
created,
delta,
finishReason = null,
}: {
id: string;
model: string;
created: number;
delta: Record<string, unknown>;
finishReason?: string | null;
}) {
return {
id,
object: "chat.completion.chunk",
created,
model,
choices: [
{
index: 0,
delta,
finish_reason: finishReason,
},
],
};
}
export function parseQoderCliFailure(stderrText: string, stdoutText = ""): QoderCliFailure {
const stderr = String(stderrText || "").trim();
const stdout = String(stdoutText || "").trim();
const combined = `${stderr}\n${stdout}`.trim() || "Qoder API request failed";
const normalized = combined.toLowerCase();
if (
normalized.includes("invalid api key") ||
normalized.includes("invalid token") ||
normalized.includes("invalid personal token") ||
normalized.includes("personal access token") ||
normalized.includes("personal token format") ||
normalized.includes("exchangejobtoken failed") ||
normalized.includes("not logged in") ||
normalized.includes("please run /login") ||
normalized.includes("login required") ||
(normalized.includes("unauthorized") && normalized.includes("qoder"))
) {
return { status: 401, message: combined, code: "upstream_auth_error" };
}
if (normalized.includes("timed out") || normalized.includes("timeout")) {
return { status: 504, message: combined, code: "timeout" };
}
return { status: 502, message: combined, code: "upstream_error" };
}
export function createQoderErrorResponse(failure: QoderCliFailure): Response {
return new Response(
JSON.stringify({
error: {
message: failure.message,
type: failure.status === 401 ? "authentication_error" : "provider_error",
code: failure.code,
},
}),
{
status: failure.status,
headers: {
"Content-Type": "application/json",
},
}
);
}
const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l
6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17
XcW+ML9FoCI6AOvOzwIDAQAB
-----END PUBLIC KEY-----`;
export function buildCosyHeadersForValidation(bodyStr: string, token: string) {
const aesKeyBytes = crypto.randomBytes(16);
const aesKeyStr = aesKeyBytes.toString("hex").slice(0, 16);
const aesKeyBuf = Buffer.from(aesKeyStr, "utf8");
const uid = "omniroute.user@qoder.sh";
const userInfo = {
uid: uid,
security_oauth_token: token,
name: "omniroute",
aid: "",
email: uid,
};
const cipher = crypto.createCipheriv("aes-128-cbc", aesKeyBuf, aesKeyBuf);
let ciphertext = cipher.update(JSON.stringify(userInfo), "utf8", "base64");
ciphertext += cipher.final("base64");
const encryptedKeyBuf = crypto.publicEncrypt(
{ key: PUBLIC_KEY, padding: crypto.constants.RSA_PKCS1_PADDING },
aesKeyBuf
);
const cosyKeyB64 = encryptedKeyBuf.toString("base64");
const timestamp = Math.floor(Date.now() / 1000).toString();
const payloadStr = JSON.stringify({
version: "v1",
requestId: crypto.randomUUID(),
info: ciphertext,
cosyVersion: "0.12.3",
ideVersion: "",
});
const payloadB64 = Buffer.from(payloadStr).toString("base64");
const sigPath = "/api/v2/service/pro/sse/agent_chat_generation";
const sigInput = `${payloadB64}\n${cosyKeyB64}\n${timestamp}\n${bodyStr}\n${sigPath}`;
const sig = crypto.createHash("md5").update(sigInput).digest("hex");
return {
Authorization: `Bearer COSY.${payloadB64}.${sig}`,
"Cosy-Key": cosyKeyB64,
"Cosy-User": uid,
"Cosy-Date": timestamp,
"Content-Type": "application/json",
};
}
// #4683: Qoder PATs (`pt-*`) cannot be used directly as the Cosy
// `security_oauth_token`. The official qodercli performs a two-step flow: it first
// exchanges the PAT for a short-lived job token (`jt-*`) at
// `openapi.qoder.sh/api/v1/jobToken/exchange`, then carries that `jt-*` in the Cosy
// envelope for chat. Passing the raw `pt-*` makes Cosy return a generic 500, which
// OmniRoute mis-surfaced as "PAT may not be valid for the chat API". We mirror the
// exchange here and cache the `jt-*` for its lifetime.
const QODER_JOB_TOKEN_EXCHANGE_URL = "https://openapi.qoder.sh/api/v1/jobToken/exchange";
// Refresh a little before the ~24h expiry to avoid using a just-expired token.
const QODER_JOB_TOKEN_DEFAULT_TTL_MS = 23 * 60 * 60 * 1000;
const QODER_JOB_TOKEN_MIN_TTL_MS = 60 * 1000;
type QoderJobTokenCacheEntry = { jobToken: string; expiresAt: number };
const qoderJobTokenCache = new Map<string, QoderJobTokenCacheEntry>();
const qoderJobTokenPending = new Map<
string,
Promise<{ jobToken: string; expiresInMs: number } | null>
>();
type FetchLike = (input: string, init?: Record<string, unknown>) => Promise<Response>;
/** A Qoder Personal Access Token is the only credential that needs the exchange. */
export function isQoderPatToken(token: string): boolean {
return typeof token === "string" && token.trim().startsWith("pt-");
}
/** Pull a `jt-*` job token out of the (loosely-specified) exchange response. */
export function parseQoderJobTokenResponse(json: unknown): {
jobToken: string;
expiresInMs: number;
} | null {
const root = asRecord(json);
const data = asRecord(root.data);
const candidates = [
root.job_token,
root.jobToken,
root.jt,
root.token,
data.job_token,
data.jobToken,
data.jt,
data.token,
];
const jobToken = candidates.map(getString).find((v) => v.trim().startsWith("jt-")) || "";
if (!jobToken) return null;
const expiresRaw = [root.expires_in, root.expiresIn, data.expires_in, data.expiresIn].find(
(v) => typeof v === "number" && Number.isFinite(v) && (v as number) > 0
) as number | undefined;
// Qoder reports expiry in seconds; fall back to the default ~24h window.
const expiresInMs = expiresRaw ? expiresRaw * 1000 : QODER_JOB_TOKEN_DEFAULT_TTL_MS;
return { jobToken, expiresInMs: Math.max(expiresInMs, QODER_JOB_TOKEN_MIN_TTL_MS) };
}
/** Exchange a `pt-*` PAT for a short-lived `jt-*` job token (no caching). */
export async function exchangeQoderJobToken(
pat: string,
options: { fetchImpl?: FetchLike; signal?: AbortSignal | null } = {}
): Promise<{ jobToken: string; expiresInMs: number } | null> {
const fetchImpl = options.fetchImpl || (fetch as unknown as FetchLike);
const res = await fetchImpl(QODER_JOB_TOKEN_EXCHANGE_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ personal_token: pat }),
signal: options.signal || AbortSignal.timeout(15000),
});
if (!res || !res.ok) return null;
let json: unknown = null;
try {
json = await res.json();
} catch {
return null;
}
return parseQoderJobTokenResponse(json);
}
/**
* Resolve the token to carry in the Cosy envelope. For a `pt-*` PAT this returns a
* cached/freshly-exchanged `jt-*` job token; any other token (already a `jt-*`, or a
* non-PAT credential) is returned unchanged. Exchange failures fall back to the
* original token so behavior is no worse than before the fix.
*/
export async function resolveQoderJobToken(
token: string,
options: { fetchImpl?: FetchLike; signal?: AbortSignal | null; now?: number } = {}
): Promise<string> {
const trimmed = (token || "").trim();
if (!isQoderPatToken(trimmed)) return trimmed;
const now = options.now ?? Date.now();
const cached = qoderJobTokenCache.get(trimmed);
if (cached && cached.expiresAt > now) return cached.jobToken;
let pending = qoderJobTokenPending.get(trimmed);
if (!pending) {
pending = exchangeQoderJobToken(trimmed, { fetchImpl: options.fetchImpl }).finally(() => {
qoderJobTokenPending.delete(trimmed);
});
qoderJobTokenPending.set(trimmed, pending);
}
const exchanged = await pending;
if (!exchanged) return trimmed; // graceful fallback — keep prior behavior
qoderJobTokenCache.set(trimmed, {
jobToken: exchanged.jobToken,
expiresAt: now + exchanged.expiresInMs,
});
return exchanged.jobToken;
}
/** Test-only: clear the job-token cache so unit tests don't leak state. */
export function __clearQoderJobTokenCache(): void {
qoderJobTokenCache.clear();
qoderJobTokenPending.clear();
}
export async function validateQoderCliPat({
apiKey,
providerSpecificData = {},
}: {
apiKey: string;
providerSpecificData?: JsonRecord;
}) {
// Resolve token: dashboard input → env var fallback
const resolvedToken =
apiKey?.trim() || String(process.env.QODER_PERSONAL_ACCESS_TOKEN || "").trim();
if (!resolvedToken) {
return {
valid: false,
error:
"No Qoder token provided. Get your Personal Access Token from https://qoder.com/account/integrations or set QODER_PERSONAL_ACCESS_TOKEN env var.",
unsupported: false,
};
}
// PAT format guidance: Qoder PATs should be non-empty strings.
// Warn if the token looks like it might be an encrypted blob (from ~/.qoder/.auth/user)
// rather than a proper PAT from the website.
if (resolvedToken.length > 500) {
return {
valid: false,
error:
"Token appears to be an encrypted auth blob (from ~/.qoder/.auth/user). " +
"Please use a Personal Access Token from https://qoder.com/account/integrations instead.",
unsupported: false,
};
}
// Reference providerSpecificData so callers can still pass validation hints
// (model id, etc.) without a signature change; the CLI resolves models itself.
void providerSpecificData;
// Validate by asking the local qodercli to list the models reachable for this
// PAT. The official CLI signs the (WASM-based) Cosy request internally, which
// the pure-HTTP path can no longer replicate — a raw Cosy call now returns a
// generic 500 for every token, so it cannot distinguish valid from invalid.
// `--list-models` authenticates without consuming any chat tokens.
const run = await listQoderCliModels({ token: resolvedToken });
const combined = `${run.stdout}\n${run.stderr}`.trim();
const normalized = combined.toLowerCase();
if (run.error && /enoent|not found|no such file|spawn/i.test(run.error)) {
return {
valid: false,
error: buildQoderCliNotFoundHint(run.error),
unsupported: false,
};
}
if (run.timedOut) {
return {
valid: false,
error:
"qodercli timed out while validating the token. Check network/proxy access from the OmniRoute host.",
unsupported: false,
};
}
if (
/not logged in|please run \/login|login required|unauthorized|forbidden|exchangejobtoken failed|personal token format|invalid[\s\w]{0,40}?(?:token|credential|api[\s_-]*key)/i.test(
normalized
)
) {
return {
valid: false,
error:
"Qoder rejected this Personal Access Token (not authorized). " +
"Check your token at https://qoder.com/account/integrations.",
unsupported: false,
};
}
// A successful `--list-models` prints the catalog (a table headed by "MODEL").
if (run.ok && normalized.includes("model")) {
return { valid: true, error: null, unsupported: false };
}
return {
valid: false,
error: `qodercli validation failed: ${(combined || run.error || "unknown error").slice(0, 300)}`,
unsupported: false,
};
}