From 5789c1ae7d224ec8e49f49ed12ac282b12a32d59 Mon Sep 17 00:00:00 2001 From: carmim777 Date: Thu, 2 Apr 2026 01:35:23 -0300 Subject: [PATCH] feat(qoder): support PAT via qodercli --- README.md | 5 +- open-sse/config/constants.ts | 4 +- open-sse/config/providerRegistry.ts | 10 +- open-sse/executors/qoder.ts | 374 +++++++++---- open-sse/services/qoderCli.ts | 498 ++++++++++++++++++ open-sse/services/tokenRefresh.ts | 8 + .../dashboard/providers/[id]/page.tsx | 85 ++- .../(dashboard)/dashboard/providers/page.tsx | 24 +- .../api/oauth/[provider]/[action]/route.ts | 8 + src/app/api/providers/[id]/models/route.ts | 2 + src/app/api/providers/[id]/test/route.ts | 11 +- src/app/api/providers/route.ts | 6 + src/lib/oauth/constants/oauth.ts | 23 +- src/lib/oauth/providers/qoder.ts | 13 + src/lib/oauth/services/qoder.ts | 18 + src/lib/providers/validation.ts | 3 + src/shared/components/OAuthModal.tsx | 7 + src/shared/constants/providers.ts | 6 + src/shared/services/cliRuntime.ts | 11 + tests/unit/cli-runtime-detection.test.mjs | 23 +- tests/unit/qoder-executor.test.mjs | 338 ++++++++---- tests/unit/qoder-oauth-config.test.mjs | 25 + 22 files changed, 1241 insertions(+), 261 deletions(-) create mode 100644 open-sse/services/qoderCli.ts create mode 100644 tests/unit/qoder-oauth-config.test.mjs diff --git a/README.md b/README.md index 016444ee97..d11e6ff14f 100644 --- a/README.md +++ b/README.md @@ -1158,7 +1158,7 @@ Cerebras (cerebras/) → Llama/Qwen world-fastest — 1M tok/day | `claude-haiku-4.5` | `kr/` | **Unlimited** | No reported daily cap | | `claude-opus-4.6` | `kr/` | **Unlimited** | Latest Opus via Kiro | -### 🟢 QODER MODELS (Free OAuth — No Credit Card) +### 🟢 QODER MODELS (Free PAT via qodercli) | Model | Prefix | Limit | Rate Limit | | ------------------ | ------ | ------------- | --------------- | @@ -1168,6 +1168,9 @@ Cerebras (cerebras/) → Llama/Qwen world-fastest — 1M tok/day | `minimax-m2.1` | `if/` | **Unlimited** | No reported cap | | `kimi-k2` | `if/` | **Unlimited** | No reported cap | +> Recommended connection method: **Personal Access Token + `qodercli`**. Browser OAuth is +> experimental and disabled by default unless `QODER_OAUTH_*` environment variables are configured. + ### 🟡 QWEN MODELS (Device Code Auth) | Model | Prefix | Limit | Rate Limit | diff --git a/open-sse/config/constants.ts b/open-sse/config/constants.ts index 0d70a63238..7357d751b4 100644 --- a/open-sse/config/constants.ts +++ b/open-sse/config/constants.ts @@ -44,8 +44,8 @@ export const OAUTH_ENDPOINTS = { auth: "https://chat.qwen.ai/api/v1/oauth2/device/code", // From CLIProxyAPI }, qoder: { - token: "https://qoder.cn/oauth/token", - auth: "https://qoder.cn/oauth", + token: process.env.QODER_OAUTH_TOKEN_URL || "", + auth: process.env.QODER_OAUTH_AUTHORIZE_URL || "", }, github: { token: "https://github.com/login/oauth/access_token", diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index c9cb25c0b3..edd08ec3ef 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -325,19 +325,17 @@ export const REGISTRY: Record = { alias: "if", format: "openai", executor: "qoder", - baseUrl: "https://apis.qoder.cn/v1/chat/completions", - authType: "oauth", + baseUrl: "https://api.qoder.com/v1/chat/completions", + authType: "apikey", authHeader: "bearer", headers: { "User-Agent": "Qoder-Cli", }, oauth: { clientIdEnv: "QODER_OAUTH_CLIENT_ID", - clientIdDefault: "10009311001", clientSecretEnv: "QODER_OAUTH_CLIENT_SECRET", - clientSecretDefault: "", - tokenUrl: "https://qoder.cn/oauth/token", - authUrl: "https://qoder.cn/oauth", + tokenUrl: process.env.QODER_OAUTH_TOKEN_URL || "", + authUrl: process.env.QODER_OAUTH_AUTHORIZE_URL || "", }, models: [ { id: "qoder-rome-30ba3b", name: "Qoder ROME" }, diff --git a/open-sse/executors/qoder.ts b/open-sse/executors/qoder.ts index e00a0a2b3b..626f0ff6e1 100644 --- a/open-sse/executors/qoder.ts +++ b/open-sse/executors/qoder.ts @@ -1,113 +1,295 @@ import crypto from "crypto"; -import { BaseExecutor } from "./base.ts"; +import { spawn } from "child_process"; +import { + BaseExecutor, + mergeUpstreamExtraHeaders, + type ExecuteInput, + type ProviderCredentials, +} from "./base.ts"; import { PROVIDERS } from "../config/constants.ts"; +import { + buildQoderChunk, + buildQoderCompletionPayload, + buildQoderPrompt, + createQoderErrorResponse, + extractTextFromQoderEnvelope, + getQoderCliCommand, + getQoderCliWorkspace, + mapQoderModelToLevel, + parseQoderCliFailure, + runQoderCliCommand, +} from "../services/qoderCli.ts"; -type QoderCredentials = { - apiKey?: string; - accessToken?: string; -}; +function getPat(credentials: ProviderCredentials): string { + if (typeof credentials.apiKey === "string" && credentials.apiKey.trim()) { + return credentials.apiKey.trim(); + } + return ""; +} -/** - * QoderExecutor - Executor for Qoder API with HMAC-SHA256 signature. - * - * Qoder requires custom headers including a session ID, timestamp, - * and an HMAC-SHA256 signature for request authentication. - * Without these headers, the API returns a 406 error. - * - * Fixes: https://github.com/diegosouzapw/OmniRoute/issues/114 - */ export class QoderExecutor extends BaseExecutor { constructor() { super("qoder", PROVIDERS.qoder); } - /** - * Create Qoder signature using HMAC-SHA256 - * @param userAgent - User agent string - * @param sessionID - Session ID - * @param timestamp - Unix timestamp in milliseconds - * @param apiKey - API key for signing - * @returns Hex-encoded signature - */ - createQoderSignature( - userAgent: string, - sessionID: string, - timestamp: number, - apiKey: string - ): string { - if (!apiKey) return ""; - const payload = `${userAgent}:${sessionID}:${timestamp}`; - const hmac = crypto.createHmac("sha256", apiKey); - hmac.update(payload); - return hmac.digest("hex"); - } - - /** - * Build headers with Qoder-specific HMAC-SHA256 signature. - * Includes session-id, x-qoder-timestamp, and x-qoder-signature. - */ - buildHeaders(credentials: QoderCredentials, stream = true) { - // Generate session ID and timestamp - const sessionID = `session-${crypto.randomUUID()}`; - const timestamp = Date.now(); - - // Get user agent from config - const userAgent = this.config.headers?.["User-Agent"] || "Qoder-Cli"; - - // Get API key (prefer apiKey, fallback to accessToken) - const apiKey = credentials.apiKey || credentials.accessToken || ""; - - // Create HMAC-SHA256 signature - const signature = this.createQoderSignature(userAgent, sessionID, timestamp, apiKey); - - // Build headers - const headers: Record = { + buildHeaders(_credentials: ProviderCredentials, stream = true): Record { + return { "Content-Type": "application/json", - ...this.config.headers, - "session-id": sessionID, - "x-qoder-timestamp": timestamp.toString(), - "x-qoder-signature": signature, + ...(stream ? { Accept: "text/event-stream" } : {}), }; - - // Add authorization - if (credentials.apiKey) { - headers["Authorization"] = `Bearer ${credentials.apiKey}`; - } else if (credentials.accessToken) { - headers["Authorization"] = `Bearer ${credentials.accessToken}`; - } - - // Add streaming header - if (stream) { - headers["Accept"] = "text/event-stream"; - } - - return headers; } - /** - * Build URL for Qoder API — uses baseUrl directly. - */ - buildUrl( - model: string, - stream: boolean, - urlIndex = 0, - credentials: QoderCredentials | null = null - ) { - void model; - void stream; - void urlIndex; - void credentials; - return this.config.baseUrl; - } + async execute({ + model, + body, + stream, + credentials, + signal, + upstreamExtraHeaders, + }: ExecuteInput) { + const headers = this.buildHeaders(credentials, stream); + mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders); - /** - * Transform request body (passthrough for Qoder). - */ - transformRequest(model: string, body: unknown, stream: boolean, credentials: QoderCredentials) { - void model; - void stream; - void credentials; - return body; + const pat = getPat(credentials); + if (!pat) { + return { + response: createQoderErrorResponse({ + status: 400, + message: + "Qoder Personal Access Token is required. Connect Qoder with a PAT via qodercli transport.", + code: "pat_required", + }), + url: "qodercli://local", + headers, + transformedBody: body, + }; + } + + const prompt = buildQoderPrompt(body); + const workspace = getQoderCliWorkspace(); + const command = getQoderCliCommand(); + + if (!stream) { + const result = await runQoderCliCommand({ + token: pat, + prompt, + stream: false, + model, + workspace, + command, + signal, + }); + + if (!result.ok) { + const failure = parseQoderCliFailure(result.stderr || result.error || "", result.stdout); + return { + response: createQoderErrorResponse(failure), + url: "qodercli://local", + headers, + transformedBody: body, + }; + } + + let assistantText = result.stdout.trim(); + try { + const parsed = JSON.parse(assistantText); + assistantText = extractTextFromQoderEnvelope(parsed) || assistantText; + } catch { + // Fall back to raw stdout if the CLI printed plain text. + } + + const payload = buildQoderCompletionPayload({ + model, + text: assistantText, + }); + + return { + response: new Response(JSON.stringify(payload), { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }), + url: "qodercli://local", + headers, + transformedBody: body, + }; + } + + const qoderStream = new ReadableStream({ + start: async (controller) => { + const encoder = new TextEncoder(); + const created = Math.floor(Date.now() / 1000); + const responseId = `chatcmpl-${crypto.randomUUID()}`; + const responseModel = model || "qoder-rome-30ba3b"; + const cliCommand = command; + const args = [ + "-q", + "-p", + prompt, + "--max-turns", + "1", + "--workspace", + workspace, + "--output-format", + "stream-json", + ]; + const level = mapQoderModelToLevel(responseModel); + if (level) { + args.push("--model", level); + } + + const child = spawn(cliCommand, args, { + env: { + ...process.env, + QODER_PERSONAL_ACCESS_TOKEN: pat, + }, + stdio: ["ignore", "pipe", "pipe"], + ...(process.platform === "win32" ? { shell: true } : {}), + }); + + let stdoutBuffer = ""; + let stderrBuffer = ""; + let emittedText = ""; + let roleSent = false; + let finished = false; + + const emitSse = (payload: unknown) => { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`)); + }; + + const finish = () => { + if (finished) return; + finished = true; + emitSse( + buildQoderChunk({ + id: responseId, + model: responseModel, + created, + delta: {}, + finishReason: "stop", + }) + ); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }; + + const abortChild = () => { + try { + child.kill("SIGTERM"); + } catch {} + }; + + if (signal?.aborted) { + abortChild(); + controller.error(new Error("aborted")); + return; + } + + signal?.addEventListener?.( + "abort", + () => { + abortChild(); + controller.error(new Error("aborted")); + }, + { once: true } + ); + + const emitDelta = (deltaText: string) => { + if (!deltaText) return; + const delta = roleSent + ? { content: deltaText } + : { role: "assistant", content: deltaText }; + roleSent = true; + emitSse( + buildQoderChunk({ + id: responseId, + model: responseModel, + created, + delta, + }) + ); + }; + + const drainStdout = () => { + let newlineIndex = stdoutBuffer.indexOf("\n"); + while (newlineIndex >= 0) { + const rawLine = stdoutBuffer.slice(0, newlineIndex).trim(); + stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1); + newlineIndex = stdoutBuffer.indexOf("\n"); + + if (!rawLine) continue; + + let parsed: unknown; + try { + parsed = JSON.parse(rawLine); + } catch { + continue; + } + + const nextText = extractTextFromQoderEnvelope(parsed); + if (nextText) { + const delta = nextText.startsWith(emittedText) + ? nextText.slice(emittedText.length) + : nextText; + emittedText += delta; + emitDelta(delta); + } + + const parsedRecord = + parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + if (parsedRecord.type === "result" && parsedRecord.done === true) { + finish(); + return; + } + } + }; + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + + child.stdout.on("data", (chunk) => { + stdoutBuffer += chunk; + drainStdout(); + }); + + child.stderr.on("data", (chunk) => { + stderrBuffer += chunk; + }); + + child.on("error", (error) => { + if (finished) return; + controller.error(error); + }); + + child.on("close", (code) => { + if (finished) return; + drainStdout(); + if (code !== 0) { + const failure = parseQoderCliFailure(stderrBuffer, stdoutBuffer); + controller.error(new Error(failure.message)); + return; + } + finish(); + }); + }, + }); + + return { + response: new Response(qoderStream, { + status: 200, + headers: { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }), + url: "qodercli://local", + headers, + transformedBody: body, + }; } } diff --git a/open-sse/services/qoderCli.ts b/open-sse/services/qoderCli.ts new file mode 100644 index 0000000000..43c60e0574 --- /dev/null +++ b/open-sse/services/qoderCli.ts @@ -0,0 +1,498 @@ +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; + +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; + 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 CLI 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("command not found") || + normalized.includes("not installed") || + normalized.includes("enoent") || + normalized.includes("no such file or directory") + ) { + return { status: 503, message: combined, code: "runtime_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", + }, + } + ); +} + +export async function runQoderCliCommand({ + token, + prompt, + stream, + model, + workspace, + command, + signal, + timeoutMs = DEFAULT_TIMEOUT_MS, +}: QoderCliRunOptions): Promise { + const cliCommand = String(command || getQoderCliCommand()).trim(); + const cwd = String(workspace || getQoderCliWorkspace()).trim() || process.cwd(); + const args = [ + "-q", + "-p", + prompt, + "--max-turns", + DEFAULT_MAX_TURNS, + "--workspace", + cwd, + "--output-format", + stream ? "stream-json" : "json", + ]; + + const level = mapQoderModelToLevel(model || QODER_DEFAULT_MODEL); + if (level) { + args.push("--model", level); + } + + return new Promise((resolve) => { + let stdout = ""; + let stderr = ""; + let timedOut = false; + let settled = false; + + const child = spawn(cliCommand, args, { + env: { + ...process.env, + QODER_PERSONAL_ACCESS_TOKEN: token, + }, + stdio: ["ignore", "pipe", "pipe"], + ...(process.platform === "win32" ? { shell: true } : {}), + }); + + const cleanup = (result: QoderCliRunResult) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener?.("abort", abortHandler); + resolve(result); + }; + + const abortChild = () => { + try { + child.kill("SIGTERM"); + } catch {} + setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch {} + }, 250).unref?.(); + }; + + const abortHandler = () => { + abortChild(); + cleanup({ + ok: false, + code: null, + stdout: stdout.trim(), + stderr: stderr.trim(), + timedOut: false, + error: "aborted", + }); + }; + + if (signal?.aborted) { + abortHandler(); + return; + } + + signal?.addEventListener?.("abort", abortHandler, { once: true }); + + const timer = setTimeout(() => { + timedOut = true; + abortChild(); + }, timeoutMs); + + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + child.on("error", (error) => { + cleanup({ + ok: false, + code: null, + stdout: stdout.trim(), + stderr: stderr.trim(), + timedOut, + error: error?.message || "spawn_error", + }); + }); + + child.on("close", (code) => { + cleanup({ + ok: !timedOut && code === 0, + code, + stdout: stdout.trim(), + stderr: stderr.trim(), + timedOut, + error: timedOut ? "timeout" : null, + }); + }); + }); +} + +export async function validateQoderCliPat({ + apiKey, + providerSpecificData = {}, +}: { + apiKey: string; + providerSpecificData?: JsonRecord; +}) { + const modelId = + getString(providerSpecificData.validationModelId).trim() || getString(providerSpecificData.modelId).trim(); + const result = await runQoderCliCommand({ + token: apiKey, + prompt: "Reply with OK only.", + stream: false, + model: modelId || QODER_DEFAULT_MODEL, + workspace: getQoderCliWorkspace(), + timeoutMs: 30_000, + }); + + if (!result.ok) { + const failure = parseQoderCliFailure(result.stderr || result.error || "", result.stdout); + return { + valid: false, + error: failure.status === 401 ? "Invalid API key" : failure.message, + unsupported: false, + }; + } + + return { valid: true, error: null, unsupported: false }; +} diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index b0d9b0c858..ad1d62aaa1 100644 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -542,6 +542,14 @@ export async function refreshKiroToken(refreshToken, providerSpecificData, log) * Specialized refresh for Qoder OAuth tokens */ export async function refreshIflowToken(refreshToken, log) { + if (!OAUTH_ENDPOINTS.qoder.token || !PROVIDERS.qoder.clientId || !PROVIDERS.qoder.clientSecret) { + log?.warn?.( + "TOKEN_REFRESH", + "Qoder OAuth refresh skipped: browser OAuth is not configured in this environment" + ); + return null; + } + const basicAuth = btoa(`${PROVIDERS.qoder.clientId}:${PROVIDERS.qoder.clientSecret}`); const response = await fetch(OAUTH_ENDPOINTS.qoder.token, { diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index e941a11b93..50cfc98ddf 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -30,6 +30,7 @@ import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider, isClaudeCodeCompatibleProvider, + supportsApiKeyOnFreeProvider, } from "@/shared/constants/providers"; import { getModelsByProviderId } from "@/shared/constants/models"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; @@ -872,7 +873,10 @@ export default function ProviderDetailPage() { : (FREE_PROVIDERS as any)[providerId] || (OAUTH_PROVIDERS as any)[providerId] || (APIKEY_PROVIDERS as any)[providerId]; - const isOAuth = !!(FREE_PROVIDERS as any)[providerId] || !!(OAUTH_PROVIDERS as any)[providerId]; + const providerSupportsOAuth = + !!(FREE_PROVIDERS as any)[providerId] || !!(OAUTH_PROVIDERS as any)[providerId]; + const providerSupportsPat = supportsApiKeyOnFreeProvider(providerId); + const isOAuth = providerSupportsOAuth && !providerSupportsPat; const models = getModelsByProviderId(providerId); const providerAlias = getProviderAlias(providerId); const isManagedAvailableModelsProvider = isCompatible || providerId === "openrouter"; @@ -1062,6 +1066,14 @@ export default function ProviderDetailPage() { setShowOAuthModal(false); }, [fetchConnections]); + const openPrimaryAddFlow = useCallback(() => { + if (isOAuth) { + setShowOAuthModal(true); + return; + } + setShowAddApiKeyModal(true); + }, [isOAuth]); + const handleSaveApiKey = async (formData) => { try { const res = await fetch("/api/providers", { @@ -2216,13 +2228,16 @@ export default function ProviderDetailPage() { )} {!isCompatible ? ( - +
+ + {providerId === "qoder" && ( + + )} +
) : ( connections.length === 0 && ( +
+ + {providerId === "qoder" && ( + + )} +
)} ) : ( @@ -2264,7 +2283,7 @@ export default function ProviderDetailPage() { handleSwapPriority(conn, sorted[index - 1])} @@ -2285,8 +2304,10 @@ export default function ProviderDetailPage() { setShowEditModal(true); }} onDelete={() => handleDelete(conn.id)} - onReauth={isOAuth ? () => setShowOAuthModal(true) : undefined} - onRefreshToken={isOAuth ? () => handleRefreshToken(conn.id) : undefined} + onReauth={conn.authType === "oauth" ? () => setShowOAuthModal(true) : undefined} + onRefreshToken={ + conn.authType === "oauth" ? () => handleRefreshToken(conn.id) : undefined + } isRefreshing={refreshingId === conn.id} onApplyCodexAuthLocal={ providerId === "codex" @@ -2361,7 +2382,7 @@ export default function ProviderDetailPage() { @@ -2388,8 +2409,14 @@ export default function ProviderDetailPage() { setShowEditModal(true); }} onDelete={() => handleDelete(conn.id)} - onReauth={isOAuth ? () => setShowOAuthModal(true) : undefined} - onRefreshToken={isOAuth ? () => handleRefreshToken(conn.id) : undefined} + onReauth={ + conn.authType === "oauth" ? () => setShowOAuthModal(true) : undefined + } + onRefreshToken={ + conn.authType === "oauth" + ? () => handleRefreshToken(conn.id) + : undefined + } isRefreshing={refreshingId === conn.id} onApplyCodexAuthLocal={ providerId === "codex" @@ -4376,6 +4403,7 @@ function AddApiKeyModal({ const isVertex = provider === "vertex"; const defaultRegion = "us-central1"; const isGlm = provider === "glm"; + const isQoder = provider === "qoder"; const [formData, setFormData] = useState({ name: "", @@ -4501,16 +4529,27 @@ function AddApiKeyModal({ label={t("nameLabel")} value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} - placeholder={t("productionKey")} + placeholder={isQoder ? "Qoder PAT" : t("productionKey")} />
setFormData({ ...formData, apiKey: e.target.value })} className="flex-1" - placeholder={isVertex ? "Cole o Service Account JSON aqui" : undefined} + placeholder={ + isVertex + ? "Cole o Service Account JSON aqui" + : isQoder + ? "Paste your Qoder Personal Access Token" + : undefined + } + hint={ + isQoder + ? "Supported path: PAT via qodercli. Browser OAuth remains experimental." + : undefined + } />
diff --git a/src/app/api/oauth/[provider]/[action]/route.ts b/src/app/api/oauth/[provider]/[action]/route.ts index 1d212e8983..11f74986d1 100644 --- a/src/app/api/oauth/[provider]/[action]/route.ts +++ b/src/app/api/oauth/[provider]/[action]/route.ts @@ -60,6 +60,14 @@ export async function GET( if (action === "authorize") { const redirectUri = searchParams.get("redirect_uri") || "http://localhost:8080/callback"; const authData = generateAuthData(provider, redirectUri); + if (provider === "qoder" && !authData.authUrl) { + return NextResponse.json({ + ...authData, + supported: false, + error: + "Qoder browser OAuth is experimental and disabled by default. Configure QODER_OAUTH_* environment variables or use a Personal Access Token.", + }); + } return NextResponse.json(authData); } diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 78d098a567..9bb0a26b80 100644 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -6,6 +6,7 @@ import { } from "@/shared/constants/providers"; import { PROVIDER_MODELS } from "@/shared/constants/models"; import { getModelIsHidden } from "@/lib/localDb"; +import { getStaticQoderModels } from "@omniroute/open-sse/services/qoderCli.ts"; type JsonRecord = Record; @@ -99,6 +100,7 @@ const STATIC_MODEL_PROVIDERS: Record Array<{ id: string; name: str { id: "glm-4.7", name: "GLM 4.7" }, { id: "kimi-k2.5", name: "Kimi K2.5" }, ], + qoder: () => getStaticQoderModels(), }; /** diff --git a/src/app/api/providers/[id]/test/route.ts b/src/app/api/providers/[id]/test/route.ts index 828856090b..3b3c46dd88 100644 --- a/src/app/api/providers/[id]/test/route.ts +++ b/src/app/api/providers/[id]/test/route.ts @@ -92,6 +92,7 @@ const OAUTH_TEST_CONFIG = { const CLI_RUNTIME_PROVIDER_MAP = { cline: "cline", kilocode: "kilo", + qoder: "qoder", }; /** POST body is optional; when present, only known fields are validated. */ @@ -206,8 +207,12 @@ function classifyFailure({ ); } -async function getProviderRuntimeStatus(provider: string) { - const toolId = CLI_RUNTIME_PROVIDER_MAP[provider]; +async function getProviderRuntimeStatus(connection: any) { + const provider = typeof connection?.provider === "string" ? connection.provider : ""; + let toolId = CLI_RUNTIME_PROVIDER_MAP[provider]; + if (provider === "qoder" && connection?.authType !== "apikey") { + toolId = null; + } if (!toolId) return null; try { @@ -566,7 +571,7 @@ export async function testSingleConnection(connectionId: string, validationModel let result; const startTime = Date.now(); - const runtime = await getProviderRuntimeStatus(provider); + const runtime = await getProviderRuntimeStatus(connection); if ((runtime as any)?.diagnosis) { result = { diff --git a/src/app/api/providers/route.ts b/src/app/api/providers/route.ts index 0c5e06a156..06ff7ebb1a 100644 --- a/src/app/api/providers/route.ts +++ b/src/app/api/providers/route.ts @@ -15,6 +15,7 @@ import { getConsistentMachineId } from "@/shared/utils/machineId"; import { syncToCloud } from "@/lib/cloudSync"; import { createProviderSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { normalizeQoderPatProviderData } from "@omniroute/open-sse/services/qoderCli.ts"; // GET /api/providers - List all connections export async function GET() { @@ -61,6 +62,7 @@ export async function POST(request: Request) { // Business validation const isValidProvider = APIKEY_PROVIDERS[provider] || + provider === "qoder" || isOpenAICompatibleProvider(provider) || isAnthropicCompatibleProvider(provider); @@ -72,6 +74,10 @@ export async function POST(request: Request) { const allowMultipleCompatibleConnections = process.env.ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE === "true"; + if (provider === "qoder") { + providerSpecificData = normalizeQoderPatProviderData(providerSpecificData || {}); + } + if (isOpenAICompatibleProvider(provider)) { const node: any = await getProviderNodeById(provider); if (!node) { diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index a62266b725..75a5335846 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -65,12 +65,25 @@ export const QWEN_CONFIG = { }; // Qoder OAuth Configuration (Authorization Code) +const QODER_OAUTH_AUTHORIZE_URL = process.env.QODER_OAUTH_AUTHORIZE_URL || ""; +const QODER_OAUTH_TOKEN_URL = process.env.QODER_OAUTH_TOKEN_URL || ""; +const QODER_OAUTH_USERINFO_URL = process.env.QODER_OAUTH_USERINFO_URL || ""; +const QODER_OAUTH_CLIENT_ID = process.env.QODER_OAUTH_CLIENT_ID || ""; +const QODER_OAUTH_CLIENT_SECRET = process.env.QODER_OAUTH_CLIENT_SECRET || ""; +const QODER_OAUTH_ENABLED = + !!QODER_OAUTH_AUTHORIZE_URL && + !!QODER_OAUTH_TOKEN_URL && + !!QODER_OAUTH_USERINFO_URL && + !!QODER_OAUTH_CLIENT_ID && + !!QODER_OAUTH_CLIENT_SECRET; + export const QODER_CONFIG = { - clientId: process.env.QODER_OAUTH_CLIENT_ID || "10009311001", - clientSecret: process.env.QODER_OAUTH_CLIENT_SECRET || "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW", - authorizeUrl: "https://qoder.cn/oauth", - tokenUrl: "https://qoder.cn/oauth/token", - userInfoUrl: "https://qoder.cn/api/oauth/getUserInfo", + enabled: QODER_OAUTH_ENABLED, + clientId: QODER_OAUTH_CLIENT_ID, + clientSecret: QODER_OAUTH_CLIENT_SECRET, + authorizeUrl: QODER_OAUTH_AUTHORIZE_URL, + tokenUrl: QODER_OAUTH_TOKEN_URL, + userInfoUrl: QODER_OAUTH_USERINFO_URL, extraParams: { loginMethod: "phone", type: "phone", diff --git a/src/lib/oauth/providers/qoder.ts b/src/lib/oauth/providers/qoder.ts index 0d89c109ca..4b45dcea1b 100644 --- a/src/lib/oauth/providers/qoder.ts +++ b/src/lib/oauth/providers/qoder.ts @@ -4,6 +4,9 @@ export const qoder = { config: QODER_CONFIG, flowType: "authorization_code", buildAuthUrl: (config, redirectUri, state) => { + if (!config?.enabled || !config?.authorizeUrl) { + return null; + } const params = new URLSearchParams({ loginMethod: config.extraParams.loginMethod, type: config.extraParams.type, @@ -14,6 +17,11 @@ export const qoder = { return `${config.authorizeUrl}?${params.toString()}`; }, exchangeToken: async (config, code, redirectUri) => { + if (!config?.enabled || !config?.tokenUrl) { + throw new Error( + "Qoder browser OAuth is experimental and disabled by default. Configure QODER_OAUTH_* environment variables or use a Personal Access Token." + ); + } const headers: Record = { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", @@ -49,6 +57,11 @@ export const qoder = { return await response.json(); }, postExchange: async (tokens) => { + if (!QODER_CONFIG.enabled || !QODER_CONFIG.userInfoUrl) { + throw new Error( + "Qoder browser OAuth is experimental and disabled by default. Configure QODER_OAUTH_* environment variables or use a Personal Access Token." + ); + } const userInfoRes = await fetch( `${QODER_CONFIG.userInfoUrl}?accessToken=${encodeURIComponent(tokens.access_token)}`, { headers: { Accept: "application/json" } } diff --git a/src/lib/oauth/services/qoder.ts b/src/lib/oauth/services/qoder.ts index dd7b357c19..2916f47908 100644 --- a/src/lib/oauth/services/qoder.ts +++ b/src/lib/oauth/services/qoder.ts @@ -20,6 +20,12 @@ export class QoderService { * Build Qoder authorization URL */ buildAuthUrl(redirectUri: string, state: string) { + if (!this.config?.enabled || !this.config?.authorizeUrl) { + throw new Error( + "Qoder browser OAuth is experimental and disabled by default. Configure QODER_OAUTH_* environment variables or use a Personal Access Token." + ); + } + const params = new URLSearchParams({ loginMethod: this.config.extraParams.loginMethod, type: this.config.extraParams.type, @@ -35,6 +41,12 @@ export class QoderService { * Exchange authorization code for tokens */ async exchangeCode(code: string, redirectUri: string) { + if (!this.config?.enabled || !this.config?.tokenUrl) { + throw new Error( + "Qoder browser OAuth is experimental and disabled by default. Configure QODER_OAUTH_* environment variables or use a Personal Access Token." + ); + } + // Create Basic Auth header const basicAuth = Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString( "base64" @@ -68,6 +80,12 @@ export class QoderService { * Get user info from Qoder */ async getUserInfo(accessToken: string) { + if (!this.config?.enabled || !this.config?.userInfoUrl) { + throw new Error( + "Qoder browser OAuth is experimental and disabled by default. Configure QODER_OAUTH_* environment variables or use a Personal Access Token." + ); + } + const response = await fetch( `${this.config.userInfoUrl}?accessToken=${encodeURIComponent(accessToken)}`, { diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 4f273e8204..343f23819a 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -14,6 +14,7 @@ import { isAnthropicCompatibleProvider, isOpenAICompatibleProvider, } from "@/shared/constants/providers"; +import { validateQoderCliPat } from "@omniroute/open-sse/services/qoderCli.ts"; const OPENAI_LIKE_FORMATS = new Set(["openai", "openai-responses"]); const GEMINI_LIKE_FORMATS = new Set(["gemini", "gemini-cli"]); @@ -747,6 +748,8 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi // ── Specialty provider validation ── const SPECIALTY_VALIDATORS = { + qoder: ({ apiKey, providerSpecificData }: any) => + validateQoderCliPat({ apiKey, providerSpecificData }), deepgram: validateDeepgramProvider, assemblyai: validateAssemblyAIProvider, nanobanana: validateNanoBananaProvider, diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index 565f14fd4b..dd0eb04370 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -313,6 +313,13 @@ export default function OAuthModal({ throw new Error(errMsg); } + if (!data.authUrl) { + throw new Error( + data.error || + "Browser OAuth is unavailable for this provider in the current environment. Use the supported auth method instead." + ); + } + setAuthData({ ...data, redirectUri }); // For non-true-localhost (LAN IPs, remote) or manual fallback: use manual input mode (user pastes callback URL) diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 934e675a3a..213528cffc 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -17,6 +17,12 @@ export const FREE_PROVIDERS = { kiro: { id: "kiro", alias: "kr", name: "Kiro AI", icon: "psychology_alt", color: "#FF6B35" }, }; +export const FREE_APIKEY_PROVIDER_IDS = new Set(["qoder"]); + +export function supportsApiKeyOnFreeProvider(providerId) { + return FREE_APIKEY_PROVIDER_IDS.has(providerId); +} + // OAuth Providers export const OAUTH_PROVIDERS = { claude: { id: "claude", alias: "cc", name: "Claude Code", icon: "smart_toy", color: "#D97757" }, diff --git a/src/shared/services/cliRuntime.ts b/src/shared/services/cliRuntime.ts index 784887f34d..29f8306123 100644 --- a/src/shared/services/cliRuntime.ts +++ b/src/shared/services/cliRuntime.ts @@ -109,6 +109,16 @@ const CLI_TOOLS: Record = { config: ".config/opencode/opencode.json", }, }, + qoder: { + defaultCommand: "qodercli", + envBinKey: "CLI_QODER_BIN", + requiresBinary: true, + healthcheckTimeoutMs: 12000, + paths: { + config: ".qoder/settings.json", + auth: ".qoder/auth.json", + }, + }, }; const isWindows = () => process.platform === "win32"; @@ -390,6 +400,7 @@ const getKnownToolPaths = (toolId: string): string[] => { cline: [["cline.cmd", "cline"]], kilo: [["kilocode.cmd", "kilocode"]], opencode: [["opencode.cmd", "opencode"]], + qoder: [["qodercli.exe", "qodercli"]], }; const bins = toolBins[toolId] || []; diff --git a/tests/unit/cli-runtime-detection.test.mjs b/tests/unit/cli-runtime-detection.test.mjs index 74206a0319..b929364d2f 100644 --- a/tests/unit/cli-runtime-detection.test.mjs +++ b/tests/unit/cli-runtime-detection.test.mjs @@ -15,7 +15,7 @@ const { getCliRuntimeStatus, CLI_TOOL_IDS } = // ─── Helpers ────────────────────────────────────────────────── function createTempDir() { - const testRoot = path.join(os.homedir(), ".omniroute-test-tmp"); + const testRoot = path.join(os.tmpdir(), "omniroute-test-tmp"); if (!fs.existsSync(testRoot)) { fs.mkdirSync(testRoot, { recursive: true }); } @@ -46,6 +46,7 @@ describe("CLI_TOOL_IDS", () => { "kilo", "continue", "opencode", + "qoder", ]; for (const id of expected) { assert.ok(CLI_TOOL_IDS.includes(id), `Missing tool: ${id}`); @@ -148,6 +149,26 @@ describe("Healthcheck — checkRunnable", () => { else delete process.env.CLI_CLINE_BIN; } }); + + it("should detect qodercli via env override and mark it runnable", async () => { + const prev = process.env.CLI_QODER_BIN; + const script = + process.platform === "win32" + ? createFile(tmpDir, "qoder.cmd", "@echo off\necho qodercli 0.1.37\n") + : createFile(tmpDir, "qodercli", "#!/bin/sh\necho qodercli 0.1.37\n"); + + process.env.CLI_QODER_BIN = script; + try { + const result = await getCliRuntimeStatus("qoder"); + assert.equal(result.installed, true); + assert.equal(result.runnable, true); + assert.equal(result.commandPath, script); + assert.equal(result.reason, null); + } finally { + if (prev !== undefined) process.env.CLI_QODER_BIN = prev; + else delete process.env.CLI_QODER_BIN; + } + }); }); // ─── Unknown tool ───────────────────────────────────────────── diff --git a/tests/unit/qoder-executor.test.mjs b/tests/unit/qoder-executor.test.mjs index 315cf550ef..69b494e9c0 100644 --- a/tests/unit/qoder-executor.test.mjs +++ b/tests/unit/qoder-executor.test.mjs @@ -1,150 +1,258 @@ import test from "node:test"; import assert from "node:assert/strict"; -import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; -// ═══════════════════════════════════════════════════════════════ -// QoderExecutor Unit Tests -// Tests for HMAC-SHA256 signature, headers, URL building -// Fixes: https://github.com/diegosouzapw/OmniRoute/issues/114 -// ═══════════════════════════════════════════════════════════════ +import { QoderExecutor } from "../../open-sse/executors/qoder.ts"; +import { + buildQoderPrompt, + getStaticQoderModels, + mapQoderModelToLevel, + normalizeQoderPatProviderData, + parseQoderCliFailure, + validateQoderCliPat, +} from "../../open-sse/services/qoderCli.ts"; -const { QoderExecutor } = await import("../../open-sse/executors/qoder.ts"); +function createTempDir() { + const testRoot = path.join(os.tmpdir(), "omniroute-test-tmp"); + fs.mkdirSync(testRoot, { recursive: true }); + return fs.mkdtempSync(path.join(testRoot, "qoder-")); +} -// ─── Constructor ────────────────────────────────────────────── +function writeExecutable(dir, name, body) { + const filePath = path.join(dir, name); + fs.writeFileSync(filePath, body, "utf8"); + if (process.platform !== "win32") { + fs.chmodSync(filePath, 0o755); + } + return filePath; +} -test("QoderExecutor: constructor sets provider to 'qoder'", () => { +function createQoderCliScript(dir, name, mode) { + if (process.platform === "win32") { + const successJson = '{"message":{"content":"OK"}}'; + const successStream = [ + '{"message":{"content":"O"}}', + '{"message":{"content":"OK"}}', + '{"type":"result","done":true}', + ].join("\\n"); + const body = + mode === "invalid" + ? `@echo off\r\nif "%1"=="--version" echo qodercli 0.1.37 & exit /b 0\r\necho Invalid API key 1>&2\r\nexit /b 1\r\n` + : `@echo off\r\nif "%1"=="--version" echo qodercli 0.1.37 & exit /b 0\r\nset MODE=json\r\n:loop\r\nif "%1"=="" goto done\r\nif "%1"=="--output-format" (\r\n set MODE=%2\r\n)\r\nshift\r\ngoto loop\r\n:done\r\nif "%MODE%"=="stream-json" (\r\n echo ${successStream}\r\n) else (\r\n echo ${successJson}\r\n)\r\nexit /b 0\r\n`; + return writeExecutable(dir, `${name}.cmd`, body); + } + + const successJson = `{"message":{"content":"OK"}}`; + const successStream = `{"message":{"content":"O"}}\n{"message":{"content":"OK"}}\n{"type":"result","done":true}`; + const body = + mode === "invalid" + ? `#!/bin/sh +if [ "$1" = "--version" ] || [ "$1" = "-v" ]; then + echo "qodercli 0.1.37" + exit 0 +fi +echo "Invalid API key" >&2 +exit 1 +` + : `#!/bin/sh +if [ "$1" = "--version" ] || [ "$1" = "-v" ]; then + echo "qodercli 0.1.37" + exit 0 +fi +MODE=json +PREV="" +for ARG in "$@"; do + if [ "$PREV" = "--output-format" ]; then + MODE="$ARG" + fi + PREV="$ARG" +done +if [ "$MODE" = "stream-json" ]; then + printf '%s\n' '${successStream}' +else + printf '%s\n' '${successJson}' +fi +exit 0 +`; + + return writeExecutable(dir, name, body); +} + +test("QoderExecutor: constructor sets provider to qoder", () => { const executor = new QoderExecutor(); assert.equal(executor.getProvider(), "qoder"); }); -// ─── createQoderSignature ───────────────────────────────────── - -test("QoderExecutor: createQoderSignature returns valid HMAC-SHA256 hex", () => { +test("QoderExecutor: buildHeaders only keeps generic JSON and stream headers", () => { const executor = new QoderExecutor(); - const userAgent = "Qoder-Cli"; - const sessionID = "session-test-123"; - const timestamp = 1700000000000; - const apiKey = "test-api-key-secret"; - - const signature = executor.createQoderSignature(userAgent, sessionID, timestamp, apiKey); - - // Verify it's a valid hex string (64 chars for SHA256) - assert.match(signature, /^[0-9a-f]{64}$/); - - // Verify reproducibility — same inputs produce same signature - const signature2 = executor.createQoderSignature(userAgent, sessionID, timestamp, apiKey); - assert.equal(signature, signature2); - - // Verify against manual HMAC computation - const payload = `${userAgent}:${sessionID}:${timestamp}`; - const expected = crypto.createHmac("sha256", apiKey).update(payload).digest("hex"); - assert.equal(signature, expected); + assert.deepEqual(executor.buildHeaders({ apiKey: "pat" }, true), { + "Content-Type": "application/json", + Accept: "text/event-stream", + }); + assert.deepEqual(executor.buildHeaders({ apiKey: "pat" }, false), { + "Content-Type": "application/json", + }); }); -test("QoderExecutor: createQoderSignature returns empty string when apiKey is empty", () => { +test("QoderExecutor: buildUrl uses the live qoder.com API base", () => { const executor = new QoderExecutor(); - const result = executor.createQoderSignature("agent", "session", 123, ""); - assert.equal(result, ""); + assert.equal(executor.buildUrl("qoder-rome-30ba3b", false), "https://api.qoder.com/v1/chat/completions"); }); -test("QoderExecutor: createQoderSignature returns empty string when apiKey is null", () => { - const executor = new QoderExecutor(); - const result = executor.createQoderSignature("agent", "session", 123, null); - assert.equal(result, ""); +test("normalizeQoderPatProviderData forces PAT + qodercli transport", () => { + assert.deepEqual(normalizeQoderPatProviderData({ region: "sa-east-1" }), { + region: "sa-east-1", + authMode: "pat", + transport: "qodercli", + }); }); -// ─── buildHeaders ───────────────────────────────────────────── - -test("QoderExecutor: buildHeaders includes qoder-specific headers", () => { - const executor = new QoderExecutor(); - const credentials = { apiKey: "test-key-123" }; - - const headers = executor.buildHeaders(credentials, true); - - // Must include required qoder headers - assert.ok(headers["session-id"], "Missing session-id header"); - assert.ok(headers["x-qoder-timestamp"], "Missing x-qoder-timestamp header"); - assert.ok(headers["x-qoder-signature"], "Missing x-qoder-signature header"); - - // session-id format - assert.ok( - headers["session-id"].startsWith("session-"), - "session-id should start with 'session-'" - ); - - // timestamp is a number string - assert.match(headers["x-qoder-timestamp"], /^\d+$/); - - // signature is hex - assert.match(headers["x-qoder-signature"], /^[0-9a-f]{64}$/); - - // Authorization - assert.equal(headers["Authorization"], "Bearer test-key-123"); - - // Content-Type - assert.equal(headers["Content-Type"], "application/json"); - - // Streaming Accept - assert.equal(headers["Accept"], "text/event-stream"); +test("mapQoderModelToLevel maps static models to qodercli levels", () => { + assert.equal(mapQoderModelToLevel("qoder-rome-30ba3b"), "qmodel"); + assert.equal(mapQoderModelToLevel("deepseek-r1"), "ultimate"); + assert.equal(mapQoderModelToLevel("qwen3-max"), "performance"); + assert.equal(mapQoderModelToLevel(""), null); }); -test("QoderExecutor: buildHeaders omits Accept header when stream is false", () => { - const executor = new QoderExecutor(); - const credentials = { apiKey: "test-key" }; - - const headers = executor.buildHeaders(credentials, false); - - assert.equal(headers["Accept"], undefined); +test("getStaticQoderModels exposes the static if/* catalog seed", () => { + const models = getStaticQoderModels(); + assert.ok(models.some((model) => model.id === "qoder-rome-30ba3b")); + assert.ok(models.some((model) => model.id === "deepseek-r1")); }); -test("QoderExecutor: buildHeaders uses accessToken when apiKey is missing", () => { - const executor = new QoderExecutor(); - const credentials = { accessToken: "oauth-token-123" }; +test("buildQoderPrompt flattens transcript and warns against local tools", () => { + const prompt = buildQoderPrompt({ + messages: [ + { role: "system", content: "Follow the user request." }, + { + role: "user", + content: [{ type: "text", text: "Reply with OK." }], + }, + { + role: "assistant", + tool_calls: [ + { + type: "function", + function: { name: "pwd", arguments: "{}" }, + }, + ], + content: "", + }, + ], + tools: [{ type: "function", function: { name: "pwd" } }], + }); - const headers = executor.buildHeaders(credentials); - - assert.equal(headers["Authorization"], "Bearer oauth-token-123"); - // Signature should still be generated using the accessToken - assert.ok(headers["x-qoder-signature"].length > 0); + assert.match(prompt, /Conversation transcript:/); + assert.match(prompt, /USER:\nReply with OK\./); + assert.match(prompt, /TOOL_CALL pwd: \{\}/); + assert.match(prompt, /Do not call those tools yourself\./); }); -test("QoderExecutor: buildHeaders generates unique session IDs per call", () => { - const executor = new QoderExecutor(); - const credentials = { apiKey: "key" }; - - const headers1 = executor.buildHeaders(credentials); - const headers2 = executor.buildHeaders(credentials); - - assert.notEqual(headers1["session-id"], headers2["session-id"]); +test("parseQoderCliFailure classifies auth, runtime and timeout failures", () => { + assert.deepEqual(parseQoderCliFailure("Invalid API key"), { + status: 401, + message: "Invalid API key", + code: "upstream_auth_error", + }); + assert.deepEqual(parseQoderCliFailure("command not found: qodercli"), { + status: 503, + message: "command not found: qodercli", + code: "runtime_error", + }); + assert.deepEqual(parseQoderCliFailure("request timed out"), { + status: 504, + message: "request timed out", + code: "timeout", + }); }); -// ─── buildUrl ───────────────────────────────────────────────── +test("validateQoderCliPat succeeds when qodercli returns a JSON response", async () => { + const prev = process.env.CLI_QODER_BIN; + const tmpDir = createTempDir(); + const script = createQoderCliScript(tmpDir, "qodercli-ok", "success"); + process.env.CLI_QODER_BIN = script; -test("QoderExecutor: buildUrl returns config baseUrl", () => { - const executor = new QoderExecutor(); - const url = executor.buildUrl("qwen3-coder-plus", true); - - assert.equal(url, "https://apis.qoder.cn/v1/chat/completions"); + try { + const result = await validateQoderCliPat({ apiKey: "pat_test" }); + assert.deepEqual(result, { valid: true, error: null, unsupported: false }); + } finally { + if (prev === undefined) delete process.env.CLI_QODER_BIN; + else process.env.CLI_QODER_BIN = prev; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } }); -// ─── transformRequest ───────────────────────────────────────── +test("validateQoderCliPat returns invalid api key for auth failures", async () => { + const prev = process.env.CLI_QODER_BIN; + const tmpDir = createTempDir(); + const script = createQoderCliScript(tmpDir, "qodercli-bad", "invalid"); + process.env.CLI_QODER_BIN = script; -test("QoderExecutor: transformRequest passes body through unchanged", () => { - const executor = new QoderExecutor(); - const body = { - model: "deepseek-r1", - messages: [{ role: "user", content: "Hello" }], - stream: true, - }; - - const result = executor.transformRequest("deepseek-r1", body, true, {}); - assert.deepEqual(result, body); + try { + const result = await validateQoderCliPat({ apiKey: "pat_bad" }); + assert.deepEqual(result, { valid: false, error: "Invalid API key", unsupported: false }); + } finally { + if (prev === undefined) delete process.env.CLI_QODER_BIN; + else process.env.CLI_QODER_BIN = prev; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } }); -// ─── Integration: executor registry ─────────────────────────── +test("QoderExecutor: non-stream calls return an OpenAI-compatible completion payload", async () => { + const prev = process.env.CLI_QODER_BIN; + const tmpDir = createTempDir(); + const script = createQoderCliScript(tmpDir, "qodercli-exec", "success"); + process.env.CLI_QODER_BIN = script; -test("QoderExecutor: getExecutor('qoder') returns QoderExecutor instance", async () => { - const { getExecutor } = await import("../../open-sse/executors/index.ts"); - const executor = getExecutor("qoder"); - assert.ok(executor instanceof QoderExecutor, "Should return QoderExecutor instance"); + try { + const executor = new QoderExecutor(); + const { response, url } = await executor.execute({ + model: "qoder-rome-30ba3b", + body: { messages: [{ role: "user", content: "Reply with OK only." }] }, + stream: false, + credentials: { apiKey: "pat_test" }, + }); + + assert.equal(url, "qodercli://local"); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(payload.object, "chat.completion"); + assert.equal(payload.choices[0].message.role, "assistant"); + assert.equal(payload.choices[0].message.content, "OK"); + } finally { + if (prev === undefined) delete process.env.CLI_QODER_BIN; + else process.env.CLI_QODER_BIN = prev; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test("QoderExecutor: stream calls emit OpenAI-compatible SSE chunks", async () => { + const prev = process.env.CLI_QODER_BIN; + const tmpDir = createTempDir(); + const script = createQoderCliScript(tmpDir, "qodercli-stream", "success"); + process.env.CLI_QODER_BIN = script; + + try { + const executor = new QoderExecutor(); + const { response } = await executor.execute({ + model: "qoder-rome-30ba3b", + body: { messages: [{ role: "user", content: "Reply with OK only." }] }, + stream: true, + credentials: { apiKey: "pat_test" }, + }); + + assert.equal(response.status, 200); + const body = await response.text(); + assert.match(body, /chat\.completion\.chunk/); + assert.match(body, /"role":"assistant"/); + assert.match(body, /"content":"O"/); + assert.match(body, /"content":"K"/); + assert.match(body, /\[DONE\]/); + } finally { + if (prev === undefined) delete process.env.CLI_QODER_BIN; + else process.env.CLI_QODER_BIN = prev; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } }); diff --git a/tests/unit/qoder-oauth-config.test.mjs b/tests/unit/qoder-oauth-config.test.mjs new file mode 100644 index 0000000000..c5e6c9251d --- /dev/null +++ b/tests/unit/qoder-oauth-config.test.mjs @@ -0,0 +1,25 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { OAUTH_ENDPOINTS } from "../../open-sse/config/constants.ts"; +import { qoder } from "../../src/lib/oauth/providers/qoder.ts"; +import { QODER_CONFIG } from "../../src/lib/oauth/constants/oauth.ts"; + +test("Qoder OAuth defaults no longer point to qoder.cn", () => { + assert.doesNotMatch(QODER_CONFIG.authorizeUrl || "", /qoder\.cn/i); + assert.doesNotMatch(QODER_CONFIG.tokenUrl || "", /qoder\.cn/i); + assert.doesNotMatch(QODER_CONFIG.userInfoUrl || "", /qoder\.cn/i); + assert.doesNotMatch(OAUTH_ENDPOINTS.qoder.auth || "", /qoder\.cn/i); + assert.doesNotMatch(OAUTH_ENDPOINTS.qoder.token || "", /qoder\.cn/i); +}); + +test("Qoder OAuth provider returns null when browser auth is not configured", () => { + if (!QODER_CONFIG.enabled) { + assert.equal(qoder.buildAuthUrl(QODER_CONFIG, "http://localhost:8080/callback", "state"), null); + return; + } + + const authUrl = qoder.buildAuthUrl(QODER_CONFIG, "http://localhost:8080/callback", "state"); + assert.equal(typeof authUrl, "string"); + assert.doesNotMatch(authUrl || "", /qoder\.cn/i); +});