mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 14:12:59 +03:00
Release v3.6.9 (#1404)
* test: resolve typescript strictness complaints in unit tests * Update Claude Code obfuscation to version 2.1.114 (#1403) * fix(cloud-code): scope thinking stripping to executor boundaries (#1401) * fix(cloud-code): scope thinking stripping to executors * fix(cloud-code): guard antigravity normalized body * Update Claude Code obfuscation to version 2.1.114 - Update Claude Code version from 2.1.87 to 2.1.114 - Update X-Stainless-Package-Version from 0.80.0 to 0.81.0 - Add new beta flags: redact-thinking-2026-02-12, advisor-tool-2026-03-01, advanced-tool-use-2025-11-20 - Add missing headers: anthropic-version, anthropic-dangerous-direct-browser-access, x-app, X-Stainless-Timeout - Add all X-Stainless-* headers (Arch, Lang, OS, Runtime, Runtime-Version, Retry-Count) - Fix accept-encoding header: identity -> gzip, deflate, br, zstd - Add connection: keep-alive header - Update tool name mapping: add lsp, apply_patch, websearch These changes ensure that requests from OpenCode through Omniroute are indistinguishable from genuine Claude Code 2.1.114 requests, allowing proper authentication with Anthropic's API without triggering extra credits errors. * fix: resolve CodeQL password hash alert and TruffleHog CI failure --------- Co-authored-by: Randi <55005611+rdself@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: Nikolay Popov <ekklesio.dev@gmail.com> Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com> * fix(claude-code): scope obfuscation to cli clients and fix tests * docs(workflows): enforce PR merge instead of manual close * docs(changelog): update 3.6.9 notes with missing PR 1403 and fixes * docs(workflows): update generate-release to use full changelog for PR body * fix(tsc): silence baseUrl deprecation warnings for TS 5.5+ * fix(chatcore): apply proactive compression before provider translation (#1406) Integrated into release/v3.6.9 * docs(changelog): add PR 1406 * Makes text visible in dark-mode (#1409) Integrated into release/v3.6.9 * docs(changelog): add PR 1409 * chore: save local work * chore(release): sync version references to 3.6.9 * fix(codex): prevent proactive token refresh consumption and strip background parameter * ci: shard long-running suites and relax timeouts * ci: allow manual CI dispatch for release branches * feat(skills): provider-aware marketplace UX, scored AUTO injection, and memory pipeline hardening (#1411) * fix/400 for GeminiCLI(add "ref" in GEMINI_UNSUPPORTED_SCHEMA_KEYS) * feat(cc-compatible): align request shape with Claude CLI * fix(cc-compatible): add Claude CLI system skeleton for OpenAI input * preserve reasoning when translating chat to responses (#1414) Integrated into release/v3.6.9 * fix(skills): optimize AUTO scoring and include Responses input context (#1418) Integrated into release/v3.6.9 * chore: fix TS errors and update review-prs workflow * fix(api): stop sending unsupported Gemini and Codex parameters Prevent Gemini request translation from injecting default thoughtSignature values that the upstream API strictly validates and rejects. Only preserve real signatures resolved from prior upstream responses, and strip additionalProperties from Gemini function schemas to avoid 400 "Unknown name" errors. Also remove fallback-injected session_id and conversation_id fields before sending Codex requests, and restore compatibility with the legacy OUTBOUND_SSRF_GUARD_ENABLED flag when determining whether private provider URLs are allowed. Updates the Gemini translator and regression tests for issue #1410 and related 400 error cases. * fix(core): stabilization fixes for token refresh, usage translation, and testing - Update Codex token refresh detection logic - Mark provider connections invalid on unrecoverable refresh error - Fix Claude usage translation under-reporting cached tokens - Update test expectations - Update CHANGELOG.md for v3.6.9 * fix(auth): reload fresh token state and unify expiry persistence Refresh checks now re-read the latest stored provider connection before attempting rotation so they do not use stale refresh tokens captured by an earlier sweep. Token updates also persist both expiresAt and tokenExpiresAt across the health check, usage-limit refresh path, and SSE refresh flow. This keeps known token expiry metadata in sync and avoids interval-based refreshes for connections whose tokens are still valid well into the future. * fix: resolve SSRF environment static evaluation bug (#1427) Fix import aliases and strict TS typings for tests and ACP agents. * test: resolve remaining strict type errors in test files * test: fix provider service assertion for anthropic-compatible header * fix(codex): respect openaiStoreEnabled setting during native passthrough (#1432) * fix(codex): fix token refresh unrecoverable detection for expired tokens * fix(ci): restore release v3.6.9 build and flaky tests * fix(cc-compatible): trim default OpenAI system skeleton (#1433) Integrated into release/v3.6.9 * fix: prevent masked API keys from being written to CLI tool configs (#1435) * feat: mark Qwen provider as deprecated and add deprecation warning to CLI tool (#1437) * docs(changelog): comprehensive v3.6.9 update with all 59 commits since v3.6.8 * test(ci): align qwen guide settings assertions * fix(security): resolve CodeQL alert 163 for incomplete URL sanitization in Qwen CLI settings --------- Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com> Co-authored-by: Nikolay Popov <74762779+nikolay-popov-ideogram@users.noreply.github.com> Co-authored-by: Randi <55005611+rdself@users.noreply.github.com> Co-authored-by: Nikolay Popov <ekklesio.dev@gmail.com> Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com> Co-authored-by: Tim Massey <tim-massey@users.noreply.github.com> Co-authored-by: Paijo <oyi77@users.noreply.github.com> Co-authored-by: dail45 <dail45@yandex.ru> Co-authored-by: R.D. <rogerproself@gmail.com>
This commit is contained in:
committed by
GitHub
parent
5be86907d7
commit
3432dfd280
@@ -5,6 +5,7 @@ export const runtime = "nodejs";
|
||||
import { NextResponse } from "next/server";
|
||||
import { cliMitmStartSchema, cliMitmStopSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { resolveApiKey } from "@/shared/services/apiKeyResolver";
|
||||
|
||||
// GET - Check MITM status
|
||||
export async function GET() {
|
||||
@@ -46,7 +47,10 @@ export async function POST(request) {
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { apiKey, sudoPassword } = validation.data;
|
||||
const { apiKey: rawApiKey, sudoPassword } = validation.data;
|
||||
// (#523) Extract keyId BEFORE validation — Zod strips unknown fields!
|
||||
const apiKeyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null;
|
||||
const apiKey = await resolveApiKey(apiKeyId, rawApiKey);
|
||||
const { startMitm, getCachedPassword, setCachedPassword } = await import("@/mitm/manager");
|
||||
const isWin = process.platform === "win32";
|
||||
const pwd = sudoPassword || getCachedPassword() || "";
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ensureCliConfigWriteAllowed } from "@/shared/services/cliRuntime";
|
||||
import { cliBackupMutationSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
const VALID_TOOLS = ["claude", "codex", "droid", "openclaw", "cline", "kilo"];
|
||||
const VALID_TOOLS = ["claude", "codex", "droid", "openclaw", "cline", "kilo", "qwen"];
|
||||
|
||||
// GET /api/cli-tools/backups?tool=claude — list backups
|
||||
export async function GET(request) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createBackup } from "@/shared/services/backupService";
|
||||
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
|
||||
import { cliModelConfigSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { getApiKeyById } from "@/lib/localDb";
|
||||
import { resolveApiKey } from "@/shared/services/apiKeyResolver";
|
||||
|
||||
const CLINE_DATA_DIR = path.join(os.homedir(), ".cline", "data");
|
||||
const GLOBAL_STATE_PATH = path.join(CLINE_DATA_DIR, "globalState.json");
|
||||
@@ -129,17 +129,8 @@ export async function POST(request: Request) {
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
let { baseUrl, apiKey, model } = validation.data;
|
||||
|
||||
// Resolve real key from DB by ID
|
||||
if (keyId) {
|
||||
try {
|
||||
const keyRecord = await getApiKeyById(keyId);
|
||||
if (keyRecord?.key) apiKey = keyRecord.key as string;
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
}
|
||||
const { baseUrl, model } = validation.data;
|
||||
const apiKey = await resolveApiKey(keyId, validation.data.apiKey);
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(CLINE_DATA_DIR, { recursive: true });
|
||||
|
||||
@@ -12,7 +12,7 @@ import { createBackup } from "@/shared/services/backupService";
|
||||
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
|
||||
import { cliModelConfigSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { getApiKeyById } from "@/lib/localDb";
|
||||
import { resolveApiKey } from "@/shared/services/apiKeyResolver";
|
||||
|
||||
const getDroidSettingsPath = () => getCliPrimaryConfigPath("droid");
|
||||
const getDroidDir = () => path.dirname(getDroidSettingsPath());
|
||||
@@ -106,19 +106,7 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { baseUrl, model } = validation.data;
|
||||
let { apiKey } = validation.data;
|
||||
|
||||
// Resolve real key from DB by ID
|
||||
if (keyId) {
|
||||
try {
|
||||
const keyRecord = await getApiKeyById(keyId);
|
||||
if (keyRecord?.key) {
|
||||
apiKey = keyRecord.key as string;
|
||||
}
|
||||
} catch {
|
||||
// Non-critical: fall back to whatever value was in apiKey
|
||||
}
|
||||
}
|
||||
const apiKey = await resolveApiKey(keyId, validation.data.apiKey);
|
||||
|
||||
const droidDir = getDroidDir();
|
||||
const settingsPath = getDroidSettingsPath();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getOpenCodeConfigPath } from "@/shared/services/cliRuntime";
|
||||
import { mergeOpenCodeConfig } from "@/shared/services/opencodeConfig";
|
||||
import { guideSettingsSaveSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { resolveApiKey } from "@/shared/services/apiKeyResolver";
|
||||
|
||||
/**
|
||||
* POST /api/cli-tools/guide-settings/:toolId
|
||||
@@ -35,7 +36,10 @@ export async function POST(request, { params }) {
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { baseUrl, apiKey, model } = validation.data;
|
||||
const { baseUrl, model } = validation.data;
|
||||
// (#523) Extract keyId BEFORE validation — Zod strips unknown fields!
|
||||
const apiKeyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null;
|
||||
const apiKey = await resolveApiKey(apiKeyId, validation.data.apiKey);
|
||||
|
||||
try {
|
||||
switch (toolId) {
|
||||
@@ -177,20 +181,50 @@ async function saveOpenCodeConfig({ baseUrl, apiKey, model }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Qwen Code config to ~/.qwen/settings.json
|
||||
* Writes the modelProviders.openai entry with OmniRoute as the provider.
|
||||
* Merges with existing config to preserve other providers.
|
||||
* Save Qwen Code config to ~/.qwen/settings.json + ~/.qwen/.env
|
||||
*
|
||||
* Per official docs, credentials go in .env via envKey references,
|
||||
* not hardcoded in settings.json modelProviders entries.
|
||||
* Writes openai, anthropic, and gemini providers pointing to OmniRoute.
|
||||
*/
|
||||
async function saveQwenConfig({ baseUrl, apiKey, model }) {
|
||||
const home = os.homedir();
|
||||
const configPath = path.join(home, ".qwen", "settings.json");
|
||||
const envPath = path.join(home, ".qwen", ".env");
|
||||
const configDir = path.dirname(configPath);
|
||||
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
|
||||
const normalizedBaseUrl = String(baseUrl || "").trim().replace(/\/+$/, "");
|
||||
const normalizedBaseUrl = String(baseUrl || "")
|
||||
.trim()
|
||||
.replace(/\/+$/, "");
|
||||
const resolvedApiKey = apiKey || "sk_omniroute";
|
||||
const resolvedModel = model || "coder-model";
|
||||
|
||||
// Read existing config to preserve other provider entries
|
||||
// --- Write API keys to .env ---
|
||||
let envContent = "";
|
||||
try {
|
||||
envContent = await fs.readFile(envPath, "utf-8");
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
|
||||
const envLines = envContent.split("\n").filter((line) => {
|
||||
// Remove old OmniRoute-related keys we're about to write
|
||||
return (
|
||||
!line.startsWith("OPENAI_API_KEY=") &&
|
||||
!line.startsWith("ANTHROPIC_API_KEY=") &&
|
||||
!line.startsWith("GEMINI_API_KEY=")
|
||||
);
|
||||
});
|
||||
|
||||
envLines.push(`OPENAI_API_KEY=${resolvedApiKey}`);
|
||||
envLines.push(`ANTHROPIC_API_KEY=${resolvedApiKey}`);
|
||||
envLines.push(`GEMINI_API_KEY=${resolvedApiKey}`);
|
||||
|
||||
await fs.writeFile(envPath, envLines.join("\n").trim() + "\n", "utf-8");
|
||||
|
||||
// --- Write modelProviders to settings.json ---
|
||||
let existingConfig: Record<string, any> = {};
|
||||
try {
|
||||
const raw = await fs.readFile(configPath, "utf-8");
|
||||
@@ -199,39 +233,75 @@ async function saveQwenConfig({ baseUrl, apiKey, model }) {
|
||||
// File doesn't exist or invalid JSON
|
||||
}
|
||||
|
||||
// Build OmniRoute openai provider entry
|
||||
const omnirouteEntry = {
|
||||
id: "omniroute",
|
||||
name: "OmniRoute",
|
||||
if (!existingConfig.modelProviders) existingConfig.modelProviders = {};
|
||||
|
||||
// openai provider — primary, supports all models via OmniRoute
|
||||
const openaiEntry = {
|
||||
id: resolvedModel,
|
||||
name: `${resolvedModel} (OmniRoute)`,
|
||||
envKey: "OPENAI_API_KEY",
|
||||
baseUrl: normalizedBaseUrl,
|
||||
apiKey: apiKey || "sk_omniroute",
|
||||
generationConfig: {
|
||||
defaultModel: model || "auto",
|
||||
contextWindowSize: 200000,
|
||||
},
|
||||
};
|
||||
|
||||
// Ensure modelProviders.openai array exists
|
||||
if (!existingConfig.modelProviders) existingConfig.modelProviders = {};
|
||||
if (!existingConfig.modelProviders.openai) existingConfig.modelProviders.openai = [];
|
||||
|
||||
const providers = existingConfig.modelProviders.openai;
|
||||
|
||||
// Replace OmniRoute entry if already present, otherwise add it
|
||||
const existingIdx = providers.findIndex(
|
||||
const openaiProviders = existingConfig.modelProviders.openai;
|
||||
const openaiIdx = openaiProviders.findIndex(
|
||||
(p: any) => p && (p.baseUrl === normalizedBaseUrl || p.id === "omniroute")
|
||||
);
|
||||
if (existingIdx >= 0) {
|
||||
providers[existingIdx] = omnirouteEntry;
|
||||
if (openaiIdx >= 0) {
|
||||
openaiProviders[openaiIdx] = openaiEntry;
|
||||
} else {
|
||||
providers.push(omnirouteEntry);
|
||||
openaiProviders.push(openaiEntry);
|
||||
}
|
||||
|
||||
// anthropic provider — for Claude models via OmniRoute
|
||||
const anthropicEntry = {
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6 (OmniRoute)",
|
||||
envKey: "ANTHROPIC_API_KEY",
|
||||
baseUrl: normalizedBaseUrl,
|
||||
generationConfig: {
|
||||
contextWindowSize: 200000,
|
||||
},
|
||||
};
|
||||
|
||||
if (!existingConfig.modelProviders.anthropic) existingConfig.modelProviders.anthropic = [];
|
||||
const anthropicProviders = existingConfig.modelProviders.anthropic;
|
||||
const anthropicIdx = anthropicProviders.findIndex(
|
||||
(p: any) => p && p.baseUrl === normalizedBaseUrl
|
||||
);
|
||||
if (anthropicIdx >= 0) {
|
||||
anthropicProviders[anthropicIdx] = anthropicEntry;
|
||||
} else {
|
||||
anthropicProviders.push(anthropicEntry);
|
||||
}
|
||||
|
||||
// gemini provider — for Gemini models via OmniRoute
|
||||
const geminiEntry = {
|
||||
id: "gemini-3-flash",
|
||||
name: "Gemini 3 Flash (OmniRoute)",
|
||||
envKey: "GEMINI_API_KEY",
|
||||
baseUrl: normalizedBaseUrl,
|
||||
};
|
||||
|
||||
if (!existingConfig.modelProviders.gemini) existingConfig.modelProviders.gemini = [];
|
||||
const geminiProviders = existingConfig.modelProviders.gemini;
|
||||
const geminiIdx = geminiProviders.findIndex((p: any) => p && p.baseUrl === normalizedBaseUrl);
|
||||
if (geminiIdx >= 0) {
|
||||
geminiProviders[geminiIdx] = geminiEntry;
|
||||
} else {
|
||||
geminiProviders.push(geminiEntry);
|
||||
}
|
||||
|
||||
await fs.writeFile(configPath, JSON.stringify(existingConfig, null, 2), "utf-8");
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Qwen Code config saved to ${configPath}`,
|
||||
message: `Qwen Code config saved to ${configPath} + ${envPath}`,
|
||||
configPath,
|
||||
envPath,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createBackup } from "@/shared/services/backupService";
|
||||
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
|
||||
import { cliModelConfigSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { getApiKeyById } from "@/lib/localDb";
|
||||
import { resolveApiKey } from "@/shared/services/apiKeyResolver";
|
||||
|
||||
const KILO_DATA_DIR = path.join(os.homedir(), ".local", "share", "kilo");
|
||||
const AUTH_PATH = path.join(KILO_DATA_DIR, "auth.json");
|
||||
@@ -138,19 +138,7 @@ export async function POST(request) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { baseUrl, model } = validation.data;
|
||||
let { apiKey } = validation.data;
|
||||
|
||||
// Resolve real key from DB by ID
|
||||
if (keyId) {
|
||||
try {
|
||||
const keyRecord = await getApiKeyById(keyId);
|
||||
if (keyRecord?.key) {
|
||||
apiKey = keyRecord.key as string;
|
||||
}
|
||||
} catch {
|
||||
// Non-critical: fall back to whatever value was in apiKey
|
||||
}
|
||||
}
|
||||
const apiKey = await resolveApiKey(keyId, validation.data.apiKey);
|
||||
|
||||
// Ensure directories exist
|
||||
await fs.mkdir(KILO_DATA_DIR, { recursive: true });
|
||||
|
||||
@@ -12,7 +12,7 @@ import { createBackup } from "@/shared/services/backupService";
|
||||
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
|
||||
import { cliModelConfigSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { getApiKeyById } from "@/lib/localDb";
|
||||
import { resolveApiKey } from "@/shared/services/apiKeyResolver";
|
||||
|
||||
const getOpenClawSettingsPath = () => getCliPrimaryConfigPath("openclaw");
|
||||
const getOpenClawDir = () => path.dirname(getOpenClawSettingsPath());
|
||||
@@ -105,17 +105,8 @@ export async function POST(request: Request) {
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
let { baseUrl, apiKey, model } = validation.data;
|
||||
|
||||
// Resolve real key from DB by ID
|
||||
if (keyId) {
|
||||
try {
|
||||
const keyRecord = await getApiKeyById(keyId);
|
||||
if (keyRecord?.key) apiKey = keyRecord.key as string;
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
}
|
||||
let { baseUrl, model } = validation.data;
|
||||
let apiKey = await resolveApiKey(keyId, validation.data.apiKey);
|
||||
|
||||
const openclawDir = getOpenClawDir();
|
||||
const settingsPath = getOpenClawSettingsPath();
|
||||
|
||||
353
src/app/api/cli-tools/qwen-settings/route.ts
Normal file
353
src/app/api/cli-tools/qwen-settings/route.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import {
|
||||
ensureCliConfigWriteAllowed,
|
||||
getCliPrimaryConfigPath,
|
||||
getCliRuntimeStatus,
|
||||
} from "@/shared/services/cliRuntime";
|
||||
import { createBackup } from "@/shared/services/backupService";
|
||||
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
|
||||
import { cliModelConfigSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { getApiKeyById } from "@/lib/localDb";
|
||||
|
||||
const getQwenSettingsPath = () => getCliPrimaryConfigPath("qwen");
|
||||
const getQwenDir = () => path.dirname(getQwenSettingsPath());
|
||||
const getQwenEnvPath = () => path.join(getQwenDir(), ".env");
|
||||
|
||||
// Read current settings.json
|
||||
const readSettings = async () => {
|
||||
try {
|
||||
const settingsPath = getQwenSettingsPath();
|
||||
const content = await fs.readFile(settingsPath, "utf-8");
|
||||
return JSON.parse(content);
|
||||
} catch (error: any) {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Read current .env file
|
||||
const readEnv = async () => {
|
||||
try {
|
||||
const envPath = getQwenEnvPath();
|
||||
return await fs.readFile(envPath, "utf-8");
|
||||
} catch (error: any) {
|
||||
if (error.code === "ENOENT") return "";
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if settings has OmniRoute config
|
||||
const hasOmniRouteConfig = (settings: any) => {
|
||||
if (!settings || !settings.modelProviders) return false;
|
||||
const openai = settings.modelProviders.openai;
|
||||
if (!Array.isArray(openai)) return false;
|
||||
return openai.some((p: any) => {
|
||||
if (p.name?.includes("OmniRoute") || p.id === "omniroute") return true;
|
||||
if (!p.baseUrl) return false;
|
||||
try {
|
||||
const urlObj = new URL(p.baseUrl);
|
||||
const host = urlObj.hostname;
|
||||
const isDashScope =
|
||||
host === "dashscope.aliyuncs.com" || host.endsWith(".dashscope.aliyuncs.com");
|
||||
const isOpenAI = host === "api.openai.com" || host.endsWith(".openai.com");
|
||||
return !isDashScope && !isOpenAI;
|
||||
} catch {
|
||||
return true; // invalid URLs are treated as custom endpoints
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// GET - Check Qwen CLI and read current settings
|
||||
export async function GET() {
|
||||
try {
|
||||
const runtime = await getCliRuntimeStatus("qwen");
|
||||
|
||||
if (!runtime.installed || !runtime.runnable) {
|
||||
return NextResponse.json({
|
||||
installed: runtime.installed,
|
||||
runnable: runtime.runnable,
|
||||
command: runtime.command,
|
||||
commandPath: runtime.commandPath,
|
||||
runtimeMode: runtime.runtimeMode,
|
||||
reason: runtime.reason,
|
||||
settings: null,
|
||||
message:
|
||||
runtime.installed && !runtime.runnable
|
||||
? "Qwen Code CLI is installed but not runnable"
|
||||
: "Qwen Code CLI is not installed",
|
||||
});
|
||||
}
|
||||
|
||||
const settings = await readSettings();
|
||||
|
||||
return NextResponse.json({
|
||||
installed: runtime.installed,
|
||||
runnable: runtime.runnable,
|
||||
command: runtime.command,
|
||||
commandPath: runtime.commandPath,
|
||||
runtimeMode: runtime.runtimeMode,
|
||||
reason: runtime.reason,
|
||||
settings,
|
||||
hasOmniRoute: hasOmniRouteConfig(settings),
|
||||
settingsPath: getQwenSettingsPath(),
|
||||
envPath: getQwenEnvPath(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error checking qwen settings:", error);
|
||||
return NextResponse.json({ error: "Failed to check qwen settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST - Write OmniRoute config to settings.json + .env
|
||||
export async function POST(request: Request) {
|
||||
let rawBody;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
message: "Invalid request",
|
||||
details: [{ field: "body", message: "Invalid JSON body" }],
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const writeGuard = ensureCliConfigWriteAllowed();
|
||||
if (writeGuard) {
|
||||
return NextResponse.json({ error: writeGuard }, { status: 403 });
|
||||
}
|
||||
|
||||
// Extract keyId BEFORE validation — Zod strips unknown fields
|
||||
const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null;
|
||||
|
||||
const validation = validateBody(cliModelConfigSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
let { baseUrl, apiKey, model } = validation.data;
|
||||
|
||||
// Resolve real key from DB by ID
|
||||
if (keyId) {
|
||||
try {
|
||||
const keyRecord = await getApiKeyById(keyId);
|
||||
if (keyRecord?.key) apiKey = keyRecord.key as string;
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedApiKey = apiKey || "sk_omniroute";
|
||||
const resolvedModel = model || "coder-model";
|
||||
const normalizedBaseUrl = String(baseUrl || "")
|
||||
.trim()
|
||||
.replace(/\/+$/, "");
|
||||
const qwenDir = getQwenDir();
|
||||
const settingsPath = getQwenSettingsPath();
|
||||
const envPath = getQwenEnvPath();
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(qwenDir, { recursive: true });
|
||||
|
||||
// Backup current settings before modifying
|
||||
await createBackup("qwen", settingsPath);
|
||||
|
||||
// --- Write API keys to ~/.qwen/.env ---
|
||||
let envContent = await readEnv();
|
||||
const envLines = envContent.split("\n").filter((line) => {
|
||||
// Remove old OmniRoute-related keys we're about to write
|
||||
return (
|
||||
!line.startsWith("OPENAI_API_KEY=") &&
|
||||
!line.startsWith("ANTHROPIC_API_KEY=") &&
|
||||
!line.startsWith("GEMINI_API_KEY=")
|
||||
);
|
||||
});
|
||||
|
||||
envLines.push(`OPENAI_API_KEY=${resolvedApiKey}`);
|
||||
envLines.push(`ANTHROPIC_API_KEY=${resolvedApiKey}`);
|
||||
envLines.push(`GEMINI_API_KEY=${resolvedApiKey}`);
|
||||
|
||||
await fs.writeFile(envPath, envLines.join("\n").trim() + "\n", "utf-8");
|
||||
|
||||
// --- Write modelProviders to settings.json ---
|
||||
let existingConfig: Record<string, any> = {};
|
||||
try {
|
||||
const raw = await fs.readFile(settingsPath, "utf-8");
|
||||
existingConfig = JSON.parse(raw);
|
||||
} catch {
|
||||
// File doesn't exist or invalid JSON
|
||||
}
|
||||
|
||||
if (!existingConfig.modelProviders) existingConfig.modelProviders = {};
|
||||
|
||||
// openai provider — primary, supports all models via OmniRoute
|
||||
const openaiEntry = {
|
||||
id: resolvedModel,
|
||||
name: `${resolvedModel} (OmniRoute)`,
|
||||
envKey: "OPENAI_API_KEY",
|
||||
baseUrl: normalizedBaseUrl,
|
||||
generationConfig: {
|
||||
contextWindowSize: 200000,
|
||||
},
|
||||
};
|
||||
|
||||
if (!existingConfig.modelProviders.openai) existingConfig.modelProviders.openai = [];
|
||||
const openaiProviders = existingConfig.modelProviders.openai;
|
||||
const openaiIdx = openaiProviders.findIndex(
|
||||
(p: any) => p && (p.baseUrl === normalizedBaseUrl || p.id === "omniroute")
|
||||
);
|
||||
if (openaiIdx >= 0) {
|
||||
openaiProviders[openaiIdx] = openaiEntry;
|
||||
} else {
|
||||
openaiProviders.push(openaiEntry);
|
||||
}
|
||||
|
||||
// anthropic provider — for Claude models via OmniRoute
|
||||
const anthropicEntry = {
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6 (OmniRoute)",
|
||||
envKey: "ANTHROPIC_API_KEY",
|
||||
baseUrl: normalizedBaseUrl,
|
||||
generationConfig: {
|
||||
contextWindowSize: 200000,
|
||||
},
|
||||
};
|
||||
|
||||
if (!existingConfig.modelProviders.anthropic) existingConfig.modelProviders.anthropic = [];
|
||||
const anthropicProviders = existingConfig.modelProviders.anthropic;
|
||||
const anthropicIdx = anthropicProviders.findIndex(
|
||||
(p: any) => p && p.baseUrl === normalizedBaseUrl
|
||||
);
|
||||
if (anthropicIdx >= 0) {
|
||||
anthropicProviders[anthropicIdx] = anthropicEntry;
|
||||
} else {
|
||||
anthropicProviders.push(anthropicEntry);
|
||||
}
|
||||
|
||||
// gemini provider — for Gemini models via OmniRoute
|
||||
const geminiEntry = {
|
||||
id: "gemini-3-flash",
|
||||
name: "Gemini 3 Flash (OmniRoute)",
|
||||
envKey: "GEMINI_API_KEY",
|
||||
baseUrl: normalizedBaseUrl,
|
||||
};
|
||||
|
||||
if (!existingConfig.modelProviders.gemini) existingConfig.modelProviders.gemini = [];
|
||||
const geminiProviders = existingConfig.modelProviders.gemini;
|
||||
const geminiIdx = geminiProviders.findIndex((p: any) => p && p.baseUrl === normalizedBaseUrl);
|
||||
if (geminiIdx >= 0) {
|
||||
geminiProviders[geminiIdx] = geminiEntry;
|
||||
} else {
|
||||
geminiProviders.push(geminiEntry);
|
||||
}
|
||||
|
||||
await fs.writeFile(settingsPath, JSON.stringify(existingConfig, null, 2), "utf-8");
|
||||
|
||||
// Persist last-configured timestamp
|
||||
try {
|
||||
saveCliToolLastConfigured("qwen");
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Qwen Code config saved successfully!",
|
||||
settingsPath,
|
||||
envPath,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error updating qwen settings:", error);
|
||||
return NextResponse.json({ error: "Failed to update qwen settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - Remove OmniRoute config from settings.json and .env
|
||||
export async function DELETE() {
|
||||
try {
|
||||
const writeGuard = ensureCliConfigWriteAllowed();
|
||||
if (writeGuard) {
|
||||
return NextResponse.json({ error: writeGuard }, { status: 403 });
|
||||
}
|
||||
|
||||
const settingsPath = getQwenSettingsPath();
|
||||
const envPath = getQwenEnvPath();
|
||||
|
||||
// Backup current settings before resetting
|
||||
await createBackup("qwen", settingsPath);
|
||||
|
||||
// --- Clean settings.json ---
|
||||
let existingConfig: Record<string, any> = {};
|
||||
try {
|
||||
const raw = await fs.readFile(settingsPath, "utf-8");
|
||||
existingConfig = JSON.parse(raw);
|
||||
} catch (error: any) {
|
||||
if (error.code === "ENOENT") {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "No settings file to reset",
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Remove OmniRoute entries from each provider type
|
||||
const providerTypes = ["openai", "anthropic", "gemini"];
|
||||
for (const type of providerTypes) {
|
||||
if (Array.isArray(existingConfig.modelProviders?.[type])) {
|
||||
existingConfig.modelProviders[type] = existingConfig.modelProviders[type].filter(
|
||||
(p: any) => !p.name?.includes("OmniRoute") && p.id !== "omniroute"
|
||||
);
|
||||
// Remove empty provider arrays
|
||||
if (existingConfig.modelProviders[type].length === 0) {
|
||||
delete existingConfig.modelProviders[type];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up empty modelProviders
|
||||
if (existingConfig.modelProviders && Object.keys(existingConfig.modelProviders).length === 0) {
|
||||
delete existingConfig.modelProviders;
|
||||
}
|
||||
|
||||
await fs.writeFile(settingsPath, JSON.stringify(existingConfig, null, 2), "utf-8");
|
||||
|
||||
// --- Clean .env ---
|
||||
const RESET_ENV_KEYS = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY"];
|
||||
|
||||
try {
|
||||
let envContent = await fs.readFile(envPath, "utf-8");
|
||||
const envLines = envContent
|
||||
.split("\n")
|
||||
.filter((line) => !RESET_ENV_KEYS.some((key) => line.startsWith(`${key}=`)));
|
||||
|
||||
await fs.writeFile(envPath, envLines.join("\n").trim() + "\n", "utf-8");
|
||||
} catch {
|
||||
// .env doesn't exist — nothing to clean
|
||||
}
|
||||
|
||||
// Clear last-configured timestamp
|
||||
try {
|
||||
deleteCliToolLastConfigured("qwen");
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "OmniRoute settings removed from Qwen Code",
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error resetting qwen settings:", error);
|
||||
return NextResponse.json({ error: "Failed to reset qwen settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,16 @@ async function checkToolConfigStatus(toolId: string): Promise<string> {
|
||||
switch (toolId) {
|
||||
case "claude":
|
||||
return config?.env?.ANTHROPIC_BASE_URL ? "configured" : "not_configured";
|
||||
case "qwen":
|
||||
// Check modelProviders for OmniRoute entries
|
||||
const mp = config?.modelProviders;
|
||||
if (!mp) return "not_configured";
|
||||
const qwenConfigStr = JSON.stringify(mp).toLowerCase();
|
||||
return qwenConfigStr.includes("omniroute") ||
|
||||
qwenConfigStr.includes(`localhost:${apiPort}`) ||
|
||||
qwenConfigStr.includes(`127.0.0.1:${apiPort}`)
|
||||
? "configured"
|
||||
: "not_configured";
|
||||
case "droid":
|
||||
case "openclaw":
|
||||
case "cline":
|
||||
@@ -128,7 +138,7 @@ export async function GET() {
|
||||
);
|
||||
|
||||
// Check config status for installed+runnable tools via direct file reads
|
||||
const settingsTools = ["claude", "codex", "droid", "openclaw", "cline", "kilo"];
|
||||
const settingsTools = ["claude", "codex", "droid", "openclaw", "cline", "kilo", "qwen"];
|
||||
|
||||
await Promise.all(
|
||||
settingsTools.map(async (toolId) => {
|
||||
|
||||
Reference in New Issue
Block a user