@@ -484,7 +514,9 @@ export default function DefaultToolCard({
{item.desc && (
- {translateOrFallback(`guides.${toolId}.steps.${item.step}.desc`, item.desc)}
+ {translateOrFallback(`guides.${toolId}.steps.${item.step}.desc`, item.desc, {
+ baseUrl: baseUrlWithV1,
+ })}
)}
{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 (
+
{
+ (e.currentTarget as HTMLElement).style.display = "none";
+ }}
+ />
+ );
+ }
if (tool.icon) {
return (
diff --git a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts
index 9ee79f1dcc..660bcd4951 100644
--- a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts
+++ b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts
@@ -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 = {};
+ // 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,
diff --git a/src/app/api/cli-tools/keys/route.ts b/src/app/api/cli-tools/keys/route.ts
new file mode 100644
index 0000000000..36ccd7c40f
--- /dev/null
+++ b/src/app/api/cli-tools/keys/route.ts
@@ -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 });
+ }
+}
diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json
index 96d4225964..6dcad01d88 100644
--- a/src/i18n/messages/ar.json
+++ b/src/i18n/messages/ar.json
@@ -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"
diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json
index d745025e2c..1cc0b28c3f 100644
--- a/src/i18n/messages/bg.json
+++ b/src/i18n/messages/bg.json
@@ -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"
diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json
index 438b59b055..3ede9e2311 100644
--- a/src/i18n/messages/bn.json
+++ b/src/i18n/messages/bn.json
@@ -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"
diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json
index 6a144b6992..4b8b76ade2 100644
--- a/src/i18n/messages/cs.json
+++ b/src/i18n/messages/cs.json
@@ -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"
diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json
index 37e5dd7a0a..66d960b4b8 100644
--- a/src/i18n/messages/da.json
+++ b/src/i18n/messages/da.json
@@ -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"
diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json
index 5a800bd6de..002e4782b9 100644
--- a/src/i18n/messages/de.json
+++ b/src/i18n/messages/de.json
@@ -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"
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json
index 438b59b055..6461ef2959 100644
--- a/src/i18n/messages/en.json
+++ b/src/i18n/messages/en.json
@@ -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"
},
diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json
index 09a789f03b..e1ca8cfa00 100644
--- a/src/i18n/messages/es.json
+++ b/src/i18n/messages/es.json
@@ -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"
diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json
index 438b59b055..3ede9e2311 100644
--- a/src/i18n/messages/fa.json
+++ b/src/i18n/messages/fa.json
@@ -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"
diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json
index f92b703b98..c1f971be15 100644
--- a/src/i18n/messages/fi.json
+++ b/src/i18n/messages/fi.json
@@ -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"
diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json
index 1ece493b73..871c381d39 100644
--- a/src/i18n/messages/fr.json
+++ b/src/i18n/messages/fr.json
@@ -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"
diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json
index 438b59b055..3ede9e2311 100644
--- a/src/i18n/messages/gu.json
+++ b/src/i18n/messages/gu.json
@@ -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"
diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json
index 83f3063301..6e2ab17043 100644
--- a/src/i18n/messages/he.json
+++ b/src/i18n/messages/he.json
@@ -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"
diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json
index 093109d16a..164a90f965 100644
--- a/src/i18n/messages/hi.json
+++ b/src/i18n/messages/hi.json
@@ -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"
diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json
index 6b7438f03f..992e71406d 100644
--- a/src/i18n/messages/hu.json
+++ b/src/i18n/messages/hu.json
@@ -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"
diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json
index 6e0338e637..28d3fcc2b6 100644
--- a/src/i18n/messages/id.json
+++ b/src/i18n/messages/id.json
@@ -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"
diff --git a/src/i18n/messages/in.json b/src/i18n/messages/in.json
index 438b59b055..3ede9e2311 100644
--- a/src/i18n/messages/in.json
+++ b/src/i18n/messages/in.json
@@ -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"
diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json
index 01019bb2c9..3cb80ba298 100644
--- a/src/i18n/messages/it.json
+++ b/src/i18n/messages/it.json
@@ -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"
diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json
index 7d098cd69a..c65ce16a71 100644
--- a/src/i18n/messages/ja.json
+++ b/src/i18n/messages/ja.json
@@ -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"
diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json
index f80ef19db9..378b135a84 100644
--- a/src/i18n/messages/ko.json
+++ b/src/i18n/messages/ko.json
@@ -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"
diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json
index 438b59b055..3ede9e2311 100644
--- a/src/i18n/messages/mr.json
+++ b/src/i18n/messages/mr.json
@@ -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"
diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json
index 55daa9af6f..4b53820197 100644
--- a/src/i18n/messages/ms.json
+++ b/src/i18n/messages/ms.json
@@ -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"
diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json
index adb82ae716..ddd6d8abb1 100644
--- a/src/i18n/messages/nl.json
+++ b/src/i18n/messages/nl.json
@@ -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"
diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json
index 27e32d6ac6..e1b75e10f4 100644
--- a/src/i18n/messages/no.json
+++ b/src/i18n/messages/no.json
@@ -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"
diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json
index 393fa43d38..5358ea4fab 100644
--- a/src/i18n/messages/phi.json
+++ b/src/i18n/messages/phi.json
@@ -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"
diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json
index dc39ed772a..2f13e03510 100644
--- a/src/i18n/messages/pl.json
+++ b/src/i18n/messages/pl.json
@@ -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"
diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json
index e9bf258db5..72d55d3c7b 100644
--- a/src/i18n/messages/pt-BR.json
+++ b/src/i18n/messages/pt-BR.json
@@ -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"
diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json
index 5f3ef33278..82c6d501c9 100644
--- a/src/i18n/messages/pt.json
+++ b/src/i18n/messages/pt.json
@@ -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"
diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json
index 2bbcefd753..886b700a4c 100644
--- a/src/i18n/messages/ro.json
+++ b/src/i18n/messages/ro.json
@@ -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"
diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json
index 67e0a7307a..e5636c49a3 100644
--- a/src/i18n/messages/ru.json
+++ b/src/i18n/messages/ru.json
@@ -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 (пример команды ниже).",
diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json
index 3d70d7078c..0bcf20f1cc 100644
--- a/src/i18n/messages/sk.json
+++ b/src/i18n/messages/sk.json
@@ -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"
diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json
index bd71253838..a2ca0eb706 100644
--- a/src/i18n/messages/sv.json
+++ b/src/i18n/messages/sv.json
@@ -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"
diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json
index 438b59b055..3ede9e2311 100644
--- a/src/i18n/messages/sw.json
+++ b/src/i18n/messages/sw.json
@@ -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"
diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json
index 438b59b055..3ede9e2311 100644
--- a/src/i18n/messages/ta.json
+++ b/src/i18n/messages/ta.json
@@ -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"
diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json
index 438b59b055..3ede9e2311 100644
--- a/src/i18n/messages/te.json
+++ b/src/i18n/messages/te.json
@@ -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"
diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json
index 18fad33b23..fe0c29b286 100644
--- a/src/i18n/messages/th.json
+++ b/src/i18n/messages/th.json
@@ -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"
diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json
index 9423e74272..c8ea91abdf 100644
--- a/src/i18n/messages/tr.json
+++ b/src/i18n/messages/tr.json
@@ -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"
diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json
index da73299deb..30827067ef 100644
--- a/src/i18n/messages/uk-UA.json
+++ b/src/i18n/messages/uk-UA.json
@@ -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"
diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json
index 438b59b055..3ede9e2311 100644
--- a/src/i18n/messages/ur.json
+++ b/src/i18n/messages/ur.json
@@ -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"
diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json
index 537a39e324..6a3d94290b 100644
--- a/src/i18n/messages/vi.json
+++ b/src/i18n/messages/vi.json
@@ -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"
diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json
index fcd96f34cc..3bd8310767 100644
--- a/src/i18n/messages/zh-CN.json
+++ b/src/i18n/messages/zh-CN.json
@@ -1273,7 +1273,7 @@
},
"3": {
"title": "设置 Base URL",
- "desc": "opencode config set baseUrl {{baseUrl}}"
+ "desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "选择模型"
diff --git a/src/shared/constants/cliTools.ts b/src/shared/constants/cliTools.ts
index 8286af97bc..43adfb0cdf 100644
--- a/src/shared/constants/cliTools.ts
+++ b/src/shared/constants/cliTools.ts
@@ -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)",
diff --git a/src/shared/services/opencodeConfig.ts b/src/shared/services/opencodeConfig.ts
index 9ccc8624fe..89cac6a6f6 100644
--- a/src/shared/services/opencodeConfig.ts
+++ b/src/shared/services/opencodeConfig.ts
@@ -1,8 +1,11 @@
+import { applyEdits, modify, parse } from "jsonc-parser";
+
type OpenCodeConfigInput = {
baseUrl?: string;
apiKey?: string;
model?: string;
models?: string[];
+ modelLabels?: Record;
};
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 => {
+ 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) =>
+ labels[modelId] || modelId;
+
export const buildOpenCodeProviderConfig = ({
baseUrl,
apiKey,
model,
models,
+ modelLabels,
}: OpenCodeConfigInput): Record => {
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 = {};
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);
+};
diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts
index 972b7d8abc..909be6f7b5 100644
--- a/src/shared/validation/schemas.ts
+++ b/src/shared/validation/schemas.ts
@@ -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",
diff --git a/tests/unit/cli-tools-i18n.test.ts b/tests/unit/cli-tools-i18n.test.ts
new file mode 100644
index 0000000000..e42ec4b337
--- /dev/null
+++ b/tests/unit/cli-tools-i18n.test.ts
@@ -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`
+ );
+ }
+});
diff --git a/tests/unit/cli-tools-keys-route.test.ts b/tests/unit/cli-tools-keys-route.test.ts
new file mode 100644
index 0000000000..2c69c714c0
--- /dev/null
+++ b/tests/unit/cli-tools-keys-route.test.ts
@@ -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);
+ }
+});
diff --git a/tests/unit/guide-settings-route.test.ts b/tests/unit/guide-settings-route.test.ts
index 4e082b617b..ee7d102726 100644
--- a/tests/unit/guide-settings-route.test.ts
+++ b/tests/unit/guide-settings-route.test.ts
@@ -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" },
+ });
+});
diff --git a/tests/unit/t40-opencode-cli-tools-integration.test.ts b/tests/unit/t40-opencode-cli-tools-integration.test.ts
index 2226c8a903..6b8c07f7c2 100644
--- a/tests/unit/t40-opencode-cli-tools-integration.test.ts
+++ b/tests/unit/t40-opencode-cli-tools-integration.test.ts
@@ -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, /^