"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import Image from "next/image"; import { Button, Card, ManualConfigModal, ModelSelectModal } from "@/shared/components"; import { getThinkingLevels } from "open-sse/providers/thinkingLevels.js"; import { DEFAULT_MODEL_TOKEN_LIMITS, getInputTokenOptions, getOutputTokenOptions } from "@/shared/constants/copilotModelTokens.js"; import { resolveInitialCliToolBaseUrl } from "@/shared/utils/cliToolEndpoint"; import BaseUrlSelect from "./BaseUrlSelect"; import ApiKeySelect from "./ApiKeySelect"; const DEFAULT_MODEL = "provider/model-id"; const COPILOT_API_KEY_INPUT = "${input:chat.lm.secret.9router}"; const normalizeV1 = (url) => { const trimmed = (url || "").replace(/\/+$/, ""); return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`; }; const toJson = (value) => JSON.stringify(value, null, 2); const withThinkingLevel = (model, thinkingLevel) => ( model && thinkingLevel ? `${model}(${thinkingLevel})` : model ); const MODEL_NAME_TERMS = { ai: "AI", claude: "Claude", codex: "Codex", deepseek: "DeepSeek", gemini: "Gemini", glm: "GLM", gpt: "GPT", kimi: "Kimi", minimax: "MiniMax", mistral: "Mistral", qwen: "Qwen", }; const formatModelName = (modelId) => { const unprefixedModelId = modelId.replace(/^.*\//, ""); const [, baseModelId, aliasSuffix] = unprefixedModelId.match(/^(.*?)(?:\(([^()]+)\))?$/) || []; const formatTerms = (value) => value .split(/[-_\s]+/) .filter(Boolean) .map((part) => MODEL_NAME_TERMS[part.toLowerCase()] || `${part.charAt(0).toUpperCase()}${part.slice(1)}`) .join(" "); const modelName = formatTerms(baseModelId || unprefixedModelId); return aliasSuffix ? `${modelName} (${formatTerms(aliasSuffix)})` : modelName; }; function buildConfigs(toolId, { baseUrl, apiKey, models, claudeModels = {}, claudeThinking = {}, codexModel = "", codexThinking = "", opencodeModels = [], opencodeDefaultModel = "", coworkThinking = {}, copilotTokens = {}, copilotThinking = {}, connectedModels = [] }) { const endpoint = normalizeV1(baseUrl); const selectedModels = models.length ? models : [DEFAULT_MODEL]; const model = selectedModels[0]; switch (toolId) { case "claude": return [{ filename: "~/.claude/settings.json", content: toJson({ hasCompletedOnboarding: true, env: { ANTHROPIC_BASE_URL: endpoint, ANTHROPIC_AUTH_TOKEN: apiKey, ANTHROPIC_DEFAULT_SONNET_MODEL: withThinkingLevel(claudeModels.sonnet || DEFAULT_MODEL, claudeThinking.sonnet), ANTHROPIC_DEFAULT_OPUS_MODEL: withThinkingLevel(claudeModels.opus || DEFAULT_MODEL, claudeThinking.opus), ANTHROPIC_DEFAULT_HAIKU_MODEL: withThinkingLevel(claudeModels.haiku || DEFAULT_MODEL, claudeThinking.haiku), }, }), }]; case "codex": return [ { filename: "~/.codex/config.toml", content: `model = "${withThinkingLevel(codexModel || DEFAULT_MODEL, codexThinking)}"\nmodel_provider = "9router"\n\n[model_providers.9router]\nname = "9Router"\nbase_url = "${endpoint}"\nwire_api = "responses"\n`, }, { filename: "~/.codex/auth.json", content: toJson({ auth_mode: "apikey", OPENAI_API_KEY: apiKey }) }, ]; case "opencode": { // 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({ $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}`, }), }]; } case "copilot": return [{ filename: "chatLanguageModels.json", content: toJson(Object.values(selectedModels.reduce((groups, id) => { const connectedModel = connectedModels.find((item) => item.fullModel === id); const providerId = connectedModel?.providerAlias || id.split("/")[0] || "9router"; const providerName = connectedModel?.provider?.name || formatModelName(providerId); const group = groups[providerId] || { name: providerName, vendor: "customendpoint", apiType: "chat-completions", // VS Code resolves this input lazily, prompting the user for their // 9Router key before the custom model can service a chat request. apiKey: COPILOT_API_KEY_INPUT, models: [], }; const tokens = copilotTokens[id] || {}; const modelId = withThinkingLevel(id, copilotThinking[id]); const entry = { id: modelId, name: formatModelName(modelId), url: `${endpoint}/chat/completions`, toolCalling: true, vision: true, streaming: true, }; if (copilotThinking[id]) { entry.thinking = true; entry.reasoningEffortFormat = "chat-completions"; } if (tokens.maxInputTokens) entry.maxInputTokens = tokens.maxInputTokens; if (tokens.maxOutputTokens) entry.maxOutputTokens = tokens.maxOutputTokens; group.models.push(entry); groups[providerId] = group; return groups; }, {}))), }]; case "cowork": return [{ // 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 }) }]; } } export default function ConfigGeneratorCard({ tool, toolId, baseUrl, apiKeys, activeProviders, availableModels = [], cloudEnabled, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, initialConfig, onSaveConfig, }) { const restoredApiKey = initialConfig?.apiKeyId ? apiKeys.find((key) => key.id === initialConfig.apiKeyId)?.key || "" : ""; const [selectedApiKey, setSelectedApiKey] = useState(() => ( initialConfig?.apiKeyMode === "custom" ? "" : initialConfig?.apiKeyId ? restoredApiKey : apiKeys?.[0]?.key || "" )); const [apiKeyMode, setApiKeyMode] = useState(() => initialConfig?.apiKeyMode || "managed"); const [selectedModels, setSelectedModels] = useState(() => initialConfig?.selectedModels || []); const [claudeModels, setClaudeModels] = useState(() => initialConfig?.claudeModels || { sonnet: "", opus: "", haiku: "" }); const [claudeThinking, setClaudeThinking] = useState(() => initialConfig?.claudeThinking || { sonnet: "", opus: "", haiku: "" }); const [claudeModelSlot, setClaudeModelSlot] = useState(""); const [codexModel, setCodexModel] = useState(() => initialConfig?.codexModel || ""); const [codexThinking, setCodexThinking] = useState(() => initialConfig?.codexThinking || ""); const [opencodeModels, setOpencodeModels] = useState(() => initialConfig?.opencodeModels || []); const [opencodeDefaultModel, setOpencodeDefaultModel] = useState(() => initialConfig?.opencodeDefaultModel || ""); const [coworkThinking, setCoworkThinking] = useState(() => initialConfig?.coworkThinking || {}); const [copilotTokens, setCopilotTokens] = useState(() => initialConfig?.copilotTokens || {}); const [copilotThinking, setCopilotThinking] = useState(() => initialConfig?.copilotThinking || {}); const connectedModels = availableModels; const [customBaseUrl, setCustomBaseUrl] = useState(() => resolveInitialCliToolBaseUrl(initialConfig?.baseUrl, baseUrl)); const [modelModalOpen, setModelModalOpen] = useState(false); const [configModalOpen, setConfigModalOpen] = useState(false); const [saveStatus, setSaveStatus] = useState("idle"); const [saveError, setSaveError] = useState(""); const initializedSaveState = useRef(false); const effectiveBaseUrl = customBaseUrl || baseUrl; const apiKey = toolId === "copilot" ? COPILOT_API_KEY_INPUT : selectedApiKey.trim() || (cloudEnabled ? "" : "sk_9router"); const configs = useMemo( () => buildConfigs(toolId, { baseUrl: effectiveBaseUrl, apiKey, models: selectedModels, claudeModels, claudeThinking, codexModel, codexThinking, opencodeModels, opencodeDefaultModel, coworkThinking, copilotTokens, copilotThinking, connectedModels: connectedModels || [] }), [toolId, effectiveBaseUrl, apiKey, selectedModels, claudeModels, claudeThinking, codexModel, codexThinking, opencodeModels, opencodeDefaultModel, coworkThinking, copilotTokens, copilotThinking, connectedModels] ); const buildPersistableConfig = () => { const config = { baseUrl: effectiveBaseUrl }; if (toolId !== "copilot") { config.apiKeyMode = apiKeyMode; config.apiKeyId = apiKeyMode === "managed" ? apiKeys.find((key) => key.key === selectedApiKey)?.id || null : null; } if (toolId === "claude") Object.assign(config, { claudeModels, claudeThinking }); if (toolId === "codex") Object.assign(config, { codexModel, codexThinking }); if (toolId === "opencode") Object.assign(config, { opencodeModels, opencodeDefaultModel }); if (toolId === "cowork") Object.assign(config, { selectedModels, coworkThinking }); if (toolId === "copilot") Object.assign(config, { selectedModels, copilotThinking, copilotTokens }); return config; }; const handleSave = async () => { setSaveStatus("saving"); setSaveError(""); try { await onSaveConfig(buildPersistableConfig()); setSaveStatus("saved"); } catch (error) { setSaveStatus("error"); setSaveError(error.message || "Failed to save configuration"); } }; useEffect(() => { if (!initializedSaveState.current) { initializedSaveState.current = true; return; } setSaveStatus((current) => current === "saving" ? current : "dirty"); setSaveError(""); }, [effectiveBaseUrl, apiKeyMode, selectedApiKey, selectedModels, claudeModels, claudeThinking, codexModel, codexThinking, opencodeModels, opencodeDefaultModel, coworkThinking, copilotTokens, copilotThinking]); const getThinkingLevelsForModel = (fullModel) => { const connectedModel = connectedModels?.find((model) => model.fullModel === fullModel); if (!connectedModel?.provider?.id || !connectedModel.model) return null; return getThinkingLevels(connectedModel.provider.id, connectedModel.model); }; const loadCopilotTokenLimits = async (modelIds) => { try { const response = await fetch("/api/models/token-limits", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ models: modelIds }), }); if (!response.ok) throw new Error("Failed to load models.dev token limits"); const { limits = {} } = await response.json(); setCopilotTokens((current) => Object.entries(limits).reduce((next, [model, limit]) => { const currentLimit = current[model]; const hasUserOverride = currentLimit && (currentLimit.maxInputTokens !== DEFAULT_MODEL_TOKEN_LIMITS.maxInputTokens || currentLimit.maxOutputTokens !== DEFAULT_MODEL_TOKEN_LIMITS.maxOutputTokens); next[model] = hasUserOverride ? currentLimit : limit; return next; }, { ...current })); } catch (error) { console.log("Error loading models.dev token limits:", error); } }; const addModel = (selected) => { if (!selected?.value || selectedModels.includes(selected.value)) return; setSelectedModels((current) => [...current, selected.value]); if (toolId === "cowork") setCoworkThinking((current) => ({ ...current, [selected.value]: "" })); if (toolId === "copilot") { setCopilotThinking((current) => ({ ...current, [selected.value]: "" })); setCopilotTokens((current) => ({ ...current, [selected.value]: DEFAULT_MODEL_TOKEN_LIMITS })); loadCopilotTokenLimits([selected.value]); } }; const removeCoworkModel = (model) => { setSelectedModels((current) => current.filter((item) => item !== model)); setCoworkThinking((current) => { const remainingThinking = { ...current }; delete remainingThinking[model]; return remainingThinking; }); }; const removeCopilotModel = (model) => { setSelectedModels((current) => current.filter((item) => item !== model)); setCopilotThinking((current) => { const remaining = { ...current }; delete remaining[model]; return remaining; }); setCopilotTokens((current) => { const remaining = { ...current }; delete remaining[model]; return remaining; }); }; const selectClaudeModel = (selected) => { if (!selected?.value || !claudeModelSlot) return; setClaudeModels((current) => ({ ...current, [claudeModelSlot]: selected.value })); setClaudeThinking((current) => ({ ...current, [claudeModelSlot]: "" })); setClaudeModelSlot(""); }; const openClaudeModelSelector = (slot) => { setClaudeModelSlot(slot); setModelModalOpen(true); }; const selectCodexModel = (selected) => { if (!selected?.value) return; setCodexModel(selected.value); 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 (
{tool.name}

