Files
OmniRoute/open-sse/services/qoderCli.ts
Diego Rodrigues de Sa e Souza 7c23dab64d Release v3.8.40
v3.8.40 cycle integration → main. All test gates green (Unit/Integration/Coverage/Node-compat/Quality-Ratchet). The only red check, 'PR Test Policy', is the test-masking heuristic firing on the cumulative ~57-commit release diff (legitimate assert consolidations already reviewed per-PR — Gemini CLI removal #5246, retired GPT models #5280, provider catalog refreshes); overridden with --admin per the documented release-PR convention. CodeQL/SonarQube advisory scans non-blocking; #5278's code already passed CodeQL on main. Homologated on VPS 192.168.0.15 (v3.8.40 healthy).
2026-06-29 08:40:06 -03:00

667 lines
22 KiB
TypeScript

import { spawn } from "child_process";
import crypto from "crypto";
const DEFAULT_TIMEOUT_MS = 45_000;
const DEFAULT_MAX_TURNS = "1";
const QODER_DEFAULT_MODEL = "qoder-rome-30ba3b";
export const QODER_STATIC_MODELS = [
{ id: "qoder-rome-30ba3b", name: "Qoder ROME" },
{ 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 getQoderCliCommand(): string {
const explicit = String(process.env.CLI_QODER_BIN || "").trim();
return explicit || "qodercli";
}
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();
}
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 }));
}
export function mapQoderModelToLevel(model: string | null | undefined): string | null {
const normalized = String(model || "")
.trim()
.toLowerCase();
if (!normalized) return null;
if (normalized.includes("deepseek-r1")) return "ultimate";
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("personal access token") ||
(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,
};
}
const modelId =
getString(providerSpecificData.validationModelId).trim() ||
getString(providerSpecificData.modelId).trim() ||
QODER_DEFAULT_MODEL;
const bodyStr = JSON.stringify({
model: modelId || "coder-model",
messages: [{ role: "user", content: "hi" }],
stream: false,
});
// Step 1: Connectivity check — verify Qoder API is reachable
try {
const pingRes = await fetch("https://api1.qoder.sh/algo/api/v1/ping", {
method: "GET",
// @ts-ignore
signal: AbortSignal.timeout(10000),
});
if (!pingRes.ok) {
return {
valid: false,
error: `Qoder API unreachable (ping returned ${pingRes.status}). Check your network/proxy configuration.`,
unsupported: false,
};
}
} catch (pingErr: any) {
return {
valid: false,
error:
`Cannot reach Qoder API (${pingErr.message}). ` +
"If behind a proxy, configure HTTPS_PROXY. For Docker, ensure the container has internet access.",
unsupported: false,
};
}
// Step 2: Auth validation — exchange the PAT for a job token (#4683), then send a
// minimal request with the `jt-*` (Cosy rejects a raw `pt-*` with a generic 500).
const cosyToken = await resolveQoderJobToken(resolvedToken);
const headers = buildCosyHeadersForValidation(bodyStr, cosyToken);
const endpoint =
"https://api1.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation?AgentId=agent_common";
try {
const res = await fetch(endpoint, {
method: "POST",
headers,
body: bodyStr,
// @ts-ignore
signal: AbortSignal.timeout(30000),
});
if (res.ok || res.status === 200) {
return { valid: true, error: null, unsupported: false };
}
// Parse error body for better diagnostics
let errorDetail = "";
try {
const errBody = await res.text();
errorDetail = errBody.slice(0, 300);
} catch {}
if (res.status === 401 || res.status === 403) {
return {
valid: false,
error:
`Authentication failed (HTTP ${res.status}). ` +
"Make sure you're using a valid Personal Access Token from https://qoder.com/account/integrations. " +
"Note: tokens from ~/.qoder/.auth/user are encrypted and cannot be used directly." +
(errorDetail ? ` Server: ${errorDetail}` : ""),
unsupported: false,
};
}
// 4xx other than auth — token was accepted but request had issues (model, format, etc.)
if (res.status >= 400 && res.status < 500) {
return { valid: true, error: null, unsupported: false };
}
// Treat 5xx as a valid bypass to prevent false negatives from legacy Qoder APIs (#1391).
// A Cosy `{"success":false}` 500 is ambiguous: it can be a genuine auth rejection OR a
// transient/generic upstream "Internal Server Error". Only mark the PAT invalid when the
// body carries an EXPLICIT auth signal — a generic 500 is a server fault, not an auth
// verdict, so a working PAT must not be reported as expired (#3247, narrowing #2860).
if (res.status >= 500) {
const isCosyResponse = /"success"\s*:\s*false/.test(errorDetail);
const hasAuthSignal =
/(unauthorized|forbidden|expired|revoked|not\s*authorized|permission\s*denied|access\s*denied|invalid\s*(?:token|credential|api[\s_-]*key)|token\s*(?:invalid|expired|revoked))/i.test(
errorDetail
);
if (isCosyResponse && hasAuthSignal) {
return {
valid: false,
error:
`Authentication failed (HTTP ${res.status}). The Qoder Cosy server rejected the token ` +
"as invalid, expired, or not authorized. " +
"Please check your token at https://qoder.com/account/integrations." +
(errorDetail ? ` Server response: ${errorDetail}` : ""),
unsupported: false,
};
}
return {
valid: true,
error: `Validation endpoint returned HTTP ${res.status}${errorDetail ? `: ${errorDetail}` : ""}, treating PAT as valid`,
unsupported: false,
};
}
return {
valid: false,
error: `Qoder API returned HTTP ${res.status}${errorDetail ? `: ${errorDetail}` : ""}`,
unsupported: false,
};
} catch (e: any) {
return {
valid: false,
error: `Qoder validation request failed: ${e.message}`,
unsupported: false,
};
}
}