Files
OmniRoute/src/app/api/cli-tools/guide-settings/[toolId]/route.ts
backryun 65e0aeda79 [Part 1/3]refactor(qwen): replace legacy Qwen Code and remove OAuth provider (#7866)
* 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>
2026-07-20 17:53:04 -03:00

273 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
});
}