mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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:
@@ -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 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
498
open-sse/services/qoderCli.ts
Normal file
498
open-sse/services/qoderCli.ts
Normal 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 };
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" } }
|
||||
|
||||
@@ -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)}`,
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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] || [];
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
25
tests/unit/qoder-oauth-config.test.mjs
Normal file
25
tests/unit/qoder-oauth-config.test.mjs
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user