diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ConfigGeneratorCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ConfigGeneratorCard.js index 215c4fe9..b2a02df0 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ConfigGeneratorCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ConfigGeneratorCard.js @@ -4,10 +4,12 @@ import { useEffect, useMemo, 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 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(/\/+$/, ""); @@ -20,7 +22,34 @@ const withThinkingLevel = (model, thinkingLevel) => ( model && thinkingLevel ? `${model}(${thinkingLevel})` : model ); -function buildConfigs(toolId, { baseUrl, apiKey, models, claudeModels = {}, claudeThinking = {}, codexModel = "", codexThinking = "", opencodeModels = [], opencodeDefaultModel = "", coworkThinking = {} }) { +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]; @@ -75,13 +104,39 @@ function buildConfigs(toolId, { baseUrl, apiKey, models, claudeModels = {}, clau case "copilot": return [{ filename: "chatLanguageModels.json", - content: toJson(selectedModels.map((id) => ({ - name: id, - vendor: "9Router", - model: id, - apiBase: endpoint, - apiKey, - }))), + 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 [{ @@ -126,16 +181,20 @@ export default function ConfigGeneratorCard({ const [opencodeModels, setOpencodeModels] = useState([]); const [opencodeDefaultModel, setOpencodeDefaultModel] = useState(""); const [coworkThinking, setCoworkThinking] = useState({}); + const [copilotTokens, setCopilotTokens] = useState({}); + const [copilotThinking, setCopilotThinking] = useState({}); const [connectedModels, setConnectedModels] = useState(null); const [customBaseUrl, setCustomBaseUrl] = useState(""); const [modelModalOpen, setModelModalOpen] = useState(false); const [configModalOpen, setConfigModalOpen] = useState(false); const effectiveBaseUrl = customBaseUrl || baseUrl; - const apiKey = selectedApiKey.trim() || (cloudEnabled ? "" : "sk_9router"); + 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 }), - [toolId, effectiveBaseUrl, apiKey, selectedModels, claudeModels, claudeThinking, codexModel, codexThinking, opencodeModels, opencodeDefaultModel, coworkThinking] + () => 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 getThinkingLevelsForModel = (fullModel) => { @@ -144,8 +203,31 @@ export default function ConfigGeneratorCard({ 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); + } + }; + useEffect(() => { - if (toolId !== "claude" && toolId !== "codex" && toolId !== "opencode" && toolId !== "cowork") return; + if (toolId !== "claude" && toolId !== "codex" && toolId !== "opencode" && toolId !== "cowork" && toolId !== "copilot") return; let cancelled = false; const loadConnectedModels = async () => { @@ -168,6 +250,11 @@ export default function ConfigGeneratorCard({ 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) => { @@ -179,6 +266,20 @@ export default function ConfigGeneratorCard({ }); }; + 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 })); @@ -235,10 +336,12 @@ export default function ConfigGeneratorCard({ cloudUrl={process.env.NEXT_PUBLIC_CLOUD_URL} /> - + {toolId !== "copilot" && ( + + )} {toolId === "claude" ? (
Default Claude models @@ -382,6 +485,78 @@ export default function ConfigGeneratorCard({ ) :

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.

+
) : (
@@ -412,13 +587,13 @@ export default function ConfigGeneratorCard({ { setModelModalOpen(false); setClaudeModelSlot(""); }} - onSelect={toolId === "claude" ? selectClaudeModel : toolId === "codex" ? selectCodexModel : toolId === "opencode" ? selectOpenCodeModel : addModel} + 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={toolId === "claude" || toolId === "codex" || toolId === "opencode" || toolId === "cowork" ? connectedModels : null} + availableModels={toolId === "claude" || toolId === "codex" || toolId === "opencode" || toolId === "cowork" || toolId === "copilot" ? connectedModels : null} /> setConfigModalOpen(false)} title={`${tool.name} configuration`} configs={configs} /> diff --git a/src/app/api/models/token-limits/route.js b/src/app/api/models/token-limits/route.js new file mode 100644 index 00000000..94a20661 --- /dev/null +++ b/src/app/api/models/token-limits/route.js @@ -0,0 +1,105 @@ +import { NextResponse } from "next/server"; +import { requireUsageDashboardUser } from "@/lib/auth/currentUser"; + +const MODELS_DEV_API_URL = "https://models.dev/api.json"; + +export const dynamic = "force-dynamic"; + +function getForbiddenResponse(error) { + if (error.message === "Unauthorized") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (error.message === "Forbidden") { + return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); + } + return null; +} + +function normalizeModelId(modelId) { + return typeof modelId === "string" + ? modelId.trim().replace(/\([^()]+\)$/, "").toLowerCase() + : ""; +} + +function getModelSuffix(modelId) { + const normalized = normalizeModelId(modelId); + return normalized.includes("/") ? normalized.slice(normalized.lastIndexOf("/") + 1) : normalized; +} + +function getCatalogEntries(catalog) { + return Object.values(catalog || {}).flatMap((provider) => Object.values(provider?.models || {})); +} + +function getCandidateScore(catalogModelId, requestedModelId) { + const candidate = normalizeModelId(catalogModelId); + const requested = normalizeModelId(requestedModelId); + const suffix = getModelSuffix(requested); + + if (!candidate || !requested || !suffix) return 0; + if (candidate === requested) return 3; + if (candidate === suffix) return 2; + if (candidate.endsWith(`/${suffix}`)) return 1; + return 0; +} + +/** + * The models.dev catalog can list a model through multiple routers. Select the + * most common limit pair at the strongest matching level to avoid one router's + * provider-specific outlier when 9Router uses a different provider alias. + */ +function resolveTokenLimits(entries, modelId) { + const scored = entries + .map((model) => ({ model, score: getCandidateScore(model?.id, modelId) })) + .filter(({ score, model }) => score > 0 && Number.isFinite(model?.limit?.context) && Number.isFinite(model?.limit?.output)); + + if (!scored.length) return null; + + const bestScore = Math.max(...scored.map(({ score }) => score)); + const frequencies = new Map(); + + for (const { model } of scored.filter(({ score }) => score === bestScore)) { + const key = `${model.limit.context}:${model.limit.output}`; + frequencies.set(key, (frequencies.get(key) || 0) + 1); + } + + const [selectedKey] = [...frequencies.entries()] + .sort(([firstKey, firstCount], [secondKey, secondCount]) => { + if (secondCount !== firstCount) return secondCount - firstCount; + const [firstContext, firstOutput] = firstKey.split(":").map(Number); + const [secondContext, secondOutput] = secondKey.split(":").map(Number); + return secondContext - firstContext || secondOutput - firstOutput; + })[0]; + const [maxInputTokens, maxOutputTokens] = selectedKey.split(":").map(Number); + + return { maxInputTokens, maxOutputTokens }; +} + +// POST /api/models/token-limits - Resolve selected model limits from models.dev. +export async function POST(request) { + try { + await requireUsageDashboardUser(); + + const { models } = await request.json(); + if (!Array.isArray(models) || models.some((model) => typeof model !== "string" || !model.trim())) { + return NextResponse.json({ error: "models must be an array of model IDs" }, { status: 400 }); + } + + const response = await fetch(MODELS_DEV_API_URL, { cache: "no-store" }); + if (!response.ok) throw new Error(`models.dev returned ${response.status}`); + + const entries = getCatalogEntries(await response.json()); + const limits = Object.fromEntries( + [...new Set(models)] + .map((modelId) => [modelId, resolveTokenLimits(entries, modelId)]) + .filter(([, value]) => value), + ); + + return NextResponse.json({ limits }); + } catch (error) { + const accessError = getForbiddenResponse(error); + if (accessError) return accessError; + + console.log("Error resolving models.dev token limits:", error); + return NextResponse.json({ error: "Failed to load token limits from models.dev" }, { status: 502 }); + } +} diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index 93d2dc09..1fa06830 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -153,6 +153,14 @@ export const CLI_TOOLS = { { 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 }, ], }, + copilot: { + id: "copilot", + name: "GitHub Copilot (VSCode)", + image: "/providers/copilot.png", + color: "#1F6FEB", + description: "GitHub Copilot in VS Code via custom models", + configType: "custom", + }, // HIDDEN: gemini-cli // "gemini-cli": { // id: "gemini-cli", diff --git a/src/shared/constants/copilotModelTokens.js b/src/shared/constants/copilotModelTokens.js new file mode 100644 index 00000000..76d1dae1 --- /dev/null +++ b/src/shared/constants/copilotModelTokens.js @@ -0,0 +1,40 @@ +/** + * Shared presentation helpers for GitHub Copilot token-limit controls. + * + * Per-model limits are deliberately not stored here. They are resolved from + * models.dev at runtime by /api/models/token-limits so the dashboard follows + * the upstream catalog without application releases. + */ + +const TOKEN_PRESETS = [4096, 8192, 16384, 32768, 65536, 100000, 128000, 200000, 256000, 400000, 500000, 1000000]; + +/** Used only while the remote catalog is loading or has no matching model. */ +export const DEFAULT_MODEL_TOKEN_LIMITS = { + maxInputTokens: 128000, + maxOutputTokens: 32768, +}; + +function buildOptions(maxValue) { + const options = TOKEN_PRESETS.filter((value) => value <= maxValue); + if (!options.length || options[options.length - 1] !== maxValue) { + options.push(maxValue); + } + return options; +} + +/** Get select-option choices for a resolved max input-token limit. */ +export function getInputTokenOptions({ maxInputTokens } = DEFAULT_MODEL_TOKEN_LIMITS) { + return buildOptions(maxInputTokens).map((value) => ({ value, label: formatToken(value) })); +} + +/** Get select-option choices for a resolved max output-token limit. */ +export function getOutputTokenOptions({ maxOutputTokens } = DEFAULT_MODEL_TOKEN_LIMITS) { + return buildOptions(maxOutputTokens).map((value) => ({ value, label: formatToken(value) })); +} + +/** Human-readable token count (for example, "128,000" or "1.05M"). */ +function formatToken(value) { + if (value >= 1000000) return `${(value / 1000000).toFixed(value % 1000000 === 0 ? 0 : 2)}M`; + if (value >= 1000) return value.toLocaleString("en-US"); + return String(value); +}