diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ConfigGeneratorCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ConfigGeneratorCard.js index aeb6772a..215c4fe9 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ConfigGeneratorCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ConfigGeneratorCard.js @@ -20,7 +20,7 @@ const withThinkingLevel = (model, thinkingLevel) => ( model && thinkingLevel ? `${model}(${thinkingLevel})` : model ); -function buildConfigs(toolId, { baseUrl, apiKey, models, claudeModels = {}, claudeThinking = {}, codexModel = "", codexThinking = "" }) { +function buildConfigs(toolId, { baseUrl, apiKey, models, claudeModels = {}, claudeThinking = {}, codexModel = "", codexThinking = "", opencodeModels = [], opencodeDefaultModel = "", coworkThinking = {} }) { const endpoint = normalizeV1(baseUrl); const selectedModels = models.length ? models : [DEFAULT_MODEL]; const model = selectedModels[0]; @@ -49,19 +49,26 @@ function buildConfigs(toolId, { baseUrl, apiKey, models, claudeModels = {}, clau { filename: "~/.codex/auth.json", content: toJson({ auth_mode: "apikey", OPENAI_API_KEY: apiKey }) }, ]; case "opencode": { - const modelEntries = Object.fromEntries(selectedModels.map((id) => [id, { - name: id, - modalities: { input: ["text", "image"], output: ["text"] }, - }])); + // OpenCode identifies a default model as /. Our + // custom provider is "9router", while the 9Router model ID itself keeps + // its upstream provider prefix (for example, "cc/claude-sonnet-5"). + const configuredModels = opencodeModels.length ? opencodeModels : [DEFAULT_MODEL]; + const modelId = configuredModels.includes(opencodeDefaultModel) + ? opencodeDefaultModel + : configuredModels[0]; return [{ filename: "~/.config/opencode/opencode.json", content: toJson({ - provider: { "9router": { - npm: "@ai-sdk/openai-compatible", - options: { baseURL: endpoint, apiKey }, - models: modelEntries, - } }, - model: `9router/${model}`, + $schema: "https://opencode.ai/config.json", + provider: { + "9router": { + npm: "@ai-sdk/openai-compatible", + name: "9Router", + options: { baseURL: endpoint, apiKey }, + models: Object.fromEntries(configuredModels.map((id) => [id, { name: id }])), + }, + }, + model: `9router/${modelId}`, }), }]; } @@ -78,8 +85,19 @@ function buildConfigs(toolId, { baseUrl, apiKey, models, claudeModels = {}, clau }]; case "cowork": return [{ - filename: "Claude Desktop third-party inference configuration.json", - content: toJson({ baseUrl: endpoint, apiKey, models: selectedModels }), + // Claude Desktop reads third-party inference profiles from its 3P + // config library. Unlike Claude Code, Cowork does not support named + // Sonnet/Opus/Haiku environment-variable slots: every entry here is + // exposed directly in Claude Desktop's model picker. + filename: "Claude-3p/configLibrary/.json", + content: toJson({ + inferenceProvider: "gateway", + inferenceGatewayBaseUrl: endpoint, + inferenceGatewayApiKey: apiKey, + inferenceModels: selectedModels.map((name) => ({ + name: withThinkingLevel(name, coworkThinking[name]), + })), + }), }]; default: return [{ filename: "config.json", content: toJson({ baseUrl: endpoint, apiKey, model }) }]; @@ -105,6 +123,9 @@ export default function ConfigGeneratorCard({ const [claudeModelSlot, setClaudeModelSlot] = useState(""); const [codexModel, setCodexModel] = useState(""); const [codexThinking, setCodexThinking] = useState(""); + const [opencodeModels, setOpencodeModels] = useState([]); + const [opencodeDefaultModel, setOpencodeDefaultModel] = useState(""); + const [coworkThinking, setCoworkThinking] = useState({}); const [connectedModels, setConnectedModels] = useState(null); const [customBaseUrl, setCustomBaseUrl] = useState(""); const [modelModalOpen, setModelModalOpen] = useState(false); @@ -113,8 +134,8 @@ export default function ConfigGeneratorCard({ const effectiveBaseUrl = customBaseUrl || baseUrl; const apiKey = selectedApiKey.trim() || (cloudEnabled ? "" : "sk_9router"); const configs = useMemo( - () => buildConfigs(toolId, { baseUrl: effectiveBaseUrl, apiKey, models: selectedModels, claudeModels, claudeThinking, codexModel, codexThinking }), - [toolId, effectiveBaseUrl, apiKey, selectedModels, claudeModels, claudeThinking, codexModel, codexThinking] + () => buildConfigs(toolId, { baseUrl: effectiveBaseUrl, apiKey, models: selectedModels, claudeModels, claudeThinking, codexModel, codexThinking, opencodeModels, opencodeDefaultModel, coworkThinking }), + [toolId, effectiveBaseUrl, apiKey, selectedModels, claudeModels, claudeThinking, codexModel, codexThinking, opencodeModels, opencodeDefaultModel, coworkThinking] ); const getThinkingLevelsForModel = (fullModel) => { @@ -124,7 +145,7 @@ export default function ConfigGeneratorCard({ }; useEffect(() => { - if (toolId !== "claude" && toolId !== "codex") return; + if (toolId !== "claude" && toolId !== "codex" && toolId !== "opencode" && toolId !== "cowork") return; let cancelled = false; const loadConnectedModels = async () => { @@ -146,6 +167,16 @@ export default function ConfigGeneratorCard({ const addModel = (selected) => { if (!selected?.value || selectedModels.includes(selected.value)) return; setSelectedModels((current) => [...current, selected.value]); + if (toolId === "cowork") setCoworkThinking((current) => ({ ...current, [selected.value]: "" })); + }; + + const removeCoworkModel = (model) => { + setSelectedModels((current) => current.filter((item) => item !== model)); + setCoworkThinking((current) => { + const remainingThinking = { ...current }; + delete remainingThinking[model]; + return remainingThinking; + }); }; const selectClaudeModel = (selected) => { @@ -166,6 +197,18 @@ export default function ConfigGeneratorCard({ setCodexThinking(""); }; + const selectOpenCodeModel = (selected) => { + if (!selected?.value || opencodeModels.includes(selected.value)) return; + setOpencodeModels((current) => [...current, selected.value]); + if (!opencodeDefaultModel) setOpencodeDefaultModel(selected.value); + }; + + const removeOpenCodeModel = (model) => { + const remainingModels = opencodeModels.filter((item) => item !== model); + setOpencodeModels(remainingModels); + if (opencodeDefaultModel === model) setOpencodeDefaultModel(remainingModels[0] || ""); + }; + return (
@@ -274,6 +317,71 @@ export default function ConfigGeneratorCard({ )}

The selected reasoning level is appended to the model ID, for example cx/gpt-5.6-sol(high).

+ ) : toolId === "opencode" ? ( +
+
+ Models + +
+ {opencodeModels.length ? ( +
+ {opencodeModels.map((model) => { + const isDefault = model === opencodeDefaultModel; + return ( +
+ + +
+ ); + })} +
+ ) :

No model selected. The generated file uses {DEFAULT_MODEL} as a placeholder.

} +

Add every model to expose in OpenCode, then click a model to make it the default.

+
+ ) : toolId === "cowork" ? ( +
+
+ Models + +
+ {selectedModels.length ? ( +
+ {selectedModels.map((model) => { + const thinkingLevels = getThinkingLevelsForModel(model); + return ( +
+ {model} + {thinkingLevels && ( + + )} + +
+ ); + })} +
+ ) :

No model selected. The generated file uses {DEFAULT_MODEL} as a placeholder.

} +

Cowork exposes every configured model in its picker. For models that support it, select a reasoning level to append it to the routed model ID.

+
) : (
@@ -304,13 +412,13 @@ export default function ConfigGeneratorCard({ { setModelModalOpen(false); setClaudeModelSlot(""); }} - onSelect={toolId === "claude" ? selectClaudeModel : toolId === "codex" ? selectCodexModel : addModel} + onSelect={toolId === "claude" ? selectClaudeModel : toolId === "codex" ? selectCodexModel : toolId === "opencode" ? selectOpenCodeModel : addModel} selectedModel="" activeProviders={activeProviders} - title={toolId === "claude" && claudeModelSlot ? `Select ${claudeModelSlot} model` : toolId === "codex" ? "Select Codex model" : `Add model for ${tool.name}`} + title={toolId === "claude" && claudeModelSlot ? `Select ${claudeModelSlot} model` : toolId === "codex" ? "Select Codex model" : toolId === "opencode" ? "Add OpenCode model" : `Add model for ${tool.name}`} closeOnSelect={toolId === "claude" || toolId === "codex"} - addedModelValues={toolId === "claude" ? Object.values(claudeModels).filter(Boolean) : toolId === "codex" ? [codexModel].filter(Boolean) : selectedModels} - availableModels={toolId === "claude" || toolId === "codex" ? connectedModels : null} + addedModelValues={toolId === "claude" ? Object.values(claudeModels).filter(Boolean) : toolId === "codex" ? [codexModel].filter(Boolean) : toolId === "opencode" ? opencodeModels : selectedModels} + availableModels={toolId === "claude" || toolId === "codex" || toolId === "opencode" || toolId === "cowork" ? connectedModels : null} /> setConfigModalOpen(false)} title={`${tool.name} configuration`} configs={configs} /> diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js index 28d47d29..e5114301 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js @@ -1,21 +1,44 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Card, ModelSelectModal } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import Image from "next/image"; import ApiKeySelect from "./ApiKeySelect"; -export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders = [], cloudEnabled = false, tunnelEnabled = false }) { +export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, activeProviders = [], cloudEnabled = false, tunnelEnabled = false }) { const [copiedField, setCopiedField] = useState(null); const [showModelModal, setShowModelModal] = useState(false); const [modelValue, setModelValue] = useState(""); + const [selectedModels, setSelectedModels] = useState([]); + const [connectedModels, setConnectedModels] = useState(null); + const [isExpanded, setIsExpanded] = useState(true); // Initialize state directly with computed value - no need for useEffect const [selectedApiKey, setSelectedApiKey] = useState(() => apiKeys?.length > 0 ? apiKeys[0].key : "" ); + useEffect(() => { + if (toolId !== "cursor") return; + + let cancelled = false; + const loadConnectedModels = async () => { + try { + const response = await fetch("/api/models/connected", { cache: "no-store" }); + if (!response.ok) throw new Error("Failed to load connected models"); + const data = await response.json(); + if (!cancelled) setConnectedModels(data.models || []); + } catch (error) { + console.log(`Error loading connected models for ${tool.name}:`, error); + if (!cancelled) setConnectedModels([]); + } + }; + + loadConnectedModels(); + return () => { cancelled = true; }; + }, [toolId, tool.name]); + const replaceVars = (text) => { const keyToUse = (selectedApiKey && selectedApiKey.trim()) ? selectedApiKey @@ -42,9 +65,19 @@ export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, ba }; const handleSelectModel = (model) => { + if (!model?.value) return; setModelValue(model.value); }; + const addSelectedModel = (model) => { + if (!model?.value || selectedModels.includes(model.value)) return; + setSelectedModels((current) => [...current, model.value]); + }; + + const removeSelectedModel = (model) => { + setSelectedModels((current) => current.filter((value) => value !== model.value)); + }; + const hasActiveProviders = activeProviders.length > 0; const renderApiKeySelector = () => ( @@ -53,7 +86,42 @@ export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, ba
); - const renderModelSelector = () => { + const renderModelSelector = (item) => { + if (item.multiple) { + return ( +
+
+ Custom models to add in Cursor + +
+ {selectedModels.length ? ( +
+ {selectedModels.map((model) => ( +
+ + +
+ ))} +
+ ) :

Select every 9Router model you plan to add as a Cursor custom model.

} +
+ ); + } + return (
{item.title}

{item.desc &&

{item.desc}

} {item.type === "apiKeySelector" && renderApiKeySelector()} - {item.type === "modelSelector" && renderModelSelector()} + {item.type === "modelSelector" && renderModelSelector(item)} {item.value && (
@@ -238,7 +306,12 @@ export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, ba return ( -
+
+ {isExpanded && (
@@ -260,10 +333,14 @@ export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, ba setShowModelModal(false)} - onSelect={handleSelectModel} + onSelect={tool.modelSelection === "multiple" ? addSelectedModel : handleSelectModel} + onDeselect={tool.modelSelection === "multiple" ? removeSelectedModel : undefined} selectedModel={modelValue} activeProviders={activeProviders} - title="Select Model" + title={tool.modelSelection === "multiple" ? "Add Cursor custom model" : "Select Model"} + closeOnSelect={tool.modelSelection !== "multiple"} + addedModelValues={tool.modelSelection === "multiple" ? selectedModels : []} + availableModels={toolId === "cursor" ? connectedModels : null} /> ); diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index 20224fa6..93d2dc09 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -138,6 +138,7 @@ export const CLI_TOOLS = { color: "#000000", description: "Cursor AI Code Editor", configType: "guide", + modelSelection: "multiple", requiresExternalUrl: true, notes: [ { type: "warning", text: "Requires Cursor Pro account to use this feature." }, @@ -148,8 +149,8 @@ export const CLI_TOOLS = { { step: 2, title: "Enable OpenAI API", desc: "Enable \"OpenAI API key\" option" }, { step: 3, title: "Base URL", value: "{{baseUrl}}", copyable: true }, { step: 4, title: "API Key", type: "apiKeySelector" }, - { step: 5, title: "Add Custom Model", desc: "Click \"View All Model\" → \"Add Custom Model\"" }, - { step: 6, title: "Select Model", type: "modelSelector" }, + { step: 5, title: "Add Custom Model", desc: "Click \"View All Models\" → \"Add Custom Model\". Repeat this step for each model selected below." }, + { step: 6, title: "Select Custom Models", desc: "Add the exact 9Router model IDs below to Cursor. Click a selected model ID to copy it.", type: "modelSelector", multiple: true }, ], }, // HIDDEN: gemini-cli