{tool.name}

Generate a configuration file to copy to your own machine.

{toolId !== "copilot" && ( )} {toolId === "claude" ? (
Default Claude models {[ { slot: "sonnet", label: "Sonnet", envKey: "ANTHROPIC_DEFAULT_SONNET_MODEL" }, { slot: "opus", label: "Opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL" }, { slot: "haiku", label: "Haiku", envKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL" }, ].map(({ slot, label, envKey }) => { const thinkingLevels = getThinkingLevelsForModel(claudeModels[slot]); return (
{thinkingLevels && ( )}
); })}

Choose a model for each Claude Code alias. Empty fields use {DEFAULT_MODEL} as a placeholder.

) : toolId === "codex" ? (
{getThinkingLevelsForModel(codexModel) && ( )}

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.

) : toolId === "copilot" ? (
Models
{selectedModels.length ? (
{selectedModels.map((model) => { const thinkingLevels = getThinkingLevelsForModel(model); const tokens = copilotTokens[model] || DEFAULT_MODEL_TOKEN_LIMITS; const inputOptions = getInputTokenOptions(tokens); const outputOptions = getOutputTokenOptions(tokens); return (
{model}
{thinkingLevels && ( )}
); })}
) : (

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

)}

Select models to expose in VS Code Copilot, then copy the generated chatLanguageModels.json to your VS Code user settings folder. VS Code prompts for your 9Router API key when you first chat with one of these models.

) : (
Models
{selectedModels.length ? (
{selectedModels.map((model) => ( ))}
) :

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

}
)}
{saveStatus === "saved" && Configuration saved.} {saveStatus === "dirty" && Unsaved changes} {saveStatus === "error" && {saveError}}
{apiKeyMode === "custom" && toolId !== "copilot" &&

The custom API key is never saved and must be entered again after reload.

}

9Router cannot inspect, modify, or apply files on your device from a deployed dashboard.

{ setModelModalOpen(false); setClaudeModelSlot(""); }} onSelect={toolId === "claude" ? selectClaudeModel : toolId === "codex" ? selectCodexModel : toolId === "opencode" ? selectOpenCodeModel : toolId === "copilot" ? addModel : addModel} selectedModel="" activeProviders={activeProviders} 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) : toolId === "opencode" ? opencodeModels : selectedModels} availableModels={availableModels} /> setConfigModalOpen(false)} title={`${tool.name} configuration`} configs={configs} />
); }