feat: multi-model support for OpenCode CLI config with subagent integration

- Allow selecting multiple models for 9router provider
- Merge models instead of overwriting (backwards-compatible)
- Support setting active model via click or Apply
- Click active model to clear default (model = '')
- Remove individual models via X button (persists to config)
- Add PATCH endpoint for clearing active model
- Update GET to return normalized model list
- Preserve subagent model configuration for explorer subagent

Made-with: Cursor
This commit is contained in:
decolua
2026-04-10 10:22:42 +07:00
parent 3c96e8d6d1
commit 1a25c6e3b5
2 changed files with 216 additions and 54 deletions
@@ -19,6 +19,8 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
const [modelAliases, setModelAliases] = useState({});
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
const [customBaseUrl, setCustomBaseUrl] = useState("");
const [selectedModels, setSelectedModels] = useState([]);
const [activeModel, setActiveModel] = useState("");
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
@@ -38,10 +40,13 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
if (isExpanded) fetchModelAliases();
}, [isExpanded]);
// Sync model and subagent model from existing config
// Sync models from existing config
useEffect(() => {
if (status?.config?.model?.startsWith("9router/")) {
setSelectedModel(status.config.model.replace("9router/", ""));
if (status?.opencode?.models) {
setSelectedModels(status.opencode.models);
}
if (status?.opencode?.activeModel) {
setActiveModel(status.opencode.activeModel);
}
// Parse subagent settings from agent.explorer if exists
@@ -104,8 +109,9 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
body: JSON.stringify({
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
model: selectedModel,
subagentModel: subagentModel || selectedModel
models: selectedModels,
activeModel: activeModel === "" ? "" : (activeModel || selectedModels[0]),
subagentModel: subagentModel
}),
});
const data = await res.json();
@@ -132,6 +138,8 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
setMessage({ type: "success", text: "Settings reset successfully!" });
setSelectedModel("");
setSubagentModel("");
setSelectedModels([]);
setActiveModel("");
checkStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
@@ -148,7 +156,14 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
const effectiveSubagentModel = subagentModel || selectedModel;
const modelsToShow = selectedModels.length > 0 ? selectedModels : ["provider/model-id"];
const activeModelToShow = activeModel || selectedModels[0] || modelsToShow[0];
const effectiveSubagentModel = subagentModel || activeModelToShow;
const modelsObj = {};
modelsToShow.forEach(m => {
modelsObj[m] = { name: m };
});
return [{
filename: "~/.config/opencode/opencode.json",
@@ -157,13 +172,10 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
"9router": {
npm: "@ai-sdk/openai-compatible",
options: { baseURL: getEffectiveBaseUrl(), apiKey: keyToUse },
models: {
[selectedModel || "provider/model-id"]: { name: selectedModel || "provider/model-id" },
[effectiveSubagentModel]: { name: effectiveSubagentModel }
},
models: modelsObj,
},
},
model: `9router/${selectedModel || "provider/model-id"}`,
model: `9router/${activeModelToShow}`,
agent: {
explorer: {
description: "Fast explorer subagent for codebase exploration",
@@ -279,13 +291,84 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
)}
</div>
{/* Model */}
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">Model</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
<input type="text" value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)} placeholder="provider/model-id" className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" />
<button onClick={() => setModalOpen(true)} disabled={!activeProviders?.length} className={`px-2 py-1.5 rounded border text-xs transition-colors shrink-0 whitespace-nowrap ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select Model</button>
{selectedModel && <button onClick={() => setSelectedModel("")} className="p-1 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
{/* Models */}
<div className="flex items-start gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right pt-1">Models</span>
<span className="material-symbols-outlined text-text-muted text-[14px] mt-1.5">arrow_forward</span>
<div className="flex-1 flex flex-col gap-2">
<div className="flex flex-wrap gap-1.5 min-h-[28px] px-2 py-1.5 bg-surface rounded border border-border">
{selectedModels.length === 0 ? (
<span className="text-xs text-text-muted">No models selected</span>
) : (
selectedModels.map((model) => (
<span
key={model}
onClick={async () => {
if (model === activeModel) {
try {
const res = await fetch("/api/cli-tools/opencode-settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ clearActiveModel: true }),
});
if (res.ok) {
setActiveModel("");
checkStatus();
}
} catch (error) {
console.log("Error clearing active model:", error);
}
} else {
setActiveModel(model);
}
}}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs cursor-pointer transition-colors ${
model === activeModel
? "bg-primary/10 text-primary border border-primary"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-transparent hover:border-border"
}`}
title={model === activeModel ? "Click to clear active model" : "Click to set as active"}
>
{model === activeModel && <span className="material-symbols-outlined text-[10px]">star</span>}
{model}
<button
onClick={async (e) => {
e.stopPropagation();
try {
const res = await fetch(`/api/cli-tools/opencode-settings?model=${encodeURIComponent(model)}`, { method: "DELETE" });
if (res.ok) {
const newModels = selectedModels.filter((m) => m !== model);
setSelectedModels(newModels);
if (activeModel === model) {
setActiveModel("");
}
checkStatus();
}
} catch (error) {
console.log("Error removing model:", error);
}
}}
className="ml-0.5 hover:text-red-500"
>
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</span>
))
)}
</div>
<div className="flex items-center gap-2">
<button onClick={() => setModalOpen(true)} disabled={!activeProviders?.length} className={`px-2 py-1 rounded border text-xs transition-colors ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Add Model</button>
<span className="text-xs text-text-muted">
{selectedModels.length > 0 && activeModel ? (
<>Active: <span className="text-primary">{activeModel}</span></>
) : selectedModels.length > 0 ? (
<span className="text-yellow-500">Click a model to set/clear active</span>
) : (
"Select models to add"
)}
</span>
</div>
</div>
</div>
{/* Subagent Model */}
@@ -326,7 +409,7 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
)}
<div className="flex items-center gap-2">
<Button variant="primary" size="sm" onClick={handleApply} disabled={!selectedModel} loading={applying}>
<Button variant="primary" size="sm" onClick={handleApply} disabled={selectedModels.length === 0 || !selectedModel} loading={applying}>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleReset} disabled={!status.has9Router} loading={restoring}>
@@ -345,17 +428,16 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={(model) => {
setSelectedModel(model.value);
// Auto-set subagent model if not set
if (!subagentModel) {
setSubagentModel(model.value);
if (!selectedModels.includes(model.value)) {
setSelectedModels([...selectedModels, model.value]);
if (!activeModel) setActiveModel(model.value);
}
setModalOpen(false);
setModalOpen(false);
}}
selectedModel={selectedModel}
selectedModel={null}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for OpenCode"
title="Add Model for OpenCode"
/>
<ModelSelectModal