Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 127 additions & 1 deletion src/coding/proxy/server/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,20 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None:
height: 100%; border-radius: 2px;
transition: width .6s cubic-bezier(.4,0,.2,1);
}
/* ── 供应商状态:拖拽排序 ── */
.vendor-item[draggable="true"] { cursor: grab; }
.vendor-item[draggable="true"]:active { cursor: grabbing; }
.vendor-item.dragging { opacity: .4; }
.drag-handle {
display: flex; align-items: center; flex-shrink: 0;
color: var(--text-tertiary); font-size: 15px; line-height: 1;
cursor: grab; user-select: none; padding: 0 2px;
opacity: 0; transition: opacity .2s ease;
}
.vendor-item:hover .drag-handle { opacity: .55; }
.drag-handle:hover { opacity: 1 !important; color: var(--text-secondary); }
.vendor-item.drag-over-before { box-shadow: inset 0 2px 0 0 var(--accent-blue); }
.vendor-item.drag-over-after { box-shadow: inset 0 -2px 0 0 var(--accent-blue); }
/* ── 故障转移表 ── */
.ft-table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
Expand Down Expand Up @@ -1332,12 +1346,15 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None:
}

function updateVendorStatus(status) {
// 拖拽进行中 / 重排序 POST 未决时跳过重渲染,避免抢占正在操作的 DOM
if (_tierDrag.active || _tierDrag.inFlight) return;
const tiers = status.tiers || [];
const list = document.getElementById('vendor-list');
if (!tiers.length) {
list.innerHTML = '<div class="empty"><div class="empty-icon">🔌</div>无供应商数据</div>';
return;
}
const draggable = tiers.length >= 2;
list.innerHTML = tiers.map(tier => {
const cb = tier.circuit_breaker || {};
const cbClass = cbStateClass(cb.state);
Expand All @@ -1351,8 +1368,12 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None:
const rlInfo = tier.rate_limit || {};
const rlHtml = rlInfo.limited ? `<span class="status-badge sb-warn">限速中</span>` : '';

return `<div class="vendor-item">
const dragAttrs = ` data-vendor="${tier.name}"` + (draggable ? ` draggable="true"` : '');
const handle = draggable ? `<div class="drag-handle" title="拖拽调整优先级">⠿</div>` : '';

return `<div class="vendor-item"${dragAttrs}>
<div class="vendor-info">
${handle}
<div class="vendor-avatar">${initial}</div>
<span class="vendor-name">${tier.name}</span>
</div>
Expand All @@ -1365,6 +1386,110 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None:
}).join('');
}

// ── 供应商状态:拖拽调整优先级(运行时重排,不重置配额) ────
const _tierDrag = { active: false, srcName: null, inFlight: false };

function _tierDragClearIndicators() {
document.querySelectorAll('#vendor-list .drag-over-before, #vendor-list .drag-over-after')
.forEach(function(el) { el.classList.remove('drag-over-before', 'drag-over-after'); });
}

function _tierDragPosition(item, clientY) {
// 鼠标相对目标行中点的位置 → 插入到上方或下方
const rect = item.getBoundingClientRect();
return clientY < rect.top + rect.height / 2 ? 'before' : 'after';
}

function initTierDrag() {
// 事件委托绑定在静态容器 #vendor-list 上一次;子节点重渲染后仍生效
const list = document.getElementById('vendor-list');
if (!list || list.dataset.dndBound === '1') return;
list.dataset.dndBound = '1';

list.addEventListener('dragstart', function(e) {
const item = e.target && e.target.closest && e.target.closest('.vendor-item');
if (!item || !item.draggable) return;
_tierDrag.active = true;
_tierDrag.srcName = item.dataset.vendor;
item.classList.add('dragging');
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
try { e.dataTransfer.setData('text/plain', _tierDrag.srcName || ''); } catch (_) {}
}
});

list.addEventListener('dragover', function(e) {
if (!_tierDrag.active) return;
e.preventDefault(); // 允许 drop(无论是否命中某一行)
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
const item = e.target && e.target.closest && e.target.closest('.vendor-item');
_tierDragClearIndicators();
if (!item || item.dataset.vendor === _tierDrag.srcName) return;
item.classList.add(_tierDragPosition(item, e.clientY) === 'before' ? 'drag-over-before' : 'drag-over-after');
});

