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 = ` +