Merge pull request #913 from carmin777/feat/qoder-pat-qodercli

feat(qoder): support PAT via qodercli and remove stale qoder.cn defaults
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-04-02 14:36:25 -03:00
committed by GitHub
22 changed files with 1241 additions and 261 deletions

View File

@@ -1162,7 +1162,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 |
| ------------------ | ------ | ------------- | --------------- |
@@ -1172,6 +1172,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 |

View File

@@ -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",

View File

@@ -325,19 +325,17 @@ export const REGISTRY: Record<string, RegistryEntry> = {
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" },

View File

@@ -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<string, string> = {
buildHeaders(_credentials: ProviderCredentials, stream = true): Record<string, string> {
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<string, unknown>)
: {};
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,
};
}
}

View File

@@ -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<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 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<QoderCliRunResult> {
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 };
}

View File

@@ -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, {

View File

@@ -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() {
</button>
)}
{!isCompatible ? (
<Button
size="sm"
icon="add"
onClick={() => (isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true))}
>
{t("add")}
</Button>
<div className="flex items-center gap-2">
<Button size="sm" icon="add" onClick={openPrimaryAddFlow}>
{providerSupportsPat ? "Add PAT" : t("add")}
</Button>
{providerId === "qoder" && (
<Button size="sm" variant="secondary" onClick={() => setShowOAuthModal(true)}>
Experimental OAuth
</Button>
)}
</div>
) : (
connections.length === 0 && (
<Button size="sm" icon="add" onClick={() => setShowAddApiKeyModal(true)}>
@@ -2242,12 +2257,16 @@ export default function ProviderDetailPage() {
<p className="text-text-main font-medium mb-1">{t("noConnectionsYet")}</p>
<p className="text-sm text-text-muted mb-4">{t("addFirstConnectionHint")}</p>
{!isCompatible && (
<Button
icon="add"
onClick={() => (isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true))}
>
{t("addConnection")}
</Button>
<div className="flex items-center justify-center gap-2">
<Button icon="add" onClick={openPrimaryAddFlow}>
{providerSupportsPat ? "Add PAT" : t("addConnection")}
</Button>
{providerId === "qoder" && (
<Button variant="secondary" onClick={() => setShowOAuthModal(true)}>
Experimental OAuth
</Button>
)}
</div>
)}
</div>
) : (
@@ -2264,7 +2283,7 @@ export default function ProviderDetailPage() {
<ConnectionRow
key={conn.id}
connection={conn}
isOAuth={isOAuth}
isOAuth={conn.authType === "oauth"}
isFirst={index === 0}
isLast={index === sorted.length - 1}
onMoveUp={() => 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() {
<ConnectionRow
key={conn.id}
connection={conn}
isOAuth={isOAuth}
isOAuth={conn.authType === "oauth"}
isFirst={gi === 0 && index === 0}
isLast={gi === groupKeys.length - 1 && index === groupConns.length - 1}
onMoveUp={() =>
@@ -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")}
/>
<div className="flex gap-2">
<Input
label={t("apiKeyLabel")}
label={isQoder ? "Personal Access Token" : t("apiKeyLabel")}
type="password"
value={formData.apiKey}
onChange={(e) => 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
}
/>
<div className="pt-6">
<Button

View File

@@ -168,9 +168,11 @@ export default function ProvidersPage() {
};
const getProviderStats = (providerId, authType) => {
const providerConnections = connections.filter(
(c) => c.provider === providerId && c.authType === authType
);
const providerConnections = connections.filter((c) => {
if (c.provider !== providerId) return false;
if (authType === "free") return true;
return c.authType === authType;
});
// Helper: check if connection is effectively active (cooldown expired)
const getEffectiveStatus = (conn) => {
@@ -217,13 +219,17 @@ export default function ProvidersPage() {
// Toggle all connections for a provider on/off
const handleToggleProvider = async (providerId: string, authType: string, newActive: boolean) => {
const providerConns = connections.filter(
(c) => c.provider === providerId && c.authType === authType
);
const providerConns = connections.filter((c) => {
if (c.provider !== providerId) return false;
if (authType === "free") return true;
return c.authType === authType;
});
// Optimistically update UI
setConnections((prev) =>
prev.map((c) =>
c.provider === providerId && c.authType === authType ? { ...c, isActive: newActive } : c
c.provider === providerId && (authType === "free" || c.authType === authType)
? { ...c, isActive: newActive }
: c
)
);
// Fire API calls in parallel
@@ -444,9 +450,9 @@ export default function ProvidersPage() {
key={key}
providerId={key}
provider={info}
stats={getProviderStats(key, "oauth")}
stats={getProviderStats(key, "free")}
authType="free"
onToggle={(active) => handleToggleProvider(key, "oauth", active)}
onToggle={(active) => handleToggleProvider(key, "free", active)}
/>
))}
</div>

View File

@@ -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);
}

View File

@@ -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<string, unknown>;
@@ -99,6 +100,7 @@ const STATIC_MODEL_PROVIDERS: Record<string, () => Array<{ id: string; name: str
{ id: "glm-4.7", name: "GLM 4.7" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
],
qoder: () => getStaticQoderModels(),
};
/**

View File

@@ -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 = {

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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<string, string> = {
"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" } }

View File

@@ -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)}`,
{

View File

@@ -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,

View File

@@ -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)

View File

@@ -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" },

View File

@@ -109,6 +109,16 @@ const CLI_TOOLS: Record<string, any> = {
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] || [];

View File

@@ -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 ─────────────────────────────────────────────

View File

@@ -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 });
}
});

View File

@@ -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);
});