list.addEventListener('drop', function(e) {
if (!_tierDrag.active) return;
e.preventDefault();
const listEl = document.getElementById('vendor-list');
const dragged = listEl.querySelector('.vendor-item.dragging');
if (!dragged) return;
let target = e.target && e.target.closest && e.target.closest('.vendor-item');
if (!target) {
// 落在容器空白处 → 移到末尾
if (listEl.lastElementChild !== dragged) listEl.appendChild(dragged);
} else if (target !== dragged) {
const ref = _tierDragPosition(target, e.clientY) === 'before' ? target : target.nextElementSibling;
if (ref !== dragged) listEl.insertBefore(dragged, ref);
}
_tierDragClearIndicators();
const order = Array.from(listEl.children)
.filter(function(el) { return el.classList.contains('vendor-item'); })
.map(function(el) { return el.dataset.vendor; })
.filter(Boolean);
persistTierOrder(order);
});

list.addEventListener('dragend', function() {
_tierDrag.active = false;
_tierDrag.srcName = null;
_tierDragClearIndicators();
document.querySelectorAll('#vendor-list .dragging')
.forEach(function(el) { el.classList.remove('dragging'); });
});
}

function persistTierOrder(names) {
if (_tierDrag.inFlight) return; // 防并发:上一次未决则忽略
if (!Array.isArray(names) || names.length < 2) return;
_tierDrag.inFlight = true;
fetch('/api/tier-order', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vendors: names })
}).then(function(res) {
_tierDrag.inFlight = false; // 先清守卫,允许随后的 refreshOverview 重渲染
if (res.ok) {
// 服务端已更新顺序;刷新以同步请求趋势图等的排序
if (!_tierDrag.active) refreshOverview().catch(function() {});
} else {
console.error('tier-order rejected:', res.status);
_tierRevertToList();
}
}).catch(function(e) {
_tierDrag.inFlight = false;
console.error('tier-order failed:', e);
_tierRevertToList();
});
}

function _tierRevertToList() {
// 回滚到服务端真实顺序(PUT 失败 → 服务端顺序未变)
fetchJSON('/api/status').then(function(status) {
updateVendorStatus(status);
}).catch(function() {});
}

// ── Model Calling 实时状态 ────────────────────────────────
function updateModelCalling(status) {
var wrap = document.getElementById('model-calling-wrap');
Expand Down Expand Up @@ -2200,6 +2325,7 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None:
fetchJSON('/api/dashboard/summary?days=7').then(function(s) {
if (s && s.version) document.getElementById('version-badge').textContent = 'v' + s.version;
}).catch(function(){});
initTierDrag(); // 绑定供应商列表拖拽(事件委托,幂等)
refresh(); // 仅加载初始页签的数据
setInterval(refresh, 600000); // 每 10 分钟刷新当前页签
if (initial === 'overview') startModelCallingPoll();
Expand Down
48 changes: 48 additions & 0 deletions src/coding/proxy/server/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,53 @@ async def reset_circuit(request: Request) -> Response:
)


def register_tier_order_route(app: Any, router: Any) -> None:
"""注册运行时 N-tier 链路顺序调整路由(纯重排序,不重置熔断器/配额守卫)."""

@app.put("/api/tier-order")
async def update_tier_order(request: Request) -> Response:
"""替换整个 N-tier 链路顺序.

JSON body: ``{"vendors": ["v1", "v2", ...]}``(需覆盖所有当前启用的 vendor)。
与 ``/api/reset`` 的重排序语义一致,但**不会**重置熔断器/配额守卫/rate limit,
以保留当日用量统计(供 Web 拖拽调整优先级使用)。
"""
try:
body = await request.json()
except Exception:
return json_error_response(
400, error_type="invalid_request_error", message="body must be JSON"
)
if not isinstance(body, dict):
return json_error_response(
400,
error_type="invalid_request_error",
message="body must be a JSON object",
)
raw = body.get("vendors")
if not isinstance(raw, list) or not raw:
return json_error_response(
400,
error_type="invalid_request_error",
message="requires non-empty 'vendors' list",
)
vendor_names = [str(v) for v in raw]
try:
router.reorder_tiers(vendor_names)
except ValueError as exc:
return json_error_response(
400, error_type="invalid_request_error", message=str(exc)
)
return Response(
content=json.dumps(
{"ok": True, "tier_order": router.get_vendor_names()},
ensure_ascii=False,
).encode(),
status_code=200,
media_type="application/json",
)


def register_session_vendor_routes(app: Any, router: Any) -> None:
"""注册 Session-Vendor 运行时绑定路由."""

