mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
* refactor(cli): remove legacy Qwen Code integration * refactor(qwen): remove deprecated Qwen OAuth provider * feat(cli): rebuild Qwen Code integration for upstream V4 * fix(qwen): clear stale CLI auth on reset * test(qwen): align retired provider coverage * fix(db): renumber qwen-cleanup migration 129 -> 130 release/v3.8.49 tip took slot 129 via #7843 (usage_history_codex_strong_identity, itself renumbered from 128 during the #7838/#7840 base-red cleanup) after this branch forked; renumber remove_unregistered_qwen_data to 130. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com>
273 lines
8.6 KiB
TypeScript
273 lines
8.6 KiB
TypeScript
import { NextResponse } from "next/server";
|
||
import fs from "fs/promises";
|
||
import path from "path";
|
||
import os from "os";
|
||
import * as yaml from "js-yaml";
|
||
import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth";
|
||
import { getRuntimePorts } from "@/lib/runtime/ports";
|
||
import { getCliPrimaryConfigPath, getOpenCodeConfigPath } from "@/shared/services/cliRuntime";
|
||
import { mergeOpenCodeConfigText } from "@/shared/services/opencodeConfig";
|
||
import { guideSettingsSaveSchema } from "@/shared/validation/schemas";
|
||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||
import { resolveApiKey, getOrCreateApiKey } from "@/shared/services/apiKeyResolver";
|
||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||
|
||
/**
|
||
* POST /api/cli-tools/guide-settings/:toolId
|
||
*
|
||
* Save configuration for guide-based tools that have config files.
|
||
* Currently supports: continue, opencode
|
||
*/
|
||
export async function GET(request, { params }) {
|
||
// cli-tools routes require the shared management auth guard on every exported handler.
|
||
const authError = await requireCliToolsAuth(request);
|
||
if (authError) return authError;
|
||
void params;
|
||
return NextResponse.json({ error: "GET not supported for this tool" }, { status: 400 });
|
||
}
|
||
|
||
export async function POST(request, { params }) {
|
||
const authError = await requireCliToolsAuth(request);
|
||
if (authError) return authError;
|
||
|
||
let rawBody;
|
||
try {
|
||
rawBody = await request.json();
|
||
} catch {
|
||
return NextResponse.json(
|
||
{
|
||
error: {
|
||
message: "Invalid request",
|
||
details: [{ field: "body", message: "Invalid JSON body" }],
|
||
},
|
||
},
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
const { toolId } = await params;
|
||
const validation = validateBody(guideSettingsSaveSchema, rawBody);
|
||
if (isValidationFailure(validation)) {
|
||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||
}
|
||
const { baseUrl, model, models, modelLabels } = validation.data;
|
||
// (#523) Extract keyId BEFORE validation — Zod strips unknown fields!
|
||
const apiKeyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null;
|
||
// If no keyId provided, auto-create a valid DB-backed key instead of using placeholder
|
||
const apiKey = apiKeyId
|
||
? await resolveApiKey(apiKeyId, validation.data.apiKey)
|
||
: await getOrCreateApiKey();
|
||
|
||
try {
|
||
switch (toolId) {
|
||
case "continue":
|
||
return await saveContinueConfig({ baseUrl, apiKey, model });
|
||
case "opencode":
|
||
// (#524) OpenCode config was never saved because only 'continue' was handled here.
|
||
// OpenCode reads ~/.config/opencode/opencode.json — write the OmniRoute settings there.
|
||
return await saveOpenCodeConfig({ baseUrl, apiKey, model, models, modelLabels });
|
||
case "hermes":
|
||
return await saveHermesConfig({ baseUrl, apiKey, model });
|
||
// hermes-agent now uses the dedicated /api/cli-tools/hermes-agent-settings endpoint
|
||
default:
|
||
return NextResponse.json(
|
||
{ error: `Direct config save not supported for: ${toolId}` },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
} catch (error) {
|
||
return NextResponse.json(
|
||
{ error: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)) },
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Save Continue config to ~/.continue/config.json
|
||
* Merges with existing config if present.
|
||
*/
|
||
async function saveContinueConfig({ baseUrl, apiKey, model }) {
|
||
const { apiPort } = getRuntimePorts();
|
||
const configPath = path.join(os.homedir(), ".continue", "config.json");
|
||
const configDir = path.dirname(configPath);
|
||
|
||
// Ensure dir exists
|
||
await fs.mkdir(configDir, { recursive: true });
|
||
|
||
// Read existing config if any
|
||
let existingConfig: any = {};
|
||
try {
|
||
const raw = await fs.readFile(configPath, "utf-8");
|
||
existingConfig = JSON.parse(raw);
|
||
} catch {
|
||
// No existing config or invalid JSON — start fresh
|
||
}
|
||
|
||
// Build the OmniRoute model entry
|
||
const normalizedBaseUrl = String(baseUrl || "")
|
||
.trim()
|
||
.replace(/\/+$/, "");
|
||
const routerModel = {
|
||
apiBase: normalizedBaseUrl,
|
||
title: model,
|
||
model: model,
|
||
provider: "openai",
|
||
apiKey: apiKey || "sk_omniroute",
|
||
omnirouteManaged: true,
|
||
};
|
||
|
||
// Merge into existing models array
|
||
const models = existingConfig.models || [];
|
||
|
||
function normalizeApiBase(value: unknown): string {
|
||
return String(value || "")
|
||
.trim()
|
||
.replace(/\/+$/, "")
|
||
.toLowerCase();
|
||
}
|
||
|
||
// Check if OmniRoute entry already exists and update it, or add new
|
||
const existingIdx = models.findIndex(
|
||
(m) =>
|
||
m &&
|
||
(m.omnirouteManaged === true ||
|
||
normalizeApiBase(m.apiBase) === normalizedBaseUrl.toLowerCase() ||
|
||
normalizeApiBase(m.apiBase).includes("omniroute") ||
|
||
normalizeApiBase(m.apiBase).includes(`localhost:${apiPort}`) ||
|
||
normalizeApiBase(m.apiBase).includes(`127.0.0.1:${apiPort}`) ||
|
||
// eslint-disable-next-line no-restricted-syntax -- teknik string kontrolü, kullanıcı metni araması değil
|
||
String(m.apiKey || "")
|
||
.toLowerCase()
|
||
.includes("sk_omniroute"))
|
||
);
|
||
|
||
if (existingIdx >= 0) {
|
||
models[existingIdx] = routerModel;
|
||
} else {
|
||
models.push(routerModel);
|
||
}
|
||
|
||
existingConfig.models = models;
|
||
|
||
// Write back
|
||
await fs.writeFile(configPath, JSON.stringify(existingConfig, null, 2), "utf-8");
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
message: `Continue config saved to ${configPath}`,
|
||
configPath,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Save OpenCode config to ~/.config/opencode/opencode.json on ALL platforms
|
||
* (XDG_CONFIG_HOME aware). OpenCode uses XDG `~/.config` even on Windows
|
||
* (%USERPROFILE%\.config), NOT %APPDATA% (#3330).
|
||
*
|
||
* (#524) OpenCode was silently failing because this handler was missing.
|
||
*/
|
||
async function saveOpenCodeConfig({ baseUrl, apiKey, model, models, modelLabels }) {
|
||
const configPath = getOpenCodeConfigPath();
|
||
const configDir = path.dirname(configPath);
|
||
|
||
// Ensure config directory exists
|
||
await fs.mkdir(configDir, { recursive: true });
|
||
|
||
const normalizedBaseUrl = String(baseUrl || "")
|
||
.trim()
|
||
.replace(/\/+$/, "");
|
||
|
||
// Read existing JSONC/JSON text to preserve unrelated config formatting and fields.
|
||
let existingConfigText = "";
|
||
try {
|
||
existingConfigText = await fs.readFile(configPath, "utf-8");
|
||
} catch {
|
||
// File doesn't exist — start fresh
|
||
}
|
||
|
||
const nextConfigText = mergeOpenCodeConfigText(existingConfigText, {
|
||
baseUrl: normalizedBaseUrl,
|
||
apiKey,
|
||
model,
|
||
models,
|
||
modelLabels,
|
||
});
|
||
|
||
await fs.writeFile(configPath, nextConfigText, "utf-8");
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
message: `OpenCode config saved to ${configPath}`,
|
||
configPath,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Save Hermes config to ~/.hermes/config.yaml
|
||
*
|
||
* Hermes stores its primary routing settings in YAML. Preserve any existing
|
||
* keys, but make sure the OmniRoute provider entry is present and selected.
|
||
*/
|
||
async function saveHermesConfig({ baseUrl, apiKey, model }) {
|
||
const configPath =
|
||
getCliPrimaryConfigPath("hermes") || path.join(os.homedir(), ".hermes", "config.yaml");
|
||
const configDir = path.dirname(configPath);
|
||
|
||
await fs.mkdir(configDir, { recursive: true });
|
||
|
||
const normalizedBaseUrl = String(baseUrl || "")
|
||
.trim()
|
||
.replace(/\/+$/, "");
|
||
const providerBaseUrl = normalizedBaseUrl.endsWith("/v1")
|
||
? normalizedBaseUrl
|
||
: `${normalizedBaseUrl}/v1`;
|
||
|
||
if (!model) {
|
||
return NextResponse.json({ error: "model is required for Hermes" }, { status: 400 });
|
||
}
|
||
const selectedModel = model;
|
||
|
||
let existingConfig: Record<string, any> = {};
|
||
try {
|
||
const raw = await fs.readFile(configPath, "utf-8");
|
||
const parsed = yaml.load(raw);
|
||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||
existingConfig = parsed as Record<string, any>;
|
||
}
|
||
} catch {
|
||
// No existing config or unparsable YAML — start fresh.
|
||
}
|
||
|
||
const nextConfig = {
|
||
...existingConfig,
|
||
model: {
|
||
...(existingConfig.model || {}),
|
||
default: selectedModel,
|
||
provider: "omniroute",
|
||
base_url: providerBaseUrl,
|
||
},
|
||
providers: {
|
||
...(existingConfig.providers || {}),
|
||
omniroute: {
|
||
...((existingConfig.providers && existingConfig.providers.omniroute) || {}),
|
||
base_url: providerBaseUrl,
|
||
api_key:
|
||
apiKey ||
|
||
(existingConfig.providers &&
|
||
existingConfig.providers.omniroute &&
|
||
existingConfig.providers.omniroute.api_key) ||
|
||
"",
|
||
},
|
||
},
|
||
};
|
||
|
||
await fs.writeFile(configPath, yaml.dump(nextConfig, { lineWidth: -1 }), "utf-8");
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
message: `Hermes config saved to ${configPath}`,
|
||
configPath,
|
||
});
|
||
}
|