mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 06:12:10 +03:00
fix(cli-tools): preserve opencode config and raw key copy (#1626)
- Use jsonc-parser to update only provider.omniroute in opencode.json,
preserving MCP servers, comments, and other provider entries
- Add /api/cli-tools/keys endpoint returning raw keys for CLI tools UI
(session-auth protected via requireCliToolsAuth)
- Fix OpenCode guide step 3 to use ICU-style {baseUrl} placeholder
instead of broken {{baseUrl}} across all 40+ locales
- Restore valid OpenCode light/dark SVG logos (were broken HTML downloads)
- Add customCliTab translation key to en.json
- Add modelLabels support for human-readable model names in config
- Fix 9 additional locale files (bn, fa, gu, in, mr, sw, ta, te, ur)
that were added after the original PR and still had double-braces
Co-authored-by: JasonLandbridge <JasonLandbridge@users.noreply.github.com>
This commit is contained in:
7
package-lock.json
generated
7
package-lock.json
generated
@@ -28,6 +28,7 @@
|
||||
"https-proxy-agent": "^9.0.0",
|
||||
"jose": "^6.1.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"lowdb": "^7.0.1",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"next": "^16.2.3",
|
||||
@@ -9164,6 +9165,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonc-parser": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
|
||||
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsx-ast-utils": {
|
||||
"version": "3.3.5",
|
||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
"https-proxy-agent": "^9.0.0",
|
||||
"jose": "^6.1.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"lowdb": "^7.0.1",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"next": "^16.2.3",
|
||||
|
||||
1
public/providers/opencode-dark.svg
Normal file
1
public/providers/opencode-dark.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width='240' height='300' viewBox='0 0 240 300' fill='none' xmlns='http://www.w3.org/2000/svg'><g clip-path='url(#clip0_1401_86283)'><mask id='mask0_1401_86283' style='mask-type:luminance' maskUnits='userSpaceOnUse' x='0' y='0' width='240' height='300'><path d='M240 0H0V300H240V0Z' fill='white'/></mask><g mask='url(#mask0_1401_86283)'><path d='M180 240H60V120H180V240Z' fill='#4B4646'/><path d='M180 60H60V240H180V60ZM240 300H0V0H240V300Z' fill='#F1ECEC'/></g></g><defs><clipPath id='clip0_1401_86283'><rect width='240' height='300' fill='white'/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 577 B |
1
public/providers/opencode-light.svg
Normal file
1
public/providers/opencode-light.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width='240' height='300' viewBox='0 0 240 300' fill='none' xmlns='http://www.w3.org/2000/svg'><g clip-path='url(#clip0_1401_86274)'><mask id='mask0_1401_86274' style='mask-type:luminance' maskUnits='userSpaceOnUse' x='0' y='0' width='240' height='300'><path d='M240 0H0V300H240V0Z' fill='white'/></mask><g mask='url(#mask0_1401_86274)'><path d='M180 240H60V120H180V240Z' fill='#CFCECD'/><path d='M180 60H60V240H180V60ZM240 300H0V0H240V300Z' fill='#211E1E'/></g></g><defs><clipPath id='clip0_1401_86274'><rect width='240' height='300' fill='white'/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 577 B |
@@ -90,7 +90,7 @@ export default function CLIToolsPageClient({ machineId: _machineId }) {
|
||||
|
||||
const fetchApiKeys = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/keys");
|
||||
const res = await fetch("/api/cli-tools/keys");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setApiKeys(data.keys || []);
|
||||
|
||||
@@ -6,6 +6,7 @@ import Image from "next/image";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { copyToClipboard } from "@/shared/utils/clipboard";
|
||||
import { buildOpenCodeConfigDocument } from "@/shared/services/opencodeConfig";
|
||||
import { useTheme } from "@/shared/hooks/useTheme";
|
||||
|
||||
export default function DefaultToolCard({
|
||||
toolId,
|
||||
@@ -37,6 +38,7 @@ export default function DefaultToolCard({
|
||||
const [message, setMessage] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const runtimeFetchStartedRef = useRef(false);
|
||||
const { isDark } = useTheme();
|
||||
|
||||
// (#523) Initialize state with key *id* instead of masked key string
|
||||
const [selectedApiKeyId, setSelectedApiKeyId] = useState(() =>
|
||||
@@ -44,6 +46,54 @@ export default function DefaultToolCard({
|
||||
);
|
||||
const isMultiModelTool = tool.modelSelectionMode === "multiple";
|
||||
const usesOpenCodePreview = tool.previewConfigMode === "opencode";
|
||||
const selectedKeyObj = apiKeys?.find((k) => k.id === selectedApiKeyId);
|
||||
|
||||
const resolveApiKeyValue = useCallback(
|
||||
() => selectedKeyObj?.rawKey || (!cloudEnabled ? "sk_omniroute" : t("yourApiKeyPlaceholder")),
|
||||
[cloudEnabled, selectedKeyObj?.rawKey, t]
|
||||
);
|
||||
|
||||
const getSelectedModelEntries = useCallback(() => {
|
||||
const selectedValues = isMultiModelTool
|
||||
? modelValues.length > 0
|
||||
? modelValues
|
||||
: modelValue
|
||||
? [modelValue]
|
||||
: []
|
||||
: modelValue
|
||||
? [modelValue]
|
||||
: [];
|
||||
|
||||
const availableModels = Array.isArray(activeProviders)
|
||||
? activeProviders.flatMap((provider) => provider?.models || [])
|
||||
: [];
|
||||
const modelMap = new Map(
|
||||
availableModels.filter((model) => model?.value).map((model) => [model.value, model])
|
||||
);
|
||||
|
||||
return selectedValues.map((value) => {
|
||||
const matched = modelMap.get(value);
|
||||
return {
|
||||
value,
|
||||
label: matched?.name || matched?.label || value,
|
||||
};
|
||||
});
|
||||
}, [activeProviders, isMultiModelTool, modelValue, modelValues]);
|
||||
|
||||
const getSelectedModelLabels = useCallback(
|
||||
() => getSelectedModelEntries().map((entry) => entry.label),
|
||||
[getSelectedModelEntries]
|
||||
);
|
||||
|
||||
const getSelectedModelLabelMap = useCallback(
|
||||
() => Object.fromEntries(getSelectedModelEntries().map((entry) => [entry.value, entry.label])),
|
||||
[getSelectedModelEntries]
|
||||
);
|
||||
|
||||
const normalizedBaseUrl = baseUrl || "http://localhost:20128";
|
||||
const baseUrlWithV1 = normalizedBaseUrl.endsWith("/v1")
|
||||
? normalizedBaseUrl
|
||||
: `${normalizedBaseUrl}/v1`;
|
||||
|
||||
// Persist and restore model selection per tool via localStorage
|
||||
useEffect(() => {
|
||||
@@ -75,7 +125,9 @@ export default function DefaultToolCard({
|
||||
const prefix = savedKey.slice(0, 8);
|
||||
const suffix = savedKey.slice(-4);
|
||||
const matchedKey = apiKeys.find(
|
||||
(k) => k.key && k.key.startsWith(prefix) && k.key.endsWith(suffix)
|
||||
(k) =>
|
||||
(k.rawKey && k.rawKey.startsWith(prefix) && k.rawKey.endsWith(suffix)) ||
|
||||
(k.key && k.key.startsWith(prefix) && k.key.endsWith(suffix))
|
||||
);
|
||||
if (matchedKey) setSelectedApiKeyId(matchedKey.id);
|
||||
}
|
||||
@@ -134,23 +186,14 @@ export default function DefaultToolCard({
|
||||
|
||||
const replaceVars = useCallback(
|
||||
(text) => {
|
||||
// (#523) Look up the key object by id to get the masked display value.
|
||||
const selectedKeyObj = apiKeys?.find((k) => k.id === selectedApiKeyId);
|
||||
let keyToUse =
|
||||
selectedKeyObj?.key || (!cloudEnabled ? "sk_omniroute" : t("yourApiKeyPlaceholder"));
|
||||
if (keyToUse.includes("***")) keyToUse = "<YOUR_API_KEY>";
|
||||
|
||||
const normalizedBaseUrl = baseUrl || "http://localhost:20128";
|
||||
const baseUrlWithV1 = normalizedBaseUrl.endsWith("/v1")
|
||||
? normalizedBaseUrl
|
||||
: `${normalizedBaseUrl}/v1`;
|
||||
const keyToUse = resolveApiKeyValue();
|
||||
|
||||
return text
|
||||
.replace(/\{\{baseUrl\}\}/g, baseUrlWithV1)
|
||||
.replace(/\{\{apiKey\}\}/g, keyToUse)
|
||||
.replace(/\{\{model\}\}/g, modelValue || t("modelPlaceholder"));
|
||||
.replace(/\{\{model\}\}/g, getSelectedModelLabels()[0] || t("modelPlaceholder"));
|
||||
},
|
||||
[apiKeys, baseUrl, cloudEnabled, modelValue, selectedApiKeyId, t]
|
||||
[baseUrl, getSelectedModelLabels, resolveApiKeyValue, t]
|
||||
);
|
||||
|
||||
const handleCopy = async (text, field) => {
|
||||
@@ -168,33 +211,24 @@ export default function DefaultToolCard({
|
||||
if (!tool.codeBlock?.code) return "";
|
||||
if (!usesOpenCodePreview) return replaceVars(tool.codeBlock.code);
|
||||
|
||||
const selectedKeyObj = apiKeys?.find((k) => k.id === selectedApiKeyId);
|
||||
let keyToUse =
|
||||
selectedKeyObj?.key || (!cloudEnabled ? "sk_omniroute" : t("yourApiKeyPlaceholder"));
|
||||
if (keyToUse.includes("***")) keyToUse = "<YOUR_API_KEY>";
|
||||
const normalizedBaseUrl = baseUrl || "http://localhost:20128";
|
||||
const baseUrlWithV1 = normalizedBaseUrl.endsWith("/v1")
|
||||
? normalizedBaseUrl
|
||||
: `${normalizedBaseUrl}/v1`;
|
||||
|
||||
const keyToUse = resolveApiKeyValue();
|
||||
return JSON.stringify(
|
||||
buildOpenCodeConfigDocument({
|
||||
baseUrl: baseUrlWithV1,
|
||||
apiKey: keyToUse,
|
||||
models: getSelectedModels(),
|
||||
model: getSelectedModels()[0],
|
||||
modelLabels: getSelectedModelLabelMap(),
|
||||
}),
|
||||
null,
|
||||
2
|
||||
);
|
||||
}, [
|
||||
apiKeys,
|
||||
baseUrl,
|
||||
cloudEnabled,
|
||||
getSelectedModels,
|
||||
getSelectedModelLabelMap,
|
||||
replaceVars,
|
||||
selectedApiKeyId,
|
||||
t,
|
||||
resolveApiKeyValue,
|
||||
tool.codeBlock?.code,
|
||||
usesOpenCodePreview,
|
||||
]);
|
||||
@@ -229,11 +263,6 @@ export default function DefaultToolCard({
|
||||
// (#523) Prefer keyId lookup so the backend writes the real key to disk.
|
||||
const selectedKeyId = selectedApiKeyId?.trim() || null;
|
||||
|
||||
const normalizedBaseUrl = baseUrl || "http://localhost:20128";
|
||||
const baseUrlWithV1 = normalizedBaseUrl.endsWith("/v1")
|
||||
? normalizedBaseUrl
|
||||
: `${normalizedBaseUrl}/v1`;
|
||||
|
||||
const res = await fetch(`/api/cli-tools/guide-settings/${toolId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -243,6 +272,7 @@ export default function DefaultToolCard({
|
||||
keyId: selectedKeyId,
|
||||
model: modelValue,
|
||||
models: isMultiModelTool ? getSelectedModels() : undefined,
|
||||
modelLabels: getSelectedModelLabelMap(),
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -284,9 +314,7 @@ export default function DefaultToolCard({
|
||||
</select>
|
||||
<button
|
||||
onClick={() => {
|
||||
// (#523) Look up the masked key by id for clipboard copy
|
||||
const keyObj = apiKeys?.find((k) => k.id === selectedApiKeyId);
|
||||
handleCopy(keyObj?.key || selectedApiKeyId, "apiKey");
|
||||
handleCopy(resolveApiKeyValue(), "apiKey");
|
||||
}}
|
||||
className="shrink-0 px-3 py-2 bg-bg-secondary hover:bg-bg-tertiary rounded-lg border border-border transition-colors"
|
||||
>
|
||||
@@ -305,7 +333,9 @@ export default function DefaultToolCard({
|
||||
};
|
||||
|
||||
const renderModelSelector = () => {
|
||||
const displayValue = isMultiModelTool ? getSelectedModels().join(", ") : modelValue;
|
||||
const displayValue = isMultiModelTool
|
||||
? getSelectedModelLabels().join(", ")
|
||||
: getSelectedModelLabels()[0] || "";
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
@@ -484,7 +514,9 @@ export default function DefaultToolCard({
|
||||
</p>
|
||||
{item.desc && (
|
||||
<p className="text-sm text-text-muted mt-0.5">
|
||||
{translateOrFallback(`guides.${toolId}.steps.${item.step}.desc`, item.desc)}
|
||||
{translateOrFallback(`guides.${toolId}.steps.${item.step}.desc`, item.desc, {
|
||||
baseUrl: baseUrlWithV1,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{item.type === "apiKeySelector" && renderApiKeySelector()}
|
||||
@@ -601,6 +633,24 @@ export default function DefaultToolCard({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (tool.imageLight || tool.imageDark) {
|
||||
const themedSrc = isDark
|
||||
? tool.imageDark || tool.imageLight
|
||||
: tool.imageLight || tool.imageDark;
|
||||
return (
|
||||
<Image
|
||||
src={themedSrc}
|
||||
alt={tool.name}
|
||||
width={32}
|
||||
height={32}
|
||||
className="size-8 object-contain rounded-lg"
|
||||
sizes="32px"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (tool.icon) {
|
||||
return (
|
||||
<span className="material-symbols-outlined text-xl" style={{ color: tool.color }}>
|
||||
|
||||
@@ -5,7 +5,7 @@ import os from "os";
|
||||
import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth";
|
||||
import { getRuntimePorts } from "@/lib/runtime/ports";
|
||||
import { getOpenCodeConfigPath } from "@/shared/services/cliRuntime";
|
||||
import { mergeOpenCodeConfig } from "@/shared/services/opencodeConfig";
|
||||
import { mergeOpenCodeConfigText } from "@/shared/services/opencodeConfig";
|
||||
import { guideSettingsSaveSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { resolveApiKey } from "@/shared/services/apiKeyResolver";
|
||||
@@ -40,7 +40,7 @@ export async function POST(request, { params }) {
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { baseUrl, model, models } = validation.data;
|
||||
const { baseUrl, model, models, modelLabels } = validation.data;
|
||||
// (#523) Extract keyId BEFORE validation — Zod strips unknown fields!
|
||||
const apiKeyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null;
|
||||
const apiKey = await resolveApiKey(apiKeyId, validation.data.apiKey);
|
||||
@@ -52,7 +52,7 @@ export async function POST(request, { params }) {
|
||||
case "opencode":
|
||||
// (#524) OpenCode config was never saved because only 'continue' was handled here.
|
||||
// OpenCode reads ~/.config/opencode/opencode.json — write the OmniRoute settings there.
|
||||
return await saveOpenCodeConfig({ baseUrl, apiKey, model, models });
|
||||
return await saveOpenCodeConfig({ baseUrl, apiKey, model, models, modelLabels });
|
||||
case "qwen":
|
||||
return await saveQwenConfig({ baseUrl, apiKey, model });
|
||||
default:
|
||||
@@ -149,7 +149,7 @@ async function saveContinueConfig({ baseUrl, apiKey, model }) {
|
||||
*
|
||||
* (#524) OpenCode was silently failing because this handler was missing.
|
||||
*/
|
||||
async function saveOpenCodeConfig({ baseUrl, apiKey, model, models }) {
|
||||
async function saveOpenCodeConfig({ baseUrl, apiKey, model, models, modelLabels }) {
|
||||
const configPath = getOpenCodeConfigPath();
|
||||
const configDir = path.dirname(configPath);
|
||||
|
||||
@@ -160,23 +160,23 @@ async function saveOpenCodeConfig({ baseUrl, apiKey, model, models }) {
|
||||
.trim()
|
||||
.replace(/\/+$/, "");
|
||||
|
||||
// Read existing JSON to preserve other provider entries
|
||||
let existingConfig: Record<string, any> = {};
|
||||
// Read existing JSONC/JSON text to preserve unrelated config formatting and fields.
|
||||
let existingConfigText = "";
|
||||
try {
|
||||
const raw = await fs.readFile(configPath, "utf-8");
|
||||
existingConfig = JSON.parse(raw);
|
||||
existingConfigText = await fs.readFile(configPath, "utf-8");
|
||||
} catch {
|
||||
// File doesn't exist or invalid JSON — start fresh
|
||||
// File doesn't exist — start fresh
|
||||
}
|
||||
|
||||
const nextConfig = mergeOpenCodeConfig(existingConfig, {
|
||||
const nextConfigText = mergeOpenCodeConfigText(existingConfigText, {
|
||||
baseUrl: normalizedBaseUrl,
|
||||
apiKey,
|
||||
model,
|
||||
models,
|
||||
modelLabels,
|
||||
});
|
||||
|
||||
await fs.writeFile(configPath, JSON.stringify(nextConfig, null, 2), "utf-8");
|
||||
await fs.writeFile(configPath, nextConfigText, "utf-8");
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
23
src/app/api/cli-tools/keys/route.ts
Normal file
23
src/app/api/cli-tools/keys/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth";
|
||||
import { getApiKeys } from "@/lib/localDb";
|
||||
import { maskStoredApiKey } from "@/lib/apiKeyExposure";
|
||||
|
||||
// GET /api/cli-tools/keys - List API keys with raw values for authenticated CLI tools UI only
|
||||
export async function GET(request: Request) {
|
||||
const authError = await requireCliToolsAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const keys = await getApiKeys();
|
||||
const cliToolKeys = keys.map((key) => ({
|
||||
...key,
|
||||
rawKey: key.key,
|
||||
key: maskStoredApiKey(key.key),
|
||||
}));
|
||||
return NextResponse.json({ keys: cliToolKeys, total: cliToolKeys.length });
|
||||
} catch (error) {
|
||||
console.log("Error fetching CLI tool keys:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch CLI tool keys" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1265,7 +1265,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1265,7 +1265,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1273,7 +1273,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1265,7 +1265,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Nastavit základní URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Vybrat model"
|
||||
|
||||
@@ -1265,7 +1265,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1265,7 +1265,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1273,7 +1273,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
@@ -1339,6 +1339,7 @@
|
||||
"allToolsTab": "All Tools",
|
||||
"guidedClientsTab": "Guided Clients",
|
||||
"mitmClientsTab": "MITM Clients",
|
||||
"customCliTab": "Custom CLI",
|
||||
"toolCategories": "Tool Categories",
|
||||
"visibleToolsCount": "{count} tools available"
|
||||
},
|
||||
|
||||
@@ -1265,7 +1265,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1273,7 +1273,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1265,7 +1265,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1265,7 +1265,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1273,7 +1273,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1265,7 +1265,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1265,7 +1265,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1265,7 +1265,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1265,7 +1265,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1273,7 +1273,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1265,7 +1265,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1265,7 +1265,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1265,7 +1265,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1273,7 +1273,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1265,7 +1265,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1265,7 +1265,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1265,7 +1265,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1265,7 +1265,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1265,7 +1265,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1293,7 +1293,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Definir URL base",
|
||||
"desc": "conjunto de configuração opencode baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Selecione o modelo"
|
||||
|
||||
@@ -1265,7 +1265,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1265,7 +1265,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1265,7 +1265,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Задайте базовый URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Выберите модель"
|
||||
@@ -1330,7 +1330,7 @@
|
||||
"guides.opencode.steps.1.desc": "Установите через npm: npm install -g opencode-ai",
|
||||
"guides.opencode.steps.2.title": "API-ключ",
|
||||
"guides.opencode.steps.3.title": "Задайте базовый URL",
|
||||
"guides.opencode.steps.3.desc": "opencode config set baseUrl {{baseUrl}}",
|
||||
"guides.opencode.steps.3.desc": "opencode config set baseUrl {baseUrl}",
|
||||
"guides.opencode.steps.4.title": "Выберите модель",
|
||||
"guides.opencode.steps.5.title": "Используйте вариант мышления",
|
||||
"guides.opencode.steps.5.desc": "Для thinking-моделей запускайте с --variant high/low/max (пример команды ниже).",
|
||||
|
||||
@@ -1265,7 +1265,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1265,7 +1265,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1273,7 +1273,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1273,7 +1273,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1273,7 +1273,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1265,7 +1265,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1265,7 +1265,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Temel URL'yi Ayarla",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Modeli Seçin"
|
||||
|
||||
@@ -1265,7 +1265,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1273,7 +1273,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1265,7 +1265,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "Set Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "Select Model"
|
||||
|
||||
@@ -1273,7 +1273,7 @@
|
||||
},
|
||||
"3": {
|
||||
"title": "设置 Base URL",
|
||||
"desc": "opencode config set baseUrl {{baseUrl}}"
|
||||
"desc": "opencode config set baseUrl {baseUrl}"
|
||||
},
|
||||
"4": {
|
||||
"title": "选择模型"
|
||||
|
||||
@@ -242,7 +242,8 @@ export const CLI_TOOLS = {
|
||||
opencode: {
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
image: "/providers/opencode.svg",
|
||||
imageLight: "/providers/opencode-light.svg",
|
||||
imageDark: "/providers/opencode-dark.svg",
|
||||
icon: "terminal",
|
||||
color: "#FF6B35",
|
||||
description: "OpenCode AI coding agent (Terminal)",
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { applyEdits, modify, parse } from "jsonc-parser";
|
||||
|
||||
type OpenCodeConfigInput = {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
models?: string[];
|
||||
modelLabels?: Record<string, string>;
|
||||
};
|
||||
|
||||
const OPENCODE_DEFAULT_MODELS = [
|
||||
@@ -22,17 +25,32 @@ const normalizeModels = (models: unknown): string[] => {
|
||||
return [...new Set(models.map((model) => normalizeValue(model)).filter(Boolean))];
|
||||
};
|
||||
|
||||
const normalizeModelLabels = (labels: unknown): Record<string, string> => {
|
||||
if (!labels || typeof labels !== "object" || Array.isArray(labels)) return {};
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(labels)
|
||||
.map(([key, value]) => [normalizeValue(key), String(value || "").trim()])
|
||||
.filter(([key, value]) => key && value)
|
||||
);
|
||||
};
|
||||
|
||||
const getModelEntryName = (modelId: string, labels: Record<string, string>) =>
|
||||
labels[modelId] || modelId;
|
||||
|
||||
export const buildOpenCodeProviderConfig = ({
|
||||
baseUrl,
|
||||
apiKey,
|
||||
model,
|
||||
models,
|
||||
modelLabels,
|
||||
}: OpenCodeConfigInput): Record<string, any> => {
|
||||
const normalizedBaseUrl = String(baseUrl || "")
|
||||
.trim()
|
||||
.replace(/\/+$/, "");
|
||||
const normalizedModel = normalizeValue(model);
|
||||
const normalizedModels = normalizeModels(models);
|
||||
const normalizedLabels = normalizeModelLabels(modelLabels);
|
||||
|
||||
const uniqueModels =
|
||||
normalizedModels.length > 0
|
||||
@@ -42,7 +60,7 @@ export const buildOpenCodeProviderConfig = ({
|
||||
const modelsRecord: Record<string, { name: string }> = {};
|
||||
for (const m of uniqueModels) {
|
||||
if (m) {
|
||||
modelsRecord[m] = { name: m };
|
||||
modelsRecord[m] = { name: getModelEntryName(m, normalizedLabels) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,3 +100,41 @@ export const mergeOpenCodeConfig = (
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const mergeOpenCodeConfigText = (
|
||||
existingText: string | null | undefined,
|
||||
input: OpenCodeConfigInput
|
||||
) => {
|
||||
const providerConfig = buildOpenCodeProviderConfig(input);
|
||||
const content = typeof existingText === "string" ? existingText : "";
|
||||
const trimmedContent = content.trim();
|
||||
|
||||
if (!trimmedContent) {
|
||||
return JSON.stringify(buildOpenCodeConfigDocument(input), null, 2);
|
||||
}
|
||||
|
||||
const errors: { error: number }[] = [];
|
||||
const parsed = parse(content, errors, { allowTrailingComma: true, disallowComments: false });
|
||||
|
||||
if (errors.length > 0 || !parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return JSON.stringify(mergeOpenCodeConfig({}, input), null, 2);
|
||||
}
|
||||
|
||||
let nextText = content;
|
||||
|
||||
const schemaEdits = modify(
|
||||
nextText,
|
||||
["$schema"],
|
||||
parsed.$schema || "https://opencode.ai/config.json",
|
||||
{
|
||||
formattingOptions: { insertSpaces: true, tabSize: 2 },
|
||||
}
|
||||
);
|
||||
nextText = applyEdits(nextText, schemaEdits);
|
||||
|
||||
const providerEdits = modify(nextText, ["provider", "omniroute"], providerConfig, {
|
||||
formattingOptions: { insertSpaces: true, tabSize: 2 },
|
||||
});
|
||||
|
||||
return applyEdits(nextText, providerEdits);
|
||||
};
|
||||
|
||||
@@ -1729,6 +1729,7 @@ export const guideSettingsSaveSchema = z
|
||||
apiKey: z.string().optional(),
|
||||
model: z.string().trim().min(1, "Model is required").optional(),
|
||||
models: z.array(z.string().trim().min(1, "Models must be non-empty")).min(1).optional(),
|
||||
modelLabels: z.record(z.string(), z.string().trim().min(1)).optional(),
|
||||
})
|
||||
.refine((data) => !!data.model || !!data.models?.length, {
|
||||
message: "Model is required",
|
||||
|
||||
34
tests/unit/cli-tools-i18n.test.ts
Normal file
34
tests/unit/cli-tools-i18n.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const en = require("../../src/i18n/messages/en.json");
|
||||
|
||||
test("CLI tools English messages include custom tab label and keep OpenCode baseUrl command literal", () => {
|
||||
assert.equal(en.cliTools?.customCliTab, "Custom CLI");
|
||||
assert.equal(
|
||||
en.cliTools?.guides?.opencode?.steps?.[3]?.desc,
|
||||
"opencode config set baseUrl {baseUrl}",
|
||||
"OpenCode baseUrl command must use injectable ICU syntax"
|
||||
);
|
||||
});
|
||||
|
||||
test("CLI tools locale messages use ICU baseUrl placeholder for OpenCode step 3", async () => {
|
||||
const messagesDir = path.join(process.cwd(), "src/i18n/messages");
|
||||
const files = (await fs.readdir(messagesDir)).filter((file) => file.endsWith(".json"));
|
||||
|
||||
for (const file of files) {
|
||||
const content = JSON.parse(await fs.readFile(path.join(messagesDir, file), "utf-8"));
|
||||
const desc = content?.cliTools?.guides?.opencode?.steps?.["3"]?.desc;
|
||||
if (typeof desc !== "string") continue;
|
||||
|
||||
assert.equal(
|
||||
desc.includes("{baseUrl}") && !desc.includes("{{baseUrl}}"),
|
||||
true,
|
||||
`${file} should use ICU-style {baseUrl} placeholder`
|
||||
);
|
||||
}
|
||||
});
|
||||
78
tests/unit/cli-tools-keys-route.test.ts
Normal file
78
tests/unit/cli-tools-keys-route.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { SignJWT } from "jose";
|
||||
|
||||
const keysRoute = await import("../../src/app/api/keys/route.ts");
|
||||
const cliToolsKeysRoute = await import("../../src/app/api/cli-tools/keys/route.ts");
|
||||
const { createApiKey, deleteApiKey } = await import("../../src/lib/db/apiKeys.ts");
|
||||
|
||||
const originalJwtSecret = process.env.JWT_SECRET;
|
||||
const originalApiKeySecret = process.env.API_KEY_SECRET;
|
||||
|
||||
async function createAuthCookie() {
|
||||
process.env.JWT_SECRET = "test-cli-tools-keys-secret";
|
||||
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
|
||||
const token = await new SignJWT({ sub: "test-user" })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("1h")
|
||||
.sign(secret);
|
||||
|
||||
return `auth_token=${token}`;
|
||||
}
|
||||
|
||||
test.afterEach(() => {
|
||||
if (originalJwtSecret === undefined) delete process.env.JWT_SECRET;
|
||||
else process.env.JWT_SECRET = originalJwtSecret;
|
||||
if (originalApiKeySecret === undefined) delete process.env.API_KEY_SECRET;
|
||||
else process.env.API_KEY_SECRET = originalApiKeySecret;
|
||||
});
|
||||
|
||||
test("CLI tools key list can return unmasked keys for authenticated internal consumers", async () => {
|
||||
process.env.API_KEY_SECRET = "test-api-key-secret";
|
||||
const created = await createApiKey("CLI Tools Test Key", "test-machine-cli-tools");
|
||||
|
||||
try {
|
||||
const cookie = await createAuthCookie();
|
||||
const request = new Request("http://localhost/api/cli-tools/keys", {
|
||||
headers: {
|
||||
cookie,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await cliToolsKeysRoute.GET(request);
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
const key = payload.keys.find((entry) => entry.id === created.id);
|
||||
|
||||
assert.ok(key, "created key should be present");
|
||||
assert.notEqual(key.key, created.key);
|
||||
assert.match(key.key, /^.{8}\*\*\*\*.*$/);
|
||||
assert.equal(key.rawKey, created.key);
|
||||
} finally {
|
||||
await deleteApiKey(created.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("general keys route stays masked for non-CLI consumers", async () => {
|
||||
process.env.API_KEY_SECRET = "test-api-key-secret";
|
||||
const created = await createApiKey("Masked Key Test", "test-machine-masked");
|
||||
|
||||
try {
|
||||
const cookie = await createAuthCookie();
|
||||
const request = new Request("http://localhost/api/keys", {
|
||||
headers: { cookie },
|
||||
});
|
||||
|
||||
const response = await keysRoute.GET(request);
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
const key = payload.keys.find((entry) => entry.id === created.id);
|
||||
|
||||
assert.ok(key, "created key should be present");
|
||||
assert.notEqual(key.key, created.key);
|
||||
assert.match(key.key, /^.{8}\*\*\*\*.*$/);
|
||||
} finally {
|
||||
await deleteApiKey(created.id);
|
||||
}
|
||||
});
|
||||
@@ -3,6 +3,8 @@ import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { SignJWT } from "jose";
|
||||
import { parse } from "jsonc-parser";
|
||||
const guideSettingsRoute =
|
||||
await import("../../src/app/api/cli-tools/guide-settings/[toolId]/route.ts");
|
||||
|
||||
@@ -11,6 +13,19 @@ const QWEN_CONFIG_PATH = path.join(DUMMY_HOME, ".qwen", "settings.json");
|
||||
const QWEN_ENV_PATH = path.join(DUMMY_HOME, ".qwen", ".env");
|
||||
const OPENCODE_CONFIG_PATH = path.join(DUMMY_HOME, ".config", "opencode", "opencode.json");
|
||||
const originalXDG = process.env.XDG_CONFIG_HOME;
|
||||
const originalJwtSecret = process.env.JWT_SECRET;
|
||||
|
||||
async function createAuthCookie() {
|
||||
process.env.JWT_SECRET = "test-cli-tools-secret";
|
||||
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
|
||||
const token = await new SignJWT({ sub: "test-user" })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("1h")
|
||||
.sign(secret);
|
||||
|
||||
return `auth_token=${token}`;
|
||||
}
|
||||
|
||||
type QwenProviderEntry = {
|
||||
id?: string;
|
||||
@@ -21,10 +36,11 @@ type QwenProviderEntry = {
|
||||
};
|
||||
};
|
||||
|
||||
function buildRequest(body: any) {
|
||||
async function buildRequest(body: any) {
|
||||
const cookie = await createAuthCookie();
|
||||
return new Request("http://localhost/api/cli-tools/guide-settings/qwen", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", cookie },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
@@ -42,10 +58,16 @@ test.afterEach(async () => {
|
||||
await fs.rm(DUMMY_HOME, { recursive: true, force: true }).catch(() => {});
|
||||
if (originalXDG === undefined) delete process.env.XDG_CONFIG_HOME;
|
||||
else process.env.XDG_CONFIG_HOME = originalXDG;
|
||||
if (originalJwtSecret === undefined) delete process.env.JWT_SECRET;
|
||||
else process.env.JWT_SECRET = originalJwtSecret;
|
||||
});
|
||||
|
||||
test("guide-settings POST creates new qwen settings.json if it doesn't exist", async () => {
|
||||
const req = buildRequest({ baseUrl: "http://my-omni", apiKey: "sk-123", model: "qwen-max" });
|
||||
const req = await buildRequest({
|
||||
baseUrl: "http://my-omni",
|
||||
apiKey: "sk-123",
|
||||
model: "qwen-max",
|
||||
});
|
||||
const response = (await guideSettingsRoute.POST(req, { params: { toolId: "qwen" } })) as Response;
|
||||
const data = (await response.json()) as any;
|
||||
|
||||
@@ -82,7 +104,7 @@ test("guide-settings POST merges into existing qwen settings.json", async () =>
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
const req = buildRequest({ baseUrl: "http://my-omni", apiKey: "sk-123", model: "auto" });
|
||||
const req = await buildRequest({ baseUrl: "http://my-omni", apiKey: "sk-123", model: "auto" });
|
||||
const response = (await guideSettingsRoute.POST(req, { params: { toolId: "qwen" } })) as Response;
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
@@ -124,9 +146,10 @@ test("guide-settings POST writes OpenCode config with current schema and multi-m
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
const cookie = await createAuthCookie();
|
||||
const req = new Request("http://localhost/api/cli-tools/guide-settings/opencode", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", cookie },
|
||||
body: JSON.stringify({
|
||||
baseUrl: "http://my-omni/v1",
|
||||
apiKey: "sk-123",
|
||||
@@ -139,7 +162,7 @@ test("guide-settings POST writes OpenCode config with current schema and multi-m
|
||||
})) as Response;
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const content = JSON.parse(await fs.readFile(OPENCODE_CONFIG_PATH, "utf-8"));
|
||||
const content = parse(await fs.readFile(OPENCODE_CONFIG_PATH, "utf-8"));
|
||||
assert.equal(content.$schema, "https://opencode.ai/config.json");
|
||||
assert.ok(content.provider.custom);
|
||||
assert.equal(content.provider.omniroute.npm, "@ai-sdk/openai-compatible");
|
||||
@@ -151,3 +174,76 @@ test("guide-settings POST writes OpenCode config with current schema and multi-m
|
||||
]);
|
||||
assert.equal(content.providers, undefined);
|
||||
});
|
||||
|
||||
test("guide-settings POST preserves existing OpenCode config fields while only updating provider.omniroute", async () => {
|
||||
await fs.mkdir(path.dirname(OPENCODE_CONFIG_PATH), { recursive: true });
|
||||
await fs.writeFile(
|
||||
OPENCODE_CONFIG_PATH,
|
||||
`{
|
||||
// existing config should survive
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"custom": {
|
||||
"name": "Custom Provider"
|
||||
},
|
||||
"omniroute": {
|
||||
"npm": "old-package",
|
||||
"name": "Old OmniRoute",
|
||||
"options": {
|
||||
"baseURL": "http://old-host/v1",
|
||||
"apiKey": "old-key"
|
||||
},
|
||||
"models": {
|
||||
"old/model": { "name": "Old Model" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"mcpServers": {
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
||||
}
|
||||
}
|
||||
}`,
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
const cookie = await createAuthCookie();
|
||||
const req = new Request("http://localhost/api/cli-tools/guide-settings/opencode", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", cookie },
|
||||
body: JSON.stringify({
|
||||
baseUrl: "http://my-omni/v1",
|
||||
apiKey: "sk-123",
|
||||
models: ["cx/gpt-5.4", "opencode-go/kimi-k2.6"],
|
||||
modelLabels: {
|
||||
"cx/gpt-5.4": "GPT-5.4",
|
||||
"opencode-go/kimi-k2.6": "Kimi K2.6",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const response = (await guideSettingsRoute.POST(req, {
|
||||
params: { toolId: "opencode" },
|
||||
})) as Response;
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const content = parse(await fs.readFile(OPENCODE_CONFIG_PATH, "utf-8"));
|
||||
assert.equal(content.$schema, "https://opencode.ai/config.json");
|
||||
assert.deepEqual(content.mcpServers, {
|
||||
filesystem: {
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
||||
},
|
||||
});
|
||||
assert.deepEqual(content.provider.custom, {
|
||||
name: "Custom Provider",
|
||||
});
|
||||
assert.equal(content.provider.omniroute.npm, "@ai-sdk/openai-compatible");
|
||||
assert.equal(content.provider.omniroute.options.baseURL, "http://my-omni/v1");
|
||||
assert.equal(content.provider.omniroute.options.apiKey, "sk-123");
|
||||
assert.deepEqual(content.provider.omniroute.models, {
|
||||
"cx/gpt-5.4": { name: "GPT-5.4" },
|
||||
"opencode-go/kimi-k2.6": { name: "Kimi K2.6" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const { CLI_TOOLS } = await import("../../src/shared/constants/cliTools.ts");
|
||||
@@ -74,6 +75,10 @@ test("T40: OpenCode config document uses current provider schema", () => {
|
||||
baseUrl: "http://localhost:20128/v1/",
|
||||
apiKey: "sk_test_opencode",
|
||||
models: ["cc/claude-sonnet-4-20250514", "gg/gemini-2.5-pro"],
|
||||
modelLabels: {
|
||||
"cc/claude-sonnet-4-20250514": "Claude Sonnet 4.5",
|
||||
"gg/gemini-2.5-pro": "Gemini 2.5 Pro",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(configDocument.$schema, "https://opencode.ai/config.json");
|
||||
@@ -85,6 +90,14 @@ test("T40: OpenCode config document uses current provider schema", () => {
|
||||
"cc/claude-sonnet-4-20250514",
|
||||
"gg/gemini-2.5-pro",
|
||||
]);
|
||||
assert.equal(
|
||||
configDocument.provider.omniroute.models["cc/claude-sonnet-4-20250514"].name,
|
||||
"Claude Sonnet 4.5"
|
||||
);
|
||||
assert.equal(
|
||||
configDocument.provider.omniroute.models["gg/gemini-2.5-pro"].name,
|
||||
"Gemini 2.5 Pro"
|
||||
);
|
||||
assert.equal(configDocument.providers, undefined);
|
||||
});
|
||||
|
||||
@@ -93,10 +106,71 @@ test("T40: OpenCode explicit multi-model selection overrides fallback defaults",
|
||||
baseUrl: "http://localhost:20128/v1/",
|
||||
apiKey: "sk_test_opencode",
|
||||
models: ["custom/provider-a", "custom/provider-b"],
|
||||
modelLabels: {
|
||||
"custom/provider-a": "Provider A",
|
||||
"custom/provider-b": "Provider B",
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(Object.keys(providerConfig.models), ["custom/provider-a", "custom/provider-b"]);
|
||||
assert.equal(providerConfig.models["claude-sonnet-4-5-thinking"], undefined);
|
||||
assert.equal(providerConfig.models["custom/provider-a"].name, "Provider A");
|
||||
assert.equal(providerConfig.models["custom/provider-b"].name, "Provider B");
|
||||
});
|
||||
|
||||
test("T40: OpenCode merge preserves unrelated config and updates only provider.omniroute", () => {
|
||||
const mergedConfig = mergeOpenCodeConfig(
|
||||
{
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
custom: { name: "Custom Provider" },
|
||||
omniroute: {
|
||||
name: "Old OmniRoute",
|
||||
options: { baseURL: "http://old-host/v1", apiKey: "old-key" },
|
||||
},
|
||||
},
|
||||
mcpServers: {
|
||||
github: { command: "npx", args: ["-y", "@modelcontextprotocol/server-github"] },
|
||||
},
|
||||
},
|
||||
{
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
apiKey: "sk_test_opencode",
|
||||
models: ["cx/gpt-5.4"],
|
||||
modelLabels: { "cx/gpt-5.4": "GPT-5.4" },
|
||||
}
|
||||
);
|
||||
|
||||
assert.deepEqual(mergedConfig.provider.custom, { name: "Custom Provider" });
|
||||
assert.deepEqual(mergedConfig.mcpServers, {
|
||||
github: { command: "npx", args: ["-y", "@modelcontextprotocol/server-github"] },
|
||||
});
|
||||
assert.deepEqual(mergedConfig.provider.omniroute.models, {
|
||||
"cx/gpt-5.4": { name: "GPT-5.4" },
|
||||
});
|
||||
});
|
||||
|
||||
test("T40: OpenCode tool card references theme-aware brand assets", () => {
|
||||
const opencode = CLI_TOOLS.opencode;
|
||||
assert.equal(opencode.image, undefined);
|
||||
assert.equal(opencode.imageLight, "/providers/opencode-light.svg");
|
||||
assert.equal(opencode.imageDark, "/providers/opencode-dark.svg");
|
||||
});
|
||||
|
||||
test("T40: OpenCode light/dark provider assets are valid SVG files", async () => {
|
||||
const light = await fs.readFile(
|
||||
path.join(process.cwd(), "public/providers/opencode-light.svg"),
|
||||
"utf-8"
|
||||
);
|
||||
const dark = await fs.readFile(
|
||||
path.join(process.cwd(), "public/providers/opencode-dark.svg"),
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
assert.match(light, /^<svg[\s>]/);
|
||||
assert.match(dark, /^<svg[\s>]/);
|
||||
assert.doesNotMatch(light, /<html/i);
|
||||
assert.doesNotMatch(dark, /<html/i);
|
||||
});
|
||||
|
||||
test("T40: Windsurf card documents current official limitations honestly", () => {
|
||||
|
||||
Reference in New Issue
Block a user