+ const dragAttrs = ` data-vendor="${tier.name}"` + (draggable ? ` draggable="true"` : '');
+ const handle = draggable ? `
+ ${handle}
${initial}
${tier.name}
@@ -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');
@@ -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();
diff --git a/src/coding/proxy/server/routes.py b/src/coding/proxy/server/routes.py
index 8b47fd0..6b91651 100644
--- a/src/coding/proxy/server/routes.py
+++ b/src/coding/proxy/server/routes.py
@@ -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 运行时绑定路由."""
@@ -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)
diff --git a/tests/test_app_routes.py b/tests/test_app_routes.py
index 39aabe6..5e61a27 100644
--- a/tests/test_app_routes.py
+++ b/tests/test_app_routes.py
@@ -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 # 仍限速