feat: 更新 4.20 模型与模型池拖拽路由

This commit is contained in:
XianYuDaXian committed 2026-03-20 13:51:29 +08:00
1 parent 7f4b1dcd75
commit 83ae47d61c
7 files changed
+510 -19

No files matched your search

+23
View File
@@ -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():
"""获取当前存储模式"""
+86 -13
View File
@@ -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"]
+4 -1
View File
@@ -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:]:
+171
View File
@@ -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;
}
}
+217 -3
View File
@@ -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 = `
<div class="routing-pane-header">
<div class="routing-pane-title">模型列表</div>
<div class="routing-pane-subtitle">拖到右侧池中即可指定路由,拖回这里表示按系统默认池处理。</div>
</div>
`;
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 = `
<div class="routing-pane-header">
<div class="routing-pane-title">Token 池</div>
<div class="routing-pane-subtitle">模型放到哪个池,后端请求就优先走哪个池。</div>
</div>
`;
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 = `
<div class="routing-pool-title">${poolName}</div>
<div class="routing-pool-hint">拖拽模型到这里</div>
`;
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 = `
<span class="routing-chip-name">${model.display_name || model.id}</span>
<span class="routing-chip-id">${model.id}</span>
`;
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') {
+2 -2
View File
@@ -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 {
+7
View File
@@ -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]
# 是否启用自动清理