feat: add the github copilot tools

This commit is contained in:
2026-07-13 15:38:00 +07:00
parent 7c9a5d2333
commit 275f20e2b8
4 changed files with 346 additions and 18 deletions
@@ -4,10 +4,12 @@ import { useEffect, useMemo, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { Button, Card, ManualConfigModal, ModelSelectModal } from "@/shared/components"; import { Button, Card, ManualConfigModal, ModelSelectModal } from "@/shared/components";
import { getThinkingLevels } from "open-sse/providers/thinkingLevels.js"; 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 BaseUrlSelect from "./BaseUrlSelect";
import ApiKeySelect from "./ApiKeySelect"; import ApiKeySelect from "./ApiKeySelect";
const DEFAULT_MODEL = "provider/model-id"; const DEFAULT_MODEL = "provider/model-id";
const COPILOT_API_KEY_INPUT = "${input:chat.lm.secret.9router}";
const normalizeV1 = (url) => { const normalizeV1 = (url) => {
const trimmed = (url || "").replace(/\/+$/, ""); const trimmed = (url || "").replace(/\/+$/, "");
@@ -20,7 +22,34 @@ const withThinkingLevel = (model, thinkingLevel) => (
model && thinkingLevel ? `${model}(${thinkingLevel})` : model 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 endpoint = normalizeV1(baseUrl);
const selectedModels = models.length ? models : [DEFAULT_MODEL]; const selectedModels = models.length ? models : [DEFAULT_MODEL];
const model = selectedModels[0]; const model = selectedModels[0];
@@ -75,13 +104,39 @@ function buildConfigs(toolId, { baseUrl, apiKey, models, claudeModels = {}, clau
case "copilot": case "copilot":
return [{ return [{
filename: "chatLanguageModels.json", filename: "chatLanguageModels.json",
content: toJson(selectedModels.map((id) => ({ content: toJson(Object.values(selectedModels.reduce((groups, id) => {
name: id, const connectedModel = connectedModels.find((item) => item.fullModel === id);
vendor: "9Router", const providerId = connectedModel?.providerAlias || id.split("/")[0] || "9router";
model: id, const providerName = connectedModel?.provider?.name || formatModelName(providerId);
apiBase: endpoint, const group = groups[providerId] || {
apiKey, 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": case "cowork":
return [{ return [{
@@ -126,16 +181,20 @@ export default function ConfigGeneratorCard({
const [opencodeModels, setOpencodeModels] = useState([]); const [opencodeModels, setOpencodeModels] = useState([]);
const [opencodeDefaultModel, setOpencodeDefaultModel] = useState(""); const [opencodeDefaultModel, setOpencodeDefaultModel] = useState("");
const [coworkThinking, setCoworkThinking] = useState({}); const [coworkThinking, setCoworkThinking] = useState({});
const [copilotTokens, setCopilotTokens] = useState({});
const [copilotThinking, setCopilotThinking] = useState({});
const [connectedModels, setConnectedModels] = useState(null); const [connectedModels, setConnectedModels] = useState(null);
const [customBaseUrl, setCustomBaseUrl] = useState(""); const [customBaseUrl, setCustomBaseUrl] = useState("");
const [modelModalOpen, setModelModalOpen] = useState(false); const [modelModalOpen, setModelModalOpen] = useState(false);
const [configModalOpen, setConfigModalOpen] = useState(false); const [configModalOpen, setConfigModalOpen] = useState(false);
const effectiveBaseUrl = customBaseUrl || baseUrl; const effectiveBaseUrl = customBaseUrl || baseUrl;
const apiKey = selectedApiKey.trim() || (cloudEnabled ? "<API_KEY_FROM_DASHBOARD>" : "sk_9router"); const apiKey = toolId === "copilot"
? COPILOT_API_KEY_INPUT
: selectedApiKey.trim() || (cloudEnabled ? "<API_KEY_FROM_DASHBOARD>" : "sk_9router");
const configs = useMemo( const configs = useMemo(
() => buildConfigs(toolId, { baseUrl: effectiveBaseUrl, apiKey, models: 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] [toolId, effectiveBaseUrl, apiKey, selectedModels, claudeModels, claudeThinking, codexModel, codexThinking, opencodeModels, opencodeDefaultModel, coworkThinking, copilotTokens, copilotThinking, connectedModels]
); );
const getThinkingLevelsForModel = (fullModel) => { const getThinkingLevelsForModel = (fullModel) => {
@@ -144,8 +203,31 @@ export default function ConfigGeneratorCard({
return getThinkingLevels(connectedModel.provider.id, connectedModel.model); 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(() => { 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; let cancelled = false;
const loadConnectedModels = async () => { const loadConnectedModels = async () => {
@@ -168,6 +250,11 @@ export default function ConfigGeneratorCard({
if (!selected?.value || selectedModels.includes(selected.value)) return; if (!selected?.value || selectedModels.includes(selected.value)) return;
setSelectedModels((current) => [...current, selected.value]); setSelectedModels((current) => [...current, selected.value]);
if (toolId === "cowork") setCoworkThinking((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) => { 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) => { const selectClaudeModel = (selected) => {
if (!selected?.value || !claudeModelSlot) return; if (!selected?.value || !claudeModelSlot) return;
setClaudeModels((current) => ({ ...current, [claudeModelSlot]: selected.value })); setClaudeModels((current) => ({ ...current, [claudeModelSlot]: selected.value }));
@@ -235,10 +336,12 @@ export default function ConfigGeneratorCard({
cloudUrl={process.env.NEXT_PUBLIC_CLOUD_URL} cloudUrl={process.env.NEXT_PUBLIC_CLOUD_URL}
/> />
</label> </label>
<label className="flex flex-col gap-1.5 text-xs font-medium text-text-muted"> {toolId !== "copilot" && (
API key <label className="flex flex-col gap-1.5 text-xs font-medium text-text-muted">
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} /> API key
</label> <ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</label>
)}
{toolId === "claude" ? ( {toolId === "claude" ? (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<span className="text-xs font-medium text-text-muted">Default Claude models</span> <span className="text-xs font-medium text-text-muted">Default Claude models</span>
@@ -382,6 +485,78 @@ export default function ConfigGeneratorCard({
) : <p className="text-xs text-text-muted">No model selected. The generated file uses <code>{DEFAULT_MODEL}</code> as a placeholder.</p>} ) : <p className="text-xs text-text-muted">No model selected. The generated file uses <code>{DEFAULT_MODEL}</code> as a placeholder.</p>}
<p className="text-xs text-text-muted">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.</p> <p className="text-xs text-text-muted">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.</p>
</div> </div>
) : toolId === "copilot" ? (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-medium text-text-muted">Models</span>
<Button variant="secondary" size="sm" onClick={() => setModelModalOpen(true)}>
<span className="material-symbols-outlined mr-1 text-[16px]">add</span>
Add model
</Button>
</div>
{selectedModels.length ? (
<div className="flex flex-col gap-2">
{selectedModels.map((model) => {
const thinkingLevels = getThinkingLevelsForModel(model);
const tokens = copilotTokens[model] || DEFAULT_MODEL_TOKEN_LIMITS;
const inputOptions = getInputTokenOptions(tokens);
const outputOptions = getOutputTokenOptions(tokens);
return (
<div key={model} className="flex flex-col gap-2 rounded-lg border border-border bg-bg-secondary px-3 py-2">
<div className="flex items-center justify-between gap-2">
<span className="min-w-0 flex-1 break-all text-xs font-medium text-text-main">{model}</span>
<Button type="button" variant="ghost" size="sm" onClick={() => removeCopilotModel(model)} aria-label={`Remove ${model}`}>Remove</Button>
</div>
{thinkingLevels && (
<label className="flex items-center gap-2 text-xs font-medium text-text-muted">
Reasoning / thinking
<select
value={copilotThinking[model] || ""}
onChange={(event) => setCopilotThinking((current) => ({ ...current, [model]: event.target.value }))}
className="min-w-28 rounded-lg border border-border bg-bg-primary px-2 py-1.5 text-xs text-text-main outline-none focus:border-primary"
>
<option value="">Default</option>
{thinkingLevels.map((level) => <option key={level} value={level}>{level === "none" ? "Disabled" : level}</option>)}
</select>
</label>
)}
<div className="flex flex-wrap gap-2">
<label className="flex items-center gap-1.5 text-xs font-medium text-text-muted">
maxInputTokens
<select
value={tokens.maxInputTokens || ""}
onChange={(event) => setCopilotTokens((current) => ({
...current,
[model]: { ...current[model], maxInputTokens: event.target.value ? Number(event.target.value) : undefined },
}))}
className="min-w-20 rounded-lg border border-border bg-bg-primary px-2 py-1.5 text-xs text-text-main outline-none focus:border-primary"
>
{inputOptions.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
</select>
</label>
<label className="flex items-center gap-1.5 text-xs font-medium text-text-muted">
maxOutputTokens
<select
value={tokens.maxOutputTokens || ""}
onChange={(event) => setCopilotTokens((current) => ({
...current,
[model]: { ...current[model], maxOutputTokens: event.target.value ? Number(event.target.value) : undefined },
}))}
className="min-w-20 rounded-lg border border-border bg-bg-primary px-2 py-1.5 text-xs text-text-main outline-none focus:border-primary"
>
{outputOptions.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
</select>
</label>
</div>
</div>
);
})}
</div>
) : (
<p className="text-xs text-text-muted">No model selected. The generated file uses <code>{DEFAULT_MODEL}</code> as a placeholder.</p>
)}
<p className="text-xs text-text-muted">Select models to expose in VS Code Copilot, then copy the generated <code>chatLanguageModels.json</code> to your VS Code user settings folder. VS Code prompts for your 9Router API key when you first chat with one of these models.</p>
</div>
) : ( ) : (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
@@ -412,13 +587,13 @@ export default function ConfigGeneratorCard({
<ModelSelectModal <ModelSelectModal
isOpen={modelModalOpen} isOpen={modelModalOpen}
onClose={() => { setModelModalOpen(false); setClaudeModelSlot(""); }} onClose={() => { 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="" selectedModel=""
activeProviders={activeProviders} activeProviders={activeProviders}
title={toolId === "claude" && claudeModelSlot ? `Select ${claudeModelSlot} model` : toolId === "codex" ? "Select Codex model" : toolId === "opencode" ? "Add OpenCode 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"} closeOnSelect={toolId === "claude" || toolId === "codex"}
addedModelValues={toolId === "claude" ? Object.values(claudeModels).filter(Boolean) : toolId === "codex" ? [codexModel].filter(Boolean) : toolId === "opencode" ? opencodeModels : selectedModels} 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}
/> />
<ManualConfigModal isOpen={configModalOpen} onClose={() => setConfigModalOpen(false)} title={`${tool.name} configuration`} configs={configs} /> <ManualConfigModal isOpen={configModalOpen} onClose={() => setConfigModalOpen(false)} title={`${tool.name} configuration`} configs={configs} />
</Card> </Card>
+105
View File
@@ -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 });
}
}
+8
View File
@@ -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 }, { 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 // HIDDEN: gemini-cli
// "gemini-cli": { // "gemini-cli": {
// id: "gemini-cli", // id: "gemini-cli",
@@ -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);
}