988 lines
32 KiB
TypeScript
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,
|
|
};
|
|
}
|