From 83ae47d61c580e10e511e2d3b972d55529896a31 Mon Sep 17 00:00:00 2001 From: XianYuDaXian Date: Fri, 20 Mar 2026 13:51:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=204.20=20=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E4=B8=8E=E6=A8=A1=E5=9E=8B=E6=B1=A0=E6=8B=96=E6=8B=BD?= =?UTF-8?q?=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/v1/admin_api/config.py | 23 +++ app/services/grok/services/model.py | 99 +++++++++++-- app/services/reverse/app_chat.py | 5 +- app/static/admin/css/config.css | 171 +++++++++++++++++++++ app/static/admin/js/config.js | 220 +++++++++++++++++++++++++++- app/static/public/js/chat.js | 4 +- config.defaults.toml | 7 + 7 files changed, 510 insertions(+), 19 deletions(-) diff --git a/app/api/v1/admin_api/config.py b/app/api/v1/admin_api/config.py index 6148e4a..1b9e0fe 100644 --- a/app/api/v1/admin_api/config.py +++ b/app/api/v1/admin_api/config.py @@ -4,6 +4,8 @@ from fastapi import APIRouter, Depends, HTTPException from app.core.auth import verify_app_key from app.core.config import config +from app.services.grok.services.model import ModelService +from app.services.token import get_token_manager from app.core.storage import ( get_storage as get_storage_backend, LocalStorage, @@ -37,6 +39,27 @@ async def update_config(data: dict): raise HTTPException(status_code=500, detail=str(e)) +@router.get("/model-routing/meta", dependencies=[Depends(verify_app_key)]) +async def get_model_routing_meta(): + """获取模型池路由界面所需的模型与池元数据。""" + token_mgr = await get_token_manager() + pool_names = set(token_mgr.pools.keys()) + pool_names.update({"ssoBasic", "ssoSuper"}) + + models = [ + { + "id": item.model_id, + "display_name": item.display_name, + } + for item in ModelService.list() + ] + + return { + "models": models, + "pools": sorted(pool_names), + } + + @router.get("/storage", dependencies=[Depends(verify_app_key)]) async def admin_get_storage(): """获取当前存储模式""" diff --git a/app/services/grok/services/model.py b/app/services/grok/services/model.py index 7ff0fe1..941d9e9 100644 --- a/app/services/grok/services/model.py +++ b/app/services/grok/services/model.py @@ -6,6 +6,7 @@ from enum import Enum from typing import Optional, Tuple, List from pydantic import BaseModel, Field +from app.core.config import get_config from app.core.exceptions import ValidationException @@ -36,6 +37,7 @@ class ModelInfo(BaseModel): is_image: bool = False is_image_edit: bool = False is_video: bool = False + public: bool = True class ModelService: @@ -164,16 +166,50 @@ class ModelService: is_video=False, ), ModelInfo( - model_id="grok-4.20-beta", + model_id="grok-4.20-auto", grok_model="grok-420", - model_mode="MODEL_MODE_GROK_420", + model_mode="auto", tier=Tier.BASIC, cost=Cost.LOW, - display_name="GROK-4.20-BETA", + display_name="GROK-4.20-AUTO", is_image=False, is_image_edit=False, is_video=False, ), + ModelInfo( + model_id="grok-4.20-fast", + grok_model="grok-420", + model_mode="fast", + tier=Tier.BASIC, + cost=Cost.LOW, + display_name="GROK-4.20-FAST", + is_image=False, + is_image_edit=False, + is_video=False, + ), + ModelInfo( + model_id="grok-4.20-expert", + grok_model="grok-420", + model_mode="expert", + tier=Tier.BASIC, + cost=Cost.HIGH, + display_name="GROK-4.20-EXPERT", + is_image=False, + is_image_edit=False, + is_video=False, + ), + ModelInfo( + model_id="grok-4.20-beta", + grok_model="grok-420", + model_mode="auto", + tier=Tier.BASIC, + cost=Cost.LOW, + display_name="GROK-4.20-AUTO", + is_image=False, + is_image_edit=False, + is_video=False, + public=False, + ), ModelInfo( model_id="grok-imagine-1.0", grok_model="grok-3", @@ -214,6 +250,46 @@ class ModelService: _map = {m.model_id: m for m in MODELS} + @classmethod + def _default_pool_candidates(cls, model_id: str) -> List[str]: + """返回模型默认池顺序。""" + model = cls.get(model_id) + if model and model.tier == Tier.SUPER: + return ["ssoSuper"] + # 基础模型优先使用 basic 池,缺失时可回退到 super 池 + return ["ssoBasic", "ssoSuper"] + + @classmethod + def _routing_lookup_keys(cls, model_id: str) -> List[str]: + """返回模型池路由查找顺序,兼容历史别名。""" + keys = [model_id] + if model_id == "grok-4.20-beta": + keys.append("grok-4.20-auto") + return keys + + @classmethod + def _configured_pool_candidates(cls, model_id: str) -> Optional[List[str]]: + """读取管理配置中的模型池路由。""" + routing = get_config("model_routing.model_pools", {}) + if not isinstance(routing, dict): + return None + + for key in cls._routing_lookup_keys(model_id): + value = routing.get(key) + if isinstance(value, str): + pool = value.strip() + if pool: + return [pool] + elif isinstance(value, list): + pools = [ + str(item).strip() + for item in value + if str(item).strip() + ] + if pools: + return pools + return None + @classmethod def get(cls, model_id: str) -> Optional[ModelInfo]: """获取模型信息""" @@ -222,7 +298,7 @@ class ModelService: @classmethod def list(cls) -> list[ModelInfo]: """获取所有模型""" - return list(cls._map.values()) + return [model for model in cls._map.values() if model.public] @classmethod def valid(cls, model_id: str) -> bool: @@ -240,19 +316,16 @@ class ModelService: @classmethod def pool_for_model(cls, model_id: str) -> str: """根据模型选择 Token 池""" - model = cls.get(model_id) - if model and model.tier == Tier.SUPER: - return "ssoSuper" - return "ssoBasic" + pools = cls.pool_candidates_for_model(model_id) + return pools[0] if pools else "ssoBasic" @classmethod def pool_candidates_for_model(cls, model_id: str) -> List[str]: """按优先级返回可用 Token 池列表""" - model = cls.get(model_id) - if model and model.tier == Tier.SUPER: - return ["ssoSuper"] - # 基础模型优先使用 basic 池,缺失时可回退到 super 池 - return ["ssoBasic", "ssoSuper"] + configured = cls._configured_pool_candidates(model_id) + if configured: + return configured + return cls._default_pool_candidates(model_id) __all__ = ["ModelService"] diff --git a/app/services/reverse/app_chat.py b/app/services/reverse/app_chat.py index b255c3a..6215036 100644 --- a/app/services/reverse/app_chat.py +++ b/app/services/reverse/app_chat.py @@ -91,7 +91,6 @@ class AppChatReverse: "isAsyncChat": False, "isReasoning": False, "message": message, - "modelMode": mode, "modelName": model, "responseMetadata": { "requestModelDetails": {"modelId": model}, @@ -108,6 +107,10 @@ class AppChatReverse: if model == "grok-420": payload["enable420"] = True + if mode: + payload["modeId"] = mode + elif mode: + payload["modelMode"] = mode custom_personality = AppChatReverse._resolve_custom_personality() if custom_personality is not None and "Greet the user" not in message[-1000:]: diff --git a/app/static/admin/css/config.css b/app/static/admin/css/config.css index ca65dd2..85b61ab 100644 --- a/app/static/admin/css/config.css +++ b/app/static/admin/css/config.css @@ -58,6 +58,169 @@ transform: translateY(-50%); } +.routing-board { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); + gap: 16px; +} + +.routing-pane { + border: 1px solid var(--border); + border-radius: 14px; + padding: 14px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(249, 250, 251, 0.96) 100%); +} + +.routing-pane-header { + margin-bottom: 12px; +} + +.routing-pane-title { + font-size: 13px; + font-weight: 700; + color: var(--accents-7); +} + +.routing-pane-subtitle { + margin-top: 4px; + font-size: 12px; + line-height: 1.5; + color: var(--accents-4); +} + +.routing-dropzone { + min-height: 148px; + border: 1px dashed var(--border); + border-radius: 12px; + padding: 12px; + display: flex; + flex-wrap: wrap; + align-content: flex-start; + gap: 10px; + background: + radial-gradient(circle at top left, rgba(0, 0, 0, 0.03), transparent 42%), + linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(244, 244, 245, 0.96)); + transition: border-color 0.2s ease, background-color 0.2s ease, transform 0.2s ease; +} + +.routing-dropzone.is-over { + border-color: #111; + background: + radial-gradient(circle at top left, rgba(0, 0, 0, 0.06), transparent 42%), + linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(237, 237, 239, 0.98)); + transform: translateY(-1px); +} + +.routing-dropzone-source { + min-height: 320px; +} + +.routing-pool-grid { + display: grid; + gap: 12px; +} + +.routing-pool-card { + border: 1px solid var(--border); + border-radius: 12px; + background: rgba(255, 255, 255, 0.86); + padding: 10px; +} + +.routing-pool-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +} + +.routing-pool-title { + font-size: 13px; + font-weight: 700; + color: var(--accents-7); +} + +.routing-pool-hint { + font-size: 11px; + color: var(--accents-4); +} + +.routing-chip { + display: inline-flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + max-width: 100%; + padding: 10px 12px; + border: 1px solid rgba(17, 17, 17, 0.12); + border-radius: 12px; + background: linear-gradient(180deg, #111 0%, #262626 100%); + color: #fff; + cursor: grab; + box-shadow: 0 6px 18px rgba(17, 17, 17, 0.1); + transition: transform 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease; +} + +.routing-chip:hover { + transform: translateY(-1px); + box-shadow: 0 10px 24px rgba(17, 17, 17, 0.14); +} + +.routing-chip.is-dragging { + opacity: 0.5; + cursor: grabbing; +} + +.routing-chip-name { + font-size: 12px; + font-weight: 700; + line-height: 1.3; +} + +.routing-chip-id { + max-width: 100%; + font-size: 11px; + color: rgba(255, 255, 255, 0.72); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.routing-empty { + width: 100%; + min-height: 72px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + padding: 12px; + font-size: 12px; + color: var(--accents-4); + background: rgba(245, 245, 245, 0.9); +} + +html[data-theme='dark'] .routing-pane, +html[data-theme='dark'] .routing-pool-card { + background: linear-gradient(180deg, rgba(29, 29, 31, 0.96), rgba(20, 20, 22, 0.96)); +} + +html[data-theme='dark'] .routing-dropzone { + background: + radial-gradient(circle at top left, rgba(255, 255, 255, 0.04), transparent 42%), + linear-gradient(180deg, rgba(24, 24, 27, 0.98), rgba(16, 16, 18, 0.98)); +} + +html[data-theme='dark'] .routing-dropzone.is-over { + border-color: rgba(255, 255, 255, 0.76); +} + +html[data-theme='dark'] .routing-empty { + background: rgba(255, 255, 255, 0.03); +} + @media (max-width: 900px) { .config-section { padding: 14px; @@ -75,4 +238,12 @@ bottom: 0; transform: none; } + + .routing-board { + grid-template-columns: 1fr; + } + + .routing-dropzone-source { + min-height: 220px; + } } diff --git a/app/static/admin/js/config.js b/app/static/admin/js/config.js index c0ebe0d..c3d69d8 100644 --- a/app/static/admin/js/config.js +++ b/app/static/admin/js/config.js @@ -1,5 +1,8 @@ let apiKey = ''; let currentConfig = {}; +let modelRoutingMeta = { models: [], pools: ['ssoBasic', 'ssoSuper'] }; +let modelRoutingAssignments = {}; +let modelRoutingDragId = ''; const byId = (id) => document.getElementById(id); const NUMERIC_FIELDS = new Set([ 'timeout', @@ -139,6 +142,12 @@ const LOCALE_MAP = { }, + "model_routing": { + "label": "模型池路由", + "model_pools": { title: "模型到 Token 池映射", desc: "为每个模型手动指定使用哪个 Token 池。值支持字符串或字符串数组,例如 {\"grok-4.20-expert\":\"ssoSuper\",\"grok-4.20-auto\":[\"ssoBasic\",\"ssoSuper\"]}。" } + }, + + "cache": { "label": "缓存管理", "enable_auto_clean": { title: "自动清理", desc: "是否启用缓存自动清理,开启后按上限自动回收。" }, @@ -164,7 +173,8 @@ const LOCALE_MAP = { // 配置部分说明(可选) const SECTION_DESCRIPTIONS = { - "proxy": "配置不正确将导致 403 错误。服务首次请求 Grok 时的 IP 必须与获取 CF Clearance 时的 IP 一致,后续服务器请求 IP 变化不会导致 403。" + "proxy": "配置不正确将导致 403 错误。服务首次请求 Grok 时的 IP 必须与获取 CF Clearance 时的 IP 一致,后续服务器请求 IP 变化不会导致 403。", + "model_routing": "这里可以手动指定每个模型优先走哪个 Token 池。未配置的模型仍会按系统默认路由。" }; // CF 自动刷新联动禁用字段(全部在 proxy section 内) @@ -312,9 +322,28 @@ function randomKey(len) { async function init() { apiKey = await ensureAdminKey(); if (apiKey === null) return; + await loadModelRoutingMeta(); loadData(); } +async function loadModelRoutingMeta() { + try { + const res = await fetch('/v1/admin/model-routing/meta', { + headers: buildAuthHeaders(apiKey) + }); + if (!res.ok) return; + const data = await res.json(); + if (Array.isArray(data.models)) { + modelRoutingMeta.models = data.models; + } + if (Array.isArray(data.pools) && data.pools.length) { + modelRoutingMeta.pools = data.pools; + } + } catch (e) { + console.warn('模型池路由元数据加载失败', e); + } +} + async function loadData() { try { const res = await fetch('/v1/admin/config', { @@ -386,6 +415,182 @@ function renderConfig(data) { applyCfRefreshState(cfEnabled); } +function normalizeModelRoutingAssignments(raw) { + const next = {}; + const source = raw && typeof raw === 'object' ? raw : {}; + Object.entries(source).forEach(([modelId, value]) => { + if (typeof value === 'string' && value.trim()) { + next[modelId] = value.trim(); + return; + } + if (Array.isArray(value)) { + const first = value.map(item => String(item).trim()).find(Boolean); + if (first) next[modelId] = first; + } + }); + return next; +} + +function buildModelRoutingInput(section, key, val) { + modelRoutingAssignments = normalizeModelRoutingAssignments(val); + + const wrapper = document.createElement('div'); + wrapper.className = 'routing-board'; + wrapper.dataset.type = 'model-routing'; + wrapper.dataset.section = section; + wrapper.dataset.key = key; + + const leftPane = document.createElement('div'); + leftPane.className = 'routing-pane routing-pane-models'; + leftPane.innerHTML = ` +
+
模型列表
+
拖到右侧池中即可指定路由,拖回这里表示按系统默认池处理。
+
+ `; + + const unassignedZone = document.createElement('div'); + unassignedZone.className = 'routing-dropzone routing-dropzone-source'; + unassignedZone.dataset.pool = ''; + registerRoutingDropzone(unassignedZone, ''); + + const rightPane = document.createElement('div'); + rightPane.className = 'routing-pane routing-pane-pools'; + rightPane.innerHTML = ` +
+
Token 池
+
模型放到哪个池,后端请求就优先走哪个池。
+
+ `; + + const poolGrid = document.createElement('div'); + poolGrid.className = 'routing-pool-grid'; + const pools = Array.isArray(modelRoutingMeta.pools) && modelRoutingMeta.pools.length + ? modelRoutingMeta.pools + : ['ssoBasic', 'ssoSuper']; + + pools.forEach(poolName => { + const card = document.createElement('div'); + card.className = 'routing-pool-card'; + + const header = document.createElement('div'); + header.className = 'routing-pool-header'; + header.innerHTML = ` +
${poolName}
+
拖拽模型到这里
+ `; + + const zone = document.createElement('div'); + zone.className = 'routing-dropzone routing-dropzone-pool'; + zone.dataset.pool = poolName; + registerRoutingDropzone(zone, poolName); + + card.appendChild(header); + card.appendChild(zone); + poolGrid.appendChild(card); + }); + + leftPane.appendChild(unassignedZone); + rightPane.appendChild(poolGrid); + wrapper.appendChild(leftPane); + wrapper.appendChild(rightPane); + + renderModelRoutingBoard(wrapper); + return { input: wrapper, node: wrapper }; +} + +function registerRoutingDropzone(zone, poolName) { + zone.addEventListener('dragover', (event) => { + event.preventDefault(); + zone.classList.add('is-over'); + }); + zone.addEventListener('dragleave', () => { + zone.classList.remove('is-over'); + }); + zone.addEventListener('drop', (event) => { + event.preventDefault(); + zone.classList.remove('is-over'); + const modelId = event.dataTransfer.getData('text/plain') || modelRoutingDragId; + if (!modelId) return; + if (poolName) { + modelRoutingAssignments[modelId] = poolName; + } else { + delete modelRoutingAssignments[modelId]; + } + const board = zone.closest('.routing-board'); + if (board) renderModelRoutingBoard(board); + modelRoutingDragId = ''; + }); +} + +function createModelChip(model) { + const chip = document.createElement('button'); + chip.type = 'button'; + chip.className = 'routing-chip'; + chip.draggable = true; + chip.dataset.modelId = model.id; + chip.innerHTML = ` + ${model.display_name || model.id} + ${model.id} + `; + + chip.addEventListener('dragstart', (event) => { + modelRoutingDragId = model.id; + event.dataTransfer.setData('text/plain', model.id); + event.dataTransfer.effectAllowed = 'move'; + chip.classList.add('is-dragging'); + }); + chip.addEventListener('dragend', () => { + chip.classList.remove('is-dragging'); + modelRoutingDragId = ''; + document.querySelectorAll('.routing-dropzone.is-over').forEach((node) => { + node.classList.remove('is-over'); + }); + }); + + return chip; +} + +function renderModelRoutingBoard(board) { + const models = Array.isArray(modelRoutingMeta.models) ? [...modelRoutingMeta.models] : []; + models.sort((a, b) => String(a.id).localeCompare(String(b.id))); + + const sourceZone = board.querySelector('.routing-dropzone-source'); + const poolZones = Array.from(board.querySelectorAll('.routing-dropzone-pool')); + if (!sourceZone) return; + + sourceZone.replaceChildren(); + poolZones.forEach(zone => zone.replaceChildren()); + + const assignedModelIds = new Set(); + poolZones.forEach(zone => { + const poolName = zone.dataset.pool || ''; + const assigned = models.filter(model => modelRoutingAssignments[model.id] === poolName); + assigned.forEach(model => { + assignedModelIds.add(model.id); + zone.appendChild(createModelChip(model)); + }); + if (!assigned.length) { + zone.appendChild(createRoutingEmptyState('拖拽模型到这里')); + } + }); + + const unassigned = models.filter(model => !assignedModelIds.has(model.id)); + unassigned.forEach(model => { + sourceZone.appendChild(createModelChip(model)); + }); + if (!unassigned.length) { + sourceZone.appendChild(createRoutingEmptyState('所有模型都已分配到池')); + } +} + +function createRoutingEmptyState(text) { + const empty = document.createElement('div'); + empty.className = 'routing-empty'; + empty.textContent = text; + return empty; +} + function applyCfRefreshState(enabled) { // 设置字段禁用状态的辅助函数 function setFieldDisabled(section, key, disabled) { @@ -468,7 +673,11 @@ function buildFieldCard(section, key, val) { ]); } else if (Array.isArray(val) || typeof val === 'object') { - built = buildJsonInput(section, key, val); + if (section === 'model_routing' && key === 'model_pools') { + built = buildModelRoutingInput(section, key, val); + } else { + built = buildJsonInput(section, key, val); + } } else { if (key === 'api_key' || key === 'app_key' || key === 'public_key') { @@ -522,11 +731,16 @@ async function saveConfig() { const newConfig = typeof structuredClone === 'function' ? structuredClone(currentConfig) : JSON.parse(JSON.stringify(currentConfig)); - const inputs = document.querySelectorAll('input[data-section], textarea[data-section], select[data-section]'); + const inputs = document.querySelectorAll('input[data-section], textarea[data-section], select[data-section], [data-type="model-routing"][data-section]'); inputs.forEach(input => { const s = input.dataset.section; const k = input.dataset.key; + if (input.dataset.type === 'model-routing') { + if (!newConfig[s]) newConfig[s] = {}; + newConfig[s][k] = { ...modelRoutingAssignments }; + return; + } let val = input.value; if (input.type === 'checkbox') { diff --git a/app/static/public/js/chat.js b/app/static/public/js/chat.js index 1c75ee8..c3fdd22 100644 --- a/app/static/public/js/chat.js +++ b/app/static/public/js/chat.js @@ -1405,8 +1405,8 @@ async function loadModels() { if (!modelSelect) return; - const fallback = ['grok-4.1-fast', 'grok-4', 'grok-3', 'grok-3-mini', 'grok-3-thinking', 'grok-4.20-beta']; - const preferred = 'grok-4.20-beta'; + const fallback = ['grok-4.1-fast', 'grok-4', 'grok-3', 'grok-3-mini', 'grok-3-thinking', 'grok-4.20-fast', 'grok-4.20-expert', 'grok-4.20-auto']; + const preferred = 'grok-4.20-auto'; let list = fallback; try { diff --git a/config.defaults.toml b/config.defaults.toml index 1426e44..dbe9d38 100644 --- a/config.defaults.toml +++ b/config.defaults.toml @@ -81,6 +81,13 @@ save_delay_ms = 500 # 多 worker 状态同步间隔(秒) reload_interval_sec = 30 +# ==================== 模型路由配置 ==================== +[model_routing] +# 模型到 Token 池的手动路由配置。 +# 值支持字符串(单池)或字符串数组(按顺序回退)。 +# 例如:{ "grok-4.20-expert" = "ssoSuper", "grok-4.20-auto" = ["ssoBasic", "ssoSuper"] } +model_pools = {} + # ==================== 缓存管理 ==================== [cache] # 是否启用自动清理