Skip to content
95 changes: 95 additions & 0 deletions src/coding/proxy/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
# 注册 Auth 子应用
app.add_typer(auth_app, name="auth")

# 注册 Session 子应用
session_app = typer.Typer(name="session", help="管理 Session-Vendor 运行时绑定")
app.add_typer(session_app, name="session")


def _build_token_store(cfg_path: Path | None = None):
"""按配置解析 Token Store 路径并完成加载."""
Expand Down Expand Up @@ -264,6 +268,97 @@ def reset(
console.print("[red]代理服务未运行[/red]")


# ── Session 子命令 ───────────────────────────────────────────────


@session_app.command("bind")
def session_bind(
key: str = typer.Option(..., "--key", "-k", help="Session key"),
vendor: str = typer.Option(
..., "--vendor", "-v", help="绑定 vendor(逗号分隔多个)"
),
port: int = typer.Option(3392, "--port", "-p", help="代理服务端口"),
) -> None:
"""为指定 Session 绑定 vendor 优先级."""
import httpx as _httpx

vendors = [v.strip() for v in vendor.split(",") if v.strip()]
try:
resp = _httpx.put(
f"http://127.0.0.1:{port}/api/session-vendor",
json={"session_key": key, "vendors": vendors},
timeout=5,
)
if resp.status_code == 200:
data = resp.json()
console.print(
f"[green]绑定成功:[/] session [cyan]{key[:16]}…[/cyan] → "
+ " → ".join(data.get("vendors", vendors))
)
else:
try:
err = resp.json()
msg = err.get("error", {}).get("message", resp.text)
except Exception:
msg = resp.text
console.print(f"[red]绑定失败: {msg}[/red]")
except _httpx.ConnectError:
console.print("[red]代理服务未运行[/red]")


@session_app.command("unbind")
def session_unbind(
key: str = typer.Option(..., "--key", "-k", help="Session key"),
port: int = typer.Option(3392, "--port", "-p", help="代理服务端口"),
) -> None:
"""解除指定 Session 的 vendor 绑定."""
from urllib.parse import quote

import httpx as _httpx

try:
resp = _httpx.delete(
f"http://127.0.0.1:{port}/api/session-vendor/{quote(key, safe='')}",
timeout=5,
)
if resp.status_code == 200:
console.print(f"[green]已解除绑定:[/] session [cyan]{key[:16]}…[/cyan]")
elif resp.status_code == 404:
console.print(f"[yellow]未找到绑定:[/] session [cyan]{key[:16]}…[/cyan]")
else:
console.print(f"[red]解除失败: {resp.status_code} {resp.text}[/red]")
except _httpx.ConnectError:
console.print("[red]代理服务未运行[/red]")


@session_app.command("list")
def session_list(
port: int = typer.Option(3392, "--port", "-p", help="代理服务端口"),
) -> None:
"""列出所有运行时 Session-Vendor 绑定."""
import httpx as _httpx

try:
resp = _httpx.get(
f"http://127.0.0.1:{port}/api/session-vendor",
timeout=5,
)
if resp.status_code == 200:
data = resp.json()
bindings = data.get("bindings", [])
if not bindings:
console.print("[dim]当前无运行时绑定[/dim]")
return
for b in bindings:
key = b.get("session_key", "?")
vendors = b.get("vendors", [])
console.print(f" [cyan]{key[:24]}…[/cyan] → " + " → ".join(vendors))
else:
console.print(f"[red]查询失败: {resp.status_code} {resp.text}[/red]")
except _httpx.ConnectError:
console.print("[red]代理服务未运行[/red]")


def _resolve_config_path(config: str | Path | None = None) -> Path | None:
"""标准化配置路径输入."""
if config is None:
Expand Down
66 changes: 63 additions & 3 deletions src/coding/proxy/routing/session_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
from __future__ import annotations

import logging
import threading

from ..config.session_policy import SessionPolicy
from ..config.session_policy import SessionPolicy, SessionPolicyMatch

logger = logging.getLogger(__name__)

Expand All @@ -16,12 +17,15 @@ class SessionPolicyResolver:
- 启动时构建索引,运行时 O(1) 查找
- 精确匹配优先:session_key > client_category > 无策略
- 无侵入性:不匹配时返回 None,路由行为与现有一致
- 运行时可变:支持 API 动态 upsert/remove session → vendor 绑定
"""

def __init__(self, policies: list[SessionPolicy] | None = None) -> None:
self._policies = policies or []
self._key_index: dict[str, SessionPolicy] = {}
self._category_index: dict[str, SessionPolicy] = {}
self._config_key_backup: dict[str, SessionPolicy] = {}
self._lock = threading.Lock()
self._build_index()

def _build_index(self) -> None:
Expand Down Expand Up @@ -49,8 +53,64 @@ def _build_index(self) -> None:
def resolve(
self, session_key: str, client_category: str = "cc"
) -> SessionPolicy | None:
"""返回匹配的策略,优先精确 session_key 匹配,其次 category 匹配."""
policy = self._key_index.get(session_key)
"""返回匹配的策略,优先精确 session_key 匹配,其次 category 匹配.

返回的 SessionPolicy 对象应为不可变引用;调用方不应修改其内部属性,
否则在并发 upsert/remove 场景下可能产生竞态。
"""
with self._lock:
policy = self._key_index.get(session_key)
if policy:
return policy
return self._category_index.get(client_category)

# ── 运行时 session → vendor 绑定 ──────────────────────────────

def upsert(self, session_key: str, tier_names: list[str]) -> SessionPolicy:
"""为指定 session key 创建或替换运行时 vendor 绑定.

运行时策略使用 ``runtime:`` 名称前缀,与配置文件驱动的策略区分。
"""
policy = SessionPolicy(
name=f"runtime:{session_key}",
match=SessionPolicyMatch(session_keys=[session_key]),
tiers=tier_names,
)
with self._lock:
existing = self._key_index.get(session_key)
if existing and not existing.name.startswith("runtime:"):
self._config_key_backup[session_key] = existing
self._key_index[session_key] = policy
logger.info(
"Session vendor binding upserted: session_key=%s → %s",
session_key,
tier_names,
)
return policy

def remove(self, session_key: str) -> bool:
"""删除指定 session key 的运行时 vendor 绑定.

Returns:
True 如果找到并删除了绑定,False 如果不存在。
"""
with self._lock:
policy = self._key_index.get(session_key)
if policy is None or not policy.name.startswith("runtime:"):
return False
del self._key_index[session_key]
# 恢复被运行时绑定覆盖的配置策略
backup = self._config_key_backup.pop(session_key, None)
if backup is not None:
self._key_index[session_key] = backup
logger.info("Session vendor binding removed: session_key=%s", session_key)
return True

def list_runtime_bindings(self) -> list[dict[str, str | list[str]]]:
"""返回所有运行时注入的绑定快照(仅 API 创建的,不含配置文件驱动的)."""
with self._lock:
return [
{"session_key": key, "vendors": policy.tiers}
for key, policy in self._key_index.items()
if policy.name.startswith("runtime:")
]
87 changes: 84 additions & 3 deletions src/coding/proxy/server/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,19 @@ def _build_favicon() -> bytes:
}
.success-bar { width: 56px; height: 4px; border-radius: 2px; background: rgba(255,255,255,.06); display: inline-block; vertical-align: middle; margin-left: 6px; }
.success-bar-fill { height: 100%; border-radius: 2px; }
/* ── Vendor Bind 选择器 ── */
.bind-select {
padding: 3px 6px; border-radius: 6px;
background: rgba(48,54,61,.6); border: 1px solid rgba(255,255,255,.1);
color: var(--text-secondary); font-size: 12px;
font-family: 'JetBrains Mono', monospace;
cursor: pointer; outline: none;
transition: all .2s ease;
max-width: 120px;
}
.bind-select:hover { border-color: rgba(88,166,255,.4); color: var(--text-primary); }
.bind-select:focus { border-color: rgba(88,166,255,.6); box-shadow: 0 0 0 2px rgba(88,166,255,.1); }
.bind-select option { background: var(--bg-card); color: var(--text-primary); }
/* ── 加载态 ── */
.loading { opacity: .4; pointer-events: none; }
/* ── 图表标签截断 ── */
Expand Down Expand Up @@ -620,11 +633,12 @@ def _build_favicon() -> bytes:
<th>Vendors</th>
<th>Avg Latency</th>
<th>Success</th>
<th>Vendor Bind</th>
<th>Client</th>
</tr>
</thead>
<tbody id="sessions-tbody">
<tr><td colspan="9" class="empty">Loading...</td></tr>
<tr><td colspan="10" class="empty">Loading...</td></tr>
</tbody>
</table>
</div>
Expand Down Expand Up @@ -1403,16 +1417,31 @@ def _build_favicon() -> bytes:
}
async function updateSessions() {
try {
var data = await fetchJSON('/api/dashboard/sessions?hours=24&limit=20');
var results = await Promise.allSettled([
fetchJSON('/api/dashboard/sessions?hours=24&limit=20'),
fetchJSON('/api/session-vendor'),
fetchJSON('/api/status'),
]);
if (results[0].status === 'rejected') throw results[0].reason;
var data = results[0].value;
var bindData = results[1].status === 'fulfilled' ? results[1].value : {bindings: []};
var statusData = results[2].status === 'fulfilled' ? results[2].value : {tiers: []};
var sessions = data.sessions || [];
var bindings = bindData.bindings || [];
var availableVendors = (statusData.tiers || []).map(function(t) { return t.name; });
var tbody = document.getElementById('sessions-tbody');
var subtitle = document.getElementById('sessions-subtitle');
if (subtitle) subtitle.textContent = 'Last ' + data.hours + 'h';
if (!sessions.length) {
tbody.innerHTML = '<tr><td colspan="9" class="empty"><div class="empty-icon">📭</div>No session data</td></tr>';
tbody.innerHTML = '<tr><td colspan="10" class="empty"><div class="empty-icon">📭</div>No session data</td></tr>';
return;
}
// Build binding lookup: session_key → vendors list
var bindMap = {};
bindings.forEach(function(b) { bindMap[b.session_key] = b.vendors; });
tbody.innerHTML = sessions.map(function(s) {
var boundVendors = bindMap[s.session_key];
var selectHtml = buildBindSelect(s.session_key, boundVendors, availableVendors);
return '<tr>' +
'<td class="session-key" title="' + escapeHtml(s.session_key) + '">' + truncateKey(s.session_key, 22) + '</td>' +
'<td>' + relativeTime(s.last_active_ts) + '</td>' +
Expand All @@ -1422,6 +1451,7 @@ def _build_favicon() -> bytes:
'<td>' + formatVendorTags(s.vendors) + '</td>' +
'<td style="font-family:JetBrains Mono,monospace">' + (s.avg_duration_ms ? Math.round(s.avg_duration_ms) + 'ms' : '–') + '</td>' +
'<td>' + successBarHtml(s.success_rate) + '</td>' +
'<td>' + selectHtml + '</td>' +
'<td>' + formatCategories(s.client_categories) + '</td>' +
'</tr>';
}).join('');
Expand All @@ -1430,6 +1460,57 @@ def _build_favicon() -> bytes:
}
}

function buildBindSelect(sessionKey, boundVendors, availableVendors) {
var isBound = boundVendors && boundVendors.length > 0;
var multiBound = isBound && boundVendors.length > 1;
var selected = isBound ? boundVendors[0] : '';
var html = '<select class="bind-select" data-session-key="' + escapeHtml(sessionKey) + '">';
html += '<option value=""' + (!isBound ? ' selected' : '') + '>Default</option>';
availableVendors.forEach(function(v) {
var label = multiBound && v === selected ? escapeHtml(v) + ' (+' + (boundVendors.length - 1) + ')' : escapeHtml(v);
html += '<option value="' + escapeHtml(v) + '"' + (v === selected ? ' selected' : '') + '>' + label + '</option>';
});
html += '</select>';
return html;
}

async function handleBindChange(sel) {
var sessionKey = sel.getAttribute('data-session-key');
var vendor = sel.value;
var previousValue = sel.getAttribute('data-previous') || '';
try {
var resp;
if (vendor) {
resp = await fetch('/api/session-vendor', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({session_key: sessionKey, vendors: [vendor]}),
});
} else {
resp = await fetch('/api/session-vendor/' + encodeURIComponent(sessionKey), {method: 'DELETE'});
}
if (!resp.ok) {
sel.value = previousValue;
console.error('Bind change rejected:', resp.status, await resp.text());
}
} catch (e) {
sel.value = previousValue;
console.error('Bind change failed:', e);
}
}

var sessionsTbody = document.getElementById('sessions-tbody');
sessionsTbody.addEventListener('focus', function(e) {
if (e.target.classList.contains('bind-select')) {
e.target.setAttribute('data-previous', e.target.value);
}
}, true);
sessionsTbody.addEventListener('change', function(e) {
if (e.target.classList.contains('bind-select')) {
handleBindChange(e.target);
}
});

// ── 主刷新逻辑(按 Tab 分发) ──────────────────────────────
let refreshing = false;
let currentTab = 'overview';
Expand Down
Loading
Loading