mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 05:31:47 +00:00
feat: add the feature for user save combo
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
- **Proxy-Pools**: auto-rotate strategy for no-auth providers (#2409)
|
- **Proxy-Pools**: auto-rotate strategy for no-auth providers (#2409)
|
||||||
|
|
||||||
## Fixes
|
## Fixes
|
||||||
|
- **CLI Tools**: persist non-MITM tool configurations per dashboard user, select the runtime deployment endpoint, and keep custom API keys out of storage
|
||||||
- **Cloudflare-AI**: support accountId in bulk key import (#2449)
|
- **Cloudflare-AI**: support accountId in bulk key import (#2449)
|
||||||
- **DB**: backup on schema change, MCP child cleanup, codex models, usage providers OOM
|
- **DB**: backup on schema change, MCP child cleanup, codex models, usage providers OOM
|
||||||
- **Codex**: avoid bare-email OAuth dedup (#2477)
|
- **Codex**: avoid bare-email OAuth dedup (#2477)
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ services:
|
|||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
cpus: "0.5"
|
cpus: "0.5"
|
||||||
memory: 2G
|
memory: 512M
|
||||||
# Dokploy/Traefik should route to this internal port. Do not publish it
|
# Dokploy/Traefik should route to this internal port. Do not publish it
|
||||||
# with `ports:`; public HTTP(S) is provided by the Dokploy domain router.
|
# with `ports:`; public HTTP(S) is provided by the Dokploy domain router.
|
||||||
expose:
|
expose:
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useCallback, useState, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { CardSkeleton } from "@/shared/components";
|
import { CardSkeleton } from "@/shared/components";
|
||||||
import { CLI_TOOLS } from "@/shared/constants/cliTools";
|
import { CLI_TOOLS } from "@/shared/constants/cliTools";
|
||||||
|
import { resolveCliToolBaseUrl } from "@/shared/utils/cliToolEndpoint";
|
||||||
import { ConfigGeneratorCard, DefaultToolCard } from "../components";
|
import { ConfigGeneratorCard, DefaultToolCard } from "../components";
|
||||||
|
|
||||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||||
|
const CONFIGURED_BASE_URL = process.env.NEXT_PUBLIC_BASE_URL;
|
||||||
|
|
||||||
export default function ToolDetailClient({ toolId, machineId }) {
|
export default function ToolDetailClient({ toolId, machineId }) {
|
||||||
const tool = CLI_TOOLS[toolId];
|
const tool = CLI_TOOLS[toolId];
|
||||||
@@ -19,17 +21,19 @@ export default function ToolDetailClient({ toolId, machineId }) {
|
|||||||
const [tailscaleUrl, setTailscaleUrl] = useState("");
|
const [tailscaleUrl, setTailscaleUrl] = useState("");
|
||||||
const [apiKeys, setApiKeys] = useState([]);
|
const [apiKeys, setApiKeys] = useState([]);
|
||||||
const [availableModels, setAvailableModels] = useState([]);
|
const [availableModels, setAvailableModels] = useState([]);
|
||||||
|
const [initialConfig, setInitialConfig] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const [provRes, settingsRes, tunnelRes, keysRes, modelsRes] = await Promise.all([
|
const [provRes, settingsRes, tunnelRes, keysRes, modelsRes, configRes] = await Promise.all([
|
||||||
fetch("/api/providers"),
|
fetch("/api/providers"),
|
||||||
fetch("/api/settings"),
|
fetch("/api/settings"),
|
||||||
fetch("/api/tunnel/status"),
|
fetch("/api/tunnel/status"),
|
||||||
fetch("/api/keys"),
|
fetch("/api/keys"),
|
||||||
fetch("/api/models/connected", { cache: "no-store" }),
|
fetch("/api/models/connected", { cache: "no-store" }),
|
||||||
|
fetch(`/api/cli-tools/config/${encodeURIComponent(toolId)}`, { cache: "no-store" }),
|
||||||
]);
|
]);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (provRes.ok) {
|
if (provRes.ok) {
|
||||||
@@ -55,6 +59,10 @@ export default function ToolDetailClient({ toolId, machineId }) {
|
|||||||
const data = await modelsRes.json();
|
const data = await modelsRes.json();
|
||||||
setAvailableModels((data.models || []).filter((model) => !model.disabled));
|
setAvailableModels((data.models || []).filter((model) => !model.disabled));
|
||||||
}
|
}
|
||||||
|
if (configRes.ok) {
|
||||||
|
const data = await configRes.json();
|
||||||
|
setInitialConfig(data.config || null);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error loading tool data:", error);
|
console.log("Error loading tool data:", error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -62,15 +70,34 @@ export default function ToolDetailClient({ toolId, machineId }) {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => { mounted = false; };
|
return () => { mounted = false; };
|
||||||
}, []);
|
}, [toolId]);
|
||||||
|
|
||||||
|
const saveConfig = useCallback(async (config) => {
|
||||||
|
const response = await fetch(`/api/cli-tools/config/${encodeURIComponent(toolId)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) throw new Error(data.error || "Failed to save configuration");
|
||||||
|
setInitialConfig(data.config);
|
||||||
|
return data.config;
|
||||||
|
}, [toolId]);
|
||||||
|
|
||||||
const getActiveProviders = () => connections.filter(c => c.isActive !== false);
|
const getActiveProviders = () => connections.filter(c => c.isActive !== false);
|
||||||
|
|
||||||
const getBaseUrl = () => {
|
const getBaseUrl = () => {
|
||||||
if (tunnelEnabled && tunnelPublicUrl) return tunnelPublicUrl;
|
return resolveCliToolBaseUrl({
|
||||||
if (cloudEnabled && CLOUD_URL) return CLOUD_URL;
|
appUrl: typeof window !== "undefined" ? window.location.origin : "",
|
||||||
if (typeof window !== "undefined") return window.location.origin;
|
configuredBaseUrl: CONFIGURED_BASE_URL,
|
||||||
return "http://localhost:20128";
|
requiresExternalUrl: tool?.requiresExternalUrl === true,
|
||||||
|
tunnelEnabled,
|
||||||
|
tunnelPublicUrl,
|
||||||
|
tailscaleEnabled,
|
||||||
|
tailscaleUrl,
|
||||||
|
cloudEnabled,
|
||||||
|
cloudUrl: CLOUD_URL,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderToolCard = () => {
|
const renderToolCard = () => {
|
||||||
@@ -86,6 +113,8 @@ export default function ToolDetailClient({ toolId, machineId }) {
|
|||||||
activeProviders: getActiveProviders(),
|
activeProviders: getActiveProviders(),
|
||||||
availableModels,
|
availableModels,
|
||||||
cloudEnabled,
|
cloudEnabled,
|
||||||
|
initialConfig,
|
||||||
|
onSaveConfig: saveConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (tool.configType === "guide") return <DefaultToolCard toolId={toolId} {...commonProps} />;
|
if (tool.configType === "guide") return <DefaultToolCard toolId={toolId} {...commonProps} />;
|
||||||
|
|||||||
@@ -1,65 +1,57 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
const CUSTOM_VALUE = "__custom__";
|
const CUSTOM_VALUE = "__custom__";
|
||||||
|
const UNSET_VALUE = "__unset__";
|
||||||
|
|
||||||
export default function ApiKeySelect({ value, onChange, apiKeys = [], cloudEnabled = false, className = "" }) {
|
export default function ApiKeySelect({ value, onChange, apiKeys = [], className = "", mode = "managed", onModeChange }) {
|
||||||
const isCustom = !apiKeys.some((k) => k.key === value) && value !== "";
|
const matchingKey = apiKeys.find((key) => key.key === value);
|
||||||
const [mode, setMode] = useState(() => {
|
const selectedMode = mode === "custom" ? CUSTOM_VALUE : (matchingKey?.key || UNSET_VALUE);
|
||||||
if (!value) return apiKeys.length > 0 ? apiKeys[0].key : CUSTOM_VALUE;
|
|
||||||
if (apiKeys.some((k) => k.key === value)) return value;
|
|
||||||
return CUSTOM_VALUE;
|
|
||||||
});
|
|
||||||
const [customInput, setCustomInput] = useState(isCustom ? value : "");
|
|
||||||
|
|
||||||
const handleSelect = (e) => {
|
const handleSelect = (e) => {
|
||||||
const next = e.target.value;
|
const next = e.target.value;
|
||||||
setMode(next);
|
if (next === UNSET_VALUE) return;
|
||||||
if (next === CUSTOM_VALUE) {
|
if (next === CUSTOM_VALUE) {
|
||||||
setCustomInput("");
|
onModeChange?.("custom");
|
||||||
onChange("");
|
onChange("");
|
||||||
} else {
|
} else {
|
||||||
|
onModeChange?.("managed");
|
||||||
onChange(next);
|
onChange(next);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCustomInput = (e) => {
|
const handleCustomInput = (e) => {
|
||||||
const v = e.target.value;
|
const v = e.target.value;
|
||||||
setCustomInput(v);
|
onModeChange?.("custom");
|
||||||
onChange(v);
|
onChange(v);
|
||||||
};
|
};
|
||||||
|
|
||||||
const noKeys = apiKeys.length === 0 && mode !== CUSTOM_VALUE;
|
|
||||||
|
|
||||||
if (noKeys && mode !== CUSTOM_VALUE) {
|
|
||||||
return (
|
|
||||||
<span className={`min-w-0 rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5 ${className}`}>
|
|
||||||
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col gap-1.5 ${className}`}>
|
<div className={`flex flex-col gap-1.5 ${className}`}>
|
||||||
<select
|
<select
|
||||||
value={mode}
|
value={selectedMode}
|
||||||
onChange={handleSelect}
|
onChange={handleSelect}
|
||||||
className="w-full min-w-0 px-2 py-2 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
|
className="w-full min-w-0 px-2 py-2 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
|
||||||
>
|
>
|
||||||
|
{selectedMode === UNSET_VALUE && <option value={UNSET_VALUE}>No managed API key selected</option>}
|
||||||
{apiKeys.map((k) => (
|
{apiKeys.map((k) => (
|
||||||
<option key={k.id} value={k.key}>{k.key}</option>
|
<option key={k.id} value={k.key}>{k.key}</option>
|
||||||
))}
|
))}
|
||||||
<option value={CUSTOM_VALUE}>Custom...</option>
|
<option value={CUSTOM_VALUE}>Custom...</option>
|
||||||
</select>
|
</select>
|
||||||
{mode === CUSTOM_VALUE && (
|
{selectedMode === CUSTOM_VALUE && (
|
||||||
|
<>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="password"
|
||||||
value={customInput}
|
value={mode === "custom" ? value : ""}
|
||||||
onChange={handleCustomInput}
|
onChange={handleCustomInput}
|
||||||
placeholder="sk-..."
|
placeholder="sk-..."
|
||||||
|
autoComplete="off"
|
||||||
className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
|
className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
|
||||||
/>
|
/>
|
||||||
|
{mode === "custom" && !value && (
|
||||||
|
<span className="text-[11px] font-normal text-amber-600 dark:text-amber-400">Custom keys are not saved. Enter it again to generate the configuration.</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { APP_CONFIG } from "@/shared/constants/config";
|
import { APP_CONFIG } from "@/shared/constants/config";
|
||||||
|
import { ensureCliToolV1Endpoint, isLocalCliToolUrl } from "@/shared/utils/cliToolEndpoint";
|
||||||
|
|
||||||
const STORAGE_KEY = "9router.cliToolEndpointPresets";
|
const STORAGE_KEY = "9router.cliToolEndpointPresets";
|
||||||
const CUSTOM_VALUE = "__custom__";
|
const CUSTOM_VALUE = "__custom__";
|
||||||
const SAVE_VALUE = "__save__";
|
const SAVE_VALUE = "__save__";
|
||||||
|
|
||||||
const ensureV1 = (url) => {
|
|
||||||
const trimmed = (url || "").replace(/\/+$/, "");
|
|
||||||
if (!trimmed) return "";
|
|
||||||
return /\/v1$/.test(trimmed) ? trimmed : `${trimmed}/v1`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const readSavedPresets = () => {
|
const readSavedPresets = () => {
|
||||||
if (typeof window === "undefined") return [];
|
if (typeof window === "undefined") return [];
|
||||||
try {
|
try {
|
||||||
@@ -29,12 +24,15 @@ const writeSavedPresets = (presets) => {
|
|||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildOptions = ({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }) => {
|
const buildOptions = ({ appUrl, requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }) => {
|
||||||
const opts = [];
|
const opts = [];
|
||||||
const wrap = (url) => (withV1 ? ensureV1(url) : (url || "").replace(/\/+$/, ""));
|
const wrap = (url) => (withV1 ? ensureCliToolV1Endpoint(url) : (url || "").replace(/\/+$/, ""));
|
||||||
if (!requiresExternalUrl) {
|
const runtimeUrl = wrap(appUrl);
|
||||||
const localUrl = wrap(`http://127.0.0.1:${APP_CONFIG.defaultPort}`);
|
if (runtimeUrl && (!requiresExternalUrl || !isLocalCliToolUrl(runtimeUrl))) {
|
||||||
opts.push({ value: "local", label: localUrl, url: localUrl });
|
opts.push({ value: isLocalCliToolUrl(runtimeUrl) ? "local" : "deployment", label: runtimeUrl, url: runtimeUrl });
|
||||||
|
} else if (!requiresExternalUrl) {
|
||||||
|
const fallbackLocalUrl = wrap(`http://127.0.0.1:${APP_CONFIG.defaultPort}`);
|
||||||
|
opts.push({ value: "local", label: fallbackLocalUrl, url: fallbackLocalUrl });
|
||||||
}
|
}
|
||||||
if (tunnelEnabled && tunnelPublicUrl) {
|
if (tunnelEnabled && tunnelPublicUrl) {
|
||||||
const u = wrap(tunnelPublicUrl);
|
const u = wrap(tunnelPublicUrl);
|
||||||
@@ -58,6 +56,7 @@ const buildOptions = ({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tai
|
|||||||
export default function BaseUrlSelect({
|
export default function BaseUrlSelect({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
appUrl = "",
|
||||||
requiresExternalUrl = false,
|
requiresExternalUrl = false,
|
||||||
tunnelEnabled = false,
|
tunnelEnabled = false,
|
||||||
tunnelPublicUrl = "",
|
tunnelPublicUrl = "",
|
||||||
@@ -69,31 +68,24 @@ export default function BaseUrlSelect({
|
|||||||
}) {
|
}) {
|
||||||
const [savedPresets, setSavedPresets] = useState([]);
|
const [savedPresets, setSavedPresets] = useState([]);
|
||||||
const [mode, setMode] = useState("");
|
const [mode, setMode] = useState("");
|
||||||
const [customInput, setCustomInput] = useState("");
|
|
||||||
const initializedRef = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSavedPresets(readSavedPresets());
|
queueMicrotask(() => setSavedPresets(readSavedPresets()));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const options = useMemo(
|
const options = useMemo(
|
||||||
() => buildOptions({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }),
|
() => buildOptions({ appUrl, requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1 }),
|
||||||
[requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1]
|
[appUrl, requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Always default to first option (127.0.0.1) on mount, ignore persisted value
|
const effectiveMode = useMemo(() => {
|
||||||
useEffect(() => {
|
if (mode) return mode;
|
||||||
if (initializedRef.current) return;
|
const normalizedValue = (value || "").replace(/\/+$/, "");
|
||||||
if (options.length === 0) return;
|
const matchingOption = options.find((option) => option.value !== CUSTOM_VALUE && option.url.replace(/\/+$/, "") === normalizedValue);
|
||||||
initializedRef.current = true;
|
if (matchingOption) return matchingOption.value;
|
||||||
const first = options.find((o) => o.value !== CUSTOM_VALUE);
|
if (value) return CUSTOM_VALUE;
|
||||||
if (first) {
|
return options.find((option) => option.value !== CUSTOM_VALUE)?.value || CUSTOM_VALUE;
|
||||||
setMode(first.value);
|
}, [mode, options, value]);
|
||||||
onChange(first.url);
|
|
||||||
} else {
|
|
||||||
setMode(CUSTOM_VALUE);
|
|
||||||
}
|
|
||||||
}, [options, onChange]);
|
|
||||||
|
|
||||||
const handleSelect = (e) => {
|
const handleSelect = (e) => {
|
||||||
const next = e.target.value;
|
const next = e.target.value;
|
||||||
@@ -112,7 +104,6 @@ export default function BaseUrlSelect({
|
|||||||
}
|
}
|
||||||
setMode(next);
|
setMode(next);
|
||||||
if (next === CUSTOM_VALUE) {
|
if (next === CUSTOM_VALUE) {
|
||||||
setCustomInput("");
|
|
||||||
onChange("");
|
onChange("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -121,31 +112,28 @@ export default function BaseUrlSelect({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCustomInput = (e) => {
|
const handleCustomInput = (e) => {
|
||||||
const v = e.target.value;
|
onChange(e.target.value);
|
||||||
setCustomInput(v);
|
|
||||||
onChange(v);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteSaved = () => {
|
const handleDeleteSaved = () => {
|
||||||
if (!mode.startsWith("saved:")) return;
|
if (!effectiveMode.startsWith("saved:")) return;
|
||||||
const name = mode.slice(6);
|
const name = effectiveMode.slice(6);
|
||||||
const updated = savedPresets.filter((p) => p.name !== name);
|
const updated = savedPresets.filter((p) => p.name !== name);
|
||||||
setSavedPresets(updated);
|
setSavedPresets(updated);
|
||||||
writeSavedPresets(updated);
|
writeSavedPresets(updated);
|
||||||
setMode(CUSTOM_VALUE);
|
setMode(CUSTOM_VALUE);
|
||||||
setCustomInput("");
|
|
||||||
onChange("");
|
onChange("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSaved = mode.startsWith("saved:");
|
const isSaved = effectiveMode.startsWith("saved:");
|
||||||
const isCustom = mode === CUSTOM_VALUE;
|
const isCustom = effectiveMode === CUSTOM_VALUE;
|
||||||
const canSave = isCustom && (customInput || "").trim().length > 0;
|
const canSave = isCustom && (value || "").trim().length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<select
|
<select
|
||||||
value={mode}
|
value={effectiveMode}
|
||||||
onChange={handleSelect}
|
onChange={handleSelect}
|
||||||
className="flex-1 min-w-0 px-2 py-2 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
|
className="flex-1 min-w-0 px-2 py-2 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
|
||||||
>
|
>
|
||||||
@@ -163,7 +151,7 @@ export default function BaseUrlSelect({
|
|||||||
{isCustom && (
|
{isCustom && (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={customInput}
|
value={value || ""}
|
||||||
onChange={handleCustomInput}
|
onChange={handleCustomInput}
|
||||||
placeholder={withV1 ? "https://example.com/v1" : "https://example.com"}
|
placeholder={withV1 ? "https://example.com/v1" : "https://example.com"}
|
||||||
className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
|
className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Button, Card, ManualConfigModal, ModelSelectModal } from "@/shared/components";
|
import { Button, Card, ManualConfigModal, ModelSelectModal } from "@/shared/components";
|
||||||
import { getThinkingLevels } from "open-sse/providers/thinkingLevels.js";
|
import { getThinkingLevels } from "open-sse/providers/thinkingLevels.js";
|
||||||
import { DEFAULT_MODEL_TOKEN_LIMITS, getInputTokenOptions, getOutputTokenOptions } from "@/shared/constants/copilotModelTokens.js";
|
import { DEFAULT_MODEL_TOKEN_LIMITS, getInputTokenOptions, getOutputTokenOptions } from "@/shared/constants/copilotModelTokens.js";
|
||||||
|
import { resolveInitialCliToolBaseUrl } from "@/shared/utils/cliToolEndpoint";
|
||||||
import BaseUrlSelect from "./BaseUrlSelect";
|
import BaseUrlSelect from "./BaseUrlSelect";
|
||||||
import ApiKeySelect from "./ApiKeySelect";
|
import ApiKeySelect from "./ApiKeySelect";
|
||||||
|
|
||||||
@@ -171,23 +172,36 @@ export default function ConfigGeneratorCard({
|
|||||||
tunnelPublicUrl,
|
tunnelPublicUrl,
|
||||||
tailscaleEnabled,
|
tailscaleEnabled,
|
||||||
tailscaleUrl,
|
tailscaleUrl,
|
||||||
|
initialConfig,
|
||||||
|
onSaveConfig,
|
||||||
}) {
|
}) {
|
||||||
const [selectedApiKey, setSelectedApiKey] = useState(() => apiKeys?.[0]?.key || "");
|
const restoredApiKey = initialConfig?.apiKeyId
|
||||||
const [selectedModels, setSelectedModels] = useState([]);
|
? apiKeys.find((key) => key.id === initialConfig.apiKeyId)?.key || ""
|
||||||
const [claudeModels, setClaudeModels] = useState({ sonnet: "", opus: "", haiku: "" });
|
: "";
|
||||||
const [claudeThinking, setClaudeThinking] = useState({ sonnet: "", opus: "", haiku: "" });
|
const [selectedApiKey, setSelectedApiKey] = useState(() => (
|
||||||
|
initialConfig?.apiKeyMode === "custom"
|
||||||
|
? ""
|
||||||
|
: initialConfig?.apiKeyId ? restoredApiKey : apiKeys?.[0]?.key || ""
|
||||||
|
));
|
||||||
|
const [apiKeyMode, setApiKeyMode] = useState(() => initialConfig?.apiKeyMode || "managed");
|
||||||
|
const [selectedModels, setSelectedModels] = useState(() => initialConfig?.selectedModels || []);
|
||||||
|
const [claudeModels, setClaudeModels] = useState(() => initialConfig?.claudeModels || { sonnet: "", opus: "", haiku: "" });
|
||||||
|
const [claudeThinking, setClaudeThinking] = useState(() => initialConfig?.claudeThinking || { sonnet: "", opus: "", haiku: "" });
|
||||||
const [claudeModelSlot, setClaudeModelSlot] = useState("");
|
const [claudeModelSlot, setClaudeModelSlot] = useState("");
|
||||||
const [codexModel, setCodexModel] = useState("");
|
const [codexModel, setCodexModel] = useState(() => initialConfig?.codexModel || "");
|
||||||
const [codexThinking, setCodexThinking] = useState("");
|
const [codexThinking, setCodexThinking] = useState(() => initialConfig?.codexThinking || "");
|
||||||
const [opencodeModels, setOpencodeModels] = useState([]);
|
const [opencodeModels, setOpencodeModels] = useState(() => initialConfig?.opencodeModels || []);
|
||||||
const [opencodeDefaultModel, setOpencodeDefaultModel] = useState("");
|
const [opencodeDefaultModel, setOpencodeDefaultModel] = useState(() => initialConfig?.opencodeDefaultModel || "");
|
||||||
const [coworkThinking, setCoworkThinking] = useState({});
|
const [coworkThinking, setCoworkThinking] = useState(() => initialConfig?.coworkThinking || {});
|
||||||
const [copilotTokens, setCopilotTokens] = useState({});
|
const [copilotTokens, setCopilotTokens] = useState(() => initialConfig?.copilotTokens || {});
|
||||||
const [copilotThinking, setCopilotThinking] = useState({});
|
const [copilotThinking, setCopilotThinking] = useState(() => initialConfig?.copilotThinking || {});
|
||||||
const connectedModels = availableModels;
|
const connectedModels = availableModels;
|
||||||
const [customBaseUrl, setCustomBaseUrl] = useState("");
|
const [customBaseUrl, setCustomBaseUrl] = useState(() => resolveInitialCliToolBaseUrl(initialConfig?.baseUrl, baseUrl));
|
||||||
const [modelModalOpen, setModelModalOpen] = useState(false);
|
const [modelModalOpen, setModelModalOpen] = useState(false);
|
||||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||||
|
const [saveStatus, setSaveStatus] = useState("idle");
|
||||||
|
const [saveError, setSaveError] = useState("");
|
||||||
|
const initializedSaveState = useRef(false);
|
||||||
|
|
||||||
const effectiveBaseUrl = customBaseUrl || baseUrl;
|
const effectiveBaseUrl = customBaseUrl || baseUrl;
|
||||||
const apiKey = toolId === "copilot"
|
const apiKey = toolId === "copilot"
|
||||||
@@ -198,6 +212,43 @@ export default function ConfigGeneratorCard({
|
|||||||
[toolId, effectiveBaseUrl, apiKey, selectedModels, claudeModels, claudeThinking, codexModel, codexThinking, opencodeModels, opencodeDefaultModel, coworkThinking, copilotTokens, copilotThinking, connectedModels]
|
[toolId, effectiveBaseUrl, apiKey, selectedModels, claudeModels, claudeThinking, codexModel, codexThinking, opencodeModels, opencodeDefaultModel, coworkThinking, copilotTokens, copilotThinking, connectedModels]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const buildPersistableConfig = () => {
|
||||||
|
const config = { baseUrl: effectiveBaseUrl };
|
||||||
|
if (toolId !== "copilot") {
|
||||||
|
config.apiKeyMode = apiKeyMode;
|
||||||
|
config.apiKeyId = apiKeyMode === "managed"
|
||||||
|
? apiKeys.find((key) => key.key === selectedApiKey)?.id || null
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (toolId === "claude") Object.assign(config, { claudeModels, claudeThinking });
|
||||||
|
if (toolId === "codex") Object.assign(config, { codexModel, codexThinking });
|
||||||
|
if (toolId === "opencode") Object.assign(config, { opencodeModels, opencodeDefaultModel });
|
||||||
|
if (toolId === "cowork") Object.assign(config, { selectedModels, coworkThinking });
|
||||||
|
if (toolId === "copilot") Object.assign(config, { selectedModels, copilotThinking, copilotTokens });
|
||||||
|
return config;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaveStatus("saving");
|
||||||
|
setSaveError("");
|
||||||
|
try {
|
||||||
|
await onSaveConfig(buildPersistableConfig());
|
||||||
|
setSaveStatus("saved");
|
||||||
|
} catch (error) {
|
||||||
|
setSaveStatus("error");
|
||||||
|
setSaveError(error.message || "Failed to save configuration");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initializedSaveState.current) {
|
||||||
|
initializedSaveState.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaveStatus((current) => current === "saving" ? current : "dirty");
|
||||||
|
setSaveError("");
|
||||||
|
}, [effectiveBaseUrl, apiKeyMode, selectedApiKey, selectedModels, claudeModels, claudeThinking, codexModel, codexThinking, opencodeModels, opencodeDefaultModel, coworkThinking, copilotTokens, copilotThinking]);
|
||||||
|
|
||||||
const getThinkingLevelsForModel = (fullModel) => {
|
const getThinkingLevelsForModel = (fullModel) => {
|
||||||
const connectedModel = connectedModels?.find((model) => model.fullModel === fullModel);
|
const connectedModel = connectedModels?.find((model) => model.fullModel === fullModel);
|
||||||
if (!connectedModel?.provider?.id || !connectedModel.model) return null;
|
if (!connectedModel?.provider?.id || !connectedModel.model) return null;
|
||||||
@@ -307,8 +358,9 @@ export default function ConfigGeneratorCard({
|
|||||||
<label className="flex flex-col gap-1.5 text-xs font-medium text-text-muted">
|
<label className="flex flex-col gap-1.5 text-xs font-medium text-text-muted">
|
||||||
Endpoint
|
Endpoint
|
||||||
<BaseUrlSelect
|
<BaseUrlSelect
|
||||||
value={customBaseUrl || baseUrl}
|
value={customBaseUrl}
|
||||||
onChange={setCustomBaseUrl}
|
onChange={setCustomBaseUrl}
|
||||||
|
appUrl={baseUrl}
|
||||||
tunnelEnabled={tunnelEnabled}
|
tunnelEnabled={tunnelEnabled}
|
||||||
tunnelPublicUrl={tunnelPublicUrl}
|
tunnelPublicUrl={tunnelPublicUrl}
|
||||||
tailscaleEnabled={tailscaleEnabled}
|
tailscaleEnabled={tailscaleEnabled}
|
||||||
@@ -320,7 +372,7 @@ export default function ConfigGeneratorCard({
|
|||||||
{toolId !== "copilot" && (
|
{toolId !== "copilot" && (
|
||||||
<label className="flex flex-col gap-1.5 text-xs font-medium text-text-muted">
|
<label className="flex flex-col gap-1.5 text-xs font-medium text-text-muted">
|
||||||
API key
|
API key
|
||||||
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
|
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} mode={apiKeyMode} onModeChange={setApiKeyMode} />
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
{toolId === "claude" ? (
|
{toolId === "claude" ? (
|
||||||
@@ -558,10 +610,20 @@ export default function ConfigGeneratorCard({
|
|||||||
) : <p className="text-xs text-text-muted">No model selected. The generated file uses <code>{DEFAULT_MODEL}</code> as a placeholder.</p>}
|
) : <p className="text-xs text-text-muted">No model selected. The generated file uses <code>{DEFAULT_MODEL}</code> as a placeholder.</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button onClick={() => setConfigModalOpen(true)} className="w-full sm:w-auto sm:self-start">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button onClick={handleSave} disabled={saveStatus === "saving"} variant="secondary" className="w-full sm:w-auto">
|
||||||
|
<span className="material-symbols-outlined mr-1 text-[16px]">{saveStatus === "saving" ? "progress_activity" : "save"}</span>
|
||||||
|
{saveStatus === "saving" ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setConfigModalOpen(true)} className="w-full sm:w-auto">
|
||||||
<span className="material-symbols-outlined mr-1 text-[16px]">code</span>
|
<span className="material-symbols-outlined mr-1 text-[16px]">code</span>
|
||||||
Show configuration file
|
Show configuration file
|
||||||
</Button>
|
</Button>
|
||||||
|
{saveStatus === "saved" && <span className="text-xs text-green-600 dark:text-green-400">Configuration saved.</span>}
|
||||||
|
{saveStatus === "dirty" && <span className="text-xs text-text-muted">Unsaved changes</span>}
|
||||||
|
{saveStatus === "error" && <span className="text-xs text-red-600 dark:text-red-400">{saveError}</span>}
|
||||||
|
</div>
|
||||||
|
{apiKeyMode === "custom" && toolId !== "copilot" && <p className="text-xs text-amber-600 dark:text-amber-400">The custom API key is never saved and must be entered again after reload.</p>}
|
||||||
<p className="text-xs text-text-muted">9Router cannot inspect, modify, or apply files on your device from a deployed dashboard.</p>
|
<p className="text-xs text-text-muted">9Router cannot inspect, modify, or apply files on your device from a deployed dashboard.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,38 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Card, ModelSelectModal } from "@/shared/components";
|
import { Button, Card, ModelSelectModal } from "@/shared/components";
|
||||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import ApiKeySelect from "./ApiKeySelect";
|
import ApiKeySelect from "./ApiKeySelect";
|
||||||
|
|
||||||
export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, activeProviders = [], availableModels = [], cloudEnabled = false, tunnelEnabled = false }) {
|
export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, activeProviders = [], availableModels = [], cloudEnabled = false, tunnelEnabled = false, initialConfig, onSaveConfig }) {
|
||||||
const [copiedField, setCopiedField] = useState(null);
|
const [copiedField, setCopiedField] = useState(null);
|
||||||
const [showModelModal, setShowModelModal] = useState(false);
|
const [showModelModal, setShowModelModal] = useState(false);
|
||||||
const [modelValue, setModelValue] = useState("");
|
const [modelValue, setModelValue] = useState("");
|
||||||
const [selectedModels, setSelectedModels] = useState([]);
|
const [selectedModels, setSelectedModels] = useState(() => initialConfig?.selectedModels || []);
|
||||||
const [isExpanded, setIsExpanded] = useState(true);
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
const restoredApiKey = initialConfig?.apiKeyId
|
||||||
|
? apiKeys.find((key) => key.id === initialConfig.apiKeyId)?.key || ""
|
||||||
|
: "";
|
||||||
|
const [selectedApiKey, setSelectedApiKey] = useState(() => (
|
||||||
|
initialConfig?.apiKeyMode === "custom"
|
||||||
|
? ""
|
||||||
|
: initialConfig?.apiKeyId ? restoredApiKey : apiKeys?.[0]?.key || ""
|
||||||
|
));
|
||||||
|
const [apiKeyMode, setApiKeyMode] = useState(() => initialConfig?.apiKeyMode || "managed");
|
||||||
|
const [saveStatus, setSaveStatus] = useState("idle");
|
||||||
|
const [saveError, setSaveError] = useState("");
|
||||||
|
const initializedSaveState = useRef(false);
|
||||||
|
|
||||||
// Initialize state directly with computed value - no need for useEffect
|
useEffect(() => {
|
||||||
const [selectedApiKey, setSelectedApiKey] = useState(() =>
|
if (!initializedSaveState.current) {
|
||||||
apiKeys?.length > 0 ? apiKeys[0].key : ""
|
initializedSaveState.current = true;
|
||||||
);
|
return;
|
||||||
|
}
|
||||||
|
setSaveStatus((current) => current === "saving" ? current : "dirty");
|
||||||
|
setSaveError("");
|
||||||
|
}, [apiKeyMode, selectedApiKey, selectedModels]);
|
||||||
|
|
||||||
const replaceVars = (text) => {
|
const replaceVars = (text) => {
|
||||||
const keyToUse = (selectedApiKey && selectedApiKey.trim())
|
const keyToUse = (selectedApiKey && selectedApiKey.trim())
|
||||||
@@ -57,11 +73,29 @@ export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, active
|
|||||||
setSelectedModels((current) => current.filter((value) => value !== model.value));
|
setSelectedModels((current) => current.filter((value) => value !== model.value));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaveStatus("saving");
|
||||||
|
setSaveError("");
|
||||||
|
try {
|
||||||
|
await onSaveConfig({
|
||||||
|
selectedModels,
|
||||||
|
apiKeyMode,
|
||||||
|
apiKeyId: apiKeyMode === "managed"
|
||||||
|
? apiKeys.find((key) => key.key === selectedApiKey)?.id || null
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
setSaveStatus("saved");
|
||||||
|
} catch (error) {
|
||||||
|
setSaveStatus("error");
|
||||||
|
setSaveError(error.message || "Failed to save configuration");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const hasActiveProviders = activeProviders.length > 0;
|
const hasActiveProviders = activeProviders.length > 0;
|
||||||
|
|
||||||
const renderApiKeySelector = () => (
|
const renderApiKeySelector = () => (
|
||||||
<div className="mt-2 flex flex-col sm:flex-row sm:items-center gap-2">
|
<div className="mt-2 flex flex-col sm:flex-row sm:items-center gap-2">
|
||||||
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} className="flex-1" />
|
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} className="flex-1" mode={apiKeyMode} onModeChange={setApiKeyMode} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -249,6 +283,20 @@ export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, active
|
|||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{canShowGuide() && (
|
||||||
|
<div className="flex flex-col gap-2 border-t border-border pt-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button type="button" variant="secondary" size="sm" onClick={handleSave} disabled={saveStatus === "saving"}>
|
||||||
|
<span className="material-symbols-outlined mr-1 text-[16px]">{saveStatus === "saving" ? "progress_activity" : "save"}</span>
|
||||||
|
{saveStatus === "saving" ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
{saveStatus === "saved" && <span className="text-xs text-green-600 dark:text-green-400">Configuration saved.</span>}
|
||||||
|
{saveStatus === "dirty" && <span className="text-xs text-text-muted">Unsaved changes</span>}
|
||||||
|
{saveStatus === "error" && <span className="text-xs text-red-600 dark:text-red-400">{saveError}</span>}
|
||||||
|
</div>
|
||||||
|
{apiKeyMode === "custom" && <p className="text-xs text-amber-600 dark:text-amber-400">The custom API key is never saved and must be entered again after reload.</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireCurrentDashboardUser } from "@/lib/auth/currentUser";
|
||||||
|
import {
|
||||||
|
getApiKeyByIdAndOwnerId,
|
||||||
|
getCliToolConfig,
|
||||||
|
upsertCliToolConfig,
|
||||||
|
} from "@/lib/db/index.js";
|
||||||
|
import {
|
||||||
|
CliToolConfigValidationError,
|
||||||
|
isPersistableCliTool,
|
||||||
|
normalizeCliToolConfig,
|
||||||
|
} from "@/shared/constants/cliToolConfig.js";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function json(body, init = {}) {
|
||||||
|
const response = NextResponse.json(body, init);
|
||||||
|
response.headers.set("Cache-Control", "no-store");
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorResponse(error, operation) {
|
||||||
|
if (error?.message === "Unauthorized") return json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
if (error instanceof CliToolConfigValidationError || error instanceof SyntaxError) {
|
||||||
|
return json({ error: error instanceof SyntaxError ? "Invalid JSON payload" : error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
console.log(`Error ${operation} CLI tool configuration:`, error);
|
||||||
|
return json({ error: `Failed to ${operation} CLI tool configuration` }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request, { params }) {
|
||||||
|
try {
|
||||||
|
const user = await requireCurrentDashboardUser();
|
||||||
|
const { toolId } = await params;
|
||||||
|
if (!isPersistableCliTool(toolId)) {
|
||||||
|
return json({ error: "Unsupported CLI tool" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = await getCliToolConfig(user.id, toolId);
|
||||||
|
return json({ config: saved?.config || null, updatedAt: saved?.updatedAt || null });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error, "load");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request, { params }) {
|
||||||
|
try {
|
||||||
|
const user = await requireCurrentDashboardUser();
|
||||||
|
const { toolId } = await params;
|
||||||
|
if (!isPersistableCliTool(toolId)) {
|
||||||
|
return json({ error: "Unsupported CLI tool" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = normalizeCliToolConfig(toolId, await request.json());
|
||||||
|
if (config.apiKeyMode === "managed" && config.apiKeyId) {
|
||||||
|
const apiKey = await getApiKeyByIdAndOwnerId(config.apiKeyId, user.id);
|
||||||
|
if (!apiKey) return json({ error: "API key not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = await upsertCliToolConfig(user.id, toolId, config);
|
||||||
|
return json({ config: saved.config, updatedAt: saved.updatedAt });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error, "save");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// Public API barrel — all DB functions
|
// Public API barrel — all DB functions
|
||||||
import { getAdapter } from "./driver.js";
|
import { getAdapter } from "./driver.js";
|
||||||
import { stringifyJson, parseJson } from "./helpers/jsonCol.js";
|
import { stringifyJson, parseJson } from "./helpers/jsonCol.js";
|
||||||
|
import { normalizeCliToolConfig, isPersistableCliTool } from "@/shared/constants/cliToolConfig.js";
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
export {
|
export {
|
||||||
@@ -45,6 +46,12 @@ export {
|
|||||||
createCombo, updateCombo, deleteCombo,
|
createCombo, updateCombo, deleteCombo,
|
||||||
} from "./repos/combosRepo.js";
|
} from "./repos/combosRepo.js";
|
||||||
|
|
||||||
|
// Per-user CLI tool configurations
|
||||||
|
export {
|
||||||
|
getCliToolConfig, getCliToolConfigsByOwnerId,
|
||||||
|
upsertCliToolConfig, deleteCliToolConfigsByOwnerId,
|
||||||
|
} from "./repos/cliToolConfigsRepo.js";
|
||||||
|
|
||||||
// Aliases (model + custom + mitm)
|
// Aliases (model + custom + mitm)
|
||||||
export {
|
export {
|
||||||
getModelAliases, setModelAlias, deleteModelAlias,
|
getModelAliases, setModelAlias, deleteModelAlias,
|
||||||
@@ -87,6 +94,7 @@ export async function exportDb() {
|
|||||||
proxyPools: db.all(`SELECT * FROM proxyPools`).map((r) => ({ ...parseJson(r.data, {}), id: r.id, isActive: r.isActive === 1, testStatus: r.testStatus, createdAt: r.createdAt, updatedAt: r.updatedAt })),
|
proxyPools: db.all(`SELECT * FROM proxyPools`).map((r) => ({ ...parseJson(r.data, {}), id: r.id, isActive: r.isActive === 1, testStatus: r.testStatus, createdAt: r.createdAt, updatedAt: r.updatedAt })),
|
||||||
apiKeys: db.all(`SELECT * FROM apiKeys`).map((r) => ({ id: r.id, key: r.key, name: r.name, machineId: r.machineId, ownerId: r.ownerId, isActive: r.isActive === 1, createdAt: r.createdAt })),
|
apiKeys: db.all(`SELECT * FROM apiKeys`).map((r) => ({ id: r.id, key: r.key, name: r.name, machineId: r.machineId, ownerId: r.ownerId, isActive: r.isActive === 1, createdAt: r.createdAt })),
|
||||||
combos: db.all(`SELECT * FROM combos`).map((r) => ({ id: r.id, name: r.name, ownerId: r.ownerId, kind: r.kind, models: parseJson(r.models, []), createdAt: r.createdAt, updatedAt: r.updatedAt })),
|
combos: db.all(`SELECT * FROM combos`).map((r) => ({ id: r.id, name: r.name, ownerId: r.ownerId, kind: r.kind, models: parseJson(r.models, []), createdAt: r.createdAt, updatedAt: r.updatedAt })),
|
||||||
|
cliToolConfigs: db.all(`SELECT * FROM cliToolConfigs`).map((r) => ({ ownerId: r.ownerId, toolId: r.toolId, config: parseJson(r.data, {}), createdAt: r.createdAt, updatedAt: r.updatedAt })),
|
||||||
modelAliases: {},
|
modelAliases: {},
|
||||||
customModels: [],
|
customModels: [],
|
||||||
mitmAlias: {},
|
mitmAlias: {},
|
||||||
@@ -121,6 +129,7 @@ export async function importDb(payload) {
|
|||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
// Wipe all tables (keep _meta)
|
// Wipe all tables (keep _meta)
|
||||||
db.run(`DELETE FROM settings`);
|
db.run(`DELETE FROM settings`);
|
||||||
|
db.run(`DELETE FROM cliToolConfigs`);
|
||||||
// Old backups predate multi-user authentication. Preserve the local
|
// Old backups predate multi-user authentication. Preserve the local
|
||||||
// administrator unless the payload explicitly carries a users array.
|
// administrator unless the payload explicitly carries a users array.
|
||||||
if (Array.isArray(payload.users)) db.run(`DELETE FROM users`);
|
if (Array.isArray(payload.users)) db.run(`DELETE FROM users`);
|
||||||
@@ -186,6 +195,22 @@ export async function importDb(payload) {
|
|||||||
[c.id, c.name, c.ownerId || fallbackOwnerId, c.kind || null, stringifyJson(c.models || []), c.createdAt || new Date().toISOString(), c.updatedAt || new Date().toISOString()]
|
[c.id, c.name, c.ownerId || fallbackOwnerId, c.kind || null, stringifyJson(c.models || []), c.createdAt || new Date().toISOString(), c.updatedAt || new Date().toISOString()]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const userOwnerIds = new Set(db.all(`SELECT id FROM users`).map((user) => user.id));
|
||||||
|
const apiKeyOwners = new Map(db.all(`SELECT id, ownerId FROM apiKeys`).map((key) => [key.id, key.ownerId]));
|
||||||
|
for (const row of payload.cliToolConfigs || []) {
|
||||||
|
if (!row?.ownerId || !userOwnerIds.has(row.ownerId) || !isPersistableCliTool(row.toolId)) continue;
|
||||||
|
try {
|
||||||
|
const config = normalizeCliToolConfig(row.toolId, row.config);
|
||||||
|
if (config.apiKeyMode === "managed" && config.apiKeyId && apiKeyOwners.get(config.apiKeyId) !== row.ownerId) continue;
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
db.run(
|
||||||
|
`INSERT OR REPLACE INTO cliToolConfigs(ownerId, toolId, data, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?)`,
|
||||||
|
[row.ownerId, row.toolId, stringifyJson(config), row.createdAt || timestamp, row.updatedAt || timestamp],
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Skip malformed or secret-bearing configuration rows from backups.
|
||||||
|
}
|
||||||
|
}
|
||||||
for (const [a, m] of Object.entries(payload.modelAliases || {})) {
|
for (const [a, m] of Object.entries(payload.modelAliases || {})) {
|
||||||
db.run(`INSERT OR REPLACE INTO kv(scope, key, value) VALUES('modelAliases', ?, ?)`, [a, stringifyJson(m)]);
|
db.run(`INSERT OR REPLACE INTO kv(scope, key, value) VALUES('modelAliases', ?, ?)`, [a, stringifyJson(m)]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { getAdapter } from "../driver.js";
|
||||||
|
import { parseJson, stringifyJson } from "../helpers/jsonCol.js";
|
||||||
|
import { normalizeCliToolConfig } from "@/shared/constants/cliToolConfig.js";
|
||||||
|
|
||||||
|
function rowToConfig(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
ownerId: row.ownerId,
|
||||||
|
toolId: row.toolId,
|
||||||
|
config: parseJson(row.data, {}),
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCliToolConfig(ownerId, toolId) {
|
||||||
|
const db = await getAdapter();
|
||||||
|
return rowToConfig(db.get(
|
||||||
|
`SELECT * FROM cliToolConfigs WHERE ownerId = ? AND toolId = ?`,
|
||||||
|
[ownerId, toolId],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCliToolConfigsByOwnerId(ownerId) {
|
||||||
|
const db = await getAdapter();
|
||||||
|
return db.all(
|
||||||
|
`SELECT * FROM cliToolConfigs WHERE ownerId = ? ORDER BY toolId ASC`,
|
||||||
|
[ownerId],
|
||||||
|
).map(rowToConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertCliToolConfig(ownerId, toolId, input) {
|
||||||
|
const config = normalizeCliToolConfig(toolId, input);
|
||||||
|
const db = await getAdapter();
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO cliToolConfigs(ownerId, toolId, data, createdAt, updatedAt)
|
||||||
|
VALUES(?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(ownerId, toolId) DO UPDATE SET data = excluded.data, updatedAt = excluded.updatedAt`,
|
||||||
|
[ownerId, toolId, stringifyJson(config), timestamp, timestamp],
|
||||||
|
);
|
||||||
|
return getCliToolConfig(ownerId, toolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCliToolConfigsByOwnerId(ownerId) {
|
||||||
|
const db = await getAdapter();
|
||||||
|
const result = db.run(`DELETE FROM cliToolConfigs WHERE ownerId = ?`, [ownerId]);
|
||||||
|
return result?.changes ?? 0;
|
||||||
|
}
|
||||||
@@ -117,8 +117,13 @@ export async function countActiveAdmins() {
|
|||||||
|
|
||||||
export async function deleteUser(id) {
|
export async function deleteUser(id) {
|
||||||
const db = await getAdapter();
|
const db = await getAdapter();
|
||||||
|
let deleted = false;
|
||||||
|
db.transaction(() => {
|
||||||
|
db.run(`DELETE FROM cliToolConfigs WHERE ownerId = ?`, [id]);
|
||||||
const result = db.run(`DELETE FROM users WHERE id = ?`, [id]);
|
const result = db.run(`DELETE FROM users WHERE id = ?`, [id]);
|
||||||
return (result?.changes ?? 0) > 0;
|
deleted = (result?.changes ?? 0) > 0;
|
||||||
|
});
|
||||||
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyUserCredentials(username, password) {
|
export async function verifyUserCredentials(username, password) {
|
||||||
|
|||||||
+12
-1
@@ -3,7 +3,7 @@
|
|||||||
// pre-change safety backup in migrate.js: when the stored version is lower,
|
// pre-change safety backup in migrate.js: when the stored version is lower,
|
||||||
// one lightweight DB backup is taken before applying schema changes. Forgetting
|
// one lightweight DB backup is taken before applying schema changes. Forgetting
|
||||||
// to bump only skips that backup — it does NOT break the additive auto-sync.
|
// to bump only skips that backup — it does NOT break the additive auto-sync.
|
||||||
export const SCHEMA_VERSION = 7;
|
export const SCHEMA_VERSION = 8;
|
||||||
|
|
||||||
export const PRAGMA_SQL = `
|
export const PRAGMA_SQL = `
|
||||||
PRAGMA journal_mode = WAL;
|
PRAGMA journal_mode = WAL;
|
||||||
@@ -123,6 +123,17 @@ export const TABLES = {
|
|||||||
"CREATE INDEX IF NOT EXISTS idx_combo_owner ON combos(ownerId)",
|
"CREATE INDEX IF NOT EXISTS idx_combo_owner ON combos(ownerId)",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
cliToolConfigs: {
|
||||||
|
columns: {
|
||||||
|
ownerId: "TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE",
|
||||||
|
toolId: "TEXT NOT NULL",
|
||||||
|
data: "TEXT NOT NULL",
|
||||||
|
createdAt: "TEXT NOT NULL",
|
||||||
|
updatedAt: "TEXT NOT NULL",
|
||||||
|
},
|
||||||
|
primaryKey: "PRIMARY KEY (ownerId, toolId)",
|
||||||
|
indexes: ["CREATE INDEX IF NOT EXISTS idx_cli_tool_configs_owner ON cliToolConfigs(ownerId)"],
|
||||||
|
},
|
||||||
kv: {
|
kv: {
|
||||||
columns: {
|
columns: {
|
||||||
scope: "TEXT NOT NULL",
|
scope: "TEXT NOT NULL",
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export {
|
|||||||
createApiKey, updateApiKey, deleteApiKey, validateApiKey,
|
createApiKey, updateApiKey, deleteApiKey, validateApiKey,
|
||||||
getCombos, getComboById, getComboByName,
|
getCombos, getComboById, getComboByName,
|
||||||
createCombo, updateCombo, deleteCombo,
|
createCombo, updateCombo, deleteCombo,
|
||||||
|
getCliToolConfig, getCliToolConfigsByOwnerId,
|
||||||
|
upsertCliToolConfig, deleteCliToolConfigsByOwnerId,
|
||||||
getModelAliases, setModelAlias, deleteModelAlias,
|
getModelAliases, setModelAlias, deleteModelAlias,
|
||||||
getCustomModels, addCustomModel, deleteCustomModel,
|
getCustomModels, addCustomModel, deleteCustomModel,
|
||||||
getMitmAlias, setMitmAliasAll,
|
getMitmAlias, setMitmAliasAll,
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { CLI_TOOLS } from "./cliTools.js";
|
||||||
|
|
||||||
|
const SUPPORTED_TOOL_IDS = new Set(Object.keys(CLI_TOOLS));
|
||||||
|
const TOOLS_WITH_API_KEYS = new Set(["claude", "codex", "opencode", "cowork", "cursor"]);
|
||||||
|
const TOOLS_WITH_BASE_URLS = new Set(["claude", "codex", "opencode", "cowork", "copilot"]);
|
||||||
|
const CLAUDE_SLOTS = ["sonnet", "opus", "haiku"];
|
||||||
|
const MAX_MODEL_COUNT = 100;
|
||||||
|
const MAX_MODEL_ID_LENGTH = 512;
|
||||||
|
const MAX_URL_LENGTH = 2048;
|
||||||
|
const MAX_THINKING_LENGTH = 32;
|
||||||
|
const MAX_TOKEN_LIMIT = 10_000_000;
|
||||||
|
|
||||||
|
export class CliToolConfigValidationError extends Error {
|
||||||
|
constructor(message) {
|
||||||
|
super(message);
|
||||||
|
this.name = "CliToolConfigValidationError";
|
||||||
|
this.status = 400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPersistableCliTool(toolId) {
|
||||||
|
return typeof toolId === "string" && SUPPORTED_TOOL_IDS.has(toolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertObject(value) {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new CliToolConfigValidationError("Configuration must be an object");
|
||||||
|
}
|
||||||
|
if (Object.hasOwn(value, "apiKey") || Object.hasOwn(value, "customApiKey") || Object.hasOwn(value, "key")) {
|
||||||
|
throw new CliToolConfigValidationError("Plaintext API keys cannot be saved");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(value) {
|
||||||
|
if (typeof value !== "string" || !value.trim()) {
|
||||||
|
throw new CliToolConfigValidationError("Endpoint is required");
|
||||||
|
}
|
||||||
|
const trimmed = value.trim().replace(/\/+$/, "");
|
||||||
|
if (trimmed.length > MAX_URL_LENGTH) {
|
||||||
|
throw new CliToolConfigValidationError("Endpoint is too long");
|
||||||
|
}
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = new URL(trimmed);
|
||||||
|
} catch {
|
||||||
|
throw new CliToolConfigValidationError("Endpoint must be a valid HTTP(S) URL");
|
||||||
|
}
|
||||||
|
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||||
|
throw new CliToolConfigValidationError("Endpoint must use HTTP or HTTPS");
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptionalString(value, field, maxLength = MAX_MODEL_ID_LENGTH) {
|
||||||
|
if (value === undefined || value === null || value === "") return "";
|
||||||
|
if (typeof value !== "string") throw new CliToolConfigValidationError(`${field} must be a string`);
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (normalized.length > maxLength) throw new CliToolConfigValidationError(`${field} is too long`);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeModels(value, field = "selectedModels") {
|
||||||
|
if (value === undefined) return [];
|
||||||
|
if (!Array.isArray(value)) throw new CliToolConfigValidationError(`${field} must be an array`);
|
||||||
|
if (value.length > MAX_MODEL_COUNT) throw new CliToolConfigValidationError(`${field} has too many models`);
|
||||||
|
const models = value.map((model) => {
|
||||||
|
const normalized = normalizeOptionalString(model, field);
|
||||||
|
if (!normalized) throw new CliToolConfigValidationError(`${field} cannot contain empty model IDs`);
|
||||||
|
return normalized;
|
||||||
|
});
|
||||||
|
return [...new Set(models)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeThinking(value, field) {
|
||||||
|
return normalizeOptionalString(value, field, MAX_THINKING_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeThinkingMap(value, allowedKeys, field) {
|
||||||
|
if (value === undefined) return {};
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new CliToolConfigValidationError(`${field} must be an object`);
|
||||||
|
}
|
||||||
|
const normalized = {};
|
||||||
|
for (const key of allowedKeys) {
|
||||||
|
if (!Object.hasOwn(value, key)) continue;
|
||||||
|
const thinking = normalizeThinking(value[key], `${field}.${key}`);
|
||||||
|
if (thinking) normalized[key] = thinking;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeApiKeyReference(input) {
|
||||||
|
const mode = input.apiKeyMode === undefined ? "managed" : input.apiKeyMode;
|
||||||
|
if (!['managed', 'custom'].includes(mode)) {
|
||||||
|
throw new CliToolConfigValidationError("apiKeyMode must be managed or custom");
|
||||||
|
}
|
||||||
|
const apiKeyId = normalizeOptionalString(input.apiKeyId, "apiKeyId", 128) || null;
|
||||||
|
return { apiKeyMode: mode, apiKeyId: mode === "managed" ? apiKeyId : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTokenMap(value, selectedModels) {
|
||||||
|
if (value === undefined) return {};
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new CliToolConfigValidationError("copilotTokens must be an object");
|
||||||
|
}
|
||||||
|
const normalized = {};
|
||||||
|
for (const model of selectedModels) {
|
||||||
|
const limits = value[model];
|
||||||
|
if (limits === undefined) continue;
|
||||||
|
if (!limits || typeof limits !== "object" || Array.isArray(limits)) {
|
||||||
|
throw new CliToolConfigValidationError(`copilotTokens.${model} must be an object`);
|
||||||
|
}
|
||||||
|
const next = {};
|
||||||
|
for (const field of ["maxInputTokens", "maxOutputTokens"]) {
|
||||||
|
if (limits[field] === undefined || limits[field] === null || limits[field] === "") continue;
|
||||||
|
const number = Number(limits[field]);
|
||||||
|
if (!Number.isSafeInteger(number) || number <= 0 || number > MAX_TOKEN_LIMIT) {
|
||||||
|
throw new CliToolConfigValidationError(`${field} must be a positive integer no greater than ${MAX_TOKEN_LIMIT}`);
|
||||||
|
}
|
||||||
|
next[field] = number;
|
||||||
|
}
|
||||||
|
if (Object.keys(next).length) normalized[model] = next;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCliToolConfig(toolId, input) {
|
||||||
|
if (!isPersistableCliTool(toolId)) {
|
||||||
|
throw new CliToolConfigValidationError("Unsupported CLI tool");
|
||||||
|
}
|
||||||
|
assertObject(input);
|
||||||
|
|
||||||
|
const config = {};
|
||||||
|
if (TOOLS_WITH_BASE_URLS.has(toolId)) config.baseUrl = normalizeBaseUrl(input.baseUrl);
|
||||||
|
if (TOOLS_WITH_API_KEYS.has(toolId)) Object.assign(config, normalizeApiKeyReference(input));
|
||||||
|
|
||||||
|
if (toolId === "claude") {
|
||||||
|
const modelsInput = input.claudeModels === undefined ? {} : input.claudeModels;
|
||||||
|
if (!modelsInput || typeof modelsInput !== "object" || Array.isArray(modelsInput)) {
|
||||||
|
throw new CliToolConfigValidationError("claudeModels must be an object");
|
||||||
|
}
|
||||||
|
config.claudeModels = Object.fromEntries(CLAUDE_SLOTS.map((slot) => [
|
||||||
|
slot,
|
||||||
|
normalizeOptionalString(modelsInput[slot], `claudeModels.${slot}`),
|
||||||
|
]));
|
||||||
|
config.claudeThinking = normalizeThinkingMap(input.claudeThinking, CLAUDE_SLOTS, "claudeThinking");
|
||||||
|
} else if (toolId === "codex") {
|
||||||
|
config.codexModel = normalizeOptionalString(input.codexModel, "codexModel");
|
||||||
|
config.codexThinking = normalizeThinking(input.codexThinking, "codexThinking");
|
||||||
|
} else if (toolId === "opencode") {
|
||||||
|
config.opencodeModels = normalizeModels(input.opencodeModels, "opencodeModels");
|
||||||
|
const requestedDefault = normalizeOptionalString(input.opencodeDefaultModel, "opencodeDefaultModel");
|
||||||
|
config.opencodeDefaultModel = config.opencodeModels.includes(requestedDefault)
|
||||||
|
? requestedDefault
|
||||||
|
: (config.opencodeModels[0] || "");
|
||||||
|
} else if (toolId === "cowork") {
|
||||||
|
config.selectedModels = normalizeModels(input.selectedModels);
|
||||||
|
config.coworkThinking = normalizeThinkingMap(input.coworkThinking, config.selectedModels, "coworkThinking");
|
||||||
|
} else if (toolId === "cursor") {
|
||||||
|
config.selectedModels = normalizeModels(input.selectedModels);
|
||||||
|
} else if (toolId === "copilot") {
|
||||||
|
config.selectedModels = normalizeModels(input.selectedModels);
|
||||||
|
config.copilotThinking = normalizeThinkingMap(input.copilotThinking, config.selectedModels, "copilotThinking");
|
||||||
|
config.copilotTokens = normalizeTokenMap(input.copilotTokens, config.selectedModels);
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { APP_CONFIG } from "@/shared/constants/config";
|
||||||
|
|
||||||
|
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
|
||||||
|
|
||||||
|
function trimTrailingSlashes(value) {
|
||||||
|
return typeof value === "string" ? value.trim().replace(/\/+$/, "") : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureCliToolV1Endpoint(value) {
|
||||||
|
const normalized = trimTrailingSlashes(value);
|
||||||
|
if (!normalized) return "";
|
||||||
|
return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLocalCliToolUrl(value) {
|
||||||
|
try {
|
||||||
|
const hostname = new URL(value).hostname.toLowerCase();
|
||||||
|
return LOOPBACK_HOSTS.has(hostname) || hostname.endsWith(".localhost");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCliToolBaseUrl({
|
||||||
|
appUrl = "",
|
||||||
|
requiresExternalUrl = false,
|
||||||
|
tunnelEnabled = false,
|
||||||
|
tunnelPublicUrl = "",
|
||||||
|
tailscaleEnabled = false,
|
||||||
|
tailscaleUrl = "",
|
||||||
|
cloudEnabled = false,
|
||||||
|
cloudUrl = "",
|
||||||
|
configuredBaseUrl = "",
|
||||||
|
} = {}) {
|
||||||
|
const runtimeUrl = trimTrailingSlashes(appUrl);
|
||||||
|
|
||||||
|
// When the dashboard itself is deployed, its browser origin is the most
|
||||||
|
// accurate public gateway URL (including custom domains and reverse proxies).
|
||||||
|
if (runtimeUrl && !isLocalCliToolUrl(runtimeUrl)) return runtimeUrl;
|
||||||
|
|
||||||
|
// Tools such as Cursor cannot call a loopback URL from their remote service.
|
||||||
|
if (requiresExternalUrl) {
|
||||||
|
if (tunnelEnabled && tunnelPublicUrl) return trimTrailingSlashes(tunnelPublicUrl);
|
||||||
|
if (tailscaleEnabled && tailscaleUrl) return trimTrailingSlashes(tailscaleUrl);
|
||||||
|
if (cloudEnabled && cloudUrl) return trimTrailingSlashes(cloudUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A locally opened dashboard should generate a local endpoint, preserving a
|
||||||
|
// custom development port from window.location.origin when present.
|
||||||
|
if (runtimeUrl) return runtimeUrl;
|
||||||
|
|
||||||
|
const configuredUrl = trimTrailingSlashes(configuredBaseUrl);
|
||||||
|
if (configuredUrl) return configuredUrl;
|
||||||
|
return `http://127.0.0.1:${APP_CONFIG.defaultPort}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveInitialCliToolBaseUrl(savedBaseUrl, runtimeBaseUrl) {
|
||||||
|
const savedUrl = trimTrailingSlashes(savedBaseUrl);
|
||||||
|
const defaultUrl = trimTrailingSlashes(runtimeBaseUrl);
|
||||||
|
|
||||||
|
// Previous CLI Tools behavior could save the hardcoded loopback endpoint on
|
||||||
|
// a deployed dashboard. Treat that specific mismatch as a stale default.
|
||||||
|
if (savedUrl && defaultUrl && isLocalCliToolUrl(savedUrl) && !isLocalCliToolUrl(defaultUrl)) {
|
||||||
|
return ensureCliToolV1Endpoint(defaultUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ensureCliToolV1Endpoint(savedUrl || defaultUrl);
|
||||||
|
}
|
||||||
@@ -268,6 +268,52 @@ exports[`GOLDEN buildHeaders (default executor providers) > cline → headers (a
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`GOLDEN buildHeaders (default executor providers) > clinepass → headers (apiKey / oauth) 1`] = `
|
||||||
|
{
|
||||||
|
"apiKey": {
|
||||||
|
"Accept": "text/event-stream",
|
||||||
|
"Authorization": "Bearer <TOK>",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"HTTP-Referer": "https://cline.bot",
|
||||||
|
"User-Agent": "9Router/0.5.30",
|
||||||
|
"X-CLIENT-TYPE": "9router",
|
||||||
|
"X-CLIENT-VERSION": "0.5.30",
|
||||||
|
"X-CORE-VERSION": "0.5.30",
|
||||||
|
"X-IS-MULTIROOT": "false",
|
||||||
|
"X-PLATFORM": "linux",
|
||||||
|
"X-PLATFORM-VERSION": "v24.15.0",
|
||||||
|
"X-Title": "Cline",
|
||||||
|
},
|
||||||
|
"nonStream": {
|
||||||
|
"Authorization": "Bearer <TOK>",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"HTTP-Referer": "https://cline.bot",
|
||||||
|
"User-Agent": "9Router/0.5.30",
|
||||||
|
"X-CLIENT-TYPE": "9router",
|
||||||
|
"X-CLIENT-VERSION": "0.5.30",
|
||||||
|
"X-CORE-VERSION": "0.5.30",
|
||||||
|
"X-IS-MULTIROOT": "false",
|
||||||
|
"X-PLATFORM": "linux",
|
||||||
|
"X-PLATFORM-VERSION": "v24.15.0",
|
||||||
|
"X-Title": "Cline",
|
||||||
|
},
|
||||||
|
"oauth": {
|
||||||
|
"Accept": "text/event-stream",
|
||||||
|
"Authorization": "Bearer <TOK>",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"HTTP-Referer": "https://cline.bot",
|
||||||
|
"User-Agent": "9Router/0.5.30",
|
||||||
|
"X-CLIENT-TYPE": "9router",
|
||||||
|
"X-CLIENT-VERSION": "0.5.30",
|
||||||
|
"X-CORE-VERSION": "0.5.30",
|
||||||
|
"X-IS-MULTIROOT": "false",
|
||||||
|
"X-PLATFORM": "linux",
|
||||||
|
"X-PLATFORM-VERSION": "v24.15.0",
|
||||||
|
"X-Title": "Cline",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > cloudflare-ai → headers (apiKey / oauth) 1`] = `
|
exports[`GOLDEN buildHeaders (default executor providers) > cloudflare-ai → headers (apiKey / oauth) 1`] = `
|
||||||
{
|
{
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
@@ -381,6 +427,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > deepseek → headers
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`GOLDEN buildHeaders (default executor providers) > featherless → headers (apiKey / oauth) 1`] = `
|
||||||
|
{
|
||||||
|
"apiKey": {
|
||||||
|
"Accept": "text/event-stream",
|
||||||
|
"Authorization": "Bearer <TOK>",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
"nonStream": {
|
||||||
|
"Authorization": "Bearer <TOK>",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
"oauth": {
|
||||||
|
"Accept": "text/event-stream",
|
||||||
|
"Authorization": "Bearer <TOK>",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > fireworks → headers (apiKey / oauth) 1`] = `
|
exports[`GOLDEN buildHeaders (default executor providers) > fireworks → headers (apiKey / oauth) 1`] = `
|
||||||
{
|
{
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
@@ -482,6 +547,40 @@ exports[`GOLDEN buildHeaders (default executor providers) > glm-cn → headers (
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`GOLDEN buildHeaders (default executor providers) > grok-cli → headers (apiKey / oauth) 1`] = `
|
||||||
|
{
|
||||||
|
"apiKey": {
|
||||||
|
"Accept": "text/event-stream",
|
||||||
|
"Authorization": "Bearer <TOK>",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
|
||||||
|
"x-authenticateresponse": "authenticate-response",
|
||||||
|
"x-grok-client-identifier": "grok-pager",
|
||||||
|
"x-grok-client-version": "0.2.93",
|
||||||
|
"x-xai-token-auth": "xai-grok-cli",
|
||||||
|
},
|
||||||
|
"nonStream": {
|
||||||
|
"Authorization": "Bearer <TOK>",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
|
||||||
|
"x-authenticateresponse": "authenticate-response",
|
||||||
|
"x-grok-client-identifier": "grok-pager",
|
||||||
|
"x-grok-client-version": "0.2.93",
|
||||||
|
"x-xai-token-auth": "xai-grok-cli",
|
||||||
|
},
|
||||||
|
"oauth": {
|
||||||
|
"Accept": "text/event-stream",
|
||||||
|
"Authorization": "Bearer <TOK>",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
|
||||||
|
"x-authenticateresponse": "authenticate-response",
|
||||||
|
"x-grok-client-identifier": "grok-pager",
|
||||||
|
"x-grok-client-version": "0.2.93",
|
||||||
|
"x-xai-token-auth": "xai-grok-cli",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > groq → headers (apiKey / oauth) 1`] = `
|
exports[`GOLDEN buildHeaders (default executor providers) > groq → headers (apiKey / oauth) 1`] = `
|
||||||
{
|
{
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
@@ -539,6 +638,28 @@ exports[`GOLDEN buildHeaders (default executor providers) > kilocode → headers
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`GOLDEN buildHeaders (default executor providers) > kimchi → headers (apiKey / oauth) 1`] = `
|
||||||
|
{
|
||||||
|
"apiKey": {
|
||||||
|
"Accept": "text/event-stream",
|
||||||
|
"Authorization": "Bearer <TOK>",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "kimchi/0.1.50",
|
||||||
|
},
|
||||||
|
"nonStream": {
|
||||||
|
"Authorization": "Bearer <TOK>",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "kimchi/0.1.50",
|
||||||
|
},
|
||||||
|
"oauth": {
|
||||||
|
"Accept": "text/event-stream",
|
||||||
|
"Authorization": "Bearer <TOK>",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "kimchi/0.1.50",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > kimi → headers (apiKey / oauth) 1`] = `
|
exports[`GOLDEN buildHeaders (default executor providers) > kimi → headers (apiKey / oauth) 1`] = `
|
||||||
{
|
{
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
@@ -828,6 +949,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > perplexity → heade
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`GOLDEN buildHeaders (default executor providers) > perplexity-agent → headers (apiKey / oauth) 1`] = `
|
||||||
|
{
|
||||||
|
"apiKey": {
|
||||||
|
"Accept": "text/event-stream",
|
||||||
|
"Authorization": "Bearer <TOK>",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
"nonStream": {
|
||||||
|
"Authorization": "Bearer <TOK>",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
"oauth": {
|
||||||
|
"Accept": "text/event-stream",
|
||||||
|
"Authorization": "Bearer <TOK>",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > siliconflow → headers (apiKey / oauth) 1`] = `
|
exports[`GOLDEN buildHeaders (default executor providers) > siliconflow → headers (apiKey / oauth) 1`] = `
|
||||||
{
|
{
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
@@ -866,6 +1006,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > together → headers
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`GOLDEN buildHeaders (default executor providers) > venice → headers (apiKey / oauth) 1`] = `
|
||||||
|
{
|
||||||
|
"apiKey": {
|
||||||
|
"Accept": "text/event-stream",
|
||||||
|
"Authorization": "Bearer <TOK>",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
"nonStream": {
|
||||||
|
"Authorization": "Bearer <TOK>",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
"oauth": {
|
||||||
|
"Accept": "text/event-stream",
|
||||||
|
"Authorization": "Bearer <TOK>",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildHeaders (default executor providers) > vercel-ai-gateway → headers (apiKey / oauth) 1`] = `
|
exports[`GOLDEN buildHeaders (default executor providers) > vercel-ai-gateway → headers (apiKey / oauth) 1`] = `
|
||||||
{
|
{
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
@@ -1012,6 +1171,13 @@ exports[`GOLDEN buildUrl (default executor providers) > cline → url (stream +
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`GOLDEN buildUrl (default executor providers) > clinepass → url (stream + non-stream) 1`] = `
|
||||||
|
{
|
||||||
|
"nonStream": "https://api.cline.bot/api/v1/chat/completions",
|
||||||
|
"stream": "https://api.cline.bot/api/v1/chat/completions",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > cloudflare-ai → url (stream + non-stream) 1`] = `
|
exports[`GOLDEN buildUrl (default executor providers) > cloudflare-ai → url (stream + non-stream) 1`] = `
|
||||||
{
|
{
|
||||||
"nonStream": "https://api.cloudflare.com/client/v4/accounts/ACC123/ai/v1/chat/completions",
|
"nonStream": "https://api.cloudflare.com/client/v4/accounts/ACC123/ai/v1/chat/completions",
|
||||||
@@ -1047,6 +1213,13 @@ exports[`GOLDEN buildUrl (default executor providers) > deepseek → url (stream
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`GOLDEN buildUrl (default executor providers) > featherless → url (stream + non-stream) 1`] = `
|
||||||
|
{
|
||||||
|
"nonStream": "https://api.featherless.ai/v1/chat/completions",
|
||||||
|
"stream": "https://api.featherless.ai/v1/chat/completions",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > fireworks → url (stream + non-stream) 1`] = `
|
exports[`GOLDEN buildUrl (default executor providers) > fireworks → url (stream + non-stream) 1`] = `
|
||||||
{
|
{
|
||||||
"nonStream": "https://api.fireworks.ai/inference/v1/chat/completions",
|
"nonStream": "https://api.fireworks.ai/inference/v1/chat/completions",
|
||||||
@@ -1082,6 +1255,13 @@ exports[`GOLDEN buildUrl (default executor providers) > glm-cn → url (stream +
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`GOLDEN buildUrl (default executor providers) > grok-cli → url (stream + non-stream) 1`] = `
|
||||||
|
{
|
||||||
|
"nonStream": "https://cli-chat-proxy.grok.com/v1/responses",
|
||||||
|
"stream": "https://cli-chat-proxy.grok.com/v1/responses",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > groq → url (stream + non-stream) 1`] = `
|
exports[`GOLDEN buildUrl (default executor providers) > groq → url (stream + non-stream) 1`] = `
|
||||||
{
|
{
|
||||||
"nonStream": "https://api.groq.com/openai/v1/chat/completions",
|
"nonStream": "https://api.groq.com/openai/v1/chat/completions",
|
||||||
@@ -1103,6 +1283,13 @@ exports[`GOLDEN buildUrl (default executor providers) > kilocode → url (stream
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`GOLDEN buildUrl (default executor providers) > kimchi → url (stream + non-stream) 1`] = `
|
||||||
|
{
|
||||||
|
"nonStream": "https://llm.kimchi.dev/openai/v1/chat/completions",
|
||||||
|
"stream": "https://llm.kimchi.dev/openai/v1/chat/completions",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > kimi → url (stream + non-stream) 1`] = `
|
exports[`GOLDEN buildUrl (default executor providers) > kimi → url (stream + non-stream) 1`] = `
|
||||||
{
|
{
|
||||||
"nonStream": "https://api.kimi.com/coding/v1/messages?beta=true",
|
"nonStream": "https://api.kimi.com/coding/v1/messages?beta=true",
|
||||||
@@ -1194,6 +1381,13 @@ exports[`GOLDEN buildUrl (default executor providers) > perplexity → url (stre
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`GOLDEN buildUrl (default executor providers) > perplexity-agent → url (stream + non-stream) 1`] = `
|
||||||
|
{
|
||||||
|
"nonStream": "https://api.perplexity.ai/v1/responses",
|
||||||
|
"stream": "https://api.perplexity.ai/v1/responses",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > siliconflow → url (stream + non-stream) 1`] = `
|
exports[`GOLDEN buildUrl (default executor providers) > siliconflow → url (stream + non-stream) 1`] = `
|
||||||
{
|
{
|
||||||
"nonStream": "https://api.siliconflow.com/v1/chat/completions",
|
"nonStream": "https://api.siliconflow.com/v1/chat/completions",
|
||||||
@@ -1208,6 +1402,13 @@ exports[`GOLDEN buildUrl (default executor providers) > together → url (stream
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`GOLDEN buildUrl (default executor providers) > venice → url (stream + non-stream) 1`] = `
|
||||||
|
{
|
||||||
|
"nonStream": "https://api.venice.ai/api/v1/chat/completions",
|
||||||
|
"stream": "https://api.venice.ai/api/v1/chat/completions",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`GOLDEN buildUrl (default executor providers) > vercel-ai-gateway → url (stream + non-stream) 1`] = `
|
exports[`GOLDEN buildUrl (default executor providers) > vercel-ai-gateway → url (stream + non-stream) 1`] = `
|
||||||
{
|
{
|
||||||
"nonStream": "https://ai-gateway.vercel.sh/v1/chat/completions",
|
"nonStream": "https://ai-gateway.vercel.sh/v1/chat/completions",
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
CliToolConfigValidationError,
|
||||||
|
isPersistableCliTool,
|
||||||
|
normalizeCliToolConfig,
|
||||||
|
} from "@/shared/constants/cliToolConfig.js";
|
||||||
|
|
||||||
|
describe("CLI tool configuration contract", () => {
|
||||||
|
it("allows every non-MITM CLI tool and rejects MITM tool IDs", () => {
|
||||||
|
for (const toolId of ["claude", "codex", "opencode", "cowork", "cursor", "copilot"]) {
|
||||||
|
expect(isPersistableCliTool(toolId)).toBe(true);
|
||||||
|
}
|
||||||
|
for (const toolId of ["antigravity", "kiro", "unknown", ""]) {
|
||||||
|
expect(isPersistableCliTool(toolId)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes Claude config and strips unknown fields", () => {
|
||||||
|
expect(normalizeCliToolConfig("claude", {
|
||||||
|
baseUrl: "https://router.example/v1/",
|
||||||
|
apiKeyMode: "managed",
|
||||||
|
apiKeyId: "key-1",
|
||||||
|
claudeModels: { sonnet: " cc/sonnet ", opus: "cc/opus", haiku: "" },
|
||||||
|
claudeThinking: { sonnet: "high", opus: "", ignored: "max" },
|
||||||
|
transientModalOpen: true,
|
||||||
|
})).toEqual({
|
||||||
|
baseUrl: "https://router.example/v1",
|
||||||
|
apiKeyMode: "managed",
|
||||||
|
apiKeyId: "key-1",
|
||||||
|
claudeModels: { sonnet: "cc/sonnet", opus: "cc/opus", haiku: "" },
|
||||||
|
claudeThinking: { sonnet: "high" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes tool-specific model selections", () => {
|
||||||
|
expect(normalizeCliToolConfig("codex", {
|
||||||
|
baseUrl: "http://localhost:20128/v1",
|
||||||
|
apiKeyMode: "custom",
|
||||||
|
apiKeyId: "must-be-removed",
|
||||||
|
codexModel: "cx/gpt",
|
||||||
|
codexThinking: "xhigh",
|
||||||
|
})).toMatchObject({ apiKeyMode: "custom", apiKeyId: null, codexModel: "cx/gpt", codexThinking: "xhigh" });
|
||||||
|
|
||||||
|
expect(normalizeCliToolConfig("opencode", {
|
||||||
|
baseUrl: "https://router.example",
|
||||||
|
apiKeyMode: "managed",
|
||||||
|
apiKeyId: null,
|
||||||
|
opencodeModels: ["cc/a", "cc/a", "cx/b"],
|
||||||
|
opencodeDefaultModel: "missing/model",
|
||||||
|
})).toMatchObject({ opencodeModels: ["cc/a", "cx/b"], opencodeDefaultModel: "cc/a" });
|
||||||
|
|
||||||
|
expect(normalizeCliToolConfig("cowork", {
|
||||||
|
baseUrl: "https://router.example",
|
||||||
|
apiKeyMode: "managed",
|
||||||
|
selectedModels: ["cc/a"],
|
||||||
|
coworkThinking: { "cc/a": "high", "stale/model": "low" },
|
||||||
|
})).toMatchObject({ selectedModels: ["cc/a"], coworkThinking: { "cc/a": "high" } });
|
||||||
|
|
||||||
|
expect(normalizeCliToolConfig("cursor", {
|
||||||
|
apiKeyMode: "managed",
|
||||||
|
apiKeyId: "key-1",
|
||||||
|
selectedModels: ["cc/a", "cx/b"],
|
||||||
|
})).toEqual({ apiKeyMode: "managed", apiKeyId: "key-1", selectedModels: ["cc/a", "cx/b"] });
|
||||||
|
|
||||||
|
expect(normalizeCliToolConfig("copilot", {
|
||||||
|
baseUrl: "https://router.example/v1",
|
||||||
|
selectedModels: ["cc/a"],
|
||||||
|
copilotThinking: { "cc/a": "high", "stale/model": "low" },
|
||||||
|
copilotTokens: {
|
||||||
|
"cc/a": { maxInputTokens: 100000, maxOutputTokens: 32000 },
|
||||||
|
"stale/model": { maxInputTokens: 1 },
|
||||||
|
},
|
||||||
|
})).toEqual({
|
||||||
|
baseUrl: "https://router.example/v1",
|
||||||
|
selectedModels: ["cc/a"],
|
||||||
|
copilotThinking: { "cc/a": "high" },
|
||||||
|
copilotTokens: { "cc/a": { maxInputTokens: 100000, maxOutputTokens: 32000 } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects plaintext secrets, invalid URLs, and invalid token limits", () => {
|
||||||
|
expect(() => normalizeCliToolConfig("claude", {
|
||||||
|
baseUrl: "https://router.example",
|
||||||
|
apiKey: "secret",
|
||||||
|
})).toThrowError(CliToolConfigValidationError);
|
||||||
|
|
||||||
|
expect(() => normalizeCliToolConfig("codex", {
|
||||||
|
baseUrl: "file:///tmp/socket",
|
||||||
|
apiKeyMode: "managed",
|
||||||
|
})).toThrow("Endpoint must use HTTP or HTTPS");
|
||||||
|
|
||||||
|
expect(() => normalizeCliToolConfig("copilot", {
|
||||||
|
baseUrl: "https://router.example",
|
||||||
|
selectedModels: ["cc/a"],
|
||||||
|
copilotTokens: { "cc/a": { maxInputTokens: -1 } },
|
||||||
|
})).toThrow("maxInputTokens must be a positive integer");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const originalDataDir = process.env.DATA_DIR;
|
||||||
|
let tempDir;
|
||||||
|
let db;
|
||||||
|
let ownerOne;
|
||||||
|
let ownerTwo;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-cli-tool-config-"));
|
||||||
|
process.env.DATA_DIR = tempDir;
|
||||||
|
try { global._dbAdapter?.instance?.close?.(); } catch {}
|
||||||
|
delete global._dbAdapter;
|
||||||
|
vi.resetModules();
|
||||||
|
db = await import("@/lib/db/index.js");
|
||||||
|
await db.initDb();
|
||||||
|
ownerOne = await db.createUser({ username: "cli-owner-one", password: "password", role: "admin" });
|
||||||
|
ownerTwo = await db.createUser({ username: "cli-owner-two", password: "password", role: "user" });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
try { global._dbAdapter?.instance?.close?.(); } catch {}
|
||||||
|
delete global._dbAdapter;
|
||||||
|
if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||||
|
else process.env.DATA_DIR = originalDataDir;
|
||||||
|
});
|
||||||
|
|
||||||
|
const claudeConfig = (model) => ({
|
||||||
|
baseUrl: "https://router.example/v1",
|
||||||
|
apiKeyMode: "managed",
|
||||||
|
apiKeyId: null,
|
||||||
|
claudeModels: { sonnet: model, opus: "", haiku: "" },
|
||||||
|
claudeThinking: { sonnet: "high" },
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CLI tool configuration persistence", () => {
|
||||||
|
it("round-trips and atomically overwrites one owner/tool row", async () => {
|
||||||
|
const created = await db.upsertCliToolConfig(ownerOne.id, "claude", claudeConfig("cc/sonnet-a"));
|
||||||
|
expect(created).toMatchObject({ ownerId: ownerOne.id, toolId: "claude", config: claudeConfig("cc/sonnet-a") });
|
||||||
|
|
||||||
|
const updated = await db.upsertCliToolConfig(ownerOne.id, "claude", claudeConfig("cc/sonnet-b"));
|
||||||
|
expect(updated.config.claudeModels.sonnet).toBe("cc/sonnet-b");
|
||||||
|
expect((await db.getCliToolConfigsByOwnerId(ownerOne.id)).filter((row) => row.toolId === "claude")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isolates users and permits concurrent saves to different tools", async () => {
|
||||||
|
await Promise.all([
|
||||||
|
db.upsertCliToolConfig(ownerOne.id, "cursor", {
|
||||||
|
apiKeyMode: "custom",
|
||||||
|
selectedModels: ["cc/a", "cx/b"],
|
||||||
|
}),
|
||||||
|
db.upsertCliToolConfig(ownerTwo.id, "cursor", {
|
||||||
|
apiKeyMode: "managed",
|
||||||
|
apiKeyId: null,
|
||||||
|
selectedModels: ["gg/c"],
|
||||||
|
}),
|
||||||
|
db.upsertCliToolConfig(ownerOne.id, "codex", {
|
||||||
|
baseUrl: "https://router.example/v1",
|
||||||
|
apiKeyMode: "managed",
|
||||||
|
apiKeyId: null,
|
||||||
|
codexModel: "cx/gpt",
|
||||||
|
codexThinking: "high",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect((await db.getCliToolConfig(ownerOne.id, "cursor")).config.selectedModels).toEqual(["cc/a", "cx/b"]);
|
||||||
|
expect((await db.getCliToolConfig(ownerTwo.id, "cursor")).config.selectedModels).toEqual(["gg/c"]);
|
||||||
|
expect((await db.getCliToolConfig(ownerOne.id, "codex")).config.codexModel).toBe("cx/gpt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes valid configurations in export/import and skips malformed rows", async () => {
|
||||||
|
const exported = await db.exportDb();
|
||||||
|
expect(exported.cliToolConfigs).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ ownerId: ownerOne.id, toolId: "claude" }),
|
||||||
|
expect.objectContaining({ ownerId: ownerTwo.id, toolId: "cursor" }),
|
||||||
|
]));
|
||||||
|
|
||||||
|
exported.cliToolConfigs.push({
|
||||||
|
ownerId: ownerOne.id,
|
||||||
|
toolId: "kiro",
|
||||||
|
config: { apiKey: "must-not-import" },
|
||||||
|
});
|
||||||
|
await db.importDb(exported);
|
||||||
|
|
||||||
|
expect(await db.getCliToolConfig(ownerOne.id, "claude")).not.toBeNull();
|
||||||
|
expect(await db.getCliToolConfig(ownerOne.id, "kiro")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes configurations when their owner is deleted", async () => {
|
||||||
|
const disposable = await db.createUser({ username: "cli-disposable", password: "password", role: "user" });
|
||||||
|
await db.upsertCliToolConfig(disposable.id, "cursor", {
|
||||||
|
apiKeyMode: "managed",
|
||||||
|
apiKeyId: null,
|
||||||
|
selectedModels: ["cc/a"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await db.deleteUser(disposable.id)).toBe(true);
|
||||||
|
expect(await db.getCliToolConfig(disposable.id, "cursor")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const requireCurrentDashboardUser = vi.fn();
|
||||||
|
const getApiKeyByIdAndOwnerId = vi.fn();
|
||||||
|
const getCliToolConfig = vi.fn();
|
||||||
|
const upsertCliToolConfig = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("next/server", () => ({
|
||||||
|
NextResponse: {
|
||||||
|
json(body, init = {}) {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status: init.status || 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/auth/currentUser", () => ({ requireCurrentDashboardUser }));
|
||||||
|
vi.mock("@/lib/db/index.js", () => ({
|
||||||
|
getApiKeyByIdAndOwnerId,
|
||||||
|
getCliToolConfig,
|
||||||
|
upsertCliToolConfig,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { GET, PUT } = await import("@/app/api/cli-tools/config/[toolId]/route.js");
|
||||||
|
const context = (toolId) => ({ params: Promise.resolve({ toolId }) });
|
||||||
|
const putRequest = (body) => new Request("https://9router.local/api/cli-tools/config/claude", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/api/cli-tools/config/[toolId]", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
requireCurrentDashboardUser.mockReset();
|
||||||
|
getApiKeyByIdAndOwnerId.mockReset();
|
||||||
|
getCliToolConfig.mockReset();
|
||||||
|
upsertCliToolConfig.mockReset();
|
||||||
|
requireCurrentDashboardUser.mockResolvedValue({ id: "user-1", role: "user" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns only the authenticated user's saved config without caching", async () => {
|
||||||
|
getCliToolConfig.mockResolvedValue({
|
||||||
|
config: { apiKeyMode: "custom", apiKeyId: null, selectedModels: ["cc/a"] },
|
||||||
|
updatedAt: "2026-07-16T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await GET(new Request("https://9router.local"), context("cursor"));
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("Cache-Control")).toBe("no-store");
|
||||||
|
expect(getCliToolConfig).toHaveBeenCalledWith("user-1", "cursor");
|
||||||
|
await expect(response.json()).resolves.toEqual({
|
||||||
|
config: { apiKeyMode: "custom", apiKeyId: null, selectedModels: ["cc/a"] },
|
||||||
|
updatedAt: "2026-07-16T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unauthenticated and MITM tool requests", async () => {
|
||||||
|
requireCurrentDashboardUser.mockRejectedValueOnce(new Error("Unauthorized"));
|
||||||
|
expect((await GET(new Request("https://9router.local"), context("cursor"))).status).toBe(401);
|
||||||
|
expect((await GET(new Request("https://9router.local"), context("kiro"))).status).toBe(404);
|
||||||
|
expect(getCliToolConfig).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates managed key ownership before saving", async () => {
|
||||||
|
getApiKeyByIdAndOwnerId.mockResolvedValue(null);
|
||||||
|
const response = await PUT(putRequest({
|
||||||
|
baseUrl: "https://router.example/v1",
|
||||||
|
apiKeyMode: "managed",
|
||||||
|
apiKeyId: "foreign-key",
|
||||||
|
claudeModels: {},
|
||||||
|
claudeThinking: {},
|
||||||
|
}), context("claude"));
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(getApiKeyByIdAndOwnerId).toHaveBeenCalledWith("foreign-key", "user-1");
|
||||||
|
expect(upsertCliToolConfig).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes a custom-key config without persisting plaintext", async () => {
|
||||||
|
upsertCliToolConfig.mockImplementation(async (ownerId, toolId, config) => ({
|
||||||
|
ownerId,
|
||||||
|
toolId,
|
||||||
|
config,
|
||||||
|
updatedAt: "2026-07-16T00:00:00.000Z",
|
||||||
|
}));
|
||||||
|
const response = await PUT(putRequest({
|
||||||
|
baseUrl: "https://router.example/v1/",
|
||||||
|
apiKeyMode: "custom",
|
||||||
|
apiKeyId: "ignored-key-id",
|
||||||
|
claudeModels: { sonnet: "cc/sonnet" },
|
||||||
|
claudeThinking: { sonnet: "high" },
|
||||||
|
}), context("claude"));
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(body.config).toMatchObject({ apiKeyMode: "custom", apiKeyId: null });
|
||||||
|
expect(JSON.stringify(body)).not.toContain("ignored-key-id");
|
||||||
|
expect(getApiKeyByIdAndOwnerId).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects plaintext API keys and malformed JSON", async () => {
|
||||||
|
const secretResponse = await PUT(putRequest({
|
||||||
|
baseUrl: "https://router.example/v1",
|
||||||
|
apiKeyMode: "custom",
|
||||||
|
apiKey: "secret-value",
|
||||||
|
claudeModels: {},
|
||||||
|
}), context("claude"));
|
||||||
|
expect(secretResponse.status).toBe(400);
|
||||||
|
expect(JSON.stringify(await secretResponse.json())).not.toContain("secret-value");
|
||||||
|
|
||||||
|
const malformedResponse = await PUT(new Request("https://9router.local", {
|
||||||
|
method: "PUT",
|
||||||
|
body: "{",
|
||||||
|
}), context("claude"));
|
||||||
|
expect(malformedResponse.status).toBe(400);
|
||||||
|
expect(upsertCliToolConfig).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
ensureCliToolV1Endpoint,
|
||||||
|
isLocalCliToolUrl,
|
||||||
|
resolveCliToolBaseUrl,
|
||||||
|
resolveInitialCliToolBaseUrl,
|
||||||
|
} from "@/shared/utils/cliToolEndpoint.js";
|
||||||
|
|
||||||
|
describe("CLI tool endpoint resolution", () => {
|
||||||
|
it("keeps local dashboard origins local, including custom ports", () => {
|
||||||
|
expect(resolveCliToolBaseUrl({ appUrl: "http://localhost:30100" })).toBe("http://localhost:30100");
|
||||||
|
expect(resolveCliToolBaseUrl({ appUrl: "http://127.0.0.1:20128/" })).toBe("http://127.0.0.1:20128");
|
||||||
|
expect(isLocalCliToolUrl("http://app.localhost:20128")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the browser deployment origin instead of localhost or a generic cloud URL", () => {
|
||||||
|
expect(resolveCliToolBaseUrl({
|
||||||
|
appUrl: "https://router.customer.example",
|
||||||
|
cloudEnabled: true,
|
||||||
|
cloudUrl: "https://9router.com",
|
||||||
|
configuredBaseUrl: "http://localhost:20128",
|
||||||
|
})).toBe("https://router.customer.example");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a public endpoint for externally hosted tools when the dashboard is local", () => {
|
||||||
|
expect(resolveCliToolBaseUrl({
|
||||||
|
appUrl: "http://localhost:20128",
|
||||||
|
requiresExternalUrl: true,
|
||||||
|
tunnelEnabled: true,
|
||||||
|
tunnelPublicUrl: "https://tunnel.example/",
|
||||||
|
cloudEnabled: true,
|
||||||
|
cloudUrl: "https://cloud.example",
|
||||||
|
})).toBe("https://tunnel.example");
|
||||||
|
|
||||||
|
expect(resolveCliToolBaseUrl({
|
||||||
|
appUrl: "http://localhost:20128",
|
||||||
|
requiresExternalUrl: true,
|
||||||
|
cloudEnabled: true,
|
||||||
|
cloudUrl: "https://cloud.example/",
|
||||||
|
})).toBe("https://cloud.example");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds /v1 exactly once to generated endpoints", () => {
|
||||||
|
expect(ensureCliToolV1Endpoint("https://router.example/")).toBe("https://router.example/v1");
|
||||||
|
expect(ensureCliToolV1Endpoint("https://router.example/v1/")).toBe("https://router.example/v1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("migrates the old hardcoded localhost default when opened on a deployment", () => {
|
||||||
|
expect(resolveInitialCliToolBaseUrl(
|
||||||
|
"http://127.0.0.1:20128/v1",
|
||||||
|
"https://router.customer.example",
|
||||||
|
)).toBe("https://router.customer.example/v1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves an explicit remote saved endpoint and local saved endpoint on local runtime", () => {
|
||||||
|
expect(resolveInitialCliToolBaseUrl(
|
||||||
|
"https://custom-gateway.example/v1",
|
||||||
|
"https://router.customer.example",
|
||||||
|
)).toBe("https://custom-gateway.example/v1");
|
||||||
|
expect(resolveInitialCliToolBaseUrl(
|
||||||
|
"http://localhost:30100/v1",
|
||||||
|
"http://localhost:20128",
|
||||||
|
)).toBe("http://localhost:30100/v1");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user