Expand Down Expand Up @@ -518,6 +565,7 @@ def register_all_routes(
register_concurrency_route(app, router)
register_copilot_routes(app, router)
register_admin_routes(app, router)
register_tier_order_route(app, router)
register_session_vendor_routes(app, router)
if reauth_coordinator:
register_reauth_routes(app, reauth_coordinator)
Expand Down
122 changes: 122 additions & 0 deletions tests/test_app_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1091,3 +1091,125 @@ def test_reorder_tiers_shared_reference():
# Executor 的列表也改变了(因为是同一个对象)
assert [t.name for t in executor_tiers] == ["c", "a", "b"]
assert router.get_vendor_names() == ["c", "a", "b"]


# ── /api/tier-order 纯重排序测试(不重置配额/熔断器/rate limit)──────────────


def test_tier_order_reorder_success():
"""PUT /api/tier-order → 精确替换链路顺序,返回最新 tier_order."""
app = _make_reorder_app()
with TestClient(app) as client:
resp = client.put(
"/api/tier-order", json={"vendors": ["copilot", "anthropic", "zhipu"]}
)
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is True
assert data["tier_order"] == ["copilot", "anthropic", "zhipu"]
assert app.state.router.get_vendor_names() == ["copilot", "anthropic", "zhipu"]


def test_tier_order_unknown_vendor_returns_400():
"""未知 vendor 名称 → 400."""
app = _make_reorder_app()
with TestClient(app) as client:
resp = client.put(
"/api/tier-order",
json={"vendors": ["nonexist", "anthropic", "zhipu", "copilot"]},
)
assert resp.status_code == 400
assert "未知 vendor" in resp.json()["error"]["message"]


def test_tier_order_duplicate_returns_400():
"""重复 vendor 名称 → 400."""
app = _make_reorder_app()
with TestClient(app) as client:
resp = client.put(
"/api/tier-order",
json={"vendors": ["anthropic", "anthropic", "zhipu", "copilot"]},
)
assert resp.status_code == 400
assert "重复" in resp.json()["error"]["message"]


def test_tier_order_incomplete_returns_400():
"""不完整的 vendor 列表(缺少现有 tier)→ 400."""
app = _make_reorder_app()
with TestClient(app) as client:
resp = client.put("/api/tier-order", json={"vendors": ["anthropic", "zhipu"]})
assert resp.status_code == 400
assert "缺少 vendor" in resp.json()["error"]["message"]


def test_tier_order_invalid_body_returns_400():
"""非法 body(非 JSON / 缺 vendors / 非列表 / 空列表)→ 400."""
app = _make_reorder_app()
with TestClient(app) as client:
# 非 JSON
resp = client.put(
"/api/tier-order",
content=b"not-json",
headers={"content-type": "application/json"},
)
assert resp.status_code == 400
# body 非 dict
resp = client.put("/api/tier-order", json=["anthropic", "zhipu", "copilot"])
assert resp.status_code == 400
# 缺 vendors
resp = client.put("/api/tier-order", json={"foo": "bar"})
assert resp.status_code == 400
# vendors 非列表
resp = client.put("/api/tier-order", json={"vendors": "zhipu"})
assert resp.status_code == 400
# vendors 空列表
resp = client.put("/api/tier-order", json={"vendors": []})
assert resp.status_code == 400


def test_tier_order_preserves_quota_guard():
"""核心:重排序不重置配额守卫的滑动窗口用量(保留当日统计)."""
app = _make_reorder_app()
router = app.state.router
# 选用首个 tier,手动启用其配额守卫并写入用量
tier = router.tiers[0]
qg = tier.quota_guard
assert qg is not None
qg._enabled = True
qg._budget = 100_000
qg.record_usage(40_000)
before = qg.get_info()
assert before["window_usage_tokens"] == 40_000
assert before["usage_percent"] == 40.0

with TestClient(app) as client:
resp = client.put(
"/api/tier-order", json={"vendors": ["zhipu", "copilot", "anthropic"]}
)
assert resp.status_code == 200

# reorder_tiers 仅重排引用、不重建对象 → 同一 QuotaGuard 实例用量保持
after = qg.get_info()
assert after["window_usage_tokens"] == 40_000
assert after["usage_percent"] == 40.0


def test_tier_order_preserves_circuit_breaker_and_rate_limit():
"""重排序不重置熔断器与 rate limit(与 /api/reset 行为相反)."""
app = _make_reorder_app()
router = app.state.router
target = router.tiers[0]
target.record_failure(retry_after_seconds=300)
target._rate_limit_deadline = 999_999.0
assert not target.can_execute()

with TestClient(app) as client:
resp = client.put(
"/api/tier-order", json={"vendors": ["zhipu", "anthropic", "copilot"]}
)
assert resp.status_code == 200

same = next(t for t in router.tiers if t.name == target.name)
assert not same.can_execute() # 仍熔断
assert same.is_rate_limited # 仍限速
Loading