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:
@@ -19,6 +19,8 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
|
|||||||
const [modelAliases, setModelAliases] = useState({});
|
const [modelAliases, setModelAliases] = useState({});
|
||||||
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
|
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
|
||||||
const [customBaseUrl, setCustomBaseUrl] = useState("");
|
const [customBaseUrl, setCustomBaseUrl] = useState("");
|
||||||
|
const [selectedModels, setSelectedModels] = useState([]);
|
||||||
|
const [activeModel, setActiveModel] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (apiKeys?.length > 0 && !selectedApiKey) {
|
if (apiKeys?.length > 0 && !selectedApiKey) {
|
||||||
@@ -38,10 +40,13 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
|
|||||||
if (isExpanded) fetchModelAliases();
|
if (isExpanded) fetchModelAliases();
|
||||||
}, [isExpanded]);
|
}, [isExpanded]);
|
||||||
|
|
||||||
// Sync model and subagent model from existing config
|
// Sync models from existing config
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status?.config?.model?.startsWith("9router/")) {
|
if (status?.opencode?.models) {
|
||||||
setSelectedModel(status.config.model.replace("9router/", ""));
|
setSelectedModels(status.opencode.models);
|
||||||
|
}
|
||||||
|
if (status?.opencode?.activeModel) {
|
||||||
|
setActiveModel(status.opencode.activeModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse subagent settings from agent.explorer if exists
|
// Parse subagent settings from agent.explorer if exists
|
||||||
@@ -104,8 +109,9 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
baseUrl: getEffectiveBaseUrl(),
|
baseUrl: getEffectiveBaseUrl(),
|
||||||
apiKey: keyToUse,
|
apiKey: keyToUse,
|
||||||
model: selectedModel,
|
models: selectedModels,
|
||||||
subagentModel: subagentModel || selectedModel
|
activeModel: activeModel === "" ? "" : (activeModel || selectedModels[0]),
|
||||||
|
subagentModel: subagentModel
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -132,6 +138,8 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
|
|||||||
setMessage({ type: "success", text: "Settings reset successfully!" });
|
setMessage({ type: "success", text: "Settings reset successfully!" });
|
||||||
setSelectedModel("");
|
setSelectedModel("");
|
||||||
setSubagentModel("");
|
setSubagentModel("");
|
||||||
|
setSelectedModels([]);
|
||||||
|
setActiveModel("");
|
||||||
checkStatus();
|
checkStatus();
|
||||||
} else {
|
} else {
|
||||||
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
|
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
|
||||||
@@ -148,7 +156,14 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
|
|||||||
? selectedApiKey
|
? selectedApiKey
|
||||||
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
|
: (!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 [{
|
return [{
|
||||||
filename: "~/.config/opencode/opencode.json",
|
filename: "~/.config/opencode/opencode.json",
|
||||||
@@ -157,13 +172,10 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
|
|||||||
"9router": {
|
"9router": {
|
||||||
npm: "@ai-sdk/openai-compatible",
|
npm: "@ai-sdk/openai-compatible",
|
||||||
options: { baseURL: getEffectiveBaseUrl(), apiKey: keyToUse },
|
options: { baseURL: getEffectiveBaseUrl(), apiKey: keyToUse },
|
||||||
models: {
|
models: modelsObj,
|
||||||
[selectedModel || "provider/model-id"]: { name: selectedModel || "provider/model-id" },
|
|
||||||
[effectiveSubagentModel]: { name: effectiveSubagentModel }
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
model: `9router/${selectedModel || "provider/model-id"}`,
|
model: `9router/${activeModelToShow}`,
|
||||||
agent: {
|
agent: {
|
||||||
explorer: {
|
explorer: {
|
||||||
description: "Fast explorer subagent for codebase exploration",
|
description: "Fast explorer subagent for codebase exploration",
|
||||||
@@ -279,13 +291,84 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model */}
|
{/* Models */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">Model</span>
|
<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]">arrow_forward</span>
|
<span className="material-symbols-outlined text-text-muted text-[14px] mt-1.5">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" />
|
<div className="flex-1 flex flex-col gap-2">
|
||||||
<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>
|
<div className="flex flex-wrap gap-1.5 min-h-[28px] px-2 py-1.5 bg-surface rounded border border-border">
|
||||||
{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>}
|
{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>
|
</div>
|
||||||
|
|
||||||
{/* Subagent Model */}
|
{/* Subagent Model */}
|
||||||
@@ -326,7 +409,7 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={handleReset} disabled={!status.has9Router} loading={restoring}>
|
<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}
|
isOpen={modalOpen}
|
||||||
onClose={() => setModalOpen(false)}
|
onClose={() => setModalOpen(false)}
|
||||||
onSelect={(model) => {
|
onSelect={(model) => {
|
||||||
setSelectedModel(model.value);
|
if (!selectedModels.includes(model.value)) {
|
||||||
// Auto-set subagent model if not set
|
setSelectedModels([...selectedModels, model.value]);
|
||||||
if (!subagentModel) {
|
if (!activeModel) setActiveModel(model.value);
|
||||||
setSubagentModel(model.value);
|
|
||||||
}
|
}
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
}}
|
}}
|
||||||
selectedModel={selectedModel}
|
selectedModel={null}
|
||||||
activeProviders={activeProviders}
|
activeProviders={activeProviders}
|
||||||
modelAliases={modelAliases}
|
modelAliases={modelAliases}
|
||||||
title="Select Model for OpenCode"
|
title="Add Model for OpenCode"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ModelSelectModal
|
<ModelSelectModal
|
||||||
|
|||||||
@@ -61,12 +61,19 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config = await readConfig();
|
const config = await readConfig();
|
||||||
|
const providerConfig = config?.provider?.["9router"];
|
||||||
|
const modelMap = providerConfig?.models || {};
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
installed: true,
|
installed: true,
|
||||||
config,
|
config,
|
||||||
has9Router: has9RouterConfig(config),
|
has9Router: has9RouterConfig(config),
|
||||||
configPath: getConfigPath(),
|
configPath: getConfigPath(),
|
||||||
|
opencode: {
|
||||||
|
models: Object.keys(modelMap),
|
||||||
|
activeModel: config?.model?.startsWith("9router/") ? config.model.replace(/^9router\//, "") : null,
|
||||||
|
baseURL: providerConfig?.options?.baseURL || null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error checking opencode settings:", error);
|
console.log("Error checking opencode settings:", error);
|
||||||
@@ -74,13 +81,16 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST - Apply 9Router as openai-compatible provider
|
// POST - Apply 9Router as openai-compatible provider (multi-model support)
|
||||||
export async function POST(request) {
|
export async function POST(request) {
|
||||||
try {
|
try {
|
||||||
const { baseUrl, apiKey, model, subagentModel } = await request.json();
|
const { baseUrl, apiKey, model, models, activeModel, subagentModel } = await request.json();
|
||||||
|
|
||||||
if (!baseUrl || !model) {
|
// Accept either `model` (string, legacy) or `models` (array of strings)
|
||||||
return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 });
|
const modelsArray = Array.isArray(models) ? models.slice() : (typeof model === "string" ? [model] : []);
|
||||||
|
|
||||||
|
if (!baseUrl || modelsArray.length === 0) {
|
||||||
|
return NextResponse.json({ error: "baseUrl and at least one model are required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const configDir = getConfigDir();
|
const configDir = getConfigDir();
|
||||||
@@ -97,24 +107,43 @@ export async function POST(request) {
|
|||||||
|
|
||||||
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||||
const keyToUse = apiKey || "sk_9router";
|
const keyToUse = apiKey || "sk_9router";
|
||||||
const effectiveSubagentModel = subagentModel || model;
|
const effectiveSubagentModel = subagentModel || modelsArray[0];
|
||||||
|
|
||||||
// Merge 9router provider
|
// Ensure provider object
|
||||||
if (!config.provider) config.provider = {};
|
if (!config.provider) config.provider = {};
|
||||||
config.provider["9router"] = {
|
|
||||||
npm: "@ai-sdk/openai-compatible",
|
// Preserve any existing 9router provider entry and its models
|
||||||
options: {
|
const existingProvider = config.provider["9router"] || { npm: "@ai-sdk/openai-compatible", options: {}, models: {} };
|
||||||
baseURL: normalizedBaseUrl,
|
|
||||||
apiKey: keyToUse,
|
// Merge options (overwrite baseURL/apiKey)
|
||||||
},
|
existingProvider.options = {
|
||||||
models: {
|
...existingProvider.options,
|
||||||
[model]: { name: model },
|
baseURL: normalizedBaseUrl,
|
||||||
[effectiveSubagentModel]: { name: effectiveSubagentModel },
|
apiKey: keyToUse,
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set as active model
|
// Ensure models map exists
|
||||||
config.model = `9router/${model}`;
|
existingProvider.models = existingProvider.models || {};
|
||||||
|
|
||||||
|
// Add or update entries for all requested models
|
||||||
|
for (const m of modelsArray) {
|
||||||
|
if (!m || typeof m !== "string") continue;
|
||||||
|
existingProvider.models[m] = { name: m };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save merged provider back
|
||||||
|
config.provider["9router"] = existingProvider;
|
||||||
|
|
||||||
|
// Set the active model: prefer explicit activeModel, else first of modelsArray
|
||||||
|
// If activeModel is explicitly empty string, clear the model
|
||||||
|
if (activeModel === "") {
|
||||||
|
config.model = "";
|
||||||
|
} else {
|
||||||
|
const finalActive = activeModel || modelsArray[0];
|
||||||
|
if (finalActive) {
|
||||||
|
config.model = `9router/${finalActive}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add subagent configuration
|
// Add subagent configuration
|
||||||
if (!config.agent) config.agent = {};
|
if (!config.agent) config.agent = {};
|
||||||
@@ -132,14 +161,52 @@ export async function POST(request) {
|
|||||||
configPath,
|
configPath,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error updating opencode settings:", error);
|
console.log("Error applying opencode settings:", error);
|
||||||
return NextResponse.json({ error: "Failed to update opencode settings" }, { status: 500 });
|
return NextResponse.json({ error: "Failed to apply settings" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE - Remove 9Router provider from config
|
// PATCH - Update specific settings (e.g., clear active model)
|
||||||
export async function DELETE() {
|
export async function PATCH(request) {
|
||||||
try {
|
try {
|
||||||
|
const { clearActiveModel } = await request.json();
|
||||||
|
const configPath = getConfigPath();
|
||||||
|
|
||||||
|
let config = {};
|
||||||
|
try {
|
||||||
|
const existing = await fs.readFile(configPath, "utf-8");
|
||||||
|
config = JSON.parse(existing);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === "ENOENT") {
|
||||||
|
return NextResponse.json({ success: true, message: "No config file found" });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clearActiveModel === true) {
|
||||||
|
// Clear active model but keep models in the list
|
||||||
|
if (config.model?.startsWith("9router/")) {
|
||||||
|
config.model = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "Settings updated",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Error patching opencode settings:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to patch settings" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE - Remove 9Router provider or specific models from config
|
||||||
|
export async function DELETE(request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const modelToRemove = searchParams.get("model");
|
||||||
const configPath = getConfigPath();
|
const configPath = getConfigPath();
|
||||||
|
|
||||||
let config = {};
|
let config = {};
|
||||||
@@ -153,11 +220,24 @@ export async function DELETE() {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove 9router provider
|
// If specific model provided, remove just that model
|
||||||
if (config.provider) delete config.provider["9router"];
|
if (modelToRemove && config.provider?.["9router"]?.models) {
|
||||||
|
delete config.provider["9router"].models[modelToRemove];
|
||||||
|
|
||||||
// Reset model if it was pointing to 9router
|
// If no models left, remove the provider
|
||||||
if (config.model?.startsWith("9router/")) delete config.model;
|
if (Object.keys(config.provider["9router"].models).length === 0) {
|
||||||
|
delete config.provider["9router"];
|
||||||
|
if (config.model?.startsWith("9router/")) delete config.model;
|
||||||
|
} else if (config.model === `9router/${modelToRemove}`) {
|
||||||
|
// If removed model was active, switch to first remaining model
|
||||||
|
const remainingModels = Object.keys(config.provider["9router"].models);
|
||||||
|
config.model = `9router/${remainingModels[0]}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No specific model - remove entire 9router provider
|
||||||
|
if (config.provider) delete config.provider["9router"];
|
||||||
|
if (config.model?.startsWith("9router/")) delete config.model;
|
||||||
|
}
|
||||||
|
|
||||||
// Remove subagent configuration
|
// Remove subagent configuration
|
||||||
if (config.agent?.explorer?.model?.startsWith("9router/")) {
|
if (config.agent?.explorer?.model?.startsWith("9router/")) {
|
||||||
@@ -170,7 +250,7 @@ export async function DELETE() {
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "9Router settings removed from OpenCode",
|
message: modelToRemove ? `Model "${modelToRemove}" removed` : "9Router settings removed from OpenCode",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error resetting opencode settings:", error);
|
console.log("Error resetting opencode settings:", error);
|
||||||
|
|||||||
Reference in New Issue
Block a user