Files
OmniRoute/open-sse/services/qoderCli.ts
Diego Rodrigues de Sa e Souza 92715c8f2c Release v3.8.46
Release v3.8.46. Full changelog: CHANGELOG.md → [3.8.46]. Contributor attribution in the CHANGELOG entries.
2026-07-07 13:14:06 -03:00

988 lines
32 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
}