mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
A working Qoder PAT was reported as "expired". The validator probes the Cosy endpoint (api1.qoder.sh) — which IS the correct PAT path (the executor falls back to it after the expected 401 from api.qoder.com). The bug was the verdict: isCosyAppError (added by #2860) marked ANY Cosy 500 with "success":false as an auth failure, including a generic {..."msgCode":500,"message":"Internal Server Error"} server fault — contradicting the older #1391 "5xx = valid bypass" rule. Narrow it: a Cosy 500 only marks the PAT invalid when the body carries an EXPLICIT auth signal (unauthorized/forbidden/expired/token invalid/...); a generic Internal Server Error falls back to valid-bypass. #2860's protection for genuine auth rejections is preserved. Regression test: tests/unit/qoder-cli.test.ts — the two pre-existing generic-500 cases now assert valid:true (they encoded the #3247 bug) + a new explicit-auth-signal case asserts valid:false. 13/13 green.
550 lines
17 KiB
TypeScript
550 lines
17 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",
|
|
};
|
|
}
|
|
|
|
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 — send a minimal request with the PAT
|
|
const headers = buildCosyHeadersForValidation(bodyStr, resolvedToken);
|
|
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,
|
|
};
|
|
}
|
|
}
|