"
+
+ print(f"[Request] {method} {path} | Body: {body_str}")
+
+ response = await call_next(request)
+
+ duration = (time.time() - start_time) * 1000
+ print(f"[Response] {method} {path} - {response.status_code} ({duration:.2f}ms)")
+ return response
+
+
+# ==================== Web UI ====================
+
+@app.get("/", response_class=HTMLResponse)
+async def index():
+ return HTML_PAGE
+
+
+@app.get("/assets/{path:path}")
+async def serve_assets(path: str):
+ """提供静态资源"""
+ file_path = get_resource_path("assets") / path
+ if file_path.exists():
+ content_type = "image/svg+xml" if path.endswith(".svg") else "application/octet-stream"
+ return StreamingResponse(open(file_path, "rb"), media_type=content_type)
+ raise HTTPException(status_code=404)
+
+
+# ==================== API 端点 ====================
+
+@app.get("/v1/models")
+async def models():
+ """获取可用模型列表"""
+ try:
+ account = state.get_available_account()
+ if not account:
+ raise Exception("No available account")
+
+ token = account.get_token()
+ machine_id = account.get_machine_id()
+ kiro_version = get_kiro_version()
+
+ headers = {
+ "content-type": "application/json",
+ "x-amz-user-agent": f"aws-sdk-js/1.0.0 KiroIDE-{kiro_version}-{machine_id}",
+ "amz-sdk-invocation-id": str(uuid.uuid4()),
+ "Authorization": f"Bearer {token}",
+ }
+ async with httpx.AsyncClient(verify=False, timeout=30) as client:
+ resp = await client.get(MODELS_URL, headers=headers, params={"origin": "AI_EDITOR"})
+ if resp.status_code == 200:
+ data = resp.json()
+ return {
+ "object": "list",
+ "data": [
+ {
+ "id": m["modelId"],
+ "object": "model",
+ "owned_by": "kiro",
+ "name": m["modelName"],
+ }
+ for m in data.get("models", [])
+ ]
+ }
+ except Exception:
+ pass
+
+ # 降级返回静态列表
+ return {"object": "list", "data": [
+ {"id": "auto", "object": "model", "owned_by": "kiro", "name": "Auto"},
+ {"id": "claude-sonnet-4.5", "object": "model", "owned_by": "kiro", "name": "Claude Sonnet 4.5"},
+ {"id": "claude-sonnet-4", "object": "model", "owned_by": "kiro", "name": "Claude Sonnet 4"},
+ {"id": "claude-haiku-4.5", "object": "model", "owned_by": "kiro", "name": "Claude Haiku 4.5"},
+ ]}
+
+
+# Anthropic 协议
+@app.post("/v1/messages")
+async def anthropic_messages(request: Request):
+ print(f"[Main] Received /v1/messages request from {request.client.host}")
+ return await anthropic.handle_messages(request)
+
+@app.post("/v1/messages/count_tokens")
+async def anthropic_count_tokens(request: Request):
+ return await anthropic.handle_count_tokens(request)
+
+
+
+@app.post("/v1/complete")
+async def anthropic_complete(request: Request):
+ print(f"[Main] Received /v1/complete request from {request.client.host}")
+ # 暂时重定向或提示,或者如果需要可以实现 handle_complete
+ return JSONResponse(
+ status_code=400,
+ content={"error": {"type": "invalid_request_error", "message": "KiroProxy currently only supports /v1/messages. Please check if your client can be configured to use Messages API."}}
+ )
+
+
+# OpenAI 协议
+@app.post("/v1/chat/completions")
+async def openai_chat(request: Request):
+ return await openai.handle_chat_completions(request)
+
+
+# OpenAI Responses API (Codex CLI 新版本)
+@app.post("/v1/responses")
+async def openai_responses(request: Request):
+ return await responses_handler.handle_responses(request)
+
+
+# Gemini 协议
+@app.post("/v1beta/models/{model_name}:generateContent")
+@app.post("/v1/models/{model_name}:generateContent")
+async def gemini_generate(model_name: str, request: Request):
+ return await gemini.handle_generate_content(model_name, request)
+
+
+# ==================== 管理 API ====================
+
+@app.get("/api/status")
+async def api_status():
+ return await admin.get_status()
+
+@app.post("/api/event_logging/batch")
+async def api_event_logging_batch(request: Request):
+ return await admin.event_logging_batch(request)
+
+
+@app.get("/api/stats")
+async def api_stats():
+ return await admin.get_stats()
+
+
+@app.get("/api/logs")
+async def api_logs(limit: int = 100):
+ return await admin.get_logs(limit)
+
+
+# ==================== 账号导入导出 API ====================
+
+@app.get("/api/accounts/export")
+async def api_export_accounts():
+ """导出所有账号配置"""
+ return await admin.export_accounts()
+
+
+@app.post("/api/accounts/import")
+async def api_import_accounts(request: Request):
+ """导入账号配置"""
+ return await admin.import_accounts(request)
+
+
+@app.post("/api/accounts/manual")
+async def api_add_manual_token(request: Request):
+ """手动添加 Token"""
+ return await admin.add_manual_token(request)
+
+
+@app.post("/api/accounts/batch")
+async def api_batch_import_accounts(request: Request):
+ """批量导入账号"""
+ return await admin.batch_import_accounts(request)
+
+
+@app.post("/api/accounts/refresh-all")
+async def api_refresh_all():
+ """刷新所有即将过期的 token"""
+ return await admin.refresh_all_tokens()
+
+
+# ==================== 额度管理 API (必须在 {account_id} 路由之前) ====================
+
+@app.get("/api/accounts/status")
+async def api_accounts_status_enhanced():
+ """获取完整账号状态(增强版)"""
+ return await admin.get_accounts_status_enhanced()
+
+
+@app.get("/api/accounts/summary")
+async def api_accounts_summary():
+ """获取账号汇总统计"""
+ return await admin.get_accounts_summary()
+
+
+@app.post("/api/accounts/refresh-all-quotas")
+async def api_refresh_all_quotas():
+ """刷新所有账号额度"""
+ return await admin.refresh_all_quotas()
+
+
+# ==================== 刷新进度 API ====================
+
+@app.get("/api/refresh/progress")
+async def api_refresh_progress():
+ """获取刷新进度"""
+ return await admin.get_refresh_progress()
+
+
+@app.post("/api/refresh/all")
+async def api_refresh_all_with_progress():
+ """批量刷新(带进度和锁检查)"""
+ return await admin.refresh_all_with_progress()
+
+
+@app.get("/api/refresh/config")
+async def api_get_refresh_config():
+ """获取刷新配置"""
+ return await admin.get_refresh_config()
+
+
+@app.put("/api/refresh/config")
+async def api_update_refresh_config(request: Request):
+ """更新刷新配置"""
+ return await admin.update_refresh_config(request)
+
+
+@app.get("/api/refresh/status")
+async def api_refresh_status():
+ """获取刷新管理器状态"""
+ return await admin.get_refresh_manager_status()
+
+
+@app.get("/api/accounts")
+async def api_accounts():
+ return await admin.get_accounts()
+
+
+@app.post("/api/accounts")
+async def api_add_account(request: Request):
+ return await admin.add_account(request)
+
+
+@app.delete("/api/accounts/{account_id}")
+async def api_delete_account(account_id: str):
+ return await admin.delete_account(account_id)
+
+
+@app.put("/api/accounts/{account_id}")
+async def api_update_account(account_id: str, request: Request):
+ return await admin.update_account(account_id, request)
+
+
+@app.post("/api/accounts/{account_id}/toggle")
+async def api_toggle_account(account_id: str):
+ return await admin.toggle_account(account_id)
+
+
+@app.post("/api/speedtest")
+async def api_speedtest():
+ return await admin.speedtest()
+
+
+@app.get("/api/accounts/{account_id}/test")
+async def api_test_account_token(account_id: str):
+ """测试指定账号的 Token 是否有效"""
+ return await admin.test_account_token(account_id)
+
+
+@app.get("/api/token/scan")
+async def api_scan_tokens():
+ return await admin.scan_tokens()
+
+
+@app.post("/api/token/add-from-scan")
+async def api_add_from_scan(request: Request):
+ return await admin.add_from_scan(request)
+
+
+@app.get("/api/config/export")
+async def api_export_config():
+ return await admin.export_config()
+
+
+@app.post("/api/config/import")
+async def api_import_config(request: Request):
+ return await admin.import_config(request)
+
+
+@app.post("/api/token/refresh-check")
+async def api_refresh_check():
+ return await admin.refresh_token_check()
+
+
+@app.post("/api/accounts/{account_id}/refresh")
+async def api_refresh_account(account_id: str):
+ """刷新指定账号的 token(集成 RefreshManager)"""
+ return await admin.refresh_account_token_with_manager(account_id)
+
+
+@app.post("/api/accounts/{account_id}/restore")
+async def api_restore_account(account_id: str):
+ """恢复账号(从冷却状态)"""
+ return await admin.restore_account(account_id)
+
+
+@app.get("/api/accounts/{account_id}/usage")
+async def api_account_usage(account_id: str):
+ """获取账号用量信息"""
+ return await admin.get_account_usage_info(account_id)
+
+
+@app.get("/api/accounts/{account_id}")
+async def api_account_detail(account_id: str):
+ """获取账号详细信息"""
+ return await admin.get_account_detail(account_id)
+
+
+@app.post("/api/accounts/{account_id}/refresh-quota")
+async def api_refresh_account_quota(account_id: str):
+ """刷新单个账号额度(先刷新 Token)"""
+ return await admin.refresh_account_quota_with_token(account_id)
+
+
+# ==================== 优先账号 API ====================
+
+@app.get("/api/priority")
+async def api_get_priority_accounts():
+ """获取优先账号列表"""
+ return await admin.get_priority_accounts()
+
+
+@app.post("/api/priority/{account_id}")
+async def api_set_priority_account(account_id: str, request: Request):
+ """设置优先账号"""
+ return await admin.set_priority_account(account_id, request)
+
+
+@app.delete("/api/priority/{account_id}")
+async def api_remove_priority_account(account_id: str):
+ """取消优先账号"""
+ return await admin.remove_priority_account(account_id)
+
+
+@app.put("/api/priority/reorder")
+async def api_reorder_priority_accounts(request: Request):
+ """调整优先账号顺序"""
+ return await admin.reorder_priority_accounts(request)
+
+
+@app.get("/api/quota")
+async def api_quota_status():
+ """获取配额状态"""
+ return await admin.get_quota_status()
+
+
+@app.get("/api/kiro/login-url")
+async def api_login_url():
+ return await admin.get_kiro_login_url()
+
+
+@app.get("/api/stats/detailed")
+async def api_detailed_stats():
+ """获取详细统计信息"""
+ return await admin.get_detailed_stats()
+
+
+@app.post("/api/health-check")
+async def api_health_check():
+ """手动触发健康检查"""
+ return await admin.run_health_check()
+
+
+@app.get("/api/browsers")
+async def api_browsers():
+ """获取可用浏览器列表"""
+ return await admin.get_browsers()
+
+
+# ==================== Kiro 登录 API ====================
+
+@app.post("/api/kiro/login/start")
+async def api_kiro_login_start(request: Request):
+ """启动 Kiro 设备授权登录"""
+ return await admin.start_kiro_login(request)
+
+
+@app.get("/api/kiro/login/poll")
+async def api_kiro_login_poll():
+ """轮询登录状态"""
+ return await admin.poll_kiro_login()
+
+
+@app.post("/api/kiro/login/cancel")
+async def api_kiro_login_cancel():
+ """取消登录"""
+ return await admin.cancel_kiro_login()
+
+
+@app.get("/api/kiro/login/status")
+async def api_kiro_login_status():
+ """获取登录状态"""
+ return await admin.get_kiro_login_status()
+
+
+# ==================== Social Auth API (Google/GitHub) ====================
+
+@app.post("/api/kiro/social/start")
+async def api_social_login_start(request: Request):
+ """启动 Social Auth 登录"""
+ return await admin.start_social_login(request)
+
+
+@app.post("/api/kiro/social/exchange")
+async def api_social_token_exchange(request: Request):
+ """交换 Social Auth Token"""
+ return await admin.exchange_social_token(request)
+
+
+@app.post("/api/kiro/social/cancel")
+async def api_social_login_cancel():
+ """取消 Social Auth 登录"""
+ return await admin.cancel_social_login()
+
+
+@app.get("/api/kiro/social/status")
+async def api_social_login_status():
+ """获取 Social Auth 状态"""
+ return await admin.get_social_login_status()
+
+
+# ==================== 协议注册 API ====================
+
+@app.post("/api/protocol/register")
+async def api_register_protocol():
+ """注册 kiro:// 协议"""
+ return await admin.register_kiro_protocol()
+
+
+@app.post("/api/protocol/unregister")
+async def api_unregister_protocol():
+ """取消注册 kiro:// 协议"""
+ return await admin.unregister_kiro_protocol()
+
+
+@app.get("/api/protocol/status")
+async def api_protocol_status():
+ """获取协议注册状态"""
+ return await admin.get_protocol_status()
+
+
+@app.get("/api/protocol/callback")
+async def api_protocol_callback():
+ """获取回调结果"""
+ return await admin.get_callback_result()
+
+
+# ==================== Flow Monitor API ====================
+
+@app.get("/api/flows")
+async def api_flows(
+ protocol: str = None,
+ model: str = None,
+ account_id: str = None,
+ state: str = None,
+ has_error: bool = None,
+ bookmarked: bool = None,
+ search: str = None,
+ limit: int = 50,
+ offset: int = 0,
+):
+ """查询 Flows"""
+ return await admin.get_flows(
+ protocol=protocol,
+ model=model,
+ account_id=account_id,
+ state_filter=state,
+ has_error=has_error,
+ bookmarked=bookmarked,
+ search=search,
+ limit=limit,
+ offset=offset,
+ )
+
+
+@app.get("/api/flows/stats")
+async def api_flow_stats():
+ """获取 Flow 统计"""
+ return await admin.get_flow_stats()
+
+
+@app.get("/api/flows/{flow_id}")
+async def api_flow_detail(flow_id: str):
+ """获取 Flow 详情"""
+ return await admin.get_flow_detail(flow_id)
+
+
+@app.post("/api/flows/{flow_id}/bookmark")
+async def api_bookmark_flow(flow_id: str, request: Request):
+ """书签 Flow"""
+ return await admin.bookmark_flow(flow_id, request)
+
+
+@app.post("/api/flows/{flow_id}/note")
+async def api_add_flow_note(flow_id: str, request: Request):
+ """添加 Flow 备注"""
+ return await admin.add_flow_note(flow_id, request)
+
+
+@app.post("/api/flows/{flow_id}/tag")
+async def api_add_flow_tag(flow_id: str, request: Request):
+ """添加 Flow 标签"""
+ return await admin.add_flow_tag(flow_id, request)
+
+
+@app.post("/api/flows/export")
+async def api_export_flows(request: Request):
+ """导出 Flows"""
+ return await admin.export_flows(request)
+
+
+# ==================== 历史消息管理 API ====================
+
+from .core import get_history_config, update_history_config, TruncateStrategy
+from .core.rate_limiter import get_rate_limiter
+
+@app.get("/api/settings/history")
+async def api_get_history_config():
+ """获取历史消息管理配置"""
+ config = get_history_config()
+ return config.to_dict()
+
+
+@app.post("/api/settings/history")
+async def api_update_history_config(request: Request):
+ """更新历史消息管理配置"""
+ data = await request.json()
+ update_history_config(data)
+ return {"ok": True, "config": get_history_config().to_dict()}
+
+
+# ==================== 限速配置 API ====================
+
+@app.get("/api/settings/rate-limit")
+async def api_get_rate_limit_config():
+ """获取限速配置"""
+ limiter = get_rate_limiter()
+ return {
+ "enabled": limiter.config.enabled,
+ "min_request_interval": limiter.config.min_request_interval,
+ "max_requests_per_minute": limiter.config.max_requests_per_minute,
+ "global_max_requests_per_minute": limiter.config.global_max_requests_per_minute,
+ "stats": limiter.get_stats()
+ }
+
+
+@app.post("/api/settings/rate-limit")
+async def api_update_rate_limit_config(request: Request):
+ """更新限速配置"""
+ data = await request.json()
+ limiter = get_rate_limiter()
+ limiter.update_config(**data)
+ return {"ok": True, "config": {
+ "enabled": limiter.config.enabled,
+ "min_request_interval": limiter.config.min_request_interval,
+ "max_requests_per_minute": limiter.config.max_requests_per_minute,
+ "global_max_requests_per_minute": limiter.config.global_max_requests_per_minute,
+ }}
+
+
+# ==================== 文档 API ====================
+
+# 文档标题映射
+DOC_TITLES = {
+ "01-quickstart": "快速开始",
+ "02-features": "功能特性",
+ "03-faq": "常见问题",
+ "04-api": "API 参考",
+ "05-server-deploy": "服务器部署",
+}
+
+@app.get("/api/docs")
+async def api_docs_list():
+ """获取文档列表"""
+ docs_dir = get_resource_path("kiro_proxy/docs")
+ docs = []
+ if docs_dir.exists():
+ for doc_file in sorted(docs_dir.glob("*.md")):
+ doc_id = doc_file.stem
+ title = DOC_TITLES.get(doc_id, doc_id)
+ docs.append({"id": doc_id, "title": title})
+ return {"docs": docs}
+
+
+@app.get("/api/docs/{doc_id}")
+async def api_docs_content(doc_id: str):
+ """获取文档内容"""
+ docs_dir = get_resource_path("kiro_proxy/docs")
+ doc_file = docs_dir / f"{doc_id}.md"
+ if not doc_file.exists():
+ raise HTTPException(status_code=404, detail="文档不存在")
+ content = doc_file.read_text(encoding="utf-8")
+ title = DOC_TITLES.get(doc_id, doc_id)
+ return {"id": doc_id, "title": title, "content": content}
+
+
+# ==================== 启动 ====================
+
+def run(port: int = 8080):
+ import uvicorn
+ print(f"\n{'='*50}")
+ print(f" Kiro API Proxy v{__version__}")
+ print(f" http://localhost:{port}")
+ print(f"{'='*50}\n")
+ uvicorn.run(app, host="0.0.0.0", port=port)
+
+
+if __name__ == "__main__":
+ import sys
+ port = int(sys.argv[1]) if len(sys.argv) > 1 else 8080
+ run(port)
diff --git a/KiroProxy/kiro_proxy/models.py b/KiroProxy/kiro_proxy/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..dd970e402f0c1872342c0e9c6d9e96eabe769e96
--- /dev/null
+++ b/KiroProxy/kiro_proxy/models.py
@@ -0,0 +1,15 @@
+"""数据模型 - 兼容层
+
+此文件保留用于向后兼容,实际实现已移至 core/ 和 credential/ 模块。
+"""
+from .core import state, ProxyState, Account
+from .core.state import RequestLog
+from .credential import CredentialStatus
+
+__all__ = [
+ "state",
+ "ProxyState",
+ "Account",
+ "RequestLog",
+ "CredentialStatus",
+]
diff --git a/KiroProxy/kiro_proxy/providers/__init__.py b/KiroProxy/kiro_proxy/providers/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..a970310ee968604b6e8da6117adfb22823754788
--- /dev/null
+++ b/KiroProxy/kiro_proxy/providers/__init__.py
@@ -0,0 +1,5 @@
+"""Provider 模块"""
+from .base import BaseProvider
+from .kiro import KiroProvider
+
+__all__ = ["BaseProvider", "KiroProvider"]
diff --git a/KiroProxy/kiro_proxy/providers/base.py b/KiroProxy/kiro_proxy/providers/base.py
new file mode 100644
index 0000000000000000000000000000000000000000..4722f0b940fe93c3de61024fdc9e238e6a8783e2
--- /dev/null
+++ b/KiroProxy/kiro_proxy/providers/base.py
@@ -0,0 +1,46 @@
+"""Provider 基类"""
+from abc import ABC, abstractmethod
+from typing import Optional, Dict, Any, Tuple
+
+
+class BaseProvider(ABC):
+ """Provider 基类
+
+ 所有 Provider(Kiro、Gemini、Qwen 等)都应继承此类。
+ """
+
+ @property
+ @abstractmethod
+ def name(self) -> str:
+ """Provider 名称"""
+ pass
+
+ @property
+ @abstractmethod
+ def api_url(self) -> str:
+ """API 端点 URL"""
+ pass
+
+ @abstractmethod
+ def build_headers(self, token: str, **kwargs) -> Dict[str, str]:
+ """构建请求头"""
+ pass
+
+ @abstractmethod
+ def build_request(self, messages: list, model: str, **kwargs) -> Dict[str, Any]:
+ """构建请求体"""
+ pass
+
+ @abstractmethod
+ def parse_response(self, raw: bytes) -> Dict[str, Any]:
+ """解析响应"""
+ pass
+
+ @abstractmethod
+ async def refresh_token(self) -> Tuple[bool, str]:
+ """刷新 token,返回 (success, new_token_or_error)"""
+ pass
+
+ def is_quota_exceeded(self, status_code: int, error_text: str) -> bool:
+ """检查是否为配额超限错误"""
+ return status_code in {429, 503, 529}
diff --git a/KiroProxy/kiro_proxy/providers/kiro.py b/KiroProxy/kiro_proxy/providers/kiro.py
new file mode 100644
index 0000000000000000000000000000000000000000..12ad0582ea87c69bba1dac93b5d64623c377a5bf
--- /dev/null
+++ b/KiroProxy/kiro_proxy/providers/kiro.py
@@ -0,0 +1,227 @@
+"""Kiro Provider"""
+import json
+import uuid
+from typing import Dict, Any, List, Optional, Tuple
+
+from .base import BaseProvider
+from ..credential import (
+ KiroCredentials, TokenRefresher,
+ generate_machine_id, get_kiro_version, get_system_info
+)
+
+
+class KiroProvider(BaseProvider):
+ """Kiro/CodeWhisperer Provider"""
+
+ API_URL = "https://q.us-east-1.amazonaws.com/generateAssistantResponse"
+ MODELS_URL = "https://q.us-east-1.amazonaws.com/ListAvailableModels"
+
+ def __init__(self, credentials: Optional[KiroCredentials] = None):
+ self.credentials = credentials
+ self._machine_id: Optional[str] = None
+
+ @property
+ def name(self) -> str:
+ return "kiro"
+
+ @property
+ def api_url(self) -> str:
+ return self.API_URL
+
+ def get_machine_id(self) -> str:
+ """获取基于凭证的 Machine ID"""
+ if self._machine_id:
+ return self._machine_id
+
+ if self.credentials:
+ self._machine_id = generate_machine_id(
+ self.credentials.profile_arn,
+ self.credentials.client_id
+ )
+ else:
+ self._machine_id = generate_machine_id()
+
+ return self._machine_id
+
+ def build_headers(
+ self,
+ token: str,
+ agent_mode: str = "vibe",
+ **kwargs
+ ) -> Dict[str, str]:
+ """构建 Kiro API 请求头 (与 kiro.rs 保持一致)"""
+ machine_id = kwargs.get("machine_id") or self.get_machine_id()
+ kiro_version = get_kiro_version()
+ os_name, node_version = get_system_info()
+
+ return {
+ "content-type": "application/json",
+ "x-amzn-codewhisperer-optout": "true",
+ "x-amzn-kiro-agent-mode": agent_mode,
+ "x-amz-user-agent": f"aws-sdk-js/1.0.27 KiroIDE-{kiro_version}-{machine_id}",
+ "user-agent": f"aws-sdk-js/1.0.27 ua/2.1 os/{os_name} lang/js md/nodejs#{node_version} api/codewhispererstreaming#1.0.27 m/E KiroIDE-{kiro_version}-{machine_id}",
+ "amz-sdk-invocation-id": str(uuid.uuid4()),
+ "amz-sdk-request": "attempt=1; max=3",
+ "Authorization": f"Bearer {token}",
+ "Connection": "close",
+ }
+
+ def build_request(
+ self,
+ messages: list = None,
+ model: str = "claude-sonnet-4",
+ user_content: str = "",
+ history: List[dict] = None,
+ tools: List[dict] = None,
+ images: List[dict] = None,
+ tool_results: List[dict] = None,
+ **kwargs
+ ) -> Dict[str, Any]:
+ """构建 Kiro API 请求体"""
+ conversation_id = str(uuid.uuid4())
+
+ # 确保 content 不为空
+ if not user_content:
+ user_content = "Continue"
+
+ user_input_message = {
+ "content": user_content,
+ "modelId": model,
+ "origin": "AI_EDITOR",
+ }
+
+ if images:
+ user_input_message["images"] = images
+
+ # 只有在有 tools 或 tool_results 时才添加 userInputMessageContext
+ context = {}
+ if tools:
+ context["tools"] = tools
+ if tool_results:
+ context["toolResults"] = tool_results
+
+ if context:
+ user_input_message["userInputMessageContext"] = context
+
+ return {
+ "conversationState": {
+ "agentContinuationId": str(uuid.uuid4()),
+ "agentTaskType": "vibe",
+ "chatTriggerType": "MANUAL",
+ "conversationId": conversation_id,
+ "currentMessage": {"userInputMessage": user_input_message},
+ "history": history or []
+ }
+ }
+
+ def parse_response(self, raw: bytes) -> Dict[str, Any]:
+ """解析 AWS event-stream 格式响应"""
+ result = {
+ "content": [],
+ "tool_uses": [],
+ "stop_reason": "end_turn"
+ }
+
+ tool_input_buffer = {}
+ pos = 0
+
+ while pos < len(raw):
+ if pos + 12 > len(raw):
+ break
+
+ total_len = int.from_bytes(raw[pos:pos+4], 'big')
+ headers_len = int.from_bytes(raw[pos+4:pos+8], 'big')
+
+ if total_len == 0 or total_len > len(raw) - pos:
+ break
+
+ header_start = pos + 12
+ header_end = header_start + headers_len
+ headers_data = raw[header_start:header_end]
+ event_type = None
+
+ try:
+ headers_str = headers_data.decode('utf-8', errors='ignore')
+ if 'toolUseEvent' in headers_str:
+ event_type = 'toolUseEvent'
+ elif 'assistantResponseEvent' in headers_str:
+ event_type = 'assistantResponseEvent'
+ except:
+ pass
+
+ payload_start = pos + 12 + headers_len
+ payload_end = pos + total_len - 4
+
+ if payload_start < payload_end:
+ try:
+ payload = json.loads(raw[payload_start:payload_end].decode('utf-8'))
+
+ if 'assistantResponseEvent' in payload:
+ e = payload['assistantResponseEvent']
+ if 'content' in e:
+ result["content"].append(e['content'])
+ elif 'content' in payload and event_type != 'toolUseEvent':
+ result["content"].append(payload['content'])
+
+ if event_type == 'toolUseEvent' or 'toolUseId' in payload:
+ tool_id = payload.get('toolUseId', '')
+ tool_name = payload.get('name', '')
+ tool_input = payload.get('input', '')
+
+ if tool_id:
+ if tool_id not in tool_input_buffer:
+ tool_input_buffer[tool_id] = {
+ "id": tool_id,
+ "name": tool_name,
+ "input_parts": []
+ }
+ if tool_name and not tool_input_buffer[tool_id]["name"]:
+ tool_input_buffer[tool_id]["name"] = tool_name
+ if tool_input:
+ tool_input_buffer[tool_id]["input_parts"].append(tool_input)
+ except:
+ pass
+
+ pos += total_len
+
+ # 组装工具调用
+ for tool_id, tool_data in tool_input_buffer.items():
+ input_str = "".join(tool_data["input_parts"])
+ try:
+ input_json = json.loads(input_str)
+ except:
+ input_json = {"raw": input_str}
+
+ result["tool_uses"].append({
+ "type": "tool_use",
+ "id": tool_data["id"],
+ "name": tool_data["name"],
+ "input": input_json
+ })
+
+ if result["tool_uses"]:
+ result["stop_reason"] = "tool_use"
+
+ return result
+
+ def parse_response_text(self, raw: bytes) -> str:
+ """解析响应,只返回文本内容"""
+ result = self.parse_response(raw)
+ return "".join(result["content"]) or "[No response]"
+
+ async def refresh_token(self) -> Tuple[bool, str]:
+ """刷新 token"""
+ if not self.credentials:
+ return False, "无凭证信息"
+
+ refresher = TokenRefresher(self.credentials)
+ return await refresher.refresh()
+
+ def is_quota_exceeded(self, status_code: int, error_text: str) -> bool:
+ """检查是否为配额超限错误"""
+ if status_code in {429, 503, 529}:
+ return True
+
+ keywords = ["rate limit", "quota", "too many requests", "throttl"]
+ error_lower = error_text.lower()
+ return any(kw in error_lower for kw in keywords)
diff --git a/KiroProxy/kiro_proxy/resources.py b/KiroProxy/kiro_proxy/resources.py
new file mode 100644
index 0000000000000000000000000000000000000000..8377ab3dd8b4ccdf85991f993bc38ede1e08bc83
--- /dev/null
+++ b/KiroProxy/kiro_proxy/resources.py
@@ -0,0 +1,7 @@
+import sys
+from pathlib import Path
+
+
+def get_resource_path(relative_path: str) -> Path:
+ base_path = Path(sys._MEIPASS) if hasattr(sys, "_MEIPASS") else Path(__file__).parent.parent
+ return base_path / relative_path
diff --git a/KiroProxy/kiro_proxy/routers/__init__.py b/KiroProxy/kiro_proxy/routers/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/KiroProxy/kiro_proxy/routers/admin.py b/KiroProxy/kiro_proxy/routers/admin.py
new file mode 100644
index 0000000000000000000000000000000000000000..77ffaab32f56966814e82789c463ae6ac9da10d8
--- /dev/null
+++ b/KiroProxy/kiro_proxy/routers/admin.py
@@ -0,0 +1,410 @@
+from fastapi import APIRouter, Request, HTTPException
+
+from ..core import get_history_config, get_rate_limiter, update_history_config
+from ..handlers import admin as admin_handler
+from ..resources import get_resource_path
+
+router = APIRouter(prefix="/api")
+
+
+@router.get("/status")
+async def api_status():
+ return await admin_handler.get_status()
+
+
+@router.post("/event_logging/batch")
+async def api_event_logging_batch(request: Request):
+ return await admin_handler.event_logging_batch(request)
+
+
+@router.get("/stats")
+async def api_stats():
+ return await admin_handler.get_stats()
+
+
+@router.get("/logs")
+async def api_logs(limit: int = 100):
+ return await admin_handler.get_logs(limit)
+
+
+@router.get("/accounts/export")
+async def api_export_accounts():
+ return await admin_handler.export_accounts()
+
+
+@router.post("/accounts/import")
+async def api_import_accounts(request: Request):
+ return await admin_handler.import_accounts(request)
+
+
+@router.post("/accounts/manual")
+async def api_add_manual_token(request: Request):
+ return await admin_handler.add_manual_token(request)
+
+
+@router.post("/accounts/batch")
+async def api_batch_import_accounts(request: Request):
+ return await admin_handler.batch_import_accounts(request)
+
+
+@router.post("/accounts/refresh-all")
+async def api_refresh_all():
+ return await admin_handler.refresh_all_tokens()
+
+
+@router.get("/accounts/status")
+async def api_accounts_status_enhanced():
+ return await admin_handler.get_accounts_status_enhanced()
+
+
+@router.get("/accounts/summary")
+async def api_accounts_summary():
+ return await admin_handler.get_accounts_summary()
+
+
+@router.post("/accounts/refresh-all-quotas")
+async def api_refresh_all_quotas():
+ return await admin_handler.refresh_all_quotas()
+
+
+@router.get("/refresh/progress")
+async def api_refresh_progress():
+ return await admin_handler.get_refresh_progress()
+
+
+@router.post("/refresh/all")
+async def api_refresh_all_with_progress():
+ return await admin_handler.refresh_all_with_progress()
+
+
+@router.get("/refresh/config")
+async def api_get_refresh_config():
+ return await admin_handler.get_refresh_config()
+
+
+@router.put("/refresh/config")
+async def api_update_refresh_config(request: Request):
+ return await admin_handler.update_refresh_config(request)
+
+
+@router.get("/refresh/status")
+async def api_refresh_status():
+ return await admin_handler.get_refresh_manager_status()
+
+
+@router.get("/accounts")
+async def api_accounts():
+ return await admin_handler.get_accounts()
+
+
+@router.post("/accounts")
+async def api_add_account(request: Request):
+ return await admin_handler.add_account(request)
+
+
+@router.delete("/accounts/{account_id}")
+async def api_delete_account(account_id: str):
+ return await admin_handler.delete_account(account_id)
+
+
+@router.put("/accounts/{account_id}")
+async def api_update_account(account_id: str, request: Request):
+ return await admin_handler.update_account(account_id, request)
+
+
+@router.post("/accounts/{account_id}/toggle")
+async def api_toggle_account(account_id: str):
+ return await admin_handler.toggle_account(account_id)
+
+
+@router.post("/speedtest")
+async def api_speedtest():
+ return await admin_handler.speedtest()
+
+
+@router.get("/accounts/{account_id}/test")
+async def api_test_account_token(account_id: str):
+ return await admin_handler.test_account_token(account_id)
+
+
+@router.get("/token/scan")
+async def api_scan_tokens():
+ return await admin_handler.scan_tokens()
+
+
+@router.post("/token/add-from-scan")
+async def api_add_from_scan(request: Request):
+ return await admin_handler.add_from_scan(request)
+
+
+@router.get("/config/export")
+async def api_export_config():
+ return await admin_handler.export_config()
+
+
+@router.post("/config/import")
+async def api_import_config(request: Request):
+ return await admin_handler.import_config(request)
+
+
+@router.post("/token/refresh-check")
+async def api_refresh_check():
+ return await admin_handler.refresh_token_check()
+
+
+@router.post("/accounts/{account_id}/refresh")
+async def api_refresh_account(account_id: str):
+ return await admin_handler.refresh_account_token_with_manager(account_id)
+
+
+@router.post("/accounts/{account_id}/restore")
+async def api_restore_account(account_id: str):
+ return await admin_handler.restore_account(account_id)
+
+
+@router.get("/accounts/{account_id}/usage")
+async def api_account_usage(account_id: str):
+ return await admin_handler.get_account_usage_info(account_id)
+
+
+@router.get("/accounts/{account_id}")
+async def api_account_detail(account_id: str):
+ return await admin_handler.get_account_detail(account_id)
+
+
+@router.post("/accounts/{account_id}/refresh-quota")
+async def api_refresh_account_quota(account_id: str):
+ return await admin_handler.refresh_account_quota_with_token(account_id)
+
+
+@router.get("/priority")
+async def api_get_priority_accounts():
+ return await admin_handler.get_priority_accounts()
+
+
+@router.post("/priority/{account_id}")
+async def api_set_priority_account(account_id: str, request: Request):
+ return await admin_handler.set_priority_account(account_id, request)
+
+
+@router.delete("/priority/{account_id}")
+async def api_remove_priority_account(account_id: str):
+ return await admin_handler.remove_priority_account(account_id)
+
+
+@router.put("/priority/reorder")
+async def api_reorder_priority_accounts(request: Request):
+ return await admin_handler.reorder_priority_accounts(request)
+
+
+@router.get("/quota")
+async def api_quota_status():
+ return await admin_handler.get_quota_status()
+
+
+@router.get("/kiro/login-url")
+async def api_login_url():
+ return await admin_handler.get_kiro_login_url()
+
+
+@router.get("/stats/detailed")
+async def api_detailed_stats():
+ return await admin_handler.get_detailed_stats()
+
+
+@router.post("/health-check")
+async def api_health_check():
+ return await admin_handler.run_health_check()
+
+
+@router.get("/browsers")
+async def api_browsers():
+ return await admin_handler.get_browsers()
+
+
+@router.post("/kiro/login/start")
+async def api_kiro_login_start(request: Request):
+ return await admin_handler.start_kiro_login(request)
+
+
+@router.get("/kiro/login/poll")
+async def api_kiro_login_poll():
+ return await admin_handler.poll_kiro_login()
+
+
+@router.post("/kiro/login/cancel")
+async def api_kiro_login_cancel():
+ return await admin_handler.cancel_kiro_login()
+
+
+@router.get("/kiro/login/status")
+async def api_kiro_login_status():
+ return await admin_handler.get_kiro_login_status()
+
+
+@router.post("/kiro/social/start")
+async def api_social_login_start(request: Request):
+ return await admin_handler.start_social_login(request)
+
+
+@router.post("/kiro/social/exchange")
+async def api_social_token_exchange(request: Request):
+ return await admin_handler.exchange_social_token(request)
+
+
+@router.post("/kiro/social/cancel")
+async def api_social_login_cancel():
+ return await admin_handler.cancel_social_login()
+
+
+@router.get("/kiro/social/status")
+async def api_social_login_status():
+ return await admin_handler.get_social_login_status()
+
+
+@router.post("/protocol/register")
+async def api_register_protocol():
+ return await admin_handler.register_kiro_protocol()
+
+
+@router.post("/protocol/unregister")
+async def api_unregister_protocol():
+ return await admin_handler.unregister_kiro_protocol()
+
+
+@router.get("/protocol/status")
+async def api_protocol_status():
+ return await admin_handler.get_protocol_status()
+
+
+@router.get("/protocol/callback")
+async def api_protocol_callback():
+ return await admin_handler.get_callback_result()
+
+
+@router.get("/flows")
+async def api_flows(
+ protocol: str = None,
+ model: str = None,
+ account_id: str = None,
+ state: str = None,
+ has_error: bool = None,
+ bookmarked: bool = None,
+ search: str = None,
+ limit: int = 50,
+ offset: int = 0,
+):
+ return await admin_handler.get_flows(
+ protocol=protocol,
+ model=model,
+ account_id=account_id,
+ state_filter=state,
+ has_error=has_error,
+ bookmarked=bookmarked,
+ search=search,
+ limit=limit,
+ offset=offset,
+ )
+
+
+@router.get("/flows/stats")
+async def api_flow_stats():
+ return await admin_handler.get_flow_stats()
+
+
+@router.get("/flows/{flow_id}")
+async def api_flow_detail(flow_id: str):
+ return await admin_handler.get_flow_detail(flow_id)
+
+
+@router.post("/flows/{flow_id}/bookmark")
+async def api_bookmark_flow(flow_id: str, request: Request):
+ return await admin_handler.bookmark_flow(flow_id, request)
+
+
+@router.post("/flows/{flow_id}/note")
+async def api_add_flow_note(flow_id: str, request: Request):
+ return await admin_handler.add_flow_note(flow_id, request)
+
+
+@router.post("/flows/{flow_id}/tag")
+async def api_add_flow_tag(flow_id: str, request: Request):
+ return await admin_handler.add_flow_tag(flow_id, request)
+
+
+@router.post("/flows/export")
+async def api_export_flows(request: Request):
+ return await admin_handler.export_flows(request)
+
+
+@router.get("/settings/history")
+async def api_get_history_config():
+ config = get_history_config()
+ return config.to_dict()
+
+
+@router.post("/settings/history")
+async def api_update_history_config(request: Request):
+ data = await request.json()
+ update_history_config(data)
+ return {"ok": True, "config": get_history_config().to_dict()}
+
+
+@router.get("/settings/rate-limit")
+async def api_get_rate_limit_config():
+ limiter = get_rate_limiter()
+ return {
+ "enabled": limiter.config.enabled,
+ "min_request_interval": limiter.config.min_request_interval,
+ "max_requests_per_minute": limiter.config.max_requests_per_minute,
+ "global_max_requests_per_minute": limiter.config.global_max_requests_per_minute,
+ "stats": limiter.get_stats(),
+ }
+
+
+@router.post("/settings/rate-limit")
+async def api_update_rate_limit_config(request: Request):
+ data = await request.json()
+ limiter = get_rate_limiter()
+ limiter.update_config(**data)
+ return {
+ "ok": True,
+ "config": {
+ "enabled": limiter.config.enabled,
+ "min_request_interval": limiter.config.min_request_interval,
+ "max_requests_per_minute": limiter.config.max_requests_per_minute,
+ "global_max_requests_per_minute": limiter.config.global_max_requests_per_minute,
+ },
+ }
+
+
+DOC_TITLES = {
+ "01-quickstart": "快速开始",
+ "02-features": "功能特性",
+ "03-faq": "常见问题",
+ "04-api": "API 参考",
+ "05-server-deploy": "服务器部署",
+}
+
+
+@router.get("/docs")
+async def api_docs_list():
+ docs_dir = get_resource_path("kiro_proxy/docs")
+ docs = []
+ if docs_dir.exists():
+ for doc_file in sorted(docs_dir.glob("*.md")):
+ doc_id = doc_file.stem
+ title = DOC_TITLES.get(doc_id, doc_id)
+ docs.append({"id": doc_id, "title": title})
+ return {"docs": docs}
+
+
+@router.get("/docs/{doc_id}")
+async def api_docs_content(doc_id: str):
+ docs_dir = get_resource_path("kiro_proxy/docs")
+ doc_file = docs_dir / f"{doc_id}.md"
+ if not doc_file.exists():
+ raise HTTPException(status_code=404, detail="文档不存在")
+ content = doc_file.read_text(encoding="utf-8")
+ title = DOC_TITLES.get(doc_id, doc_id)
+ return {"id": doc_id, "title": title, "content": content}
diff --git a/KiroProxy/kiro_proxy/routers/protocols.py b/KiroProxy/kiro_proxy/routers/protocols.py
new file mode 100644
index 0000000000000000000000000000000000000000..3ce3ad93f622dbcfd1b5cbd977567bc4d413646c
--- /dev/null
+++ b/KiroProxy/kiro_proxy/routers/protocols.py
@@ -0,0 +1,111 @@
+import uuid
+
+import httpx
+from fastapi import APIRouter, Request
+from fastapi.responses import JSONResponse
+
+from ..config import MODELS_URL
+from ..core import state
+from ..credential import get_kiro_version
+from ..handlers import anthropic, gemini, openai
+from ..handlers import responses as responses_handler
+
+router = APIRouter()
+
+
+@router.get("/v1/models")
+async def models():
+ try:
+ account = state.get_available_account()
+ if not account:
+ raise Exception("No available account")
+
+ token = account.get_token()
+ machine_id = account.get_machine_id()
+ kiro_version = get_kiro_version()
+
+ headers = {
+ "content-type": "application/json",
+ "x-amz-user-agent": f"aws-sdk-js/1.0.0 KiroIDE-{kiro_version}-{machine_id}",
+ "amz-sdk-invocation-id": str(uuid.uuid4()),
+ "Authorization": f"Bearer {token}",
+ }
+ async with httpx.AsyncClient(verify=False, timeout=30) as client:
+ resp = await client.get(MODELS_URL, headers=headers, params={"origin": "AI_EDITOR"})
+ if resp.status_code == 200:
+ data = resp.json()
+ return {
+ "object": "list",
+ "data": [
+ {
+ "id": m["modelId"],
+ "object": "model",
+ "owned_by": "kiro",
+ "name": m["modelName"],
+ }
+ for m in data.get("models", [])
+ ],
+ }
+ except Exception:
+ pass
+
+ return {
+ "object": "list",
+ "data": [
+ {"id": "auto", "object": "model", "owned_by": "kiro", "name": "Auto"},
+ {
+ "id": "claude-sonnet-4.5",
+ "object": "model",
+ "owned_by": "kiro",
+ "name": "Claude Sonnet 4.5",
+ },
+ {"id": "claude-sonnet-4", "object": "model", "owned_by": "kiro", "name": "Claude Sonnet 4"},
+ {
+ "id": "claude-haiku-4.5",
+ "object": "model",
+ "owned_by": "kiro",
+ "name": "Claude Haiku 4.5",
+ },
+ ],
+ }
+
+
+@router.post("/v1/messages")
+async def anthropic_messages(request: Request):
+ print(f"[Main] Received /v1/messages request from {request.client.host}")
+ return await anthropic.handle_messages(request)
+
+
+@router.post("/v1/messages/count_tokens")
+async def anthropic_count_tokens(request: Request):
+ return await anthropic.handle_count_tokens(request)
+
+
+@router.post("/v1/complete")
+async def anthropic_complete(request: Request):
+ print(f"[Main] Received /v1/complete request from {request.client.host}")
+ return JSONResponse(
+ status_code=400,
+ content={
+ "error": {
+ "type": "invalid_request_error",
+ "message": "KiroProxy currently only supports /v1/messages. Please check if your client can be configured to use Messages API.",
+ }
+ },
+ )
+
+
+@router.post("/v1/chat/completions")
+async def openai_chat(request: Request):
+ return await openai.handle_chat_completions(request)
+
+
+@router.post("/v1/responses")
+async def openai_responses(request: Request):
+ return await responses_handler.handle_responses(request)
+
+
+@router.post("/v1beta/models/{model_name}:generateContent")
+@router.post("/v1/models/{model_name}:generateContent")
+async def gemini_generate(model_name: str, request: Request):
+ return await gemini.handle_generate_content(model_name, request)
diff --git a/KiroProxy/kiro_proxy/routers/web.py b/KiroProxy/kiro_proxy/routers/web.py
new file mode 100644
index 0000000000000000000000000000000000000000..f83c362e9e4f99b57a74f88177ab88b8d88b2669
--- /dev/null
+++ b/KiroProxy/kiro_proxy/routers/web.py
@@ -0,0 +1,21 @@
+from fastapi import APIRouter, HTTPException
+from fastapi.responses import HTMLResponse, StreamingResponse
+
+from ..resources import get_resource_path
+from ..web.html import HTML_PAGE
+
+router = APIRouter()
+
+
+@router.get("/", response_class=HTMLResponse)
+async def index():
+ return HTML_PAGE
+
+
+@router.get("/assets/{path:path}")
+async def serve_assets(path: str):
+ file_path = get_resource_path("assets") / path
+ if file_path.exists():
+ content_type = "image/svg+xml" if path.endswith(".svg") else "application/octet-stream"
+ return StreamingResponse(open(file_path, "rb"), media_type=content_type)
+ raise HTTPException(status_code=404)
diff --git a/KiroProxy/kiro_proxy/web/__init__.py b/KiroProxy/kiro_proxy/web/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e97628f941fb285046fbcb62dda30922d857d81e
--- /dev/null
+++ b/KiroProxy/kiro_proxy/web/__init__.py
@@ -0,0 +1 @@
+# Web UI
diff --git a/KiroProxy/kiro_proxy/web/html.py b/KiroProxy/kiro_proxy/web/html.py
new file mode 100644
index 0000000000000000000000000000000000000000..6ce0981e993e461d53d1a5fc6f1e686ca9076487
--- /dev/null
+++ b/KiroProxy/kiro_proxy/web/html.py
@@ -0,0 +1,3735 @@
+"""Web UI - 组件化单文件结构"""
+
+# ==================== CSS 样式 ====================
+CSS_BASE = '''
+* { margin: 0; padding: 0; box-sizing: border-box; }
+:root {
+ --bg: #0a0a0a;
+ --card: #1a1a1a;
+ --border: #333;
+ --text: #fafafa;
+ --muted: #a3a3a3;
+ --accent: #3b82f6;
+ --success: #22c55e;
+ --error: #ef4444;
+ --warn: #f59e0b;
+ --info: #3b82f6;
+ --primary: #6366f1;
+ --secondary: #8b5cf6;
+}
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ line-height: 1.6;
+ min-height: 100vh;
+}
+.container {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 1rem;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+'''
+
+CSS_LAYOUT = '''
+/* Header */
+header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 2rem;
+ padding: 1.5rem;
+ background: var(--card);
+ border-radius: 16px;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
+}
+h1 {
+ font-size: 1.75rem;
+ font-weight: 700;
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ background: linear-gradient(135deg, var(--primary), var(--secondary));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+h1 img {
+ width: 32px;
+ height: 32px;
+ border-radius: 8px;
+}
+.status {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ font-size: 0.875rem;
+ color: var(--muted);
+}
+.status-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ box-shadow: 0 0 8px currentColor;
+}
+.status-dot.ok {
+ background: var(--success);
+ color: var(--success);
+}
+.status-dot.err {
+ background: var(--error);
+ color: var(--error);
+}
+
+/* Navigation Tabs */
+.tabs {
+ display: flex;
+ justify-content: center;
+ gap: 0.5rem;
+ margin-bottom: 2rem;
+ padding: 0.5rem;
+ background: var(--card);
+ border-radius: 16px;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
+}
+.tab {
+ padding: 0.75rem 1.5rem;
+ border: none;
+ background: transparent;
+ color: var(--muted);
+ cursor: pointer;
+ font-size: 0.875rem;
+ font-weight: 500;
+ transition: all 0.3s ease;
+ border-radius: 12px;
+ position: relative;
+}
+.tab:hover {
+ color: var(--text);
+ background: rgba(255,255,255,0.05);
+}
+.tab.active {
+ background: linear-gradient(135deg, var(--primary), var(--secondary));
+ color: white;
+ box-shadow: 0 4px 12px rgba(59,130,246,0.3);
+}
+
+/* Panels */
+.panel {
+ display: none;
+ flex: 1;
+}
+.panel.active {
+ display: block;
+}
+
+/* Footer */
+.footer {
+ text-align: center;
+ color: var(--muted);
+ font-size: 0.75rem;
+ margin-top: 2rem;
+ padding: 1rem;
+ border-top: 1px solid var(--border);
+}
+'''
+
+CSS_COMPONENTS = '''
+/* Cards */
+.card {
+ background: var(--card);
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ padding: 2rem;
+ margin-bottom: 1.5rem;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
+ transition: all 0.3s ease;
+}
+.card:hover {
+ box-shadow: 0 8px 24px rgba(0,0,0,0.4);
+ transform: translateY(-2px);
+}
+.card h3 {
+ font-size: 1.25rem;
+ font-weight: 600;
+ margin-bottom: 1.5rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ color: var(--text);
+}
+
+/* Stats Grid - OXO Style */
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+}
+.stat-item {
+ text-align: center;
+ padding: 1.5rem;
+ background: linear-gradient(135deg, rgba(59,130,246,0.1), rgba(139,92,246,0.1));
+ border-radius: 16px;
+ border: 1px solid rgba(59,130,246,0.2);
+ transition: all 0.3s ease;
+}
+.stat-item:hover {
+ transform: translateY(-4px);
+ box-shadow: 0 8px 24px rgba(59,130,246,0.2);
+}
+.stat-value {
+ font-size: 2rem;
+ font-weight: 700;
+ background: linear-gradient(135deg, var(--primary), var(--secondary));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ margin-bottom: 0.5rem;
+}
+.stat-label {
+ font-size: 0.875rem;
+ color: var(--muted);
+ font-weight: 500;
+}
+
+/* Badges */
+.badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.375rem 0.75rem;
+ border-radius: 12px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.025em;
+}
+.badge.success {
+ background: linear-gradient(135deg, #22c55e, #16a34a);
+ color: white;
+ box-shadow: 0 2px 8px rgba(34,197,94,0.3);
+}
+.badge.error {
+ background: linear-gradient(135deg, #ef4444, #dc2626);
+ color: white;
+ box-shadow: 0 2px 8px rgba(239,68,68,0.3);
+}
+.badge.warn {
+ background: linear-gradient(135deg, #f59e0b, #d97706);
+ color: white;
+ box-shadow: 0 2px 8px rgba(245,158,11,0.3);
+}
+.badge.info {
+ background: linear-gradient(135deg, #3b82f6, #2563eb);
+ color: white;
+ box-shadow: 0 2px 8px rgba(59,130,246,0.3);
+}
+
+/* Circular Progress */
+.progress-circle {
+ width: 80px;
+ height: 80px;
+ border-radius: 50%;
+ background: conic-gradient(var(--primary) 0deg, var(--secondary) 180deg, var(--border) 180deg);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+}
+.progress-circle::before {
+ content: '';
+ width: 60px;
+ height: 60px;
+ border-radius: 50%;
+ background: var(--card);
+ position: absolute;
+}
+.progress-text {
+ position: relative;
+ z-index: 1;
+ font-weight: 700;
+ font-size: 0.875rem;
+}
+'''
+
+CSS_FORMS = '''
+/* Buttons - OXO Style */
+button {
+ padding: 0.75rem 1.5rem;
+ background: linear-gradient(135deg, var(--primary), var(--secondary));
+ color: white;
+ border: none;
+ border-radius: 12px;
+ cursor: pointer;
+ font-size: 0.875rem;
+ font-weight: 600;
+ transition: all 0.3s ease;
+ box-shadow: 0 4px 12px rgba(59,130,246,0.3);
+ text-transform: uppercase;
+ letter-spacing: 0.025em;
+}
+button:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 16px rgba(59,130,246,0.4);
+}
+button:active {
+ transform: translateY(0);
+}
+button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ transform: none;
+}
+button.secondary {
+ background: var(--card);
+ color: var(--text);
+ border: 1px solid var(--border);
+ box-shadow: 0 2px 8px rgba(0,0,0,0.1);
+}
+button.secondary:hover {
+ background: rgba(255,255,255,0.05);
+ border-color: var(--primary);
+}
+button.small {
+ padding: 0.5rem 1rem;
+ font-size: 0.75rem;
+ border-radius: 8px;
+}
+button.circle {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+button.large {
+ padding: 1rem 2rem;
+ font-size: 1rem;
+ border-radius: 16px;
+}
+
+/* Inputs */
+input[type="text"],
+input[type="number"],
+input[type="search"],
+input[type="password"],
+textarea {
+ padding: 0.75rem 1rem;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ background: var(--card);
+ color: var(--text);
+ font-size: 0.875rem;
+ transition: all 0.3s ease;
+ width: 100%;
+}
+input:hover, textarea:hover {
+ border-color: var(--primary);
+}
+input:focus, textarea:focus {
+ outline: none;
+ border-color: var(--primary);
+ box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
+}
+input::placeholder, textarea::placeholder {
+ color: var(--muted);
+}
+
+/* Select */
+select {
+ padding: 0.75rem 1rem;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ background: var(--card);
+ color: var(--text);
+ font-size: 0.875rem;
+ cursor: pointer;
+ appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23a3a3a3' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 1rem center;
+ padding-right: 3rem;
+ transition: all 0.3s ease;
+}
+select:hover {
+ border-color: var(--primary);
+}
+select:focus {
+ outline: none;
+ border-color: var(--primary);
+ box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
+}
+
+/* Tables */
+table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.875rem;
+ background: var(--card);
+ border-radius: 12px;
+ overflow: hidden;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
+}
+th, td {
+ padding: 1rem;
+ text-align: left;
+ border-bottom: 1px solid var(--border);
+}
+th {
+ font-weight: 600;
+ color: var(--muted);
+ background: rgba(59,130,246,0.05);
+}
+tr:hover {
+ background: rgba(255,255,255,0.02);
+}
+
+/* Code blocks */
+pre {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 1.5rem;
+ overflow-x: auto;
+ font-size: 0.8rem;
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+}
+code {
+ background: rgba(59,130,246,0.1);
+ padding: 0.25rem 0.5rem;
+ border-radius: 6px;
+ font-size: 0.875em;
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+}
+'''
+
+CSS_ACCOUNTS = '''
+.account-card { border: 1px solid var(--border); border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem; background: var(--card); }
+.account-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; }
+.account-name { font-weight: 500; display: flex; align-items: center; gap: 0.5rem; }
+.account-meta { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.5rem; font-size: 0.8rem; color: var(--muted); }
+.account-meta-item { display: flex; justify-content: space-between; padding: 0.25rem 0; }
+.account-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--border); }
+'''
+
+CSS_API = '''
+.endpoint { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
+.method { padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
+.method.get { background: #dcfce7; color: #166534; }
+.method.post { background: #fef3c7; color: #92400e; }
+@media (prefers-color-scheme: dark) {
+ .method.get { background: #14532d; color: #86efac; }
+ .method.post { background: #78350f; color: #fde68a; }
+}
+.copy-btn { padding: 0.25rem 0.5rem; font-size: 0.75rem; background: var(--card); border: 1px solid var(--border); color: var(--text); }
+'''
+
+CSS_DOCS = '''
+.docs-container { display: flex; gap: 1.5rem; min-height: 500px; }
+.docs-nav { width: 200px; flex-shrink: 0; }
+.docs-nav-item { display: block; padding: 0.5rem 0.75rem; margin-bottom: 0.25rem; border-radius: 6px; cursor: pointer; font-size: 0.875rem; color: var(--text); text-decoration: none; transition: background 0.2s; }
+.docs-nav-item:hover { background: var(--bg); }
+.docs-nav-item.active { background: var(--accent); color: var(--bg); }
+.docs-content { flex: 1; min-width: 0; }
+.docs-content h1 { font-size: 1.5rem; margin-bottom: 1rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--border); }
+.docs-content h2 { font-size: 1.25rem; margin: 1.5rem 0 0.75rem; color: var(--text); }
+.docs-content h3 { font-size: 1rem; margin: 1rem 0 0.5rem; color: var(--text); }
+.docs-content h4 { font-size: 0.9rem; margin: 0.75rem 0 0.5rem; color: var(--muted); }
+.docs-content p { margin: 0.5rem 0; }
+.docs-content ul, .docs-content ol { margin: 0.5rem 0; padding-left: 1.5rem; }
+.docs-content li { margin: 0.25rem 0; }
+.docs-content code { background: var(--bg); padding: 0.2em 0.4em; border-radius: 3px; font-size: 0.9em; }
+.docs-content pre { margin: 0.75rem 0; }
+.docs-content pre code { background: none; padding: 0; }
+.docs-content table { margin: 0.75rem 0; }
+.docs-content blockquote { margin: 0.75rem 0; padding: 0.5rem 1rem; border-left: 3px solid var(--border); color: var(--muted); background: var(--bg); border-radius: 0 6px 6px 0; }
+.docs-content hr { margin: 1.5rem 0; border: none; border-top: 1px solid var(--border); }
+.docs-content a { color: var(--info); text-decoration: none; }
+.docs-content a:hover { text-decoration: underline; }
+@media (max-width: 768px) {
+ .docs-container { flex-direction: column; }
+ .docs-nav { width: 100%; display: flex; flex-wrap: wrap; gap: 0.5rem; }
+ .docs-nav-item { margin-bottom: 0; }
+}
+'''
+
+# ==================== UI 组件库样式 ====================
+CSS_UI_COMPONENTS = '''
+/* Modal 模态框 */
+.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; opacity: 0; visibility: hidden; transition: all 0.2s; }
+.modal-overlay.active { opacity: 1; visibility: visible; }
+.modal { background: var(--card); border-radius: 12px; max-width: 500px; width: 90%; max-height: 90vh; overflow: hidden; transform: scale(0.9); transition: transform 0.2s; }
+.modal-overlay.active .modal { transform: scale(1); }
+.modal-header { padding: 1rem 1.5rem; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
+.modal-header h3 { font-size: 1.1rem; font-weight: 600; }
+.modal-close { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: var(--muted); padding: 0; line-height: 1; }
+.modal-body { padding: 1.5rem; overflow-y: auto; max-height: 60vh; }
+.modal-footer { padding: 1rem 1.5rem; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 0.5rem; }
+.modal.danger .modal-header { background: #fee2e2; }
+.modal.warning .modal-header { background: #fef3c7; }
+@media (prefers-color-scheme: dark) {
+ .modal.danger .modal-header { background: #7f1d1d; }
+ .modal.warning .modal-header { background: #78350f; }
+}
+
+/* Toast 通知 */
+.toast-container { position: fixed; top: 1rem; right: 1rem; z-index: 1100; display: flex; flex-direction: column; gap: 0.5rem; }
+.toast { padding: 0.75rem 1rem; border-radius: 8px; background: var(--card); border: 1px solid var(--border); box-shadow: 0 4px 12px rgba(0,0,0,0.15); display: flex; align-items: center; gap: 0.5rem; animation: slideIn 0.3s ease; min-width: 250px; }
+.toast.success { border-left: 4px solid var(--success); }
+.toast.error { border-left: 4px solid var(--error); }
+.toast.warning { border-left: 4px solid var(--warn); }
+.toast.info { border-left: 4px solid var(--info); }
+.toast-close { margin-left: auto; background: none; border: none; cursor: pointer; color: var(--muted); font-size: 1.2rem; padding: 0; }
+@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
+
+/* Select 下拉选择 */
+.custom-select { position: relative; }
+.custom-select-trigger { padding: 0.75rem 1rem; border: 1px solid var(--border); border-radius: 6px; background: var(--card); cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
+.custom-select-trigger::after { content: "▼"; font-size: 0.7rem; color: var(--muted); }
+.custom-select-options { position: absolute; top: 100%; left: 0; right: 0; background: var(--card); border: 1px solid var(--border); border-radius: 6px; margin-top: 4px; max-height: 200px; overflow-y: auto; z-index: 100; display: none; }
+.custom-select.open .custom-select-options { display: block; }
+.custom-select-option { padding: 0.5rem 1rem; cursor: pointer; }
+.custom-select-option:hover { background: var(--bg); }
+.custom-select-option.selected { background: var(--accent); color: var(--bg); }
+
+/* ProgressBar 进度条 */
+.progress-bar { height: 8px; background: var(--bg); border-radius: 4px; overflow: hidden; }
+.progress-bar.large { height: 12px; }
+.progress-bar.small { height: 4px; }
+.progress-fill { height: 100%; background: var(--info); transition: width 0.3s; }
+.progress-fill.success { background: var(--success); }
+.progress-fill.warning { background: var(--warn); }
+.progress-fill.error { background: var(--error); }
+.progress-label { display: flex; justify-content: space-between; font-size: 0.75rem; color: var(--muted); margin-top: 0.25rem; }
+
+/* Dropdown 下拉菜单 */
+.dropdown { position: relative; display: inline-block; }
+.dropdown-menu { position: absolute; top: 100%; right: 0; background: var(--card); border: 1px solid var(--border); border-radius: 8px; min-width: 120px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 100; display: none; margin-top: 4px; overflow: hidden; }
+.dropdown.open .dropdown-menu { display: block; }
+.dropdown-item { padding: 0.5rem 0.75rem; cursor: pointer; display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; white-space: nowrap; }
+.dropdown-item:hover { background: var(--bg); }
+.dropdown-item.danger { color: var(--error); }
+.dropdown-divider { height: 1px; background: var(--border); margin: 0.25rem 0; }
+
+/* 账号卡片增强 */
+.account-card-enhanced { border: 1px solid var(--border); border-radius: 12px; padding: 1.25rem; margin-bottom: 1rem; background: var(--card); }
+.account-card-enhanced.priority { border-color: var(--info); border-width: 2px; }
+.account-card-enhanced.active { box-shadow: 0 0 0 2px var(--success); }
+.account-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1rem; }
+.account-card-title { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
+.account-card-badges { display: flex; gap: 0.25rem; flex-wrap: wrap; }
+.account-quota-section { margin: 1rem 0; }
+.quota-header { display: flex; justify-content: space-between; margin-bottom: 0.5rem; font-size: 0.875rem; }
+.quota-detail { display: flex; gap: 1rem; font-size: 0.75rem; color: var(--muted); margin-top: 0.5rem; flex-wrap: wrap; }
+.quota-reset-info { display: flex; gap: 1rem; flex-wrap: wrap; }
+.quota-reset-info span { display: inline-flex; align-items: center; gap: 0.25rem; }
+.account-stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.5rem; margin: 1rem 0; }
+.account-stat { text-align: center; padding: 0.5rem; background: var(--bg); border-radius: 6px; }
+.account-stat-value { font-weight: 600; font-size: 0.9rem; }
+.account-stat-label { font-size: 0.7rem; color: var(--muted); }
+
+/* 账号网格布局 - 动态自适应 */
+.accounts-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; margin-top: 1rem; }
+.account-card-compact { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 0.875rem; transition: all 0.2s; }
+.account-card-compact:hover { border-color: var(--accent); }
+.account-card-compact.priority { border-color: var(--info); border-width: 2px; }
+.account-card-compact.low-balance { border-color: var(--warn); }
+.account-card-compact.exhausted { border-color: var(--error); border-width: 2px; }
+.account-card-compact.suspended { border-color: var(--error); border-width: 2px; background: rgba(239, 68, 68, 0.1); }
+.account-card-compact.unavailable { opacity: 0.6; }
+.account-card-top { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem; }
+.account-card-info { flex: 1; min-width: 0; }
+.account-card-name { font-weight: 600; font-size: 0.95rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 0.25rem; }
+.account-card-email { font-size: 0.75rem; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.account-card-status { display: flex; gap: 0.25rem; flex-wrap: wrap; }
+.account-card-quota { margin: 0.75rem 0; }
+.account-card-quota-bar { height: 6px; background: var(--bg); border-radius: 3px; overflow: hidden; }
+.account-card-quota-fill { height: 100%; transition: width 0.3s; }
+.account-card-quota-text { display: flex; justify-content: space-between; font-size: 0.7rem; color: var(--muted); margin-top: 0.25rem; }
+.account-card-stats { display: flex; gap: 1rem; font-size: 0.75rem; color: var(--muted); margin-bottom: 0.75rem; }
+.account-card-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; padding-top: 0.75rem; border-top: 1px solid var(--border); }
+.account-card-actions button { flex: 1; min-width: 60px; }
+
+/* 紧凑汇总面板 */
+.summary-compact { display: flex; gap: 1rem; flex-wrap: wrap; align-items: center; padding: 0.75rem; background: var(--bg); border-radius: 8px; }
+.summary-compact-item { display: flex; align-items: center; gap: 0.5rem; }
+.summary-compact-value { font-weight: 600; font-size: 1.1rem; }
+.summary-compact-label { font-size: 0.75rem; color: var(--muted); }
+.summary-compact-divider { width: 1px; height: 24px; background: var(--border); }
+.summary-quota-bar { flex: 1; min-width: 200px; }
+
+/* 全局进度条 - 批量刷新操作进度显示 */
+.global-progress-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 1200; background: var(--card); border-bottom: 1px solid var(--border); box-shadow: 0 2px 8px rgba(0,0,0,0.1); transform: translateY(-100%); transition: transform 0.3s ease; }
+.global-progress-bar.active { transform: translateY(0); }
+.global-progress-bar-inner { max-width: 1400px; margin: 0 auto; padding: 0.75rem 1rem; }
+.global-progress-bar-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; }
+.global-progress-bar-title { font-weight: 600; font-size: 0.9rem; display: flex; align-items: center; gap: 0.5rem; }
+.global-progress-bar-title .spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 1s linear infinite; }
+.global-progress-bar-stats { display: flex; gap: 1rem; font-size: 0.8rem; color: var(--muted); }
+.global-progress-bar-stats span { display: flex; align-items: center; gap: 0.25rem; }
+.global-progress-bar-stats .success { color: var(--success); }
+.global-progress-bar-stats .error { color: var(--error); }
+.global-progress-bar-track { height: 6px; background: var(--bg); border-radius: 3px; overflow: hidden; margin-bottom: 0.5rem; }
+.global-progress-bar-fill { height: 100%; background: var(--info); transition: width 0.3s ease; border-radius: 3px; }
+.global-progress-bar-fill.complete { background: var(--success); }
+.global-progress-bar-current { font-size: 0.75rem; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.global-progress-bar-close { background: none; border: none; font-size: 1.2rem; cursor: pointer; color: var(--muted); padding: 0; margin-left: 0.5rem; }
+.global-progress-bar-close:hover { color: var(--text); }
+
+/* 汇总面板 */
+.summary-panel { background: linear-gradient(135deg, var(--card) 0%, var(--bg) 100%); border: 1px solid var(--border); border-radius: 12px; padding: 1.5rem; margin-bottom: 1.5rem; }
+.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 1rem; margin-bottom: 1rem; }
+.summary-item { text-align: center; }
+.summary-value { font-size: 1.75rem; font-weight: 700; }
+.summary-label { font-size: 0.75rem; color: var(--muted); }
+.summary-item.success .summary-value { color: var(--success); }
+.summary-item.warning .summary-value { color: var(--warn); }
+.summary-item.error .summary-value { color: var(--error); }
+.summary-quota { margin: 1rem 0; }
+.summary-info { display: flex; gap: 2rem; flex-wrap: wrap; font-size: 0.875rem; color: var(--muted); }
+.summary-actions { margin-top: 1rem; display: flex; gap: 0.5rem; }
+'''
+
+CSS_STYLES = CSS_BASE + CSS_LAYOUT + CSS_COMPONENTS + CSS_FORMS + CSS_ACCOUNTS + CSS_API + CSS_DOCS + CSS_UI_COMPONENTS
+
+
+# ==================== HTML 模板 ====================
+# 全局进度条容器 - 显示在页面顶部
+HTML_GLOBAL_PROGRESS = '''
+
+
+'''
+
+HTML_HEADER = '''
+
+
Kiro API Proxy
+
+
+ 检查中...
+
+
+
+
+
+
📚 帮助
+
📊 监控
+
👥 账号
+
🔌 API
+
⚙️ 设置
+
+'''
+
+HTML_HELP = '''
+
+'''
+
+HTML_FLOWS = '''
+
+
+
+
流量监控
+
+
+
+
+
+
+
+
+
+
+
+'''
+
+HTML_MONITOR = '''
+
+
+
+
+
+
+
+
+
+
+
+
+
🎯 速度测试
+
+
+ 点击开始测试
+
+
+
+
+
+
📋 请求监控
+
+
+
+
+
+
+
+
+
+
+
+
+
📝 请求日志
+
+
+
+
+ | 时间 |
+ 路径 |
+ 模型 |
+ 账号 |
+ 状态 |
+ 耗时 |
+
+
+
+
+
+
+
+
+
+
+'''
+
+
+HTML_ACCOUNTS = '''
+
+
+
+
+
账号管理
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
添加账号
+
+
+
+
+
+
🌐 在线登录
+
+
+
+
+
+
+
+
+
📋 其他方式
+
+
+
+
+
+
+
+
+
+
+
+
+
+
手动添加 Token
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
提示:根据 Refresh Token 自动去重,相同 Refresh Token 的账号不会重复添加
+
+
+
+
+
+
+
+
+'''
+
+HTML_LOGS = '''
+
+'''
+
+HTML_API = '''
+
+
+
API 端点
+
支持 OpenAI、Anthropic、Gemini 三种协议
+
OpenAI 协议
+
POST/v1/chat/completions
+
GET/v1/models
+
Anthropic 协议
+
POST/v1/messages
+
POST/v1/messages/count_tokens
+
Gemini 协议
+
POST/v1/models/{model}:generateContent
+
Base URL
+
+
+
+
+
配置示例
+
Claude Code
+
Base URL:
+API Key: any
+模型: claude-sonnet-4
+
Codex CLI
+
Endpoint: /v1
+API Key: any
+模型: gpt-4o
+
+
+
Claude Code 终端配置
+
Claude Code 终端版需要配置 ~/.claude/settings.json 才能跳过登录使用代理
+
+
临时生效(当前终端)
+
export ANTHROPIC_BASE_URL=""
+export ANTHROPIC_AUTH_TOKEN="sk-any"
+export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
+
+
+
永久生效(推荐,写入配置文件)
+
# 写入 Claude Code 配置文件
+mkdir -p ~/.claude
+cat > ~/.claude/settings.json << 'EOF'
+{
+ "env": {
+ "ANTHROPIC_BASE_URL": "",
+ "ANTHROPIC_AUTH_TOKEN": "sk-any",
+ "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"
+ }
+}
+EOF
+
+
+
清除配置
+
# 删除 Claude Code 配置
+rm -f ~/.claude/settings.json
+unset ANTHROPIC_BASE_URL ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
+
+
+
+ 💡 使用 ANTHROPIC_AUTH_TOKEN + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 可跳过登录
+
+
+
+
模型映射
+
支持多种模型名称,自动映射到 Kiro 模型
+
+ | Kiro 模型 | 能力 | 可用名称 |
+
+ claude-sonnet-4 | ⭐⭐⭐ 推荐 | gpt-4o, gpt-4, claude-3-5-sonnet-*, sonnet |
+ claude-sonnet-4.5 | ⭐⭐⭐⭐ 更强 | gemini-1.5-pro, o1, o1-preview, claude-3-opus-*, opus |
+ claude-haiku-4.5 | ⚡ 快速 | gpt-4o-mini, gpt-3.5-turbo, haiku |
+ auto | 🤖 自动 | auto |
+
+
+
+ 💡 直接使用 Kiro 模型名(如 claude-sonnet-4)或任意映射名称均可
+
+
+
+'''
+
+HTML_SETTINGS = '''
+
+
+
+
🤖 自动化管理
+
+ 以下功能已启用自动化管理,无需手动配置:
+
+
+
+
+ 🔄
+ Token 与额度刷新
+ 自动
+
+
+ Token 过期前自动刷新,额度信息定期更新
+
+
+
+
+ ⚡
+ 请求限速与 429 冷却
+ 自动
+
+
+ 遇到 429 错误自动冷却 5 分钟,自动切换到其他可用账号
+
+
+
+
+ 📝
+ 历史消息压缩
+ 自动
+
+
+ 上下文超限时自动压缩,智能生成摘要保留关键信息
+
+
+
+
+ 🎲
+ 账号负载均衡
+ 自动
+
+
+ 支持随机、轮询、最少请求等多种账号选择策略,分散请求压力
+
+
+
+
+
+
+
+
刷新配置
+
+
+
+
+ 配置 Token 刷新和额度刷新的相关参数
+
+
+
+
+
+
+
+
+
+
请求限速
+
+
+
+
+ 启用后会限制请求频率,降低被检测为异常活动的风险
+
+
+
+
+
+
+
+
+ 🔄
+ 429 冷却自动管理
+ 自动
+
+
+ 遇到 429 错误时自动冷却账号 5 分钟,无需手动配置。冷却期间自动切换到其他可用账号。
+
+
+
+
+
+
+
+
+
历史消息管理
+
+
+
+
+ 自动处理 Kiro API 的输入长度限制,收到超限错误时智能压缩而非强硬截断
+
+
+
+
+ 🤖
+ 错误触发压缩模式
+ 自动
+
+
+ 不再依赖阈值预检测,仅在收到上下文超限错误后自动压缩到 20K-50K 字符范围
+
+
+
+
+
+
+
+
+
+ 工作原理:
+ 1. 正常发送请求,不进行预检测
+ 2. 收到 CONTENT_LENGTH_EXCEEDS_THRESHOLD 错误时触发压缩
+ 3. 用 AI 生成早期对话摘要,保留最近 6-20 条消息
+ 4. 压缩目标: 20K-50K 字符,自动重试
+
+ ✓ 最大化利用上下文
+ ✓ 错误触发无需预估
+ ✓ 智能缓存避免重复调用
+
+
+
+
+'''
+
+HTML_BODY = HTML_GLOBAL_PROGRESS + HTML_HEADER + HTML_HELP + HTML_MONITOR + HTML_ACCOUNTS + HTML_API + HTML_SETTINGS
+
+
+# ==================== JavaScript ====================
+JS_UTILS = '''
+const $=s=>document.querySelector(s);
+const $$=s=>document.querySelectorAll(s);
+
+function copy(text){
+ navigator.clipboard.writeText(text).then(()=>{
+ const toast=document.createElement('div');
+ toast.textContent='已复制';
+ toast.style.cssText='position:fixed;bottom:2rem;left:50%;transform:translateX(-50%);background:var(--accent);color:var(--bg);padding:0.5rem 1rem;border-radius:6px;font-size:0.875rem;z-index:1000';
+ document.body.appendChild(toast);
+ setTimeout(()=>toast.remove(),1500);
+ });
+}
+
+function copyEnvTemp(){
+ const url=location.origin;
+ copy(`export ANTHROPIC_BASE_URL="${url}"
+export ANTHROPIC_AUTH_TOKEN="sk-any"
+export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`);
+}
+
+function copyEnvPerm(){
+ const url=location.origin;
+ copy(`# 写入 Claude Code 配置文件(推荐)
+mkdir -p ~/.claude
+cat > ~/.claude/settings.json << 'EOF'
+{
+ "env": {
+ "ANTHROPIC_BASE_URL": "${url}",
+ "ANTHROPIC_AUTH_TOKEN": "sk-any",
+ "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"
+ }
+}
+EOF
+echo "配置完成,请重新打开终端运行 claude"`);
+}
+
+function copyEnvClear(){
+ copy(`# 删除 Claude Code 配置
+rm -f ~/.claude/settings.json
+unset ANTHROPIC_BASE_URL ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
+echo "配置已清除"`);
+}
+
+function formatUptime(s){
+ if(s<60)return s+'秒';
+ if(s<3600)return Math.floor(s/60)+'分钟';
+ return Math.floor(s/3600)+'小时'+Math.floor((s%3600)/60)+'分钟';
+}
+
+function escapeHtml(text){
+ const div=document.createElement('div');
+ div.textContent=text;
+ return div.innerHTML;
+}
+'''
+
+JS_TABS = '''
+// Tabs
+$$('.tab').forEach(t=>t.onclick=()=>{
+ $$('.tab').forEach(x=>x.classList.remove('active'));
+ $$('.panel').forEach(x=>x.classList.remove('active'));
+ t.classList.add('active');
+ $('#'+t.dataset.tab).classList.add('active');
+
+ // 监控面板加载所有数据
+ if(t.dataset.tab==='monitor'){
+ loadStats();
+ loadQuota();
+ loadFlowStats();
+ loadFlows();
+ loadLogs();
+ }
+ if(t.dataset.tab==='accounts'){
+ loadAccounts();
+ loadAccountsEnhanced();
+ }
+});
+'''
+
+JS_STATUS = '''
+// Status
+async function checkStatus(){
+ try{
+ const r=await fetch('/api/status');
+ const d=await r.json();
+ $('#statusDot').className='status-dot '+(d.ok?'ok':'err');
+ $('#statusText').textContent=d.ok?'已连接':'未连接';
+ if(d.stats)$('#uptime').textContent='运行 '+formatUptime(d.stats.uptime_seconds);
+ }catch(e){
+ $('#statusDot').className='status-dot err';
+ $('#statusText').textContent='连接失败';
+ }
+}
+checkStatus();
+setInterval(checkStatus,30000);
+
+// URLs
+$('#baseUrl').textContent=location.origin;
+$$('.pyUrl').forEach(e=>e.textContent=location.origin);
+'''
+
+JS_DOCS = '''
+// 文档浏览
+let docsData = [];
+let currentDoc = null;
+
+// 简单的 Markdown 渲染
+function renderMarkdown(text) {
+ return text
+ .replace(/```(\\w*)\\n([\\s\\S]*?)```/g, '$2
')
+ .replace(/`([^`]+)`/g, '$1')
+ .replace(/^#### (.+)$/gm, '$1
')
+ .replace(/^### (.+)$/gm, '$1
')
+ .replace(/^## (.+)$/gm, '$1
')
+ .replace(/^# (.+)$/gm, '$1
')
+ .replace(/\\*\\*(.+?)\\*\\*/g, '$1')
+ .replace(/\\*(.+?)\\*/g, '$1')
+ .replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '$1')
+ .replace(/^- (.+)$/gm, '$1')
+ .replace(/(.*<\\/li>\\n?)+/g, '')
+ .replace(/^\\d+\\. (.+)$/gm, '$1')
+ .replace(/^> (.+)$/gm, '$1
')
+ .replace(/^---$/gm, '
')
+ .replace(/\\|(.+)\\|/g, function(match) {
+ const cells = match.split('|').filter(c => c.trim());
+ if (cells.every(c => /^[\\s-:]+$/.test(c))) return '';
+ const tag = match.includes('---') ? 'th' : 'td';
+ return '' + cells.map(c => '<' + tag + '>' + c.trim() + '' + tag + '>').join('') + '
';
+ })
+ .replace(/(.*<\\/tr>\\n?)+/g, '')
+ .replace(/\\n\\n/g, '')
+ .replace(/\\n/g, '
');
+}
+
+async function loadDocs() {
+ try {
+ const r = await fetch('/api/docs');
+ const d = await r.json();
+ docsData = d.docs || [];
+
+ // 渲染导航
+ $('#docsNav').innerHTML = docsData.map((doc, i) =>
+ '' + doc.title + ''
+ ).join('');
+
+ // 显示第一个文档
+ if (docsData.length > 0) {
+ showDoc(docsData[0].id);
+ }
+ } catch (e) {
+ $('#docsContent').innerHTML = '
加载文档失败
';
+ }
+}
+
+async function showDoc(id) {
+ // 更新导航状态
+ $$('.docs-nav-item').forEach(item => {
+ item.classList.toggle('active', item.dataset.id === id);
+ });
+
+ // 获取文档内容
+ try {
+ const r = await fetch('/api/docs/' + id);
+ const d = await r.json();
+ currentDoc = d;
+ $('#docsContent').innerHTML = renderMarkdown(d.content);
+ } catch (e) {
+ $('#docsContent').innerHTML = '加载文档失败
';
+ }
+}
+
+// 页面加载时加载文档
+loadDocs();
+'''
+
+JS_STATS = '''
+// Stats
+async function loadStats(){
+ try{
+ const r=await fetch('/api/stats');
+ const d=await r.json();
+ $('#statsGrid').innerHTML=`
+
+
+
+ ${d.accounts_available}/${d.accounts_total}
可用账号
+ ${d.accounts_cooldown||0}
冷却中
+ `;
+ }catch(e){console.error(e)}
+}
+
+// Quota
+async function loadQuota(){
+ try{
+ const r=await fetch('/api/quota');
+ const d=await r.json();
+ if(d.exceeded_credentials&&d.exceeded_credentials.length>0){
+ $('#quotaStatus').innerHTML=d.exceeded_credentials.map(c=>`
+
+ 冷却中 ${c.credential_id}
+ 剩余 ${c.remaining_seconds}秒
+
+
+ `).join('');
+ }else{
+ $('#quotaStatus').innerHTML='无冷却中的账号
';
+ }
+ }catch(e){console.error(e)}
+}
+
+// Speedtest
+async function runSpeedtest(){
+ $('#speedtestBtn').disabled=true;
+ $('#speedtestResult').textContent='测试中...';
+ try{
+ const r=await fetch('/api/speedtest',{method:'POST'});
+ const d=await r.json();
+ $('#speedtestResult').textContent=d.ok?`延迟: ${d.latency_ms.toFixed(0)}ms (${d.account_id})`:'测试失败: '+d.error;
+ }catch(e){$('#speedtestResult').textContent='测试失败'}
+ $('#speedtestBtn').disabled=false;
+}
+'''
+
+JS_LOGS = '''
+// Logs
+async function loadLogs(){
+ try{
+ const r=await fetch('/api/logs?limit=50');
+ const d=await r.json();
+ $('#logTable').innerHTML=(d.logs||[]).map(l=>`
+
+ | ${new Date(l.timestamp*1000).toLocaleTimeString()} |
+ ${l.path} |
+ ${l.model||'-'} |
+ ${l.account_id||'-'} |
+ ${l.status} |
+ ${l.duration_ms.toFixed(0)}ms |
+
+ `).join('');
+ }catch(e){console.error(e)}
+}
+'''
+
+
+JS_ACCOUNTS = '''
+// Accounts
+async function loadAccounts(){
+ try{
+ const r=await fetch('/api/accounts');
+ const d=await r.json();
+ if(!d.accounts||d.accounts.length===0){
+ $('#accountList').innerHTML='暂无账号,请点击"扫描 Token"
';
+ return;
+ }
+ $('#accountList').innerHTML=d.accounts.map(a=>{
+ const statusBadge=a.status==='active'?'success':a.status==='cooldown'?'warn':a.status==='suspended'?'error':'error';
+ const statusText={active:'可用',cooldown:'冷却中',unhealthy:'不健康',disabled:'已禁用',suspended:'已封禁'}[a.status]||a.status;
+ const authBadge=a.auth_method==='idc'?'info':'success';
+ const authText=a.auth_method==='idc'?'IdC':'Social';
+ return `
+
+
+
+
+
+
+
+
+ ${a.status==='cooldown'?``:''}
+
+
+
+
+ `;
+ }).join('');
+ }catch(e){console.error(e)}
+}
+
+async function queryUsage(id){
+ const usageDiv=$('#usage-'+id);
+ usageDiv.style.display='block';
+ usageDiv.innerHTML='查询中...';
+ try{
+ const r=await fetch('/api/accounts/'+id+'/usage');
+ const d=await r.json();
+ if(d.ok){
+ const u=d.usage;
+ const pct=u.usage_limit>0?((u.current_usage/u.usage_limit)*100).toFixed(1):0;
+ const barColor=u.is_low_balance?'var(--error)':'var(--success)';
+ usageDiv.innerHTML=`
+
+ ${u.subscription_title}
+ ${u.is_low_balance?'余额不足':'正常'}
+
+
+
+
已用/总额: ${u.current_usage.toFixed(2)} / ${u.usage_limit.toFixed(2)}
+
使用率: ${pct}%
+ ${u.reset_date_text ? `
重置时间: ${u.reset_date_text}
` : ''}
+ ${u.trial_expiry_text ? `
试用过期: ${u.trial_expiry_text}
` : ''}
+
+ `;
+ }else{
+ usageDiv.innerHTML=`查询失败: ${d.error}`;
+ }
+ }catch(e){
+ usageDiv.innerHTML=`查询失败: ${e.message}`;
+ }
+}
+
+async function refreshToken(id){
+ try{
+ Toast.info('正在刷新 Token...');
+ const r=await fetch('/api/accounts/'+id+'/refresh',{method:'POST'});
+ const d=await r.json();
+ if(d.ok) {
+ Toast.success('Token 刷新成功');
+ } else {
+ Toast.error('刷新失败: '+(d.message||d.error));
+ }
+ loadAccounts();
+ loadAccountsEnhanced();
+ }catch(e){Toast.error('刷新失败: '+e.message)}
+}
+
+async function refreshAllTokens(){
+ try{
+ Toast.info('正在刷新所有 Token...');
+ const r=await fetch('/api/accounts/refresh-all',{method:'POST'});
+ const d=await r.json();
+ Toast.success(`刷新完成: ${d.refreshed} 个账号`);
+ loadAccounts();
+ loadAccountsEnhanced();
+ }catch(e){Toast.error('刷新失败: '+e.message)}
+}
+
+async function restoreAccount(id){
+ try{
+ Toast.info('正在恢复账号...');
+ const r = await fetch('/api/accounts/'+id+'/restore',{method:'POST'});
+ const d = await r.json();
+ if(d.ok) {
+ Toast.success('账号已恢复');
+ } else {
+ Toast.error(d.error || '恢复失败');
+ }
+ loadAccounts();
+ loadAccountsEnhanced();
+ loadQuota();
+ }catch(e){Toast.error('恢复失败: '+e.message)}
+}
+
+async function viewAccountDetail(id){
+ try{
+ const r=await fetch('/api/accounts/'+id);
+ const d=await r.json();
+ Modal.info('账号详情', `
+
+
账号名: ${d.name}
+
ID: ${d.id}
+
状态: ${d.status}
+
请求数: ${d.request_count}
+
错误数: ${d.error_count}
+
+ `);
+ }catch(e){Toast.error('获取详情失败: '+e.message)}
+}
+
+async function toggleAccount(id){
+ try {
+ const r = await fetch('/api/accounts/'+id+'/toggle',{method:'POST'});
+ const d = await r.json();
+ if(d.ok) {
+ Toast.success(d.enabled ? '账号已启用' : '账号已禁用');
+ } else {
+ Toast.error(d.error || '操作失败');
+ }
+ } catch(e) {
+ Toast.error('操作失败: ' + e.message);
+ }
+ loadAccounts();
+ loadAccountsEnhanced();
+}
+
+async function deleteAccount(id){
+ if(confirm('确定删除此账号?')){
+ try {
+ const r = await fetch('/api/accounts/'+id,{method:'DELETE'});
+ const d = await r.json();
+ if(d.ok) {
+ Toast.success('账号已删除');
+ } else {
+ Toast.error(d.error || '删除失败');
+ }
+ } catch(e) {
+ Toast.error('删除失败: ' + e.message);
+ }
+ loadAccounts();
+ loadAccountsEnhanced();
+ }
+}
+
+function showAddAccount(){
+ const path=prompt('输入 Token 文件路径:');
+ if(path){
+ const name=prompt('账号名称:','账号');
+ fetch('/api/accounts',{
+ method:'POST',
+ headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({name,token_path:path})
+ }).then(r=>r.json()).then(d=>{
+ if(d.ok){
+ Toast.success('账号添加成功');
+ loadAccounts();
+ loadAccountsEnhanced();
+ }
+ else alert(d.detail||'添加失败');
+ });
+ }
+}
+
+async function scanTokens(){
+ try{
+ const r=await fetch('/api/token/scan');
+ const d=await r.json();
+ const panel=$('#scanResults');
+ const list=$('#scanList');
+ if(d.tokens&&d.tokens.length>0){
+ panel.style.display='block';
+ list.innerHTML=d.tokens.map(t=>{
+ const path=encodeURIComponent(t.path||'');
+ const name=encodeURIComponent(t.name||'');
+ return `
+
+
+
${t.name}
+
${t.path}
+
+ ${t.already_added?'
已添加':`
`}
+
+ `;
+ }).join('');
+ }else{
+ alert('未找到 Token 文件');
+ }
+ }catch(e){alert('扫描失败: '+e.message)}
+}
+
+async function addFromScan(path,name){
+ try{
+ const r=await fetch('/api/token/add-from-scan',{
+ method:'POST',
+ headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({path,name})
+ });
+ const d=await r.json();
+ if(d.ok){
+ loadAccounts();
+ scanTokens();
+ }else{
+ alert(d.detail||'添加失败');
+ }
+ }catch(e){alert('添加失败: '+e.message)}
+}
+
+async function checkTokens(){
+ try{
+ const r=await fetch('/api/token/refresh-check',{method:'POST'});
+ const d=await r.json();
+ let msg='Token 状态:\\n\\n';
+ (d.accounts||[]).forEach(a=>{
+ const status=a.valid?'✅ 有效':'❌ 无效';
+ msg+=`${a.name}: ${status}\\n`;
+ });
+ alert(msg);
+ }catch(e){alert('检查失败: '+e.message)}
+}
+
+// 手动添加 Token
+function showManualAdd(){
+ $('#manualAddPanel').style.display='block';
+ $('#manualName').value='';
+ $('#manualAccessToken').value='';
+ $('#manualRefreshToken').value='';
+}
+
+async function submitManualToken(){
+ const name=$('#manualName').value.trim();
+ const accessToken=$('#manualAccessToken').value.trim();
+ const refreshToken=$('#manualRefreshToken').value.trim();
+ const authMethod=$('#manualAuthMethod').value;
+ const provider=$('#manualProvider')?.value || '';
+ const clientId=$('#manualClientId')?.value?.trim() || '';
+ const clientSecret=$('#manualClientSecret')?.value?.trim() || '';
+ const region=$('#manualRegion')?.value?.trim() || 'us-east-1';
+
+ // Refresh Token 必填
+ if (!refreshToken) {
+ Toast.error('Refresh Token 是必填项');
+ return;
+ }
+
+ // 验证 Refresh Token 格式
+ if (refreshToken.length < 100) {
+ Toast.error('Refresh Token 格式不正确(太短)');
+ return;
+ }
+
+ // IDC 认证需要 clientId 和 clientSecret
+ if (authMethod === 'idc' && (!clientId || !clientSecret)) {
+ Toast.error('IDC 认证需要填写 Client ID 和 Client Secret');
+ return;
+ }
+
+ Toast.info('正在添加账号...');
+
+ try{
+ const r=await fetchWithRetry('/api/accounts/manual',{
+ method:'POST',
+ headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({
+ name: name || '',
+ access_token: accessToken,
+ refresh_token: refreshToken,
+ auth_method: authMethod,
+ provider: provider,
+ client_id: clientId,
+ client_secret: clientSecret,
+ region: region
+ })
+ });
+ const d=await r.json();
+ if(d.ok){
+ let msg = '添加成功';
+ if (d.auto_name) {
+ msg += '(已自动获取邮箱作为名称)';
+ }
+ Toast.success(msg);
+ $('#manualAddPanel').style.display='none';
+ // 清空表单
+ $('#manualName').value = '';
+ $('#manualAccessToken').value = '';
+ $('#manualRefreshToken').value = '';
+ if ($('#manualClientId')) $('#manualClientId').value = '';
+ if ($('#manualClientSecret')) $('#manualClientSecret').value = '';
+ loadAccounts();
+ loadAccountsEnhanced();
+ }else{
+ Toast.error(d.detail||'添加失败');
+ }
+ }catch(e){Toast.error('添加失败: '+e.message)}
+}
+
+// 切换手动添加表单字段显示
+function toggleManualFields() {
+ const authMethod = $('#manualAuthMethod').value;
+ const idcFields = $('#manualIdcFields');
+ const providerField = $('#manualProviderField');
+
+ if (authMethod === 'idc') {
+ if (idcFields) idcFields.style.display = 'block';
+ if (providerField) providerField.style.display = 'none';
+ } else {
+ if (idcFields) idcFields.style.display = 'none';
+ if (providerField) providerField.style.display = 'block';
+ }
+}
+
+// 导出账号
+async function exportAccounts(){
+ try{
+ const r=await fetch('/api/accounts/export');
+ const d=await r.json();
+ if(!d.ok){alert('导出失败');return;}
+ const blob=new Blob([JSON.stringify(d,null,2)],{type:'application/json'});
+ const url=URL.createObjectURL(blob);
+ const a=document.createElement('a');
+ a.href=url;
+ a.download='kiro-accounts-'+new Date().toISOString().slice(0,10)+'.json';
+ a.click();
+ }catch(e){alert('导出失败: '+e.message)}
+}
+
+// 导入账号
+function importAccounts(){
+ const input=document.createElement('input');
+ input.type='file';
+ input.accept='.json';
+ input.onchange=async(e)=>{
+ const file=e.target.files[0];
+ if(!file)return;
+ try{
+ const text=await file.text();
+ const data=JSON.parse(text);
+ const r=await fetch('/api/accounts/import',{
+ method:'POST',
+ headers:{'Content-Type':'application/json'},
+ body:JSON.stringify(data)
+ });
+ const d=await r.json();
+ if(d.ok){
+ alert(`导入成功: ${d.imported} 个账号`+(d.errors?.length?`\\n错误: ${d.errors.join(', ')}`:''));
+ loadAccounts();
+ }else{
+ alert('导入失败');
+ }
+ }catch(e){alert('导入失败: '+e.message)}
+ };
+ input.click();
+}
+'''
+
+JS_LOGIN = '''
+// Kiro 在线登录
+let loginPollTimer=null;
+
+function showLoginOptions(){
+ $('#loginOptions').style.display='block';
+}
+
+async function startSocialLogin(provider){
+ $('#loginOptions').style.display='none';
+ try{
+ const r=await fetch('/api/kiro/social/start',{
+ method:'POST',
+ headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({provider})
+ });
+ const d=await r.json();
+ if(!d.ok){alert('启动登录失败: '+d.error);return;}
+ showSocialLoginPanel(d.provider, d.login_url);
+ }catch(e){alert('启动登录失败: '+e.message)}
+}
+
+// 协议注册状态
+let protocolRegistered = false;
+let callbackPollTimer = null;
+
+function showSocialLoginPanel(provider, loginUrl){
+ $('#loginPanel').style.display='block';
+ $('#loginContent').innerHTML=`
+
+
${provider} 登录
+
+
+
步骤 1:打开登录链接
+
+
+
+
+
+
步骤 2:完成授权后粘贴回调 URL
+
+ 授权完成后,浏览器会尝试打开 kiro:// 链接。
+ 如果提示"无法打开",请复制地址栏中的完整 URL 粘贴到下方。
+
+
+
+
+
+
+
可选:自动回调模式
+
+
+
+
+
+
+ `;
+}
+
+async function registerProtocolAndWait(provider) {
+ $('#loginStatus').textContent = '正在注册协议处理器...';
+ $('#loginStatus').style.color = 'var(--muted)';
+
+ try {
+ const regResp = await fetch('/api/protocol/register', { method: 'POST' });
+ const regData = await regResp.json();
+
+ if (!regData.ok) {
+ $('#loginStatus').textContent = '协议注册失败: ' + regData.error;
+ $('#loginStatus').style.color = 'var(--error)';
+ return;
+ }
+
+ protocolRegistered = true;
+ $('#loginStatus').textContent = '✅ 协议已注册,授权完成后将自动接收回调';
+ $('#loginStatus').style.color = 'var(--success)';
+
+ // 开始轮询回调结果
+ startCallbackPolling(provider);
+
+ } catch(e) {
+ $('#loginStatus').textContent = '操作失败: ' + e.message;
+ $('#loginStatus').style.color = 'var(--error)';
+ }
+}
+
+function startCallbackPolling(provider) {
+ if (callbackPollTimer) clearInterval(callbackPollTimer);
+
+ let pollCount = 0;
+ const maxPolls = 300; // 5分钟超时 (300 * 1秒)
+
+ callbackPollTimer = setInterval(async () => {
+ pollCount++;
+
+ if (pollCount > maxPolls) {
+ clearInterval(callbackPollTimer);
+ callbackPollTimer = null;
+ $('#loginStatus').textContent = '等待超时,请重试';
+ $('#loginStatus').style.color = 'var(--error)';
+ return;
+ }
+
+ try {
+ const resp = await fetch('/api/protocol/callback');
+ const data = await resp.json();
+
+ if (data.ok && data.result) {
+ clearInterval(callbackPollTimer);
+ callbackPollTimer = null;
+
+ if (data.result.error) {
+ $('#loginStatus').textContent = '授权失败: ' + data.result.error;
+ $('#loginStatus').style.color = 'var(--error)';
+ } else if (data.result.code && data.result.state) {
+ // 自动交换 Token
+ $('#loginStatus').textContent = '正在交换 Token...';
+ await exchangeTokenWithCode(data.result.code, data.result.state);
+ }
+ }
+ } catch(e) {
+ console.error('轮询回调失败:', e);
+ }
+ }, 1000);
+}
+
+async function exchangeTokenWithCode(code, state) {
+ try {
+ const r = await fetch('/api/kiro/social/exchange', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({ code, state })
+ });
+ const d = await r.json();
+
+ if (d.ok && d.completed) {
+ $('#loginStatus').textContent = '✅ ' + d.message;
+ $('#loginStatus').style.color = 'var(--success)';
+ setTimeout(() => {
+ $('#loginPanel').style.display = 'none';
+ loadAccounts();
+ loadAccountsEnhanced();
+ }, 1500);
+ } else {
+ $('#loginStatus').textContent = '❌ ' + (d.error || '登录失败');
+ $('#loginStatus').style.color = 'var(--error)';
+ }
+ } catch(e) {
+ $('#loginStatus').textContent = '交换 Token 失败: ' + e.message;
+ $('#loginStatus').style.color = 'var(--error)';
+ }
+}
+
+function cancelSocialLogin(){
+ if (callbackPollTimer) {
+ clearInterval(callbackPollTimer);
+ callbackPollTimer = null;
+ }
+ fetch('/api/kiro/social/cancel',{method:'POST'});
+ $('#loginPanel').style.display='none';
+}
+
+async function handleSocialCallback(){
+ const url=$('#callbackUrl').value.trim();
+ if(!url){alert('请粘贴回调 URL');return;}
+ try{
+ // 支持 kiro:// 协议的 URL 解析
+ let code, state;
+ if(url.startsWith('kiro://')){
+ // kiro://kiro.kiroAgent/authenticate-success?code=xxx&state=xxx
+ const queryStart = url.indexOf('?');
+ if(queryStart > -1){
+ const params = new URLSearchParams(url.substring(queryStart + 1));
+ code = params.get('code');
+ state = params.get('state');
+ }
+ } else {
+ // 标准 http/https URL
+ const urlObj=new URL(url);
+ code=urlObj.searchParams.get('code');
+ state=urlObj.searchParams.get('state');
+ }
+ if(!code||!state){alert('无效的回调 URL,缺少 code 或 state 参数');return;}
+ $('#loginStatus').textContent='正在交换 Token...';
+ const r=await fetch('/api/kiro/social/exchange',{
+ method:'POST',
+ headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({code,state})
+ });
+ const d=await r.json();
+ if(d.ok&&d.completed){
+ $('#loginStatus').textContent='✅ '+d.message;
+ $('#loginStatus').style.color='var(--success)';
+ setTimeout(()=>{$('#loginPanel').style.display='none';loadAccounts();},1500);
+ }else{
+ $('#loginStatus').textContent='❌ '+(d.error||'登录失败');
+ $('#loginStatus').style.color='var(--error)';
+ }
+ }catch(e){alert('处理回调失败: '+e.message)}
+}
+
+async function startAwsLogin(){
+ $('#loginOptions').style.display='none';
+ try{
+ const r=await fetch('/api/kiro/login/start',{
+ method:'POST',
+ headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({})
+ });
+ const d=await r.json();
+ if(!d.ok){alert('启动登录失败: '+d.error);return;}
+ showAwsLoginPanel(d);
+ startLoginPoll();
+ }catch(e){alert('启动登录失败: '+e.message)}
+}
+
+function showAwsLoginPanel(data){
+ $('#loginPanel').style.display='block';
+ $('#loginContent').innerHTML=`
+
+
AWS Builder ID 登录
+
${data.user_code}
+
复制上方授权码,然后打开以下链接完成授权:
+
+
+
+
+
授权码有效期: ${Math.floor(data.expires_in/60)} 分钟
+
+
等待授权...
+
+ `;
+}
+
+function startLoginPoll(){
+ if(loginPollTimer)clearInterval(loginPollTimer);
+ loginPollTimer=setInterval(pollLogin,3000);
+}
+
+async function pollLogin(){
+ try{
+ const r=await fetch('/api/kiro/login/poll');
+ const d=await r.json();
+ if(!d.ok){$('#loginStatus').textContent='错误: '+d.error;stopLoginPoll();return;}
+ if(d.completed){
+ $('#loginStatus').textContent='✅ 登录成功!';
+ $('#loginStatus').style.color='var(--success)';
+ stopLoginPoll();
+ setTimeout(()=>{$('#loginPanel').style.display='none';loadAccounts();},1500);
+ }
+ }catch(e){$('#loginStatus').textContent='轮询失败: '+e.message}
+}
+
+function stopLoginPoll(){
+ if(loginPollTimer){clearInterval(loginPollTimer);loginPollTimer=null;}
+}
+
+async function cancelKiroLogin(){
+ stopLoginPoll();
+ await fetch('/api/kiro/login/cancel',{method:'POST'});
+ $('#loginPanel').style.display='none';
+}
+'''
+
+
+JS_FLOWS = '''
+// Flow Monitor
+async function loadFlowStats(){
+ try{
+ const r=await fetch('/api/flows/stats');
+ const d=await r.json();
+ $('#flowStatsGrid').innerHTML=`
+
+
+
+
+ ${d.avg_duration_ms.toFixed(0)}ms
平均延迟
+ ${d.total_tokens_in}
输入Token
+ ${d.total_tokens_out}
输出Token
+ `;
+ }catch(e){console.error(e)}
+}
+
+async function loadFlows(){
+ try{
+ const protocol=$('#flowProtocol').value;
+ const state=$('#flowState').value;
+ const search=$('#flowSearch').value;
+ let url='/api/flows?limit=50';
+ if(protocol)url+=`&protocol=${protocol}`;
+ if(state)url+=`&state=${state}`;
+ if(search)url+=`&search=${encodeURIComponent(search)}`;
+ const r=await fetch(url);
+ const d=await r.json();
+ if(!d.flows||d.flows.length===0){
+ $('#flowList').innerHTML='暂无请求记录
';
+ return;
+ }
+ $('#flowList').innerHTML=d.flows.map(f=>{
+ const stateBadge={completed:'success',error:'error',streaming:'info',pending:'warn'}[f.state]||'info';
+ const stateText={completed:'完成',error:'错误',streaming:'流式中',pending:'等待中'}[f.state]||f.state;
+ const time=new Date(f.timing.created_at*1000).toLocaleTimeString();
+ const duration=f.timing.duration_ms?f.timing.duration_ms.toFixed(0)+'ms':'-';
+ const model=f.request?.model||'-';
+ const tokens=f.response?.usage?(f.response.usage.input_tokens+'/'+f.response.usage.output_tokens):'-';
+ return `
+
+
+
+ ${stateText}
+ ${model}
+ ${f.bookmarked?'★':''}
+
+
+ ${time} · ${duration} · ${tokens} tokens · ${f.protocol}
+
+
+
+
+ `;
+ }).join('');
+ }catch(e){console.error(e)}
+}
+
+async function viewFlow(id){
+ try{
+ const r=await fetch('/api/flows/'+id);
+ const f=await r.json();
+ let html=`ID: ${f.id}
协议: ${f.protocol}
状态: ${f.state}
时间: ${new Date(f.timing.created_at*1000).toLocaleString()}
延迟: ${f.timing.duration_ms?f.timing.duration_ms.toFixed(0)+'ms':'N/A'}
`;
+ if(f.request){
+ html+=`请求
模型: ${f.request.model}
流式: ${f.request.stream?'是':'否'}
`;
+ }
+ if(f.response){
+ html+=`响应
状态码: ${f.response.status_code}
Token: ${f.response.usage?.input_tokens||0} in / ${f.response.usage?.output_tokens||0} out
`;
+ }
+ if(f.error){
+ html+=`错误
类型: ${f.error.type}
消息: ${f.error.message}
`;
+ }
+ $('#flowDetailContent').innerHTML=html;
+ $('#flowDetail').style.display='block';
+ }catch(e){alert('获取详情失败: '+e.message)}
+}
+
+async function toggleBookmark(id,bookmarked){
+ await fetch('/api/flows/'+id+'/bookmark',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({bookmarked})});
+ loadFlows();
+}
+
+async function exportFlows(){
+ try{
+ const r=await fetch('/api/flows/export',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({format:'json'})});
+ const d=await r.json();
+ const blob=new Blob([d.content],{type:'application/json'});
+ const url=URL.createObjectURL(blob);
+ const a=document.createElement('a');
+ a.href=url;
+ a.download='flows_'+new Date().toISOString().slice(0,10)+'.json';
+ a.click();
+ }catch(e){alert('导出失败: '+e.message)}
+}
+'''
+
+JS_SETTINGS = '''
+// 设置页面
+// 历史消息管理(简化版,自动管理)
+
+async function loadHistoryConfig(){
+ try{
+ const r=await fetch('/api/settings/history');
+ const d=await r.json();
+ $('#maxRetries').value=d.max_retries||3;
+ $('#summaryCacheMaxAge').value=d.summary_cache_max_age_seconds||300;
+ $('#addWarningHeader').checked=d.add_warning_header!==false;
+ }catch(e){console.error('加载配置失败:',e)}
+}
+
+async function updateHistoryConfig(){
+ const config={
+ strategies:['error_retry'], // 固定使用错误重试策略
+ max_retries:parseInt($('#maxRetries').value)||3,
+ summary_cache_enabled:true,
+ summary_cache_max_age_seconds:parseInt($('#summaryCacheMaxAge').value)||300,
+ add_warning_header:$('#addWarningHeader').checked
+ };
+ try{
+ await fetch('/api/settings/history',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(config)});
+ }catch(e){console.error('保存配置失败:',e)}
+}
+
+// 刷新配置
+async function loadRefreshConfig(){
+ try{
+ const r=await fetch('/api/refresh/config');
+ const d=await r.json();
+ if(d.ok && d.config){
+ const c=d.config;
+ $('#refreshMaxRetries').value=c.max_retries||3;
+ $('#refreshConcurrency').value=c.concurrency||3;
+ $('#refreshAutoInterval').value=c.auto_refresh_interval||60;
+ $('#refreshRetryDelay').value=c.retry_base_delay||1.0;
+ $('#refreshBeforeExpiry').value=c.token_refresh_before_expiry||300;
+ // 更新状态显示
+ $('#refreshConfigStatus').innerHTML=`
+
+ 最大重试: ${c.max_retries||3} 次
+ 并发数: ${c.concurrency||3}
+ 自动刷新间隔: ${c.auto_refresh_interval||60} 秒
+ 提前刷新: ${c.token_refresh_before_expiry||300} 秒
+
+ `;
+ }
+ }catch(e){console.error('加载刷新配置失败:',e)}
+}
+
+async function saveRefreshConfig(){
+ const config={
+ max_retries:parseInt($('#refreshMaxRetries').value)||3,
+ concurrency:parseInt($('#refreshConcurrency').value)||3,
+ auto_refresh_interval:parseInt($('#refreshAutoInterval').value)||60,
+ retry_base_delay:parseFloat($('#refreshRetryDelay').value)||1.0,
+ token_refresh_before_expiry:parseInt($('#refreshBeforeExpiry').value)||300
+ };
+ try{
+ const r=await fetch('/api/refresh/config',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(config)});
+ const d=await r.json();
+ if(d.ok){
+ Toast.success('刷新配置保存成功');
+ loadRefreshConfig();
+ }else{
+ Toast.error(d.error||'保存失败');
+ }
+ }catch(e){
+ console.error('保存刷新配置失败:',e);
+ Toast.error('保存刷新配置失败');
+ }
+}
+
+// 限速配置
+async function loadRateLimitConfig(){
+ try{
+ const r=await fetch('/api/settings/rate-limit');
+ const d=await r.json();
+ $('#rateLimitEnabled').checked=d.enabled;
+ $('#minRequestInterval').value=d.min_request_interval||0.5;
+ $('#maxRequestsPerMinute').value=d.max_requests_per_minute||60;
+ $('#globalMaxRequestsPerMinute').value=d.global_max_requests_per_minute||120;
+ // 更新统计
+ const stats=d.stats||{};
+ $('#rateLimitStats').innerHTML=`
+
+ 状态: ${d.enabled?'已启用':'已禁用'}
+ 全局 RPM: ${stats.global_rpm||0}
+ 429 冷却: 自动 5 分钟
+
+ `;
+ }catch(e){console.error('加载限速配置失败:',e)}
+}
+
+async function updateRateLimitConfig(){
+ const config={
+ enabled:$('#rateLimitEnabled').checked,
+ min_request_interval:parseFloat($('#minRequestInterval').value)||0.5,
+ max_requests_per_minute:parseInt($('#maxRequestsPerMinute').value)||60,
+ global_max_requests_per_minute:parseInt($('#globalMaxRequestsPerMinute').value)||120
+ };
+ try{
+ await fetch('/api/settings/rate-limit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(config)});
+ loadRateLimitConfig();
+ }catch(e){console.error('保存限速配置失败:',e)}
+}
+
+// 还原默认配置函数
+async function resetRefreshConfig(){
+ if(!confirm('确定要还原刷新配置为默认值吗?')) return;
+ const defaultConfig={
+ max_retries:3,
+ concurrency:3,
+ auto_refresh_interval:60,
+ retry_base_delay:1.0,
+ token_refresh_before_expiry:300
+ };
+ try{
+ const r=await fetch('/api/refresh/config',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(defaultConfig)});
+ const d=await r.json();
+ if(d.ok){
+ Toast.success('已还原为默认配置');
+ loadRefreshConfig();
+ }else{
+ Toast.error(d.error||'还原失败');
+ }
+ }catch(e){
+ Toast.error('还原配置失败');
+ }
+}
+
+async function resetRateLimitConfig(){
+ if(!confirm('确定要还原限速配置为默认值吗?')) return;
+ const defaultConfig={
+ enabled:false,
+ min_request_interval:0.5,
+ max_requests_per_minute:60,
+ global_max_requests_per_minute:120
+ };
+ try{
+ await fetch('/api/settings/rate-limit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(defaultConfig)});
+ Toast.success('已还原为默认配置');
+ loadRateLimitConfig();
+ }catch(e){
+ Toast.error('还原配置失败');
+ }
+}
+
+async function resetHistoryConfig(){
+ if(!confirm('确定要还原历史消息配置为默认值吗?')) return;
+ const defaultConfig={
+ strategies:['error_retry'],
+ max_retries:3,
+ summary_cache_enabled:true,
+ summary_cache_max_age_seconds:300,
+ add_warning_header:true
+ };
+ try{
+ await fetch('/api/settings/history',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(defaultConfig)});
+ Toast.success('已还原为默认配置');
+ loadHistoryConfig();
+ }catch(e){
+ Toast.error('还原配置失败');
+ }
+}
+
+// 页面加载时加载设置
+loadHistoryConfig();
+loadRateLimitConfig();
+loadRefreshConfig();
+'''
+
+# ==================== UI 组件库 JavaScript ====================
+JS_UI_COMPONENTS = '''
+// ==================== Modal 模态框组件 ====================
+class Modal {
+ constructor(options = {}) {
+ this.title = options.title || '';
+ this.content = options.content || '';
+ this.type = options.type || 'default';
+ this.confirmText = options.confirmText || '确认';
+ this.cancelText = options.cancelText || '取消';
+ this.onConfirm = options.onConfirm;
+ this.onCancel = options.onCancel;
+ this.showCancel = options.showCancel !== false;
+ this.element = null;
+ }
+
+ show() {
+ const overlay = document.createElement('div');
+ overlay.className = 'modal-overlay';
+ overlay.innerHTML = `
+
+ `;
+ overlay.modal = this;
+ this.element = overlay;
+ document.body.appendChild(overlay);
+
+ // 键盘事件
+ this.keyHandler = (e) => {
+ if (e.key === 'Escape') this.hide();
+ if (e.key === 'Enter' && !e.target.matches('textarea')) this.confirm();
+ };
+ document.addEventListener('keydown', this.keyHandler);
+
+ // 点击遮罩关闭
+ overlay.addEventListener('click', (e) => {
+ if (e.target === overlay) this.hide();
+ });
+
+ requestAnimationFrame(() => overlay.classList.add('active'));
+ return this;
+ }
+
+ hide() {
+ if (this.element) {
+ this.element.classList.remove('active');
+ document.removeEventListener('keydown', this.keyHandler);
+ setTimeout(() => this.element.remove(), 200);
+ }
+ }
+
+ confirm() {
+ if (this.onConfirm) this.onConfirm();
+ this.hide();
+ }
+
+ cancel() {
+ if (this.onCancel) this.onCancel();
+ this.hide();
+ }
+
+ setLoading(loading) {
+ const btn = this.element?.querySelector('.modal-footer button:last-child');
+ if (btn) {
+ btn.disabled = loading;
+ btn.textContent = loading ? '处理中...' : this.confirmText;
+ }
+ }
+
+ static confirm(title, message, onConfirm) {
+ return new Modal({ title, content: `${message}
`, onConfirm }).show();
+ }
+
+ static alert(title, message) {
+ return new Modal({ title, content: `${message}
`, showCancel: false }).show();
+ }
+
+ static danger(title, message, onConfirm) {
+ return new Modal({ title, content: `${message}
`, type: 'danger', onConfirm, confirmText: '删除' }).show();
+ }
+}
+
+// ==================== Toast 通知组件 ====================
+class Toast {
+ static container = null;
+
+ static getContainer() {
+ if (!this.container) {
+ this.container = document.createElement('div');
+ this.container.className = 'toast-container';
+ document.body.appendChild(this.container);
+ }
+ return this.container;
+ }
+
+ static show(message, type = 'info', duration = 3000) {
+ const toast = document.createElement('div');
+ toast.className = `toast ${type}`;
+ toast.innerHTML = `
+ ${message}
+
+ `;
+ this.getContainer().appendChild(toast);
+
+ if (duration > 0) {
+ setTimeout(() => toast.remove(), duration);
+ }
+ return toast;
+ }
+
+ static success(message, duration) { return this.show(message, 'success', duration); }
+ static error(message, duration) { return this.show(message, 'error', duration); }
+ static warning(message, duration) { return this.show(message, 'warning', duration); }
+ static info(message, duration) { return this.show(message, 'info', duration); }
+}
+
+// ==================== Dropdown 下拉菜单组件 ====================
+class Dropdown {
+ constructor(trigger, items) {
+ this.trigger = trigger;
+ this.items = items;
+ this.element = null;
+ this.init();
+ }
+
+ init() {
+ const wrapper = document.createElement('div');
+ wrapper.className = 'dropdown';
+
+ this.trigger.parentNode.insertBefore(wrapper, this.trigger);
+ wrapper.appendChild(this.trigger);
+
+ const menu = document.createElement('div');
+ menu.className = 'dropdown-menu';
+ menu.innerHTML = this.items.map(item => {
+ if (item.divider) return '';
+ return `${item.icon || ''}${item.label}
`;
+ }).join('');
+ wrapper.appendChild(menu);
+
+ this.element = wrapper;
+
+ this.trigger.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this.toggle();
+ });
+
+ menu.addEventListener('click', (e) => {
+ const item = e.target.closest('.dropdown-item');
+ if (item) {
+ const action = item.dataset.action;
+ const itemConfig = this.items.find(i => i.action === action);
+ if (itemConfig?.onClick) itemConfig.onClick();
+ this.close();
+ }
+ });
+
+ document.addEventListener('click', () => this.close());
+ }
+
+ toggle() {
+ this.element.classList.toggle('open');
+ }
+
+ close() {
+ this.element.classList.remove('open');
+ }
+}
+
+// ==================== 进度条渲染函数 ====================
+function renderProgressBar(value, max, options = {}) {
+ const percent = max > 0 ? (value / max * 100) : 0;
+ const color = options.color || (percent > 80 ? 'error' : percent > 60 ? 'warning' : 'success');
+ const size = options.size || '';
+ const showLabel = options.showLabel !== false;
+
+ return `
+
+ ${showLabel ? `${options.leftLabel || ''}${options.rightLabel || Math.round(percent) + '%'}
` : ''}
+ `;
+}
+
+// ==================== 账号卡片渲染函数 ====================
+function renderAccountCard(account) {
+ const quota = account.quota;
+ const isPriority = account.is_priority;
+ const isActive = account.is_active;
+
+ let statusBadge = '';
+ if (!account.enabled) statusBadge = '禁用';
+ else if (account.cooldown_remaining > 0) statusBadge = `冷却 ${account.cooldown_remaining}s`;
+ else if (account.available) statusBadge = '正常';
+ else statusBadge = '不可用';
+
+ let quotaSection = '';
+ if (quota && !quota.error) {
+ const usedPercent = quota.usage_limit > 0 ? (quota.current_usage / quota.usage_limit * 100) : 0;
+ quotaSection = `
+
+
+ ${renderProgressBar(quota.current_usage, quota.usage_limit, {
+ color: quota.is_low_balance ? 'error' : usedPercent > 60 ? 'warning' : 'success',
+ rightLabel: usedPercent.toFixed(1) + '%'
+ })}
+
+ ${quota.free_trial_limit > 0 ? `试用: ${quota.free_trial_usage.toFixed(0)}/${quota.free_trial_limit.toFixed(0)}` : ''}
+ ${quota.bonus_limit > 0 ? `奖励: ${quota.bonus_usage.toFixed(0)}/${quota.bonus_limit.toFixed(0)} (${quota.active_bonuses || 0}个)` : ''}
+ 更新: ${quota.updated_at || '未知'}
+
+ ${quota.reset_date_text || quota.free_trial_expiry ? `
+
+ ${quota.reset_date_text ? `🔄 重置: ${quota.reset_date_text}` : ''}
+ ${quota.free_trial_expiry ? `🎁 试用过期: ${quota.trial_expiry_text}` : ''}
+
+ ` : ''}
+
+ `;
+ } else if (quota?.error) {
+ quotaSection = `额度获取失败: ${quota.error}
`;
+ }
+
+ return `
+
+
+ ${quotaSection}
+
+
${account.request_count}
请求数
+
${account.error_rate || '0%'}
错误率
+
${account.last_used_ago || '-'}
最后使用
+
${account.auth_method || '-'}
认证方式
+
+
+ `;
+}
+
+// ==================== 汇总面板渲染函数 ====================
+function renderSummaryPanel(summary) {
+ const strategyLabel = {
+ lowest_balance: '剩余额度最少优先',
+ round_robin: '轮询',
+ least_requests: '请求最少优先',
+ random: '随机'
+ }[summary.strategy] || summary.strategy;
+
+ return `
+
+
+
${summary.total_accounts}
总账号
+
${summary.available_accounts}
可用
+
${summary.cooldown_accounts}
冷却中
+
${summary.unhealthy_accounts + summary.disabled_accounts}
不可用
+
+
+
+ ${renderProgressBar(summary.total_usage, summary.total_limit, {
+ size: 'large',
+ leftLabel: `已用 ${summary.total_usage.toFixed(0)}`,
+ rightLabel: `总计 ${summary.total_limit.toFixed(0)}`
+ })}
+
+
+ 选择策略: ${strategyLabel}
+ 优先账号: ${summary.priority_accounts.length > 0 ? summary.priority_accounts.join(', ') : '无'}
+ 最后刷新: ${summary.last_refresh || '未刷新'}
+
+
+
+
+
+
+ `;
+}
+
+// ==================== 账号操作菜单 ====================
+let currentAccountMenu = null;
+
+function showAccountMenu(accountId, btn) {
+ if (currentAccountMenu) {
+ currentAccountMenu.remove();
+ currentAccountMenu = null;
+ }
+
+ const menu = document.createElement('div');
+ menu.className = 'dropdown-menu';
+ menu.style.cssText = 'display:block;position:absolute;z-index:100;';
+ menu.innerHTML = `
+ 🔄 刷新额度
+ ⭐ 设为优先
+ 🔒 启用/禁用
+
+ 🗑️ 删除账号
+ `;
+
+ const rect = btn.getBoundingClientRect();
+ menu.style.top = (rect.bottom + window.scrollY) + 'px';
+ menu.style.left = (rect.left + window.scrollX - 100) + 'px';
+
+ document.body.appendChild(menu);
+ currentAccountMenu = menu;
+
+ setTimeout(() => {
+ document.addEventListener('click', function closeMenu() {
+ if (currentAccountMenu) {
+ currentAccountMenu.remove();
+ currentAccountMenu = null;
+ }
+ document.removeEventListener('click', closeMenu);
+ }, { once: true });
+ }, 0);
+}
+
+// ==================== 额度管理 API 调用 ====================
+async function loadAccountsEnhanced() {
+ showLoading('#accountsGrid', '加载账号列表...');
+ try {
+ const r = await fetchWithRetry('/api/accounts/status');
+ const d = await r.json();
+ if (d.ok) {
+ $('#accountsSummaryCompact').innerHTML = renderSummaryCompact(d.summary);
+ $('#accountsGrid').innerHTML = d.accounts.map(renderAccountCardCompact).join('');
+ } else {
+ $('#accountsGrid').innerHTML = `加载失败: ${d.error || '未知错误'}
`;
+ }
+ } catch(e) {
+ $('#accountsGrid').innerHTML = `网络错误,点击重试
`;
+ Toast.error('加载账号列表失败');
+ }
+}
+
+// ==================== 紧凑汇总面板 ====================
+function renderSummaryCompact(summary) {
+ const usedPercent = summary.total_limit > 0 ? (summary.total_usage / summary.total_limit * 100) : 0;
+ const barColor = usedPercent > 80 ? 'var(--error)' : usedPercent > 60 ? 'var(--warn)' : 'var(--success)';
+ return `
+
+
+ ${summary.total_accounts}
+ 总账号
+
+
+ ${summary.available_accounts}
+ 可用
+
+
+ ${summary.cooldown_accounts}
+ 冷却
+
+
+
+
+ 总额度
+ ${summary.total_balance.toFixed(0)} / ${summary.total_limit.toFixed(0)}
+
+
+
+
+ ${summary.last_refresh || '未刷新'}
+
+
+ `;
+}
+
+// ==================== 紧凑账号卡片 ====================
+function renderAccountCardCompact(account) {
+ const quota = account.quota;
+ const isPriority = account.is_priority;
+ const isLowBalance = quota?.is_low_balance;
+ const isExhausted = quota?.is_exhausted || (quota && quota.balance <= 0); // 额度耗尽
+ const isSuspended = quota?.is_suspended; // 账号被封禁
+ const isUnavailable = !account.available;
+
+ let cardClass = 'account-card-compact';
+ if (isPriority) cardClass += ' priority';
+ if (isSuspended) cardClass += ' suspended'; // 封禁状态
+ else if (isExhausted) cardClass += ' exhausted'; // 无额度状态
+ else if (isLowBalance) cardClass += ' low-balance';
+ if (isUnavailable) cardClass += ' unavailable';
+
+ // 状态徽章
+ let statusBadges = '';
+ if (!account.enabled) statusBadges += '禁用';
+ else if (account.cooldown_remaining > 0) statusBadges += `冷却`;
+ else if (account.available) statusBadges += '正常';
+ else statusBadges += '异常';
+
+ if (isPriority) statusBadges += `#${account.priority_order}`;
+ // Provider 徽章 (Google/Github)
+ if (account.provider) {
+ const providerIcon = account.provider === 'Google' ? '🔵' : account.provider === 'Github' ? '⚫' : '';
+ statusBadges += `${providerIcon}${account.provider}`;
+ }
+ // 状态徽章:封禁(红色)> 无额度(红色)> 低额度(黄色)
+ if (isSuspended) statusBadges += '已封禁';
+ else if (isExhausted) statusBadges += '无额度';
+ else if (isLowBalance) statusBadges += '低额度';
+
+ // Token 过期状态徽章
+ if (account.token_expired) statusBadges += 'Token过期';
+ else if (account.token_expiring_soon) statusBadges += 'Token即将过期';
+
+ // 额度条 - 根据状态显示不同颜色
+ let quotaBar = '';
+ if (quota && !quota.error) {
+ const usedPercent = quota.usage_limit > 0 ? (quota.current_usage / quota.usage_limit * 100) : 0;
+ // 颜色逻辑:无额度(红色) > 低额度(黄色) > 正常(绿色)
+ let barColor = 'var(--success)';
+ if (isExhausted) barColor = 'var(--error)';
+ else if (isLowBalance) barColor = 'var(--warn)';
+ else if (usedPercent > 60) barColor = 'var(--warn)';
+
+ quotaBar = `
+
+
+
+ ${quota.current_usage.toFixed(1)} / ${quota.usage_limit.toFixed(1)}
+ ${usedPercent.toFixed(0)}%
+
+ ${quota.reset_date_text || quota.trial_expiry_text ? `
+
+ ${quota.reset_date_text ? `🔄 ${quota.reset_date_text}` : ''}
+ ${quota.trial_expiry_text ? `🎁 ${quota.trial_expiry_text}` : ''}
+
+ ` : ''}
+
+ `;
+ } else if (quota?.error) {
+ // 额度获取失败时显示重试按钮
+ // 如果是封禁错误,显示封禁状态
+ const errorMsg = quota.error;
+ const isSuspendedError = errorMsg && (
+ errorMsg.toLowerCase().includes('temporarily_suspended') ||
+ errorMsg.toLowerCase().includes('suspended') ||
+ errorMsg.toLowerCase().includes('accountsuspendedexception')
+ );
+
+ if (isSuspendedError) {
+ quotaBar = `
+
+ 账号已封禁
+
+
+ `;
+ } else {
+ quotaBar = `
+
+ 额度获取失败: ${quota.error}
+
+
+ `;
+ }
+ } else {
+ // 未查询额度时显示查询按钮
+ quotaBar = `
+
+ 额度未查询
+
+
+ `;
+ }
+
+ // Token 过期时间显示
+ let tokenExpireInfo = '';
+ if (account.token_expires_at) {
+ // expires_at 可能是 ISO 字符串或时间戳
+ let expireDate;
+ if (typeof account.token_expires_at === 'string') {
+ // ISO 格式字符串
+ expireDate = new Date(account.token_expires_at);
+ } else if (account.token_expires_at > 1000000000000) {
+ // 毫秒时间戳
+ expireDate = new Date(account.token_expires_at);
+ } else {
+ // 秒时间戳
+ expireDate = new Date(account.token_expires_at * 1000);
+ }
+
+ const now = new Date();
+ const diffMs = expireDate - now;
+
+ // 检查是否为有效日期
+ if (!isNaN(expireDate.getTime()) && !isNaN(diffMs)) {
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
+ const diffDays = Math.floor(diffHours / 24);
+
+ let expireText = '';
+ if (diffMs < 0) {
+ expireText = '已过期';
+ } else if (diffDays > 0) {
+ expireText = `${diffDays}天`;
+ } else if (diffHours > 0) {
+ expireText = `${diffHours}时`;
+ } else {
+ const diffMins = Math.floor(diffMs / (1000 * 60));
+ expireText = diffMins > 0 ? `${diffMins}分` : '即将过期';
+ }
+ tokenExpireInfo = `Token ${expireText}`;
+ }
+ }
+
+ return `
+
+
+
+
${account.name}
+
${account.id}
+
+
${statusBadges}
+
+ ${quotaBar}
+
+ 请求: ${account.request_count}
+ 错误: ${account.error_count}
+ ${tokenExpireInfo}
+
+
+
+
+
+
+
+
+
+
+ `;
+}
+
+// ==================== 导入导出菜单 ====================
+let importExportMenu = null;
+
+function showImportExportMenu(btn) {
+ if (importExportMenu) {
+ importExportMenu.remove();
+ importExportMenu = null;
+ return;
+ }
+
+ const menu = document.createElement('div');
+ menu.className = 'dropdown-menu';
+ menu.style.cssText = 'display:block;position:absolute;z-index:100;min-width:140px;';
+ menu.innerHTML = `
+ 📤 导出账号
+ 📥 导入账号
+
+ 🔄 刷新 Token
+ `;
+
+ const rect = btn.getBoundingClientRect();
+ menu.style.top = (rect.bottom + window.scrollY + 4) + 'px';
+ menu.style.left = (rect.left + window.scrollX) + 'px';
+
+ document.body.appendChild(menu);
+ importExportMenu = menu;
+
+ setTimeout(() => {
+ document.addEventListener('click', function closeMenu(e) {
+ if (importExportMenu && !importExportMenu.contains(e.target)) {
+ importExportMenu.remove();
+ importExportMenu = null;
+ }
+ document.removeEventListener('click', closeMenu);
+ }, { once: true });
+ }, 10);
+}
+
+async function refreshAllQuotas() {
+ // 检查是否正在刷新中
+ if (GlobalProgressBar.isRefreshing) {
+ Toast.warning('正在刷新中,请稍候...');
+ return;
+ }
+
+ try {
+ // 先获取账号数量用于显示
+ const statusR = await fetch('/api/accounts/status');
+ const statusD = await statusR.json();
+ const total = statusD.ok ? statusD.accounts?.length || 0 : 0;
+
+ // 显示进度条
+ GlobalProgressBar.show(total);
+
+ // 调用新的批量刷新 API
+ const r = await fetch('/api/refresh/all', { method: 'POST' });
+ const d = await r.json();
+
+ if (d.ok) {
+ // 开始轮询进度
+ GlobalProgressBar.startPolling();
+ } else {
+ GlobalProgressBar.hide();
+ Toast.error('启动刷新失败: ' + (d.error || '未知错误'));
+ }
+ } catch(e) {
+ GlobalProgressBar.hide();
+ Toast.error('刷新失败: ' + e.message);
+ }
+}
+
+async function refreshAccountQuota(accountId) {
+ Toast.info('正在刷新额度...');
+ try {
+ const r = await fetch(`/api/accounts/${accountId}/refresh-quota`, { method: 'POST' });
+ const d = await r.json();
+ if (d.ok) {
+ Toast.success('额度刷新成功');
+ loadAccounts();
+ loadAccountsEnhanced();
+ } else {
+ Toast.error(d.error || '刷新失败');
+ }
+ } catch(e) {
+ Toast.error('刷新失败: ' + e.message);
+ }
+}
+
+// ==================== 测试账号 Token ====================
+async function testAccountToken(accountId) {
+ // 显示测试中的模态框
+ const modal = document.createElement('div');
+ modal.className = 'modal';
+ modal.id = 'testTokenModal';
+ modal.innerHTML = `
+
+ `;
+ document.body.appendChild(modal);
+ modal.style.display = 'flex';
+
+ try {
+ const r = await fetch('/api/accounts/' + accountId + '/test');
+ const d = await r.json();
+
+ const resultDiv = document.getElementById('testTokenResult');
+ if (!resultDiv) return;
+
+ if (d.ok) {
+ // 测试通过
+ let testsHtml = '';
+ for (const [key, test] of Object.entries(d.tests || {})) {
+ const icon = test.passed ? '✅' : '❌';
+ const color = test.passed ? 'var(--success)' : 'var(--error)';
+ testsHtml += `
+
+
${icon}
+
+
${test.message}
+ ${test.suggestion ? `
${test.suggestion}
` : ''}
+ ${test.latency_ms ? `
延迟: ${test.latency_ms.toFixed(0)}ms
` : ''}
+ ${test.email ? `
邮箱: ${test.email}
` : ''}
+
+
+ `;
+ }
+
+ resultDiv.innerHTML = `
+
+
✅
+
Token 有效
+
${d.summary}
+
+
+ ${testsHtml}
+
+ `;
+ } else {
+ // 测试失败
+ let testsHtml = '';
+ for (const [key, test] of Object.entries(d.tests || {})) {
+ const icon = test.passed ? '✅' : '❌';
+ testsHtml += `
+
+
${icon}
+
+
${test.message}
+ ${test.suggestion ? `
💡 ${test.suggestion}
` : ''}
+
+
+ `;
+ }
+
+ resultDiv.innerHTML = `
+
+
❌
+
Token 无效
+
${d.summary || d.error || '测试失败'}
+
+ ${Object.keys(d.tests || {}).length > 0 ? `
+
+ ${testsHtml}
+
+ ` : ''}
+
+
+
+ `;
+ }
+ } catch(e) {
+ const resultDiv = document.getElementById('testTokenResult');
+ if (resultDiv) {
+ resultDiv.innerHTML = `
+
+
⚠️
+
测试失败
+
${e.message}
+
+ `;
+ }
+ }
+}
+
+function closeTestTokenModal() {
+ const modal = document.getElementById('testTokenModal');
+ if (modal) modal.remove();
+}
+
+// ==================== 单账号额度查询 (任务 19.2) ====================
+async function refreshSingleAccountQuota(accountId) {
+ // 获取按钮元素,显示加载状态
+ const safeId = accountId.replace(/[^a-zA-Z0-9]/g, '_');
+ const btn = document.getElementById('quota-btn-' + safeId);
+ const card = document.getElementById('account-card-' + safeId);
+
+ if (btn) {
+ btn.disabled = true;
+ btn.dataset.originalText = btn.textContent;
+ btn.textContent = '查询中...';
+ }
+
+ try {
+ const r = await fetch(`/api/accounts/${accountId}/refresh-quota`, { method: 'POST' });
+ const d = await r.json();
+
+ if (d.ok) {
+ Toast.success('额度查询成功');
+ // 刷新整个账号列表以更新显示
+ loadAccounts();
+ loadAccountsEnhanced();
+ } else {
+ // 失败时显示错误信息和重试按钮
+ Toast.error(d.error || '额度查询失败');
+ if (btn) {
+ btn.textContent = '重试';
+ btn.disabled = false;
+ btn.classList.add('error-state');
+ }
+ // 在卡片上显示错误状态
+ if (card) {
+ const quotaDiv = card.querySelector('.account-card-quota');
+ if (quotaDiv) {
+ quotaDiv.innerHTML = `
+ 查询失败: ${d.error || '未知错误'}
+
+ `;
+ }
+ }
+ }
+ } catch(e) {
+ Toast.error('网络错误: ' + e.message);
+ if (btn) {
+ btn.textContent = '重试';
+ btn.disabled = false;
+ }
+ } finally {
+ // 恢复按钮状态(如果没有错误)
+ if (btn && !btn.classList.contains('error-state')) {
+ btn.disabled = false;
+ if (btn.dataset.originalText) {
+ btn.textContent = btn.dataset.originalText;
+ }
+ }
+ if (btn) {
+ btn.classList.remove('error-state');
+ }
+ }
+}
+
+// ==================== 单账号 Token 刷新 (任务 19.2) ====================
+async function refreshSingleAccountToken(accountId) {
+ // 获取按钮元素,显示加载状态
+ const safeId = accountId.replace(/[^a-zA-Z0-9]/g, '_');
+ const btn = document.getElementById('token-btn-' + safeId);
+
+ if (btn) {
+ btn.disabled = true;
+ btn.dataset.originalText = btn.textContent;
+ btn.textContent = '刷新中...';
+ }
+
+ try {
+ const r = await fetch(`/api/accounts/${accountId}/refresh`, { method: 'POST' });
+ const d = await r.json();
+
+ if (d.ok) {
+ Toast.success('Token 刷新成功');
+ // 刷新整个账号列表以更新显示
+ loadAccounts();
+ loadAccountsEnhanced();
+ } else {
+ // 失败时显示错误信息
+ Toast.error(d.message || d.error || 'Token 刷新失败');
+ if (btn) {
+ btn.textContent = '重试';
+ btn.disabled = false;
+ }
+ }
+ } catch(e) {
+ Toast.error('网络错误: ' + e.message);
+ if (btn) {
+ btn.textContent = '重试';
+ btn.disabled = false;
+ }
+ } finally {
+ // 恢复按钮状态
+ if (btn && btn.textContent !== '重试') {
+ btn.disabled = false;
+ if (btn.dataset.originalText) {
+ btn.textContent = btn.dataset.originalText;
+ }
+ }
+ }
+}
+
+async function togglePriority(accountId) {
+ try {
+ // 先检查是否已是优先账号
+ const r1 = await fetch('/api/priority');
+ const d1 = await r1.json();
+ const isPriority = d1.priority_accounts?.some(a => a.id === accountId);
+
+ if (isPriority) {
+ const r = await fetch(`/api/priority/${accountId}`, { method: 'DELETE' });
+ const d = await r.json();
+ Toast.show(d.message, d.ok ? 'success' : 'error');
+ } else {
+ const r = await fetch(`/api/priority/${accountId}`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}' });
+ const d = await r.json();
+ Toast.show(d.message, d.ok ? 'success' : 'error');
+ }
+ loadAccounts();
+ loadAccountsEnhanced();
+ } catch(e) {
+ Toast.error('操作失败: ' + e.message);
+ }
+}
+
+function confirmDeleteAccount(accountId) {
+ Modal.danger('删除账号', `确定要删除账号 ${accountId} 吗?此操作不可恢复。`, async () => {
+ try {
+ const r = await fetch(`/api/accounts/${accountId}`, { method: 'DELETE' });
+ const d = await r.json();
+ if (d.ok) {
+ Toast.success('账号已删除');
+ loadAccounts();
+ loadAccountsEnhanced();
+ } else {
+ Toast.error('删除失败');
+ }
+ } catch(e) {
+ Toast.error('删除失败: ' + e.message);
+ }
+ });
+}
+
+// ==================== 账号编辑功能 ====================
+function showEditAccountModal(accountId, currentName) {
+ const modal = new Modal({
+ title: '编辑账号',
+ content: `
+
+ `,
+ confirmText: '保存',
+ onConfirm: async () => {
+ const name = document.getElementById('editAccountName').value.trim();
+ const provider = document.getElementById('editAccountProvider').value;
+ const region = document.getElementById('editAccountRegion').value.trim();
+
+ const updateData = {};
+ if (name) updateData.name = name;
+ if (provider) updateData.provider = provider;
+ if (region) updateData.region = region;
+
+ try {
+ const r = await fetch(`/api/accounts/${accountId}`, {
+ method: 'PUT',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify(updateData)
+ });
+ const d = await r.json();
+ if (d.ok) {
+ Toast.success(d.message || '账号已更新');
+ loadAccounts();
+ loadAccountsEnhanced();
+ } else {
+ Toast.error(d.error || '更新失败');
+ }
+ } catch(e) {
+ Toast.error('更新失败: ' + e.message);
+ }
+ }
+ });
+ modal.show();
+
+ // 加载当前账号信息填充表单
+ loadAccountForEdit(accountId);
+}
+
+async function refreshTokenInModal(accountId) {
+ const btn = document.getElementById('refreshTokenBtn');
+ if (btn) {
+ btn.disabled = true;
+ btn.textContent = '刷新中...';
+ }
+
+ try {
+ const r = await fetch(`/api/accounts/${accountId}/refresh`, { method: 'POST' });
+ const d = await r.json();
+ if (d.ok) {
+ Toast.success('Token 刷新成功');
+ // 重新加载账号信息
+ await loadAccountForEdit(accountId);
+ loadAccounts();
+ loadAccountsEnhanced();
+ } else {
+ Toast.error(d.message || d.error || 'Token 刷新失败');
+ }
+ } catch(e) {
+ Toast.error('刷新失败: ' + e.message);
+ } finally {
+ if (btn) {
+ btn.disabled = false;
+ btn.textContent = '🔄 刷新 Token';
+ }
+ }
+}
+
+function copyToClipboard(text, label) {
+ navigator.clipboard.writeText(text).then(() => {
+ Toast.success(label + ' 已复制');
+ }).catch(() => {
+ // 降级方案
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand('copy');
+ document.body.removeChild(ta);
+ Toast.success(label + ' 已复制');
+ });
+}
+
+function renderTokenField(label, value, fieldId) {
+ if (!value) return '';
+ const shortValue = value.length > 50 ? value.substring(0, 50) + '...' : value;
+ return `
+
+
+ ${label}:
+
+
+
${shortValue}
+
+ `;
+}
+
+async function loadAccountForEdit(accountId) {
+ try {
+ const r = await fetch(`/api/accounts/${accountId}`);
+ const d = await r.json();
+
+ const providerSelect = document.getElementById('editAccountProvider');
+ const regionInput = document.getElementById('editAccountRegion');
+ const tokenSection = document.getElementById('tokenInfoSection');
+ const tokenDetails = document.getElementById('tokenDetails');
+
+ if (d.credentials) {
+ if (providerSelect && d.credentials.provider) {
+ providerSelect.value = d.credentials.provider;
+ }
+ if (regionInput && d.credentials.region) {
+ regionInput.value = d.credentials.region;
+ }
+
+ // 显示 Token 信息
+ if (tokenSection && tokenDetails) {
+ tokenSection.style.display = 'block';
+
+ let html = '';
+
+ // Access Token
+ if (d.credentials.access_token) {
+ html += renderTokenField('Access Token', d.credentials.access_token, 'field_access_token');
+ }
+
+ // Refresh Token
+ if (d.credentials.refresh_token) {
+ html += renderTokenField('Refresh Token', d.credentials.refresh_token, 'field_refresh_token');
+ }
+
+ // Profile ARN
+ if (d.credentials.profile_arn) {
+ html += renderTokenField('Profile ARN', d.credentials.profile_arn, 'field_profile_arn');
+ }
+
+ // Client ID
+ if (d.credentials.client_id) {
+ html += renderTokenField('Client ID', d.credentials.client_id, 'field_client_id');
+ }
+
+ // 过期时间
+ if (d.credentials.expires_at) {
+ const expiresAt = new Date(d.credentials.expires_at);
+ const now = new Date();
+ const diffMs = expiresAt - now;
+ const diffMins = Math.floor(diffMs / 60000);
+ let expiryText = expiresAt.toLocaleString();
+ if (diffMs < 0) {
+ expiryText += ' (已过期)';
+ } else if (diffMins < 60) {
+ expiryText += ' (' + diffMins + '分钟后过期)';
+ } else {
+ expiryText += ' (' + Math.floor(diffMins/60) + '小时后过期)';
+ }
+ html += '过期时间: ' + expiryText + '
';
+ }
+
+ // Auth Method
+ if (d.credentials.auth_method) {
+ html += '认证方式: ' + d.credentials.auth_method + '
';
+ }
+
+ tokenDetails.innerHTML = html || '无 Token 信息';
+ }
+ }
+ } catch(e) {
+ console.error('加载账号信息失败:', e);
+ }
+}
+
+// ==================== 自动刷新功能 (任务 10.2) ====================
+let autoRefreshTimer = null;
+const AUTO_REFRESH_INTERVAL = 60000; // 60秒
+
+function startAutoRefresh() {
+ if (autoRefreshTimer) clearInterval(autoRefreshTimer);
+ autoRefreshTimer = setInterval(() => {
+ const accountsTab = document.querySelector('.tab[data-tab="accounts"]');
+ if (accountsTab && accountsTab.classList.contains('active')) {
+ loadAccounts();
+ loadAccountsEnhanced();
+ }
+ }, AUTO_REFRESH_INTERVAL);
+}
+
+function stopAutoRefresh() {
+ if (autoRefreshTimer) {
+ clearInterval(autoRefreshTimer);
+ autoRefreshTimer = null;
+ }
+}
+
+// 页面加载时启动自动刷新
+startAutoRefresh();
+
+// ==================== 加载状态指示器 (任务 10.1) ====================
+function showLoading(container, message = '加载中...') {
+ const el = typeof container === 'string' ? document.querySelector(container) : container;
+ if (el) {
+ el.innerHTML = ``;
+ }
+}
+
+// 添加旋转动画
+if (!document.querySelector('#spinKeyframes')) {
+ const style = document.createElement('style');
+ style.id = 'spinKeyframes';
+ style.textContent = '@keyframes spin { to { transform: rotate(360deg); } }';
+ document.head.appendChild(style);
+}
+
+// ==================== 表单验证 (任务 10.3) ====================
+function validateToken(token) {
+ if (!token || token.trim().length === 0) {
+ return { valid: false, error: 'Token 不能为空' };
+ }
+ if (token.trim().length < 20) {
+ return { valid: false, error: 'Token 格式不正确,长度过短' };
+ }
+ return { valid: true };
+}
+
+function validateAccountName(name) {
+ if (!name || name.trim().length === 0) {
+ return { valid: true, default: '手动添加账号' }; // 名称可选
+ }
+ if (name.length > 50) {
+ return { valid: false, error: '账号名称不能超过50个字符' };
+ }
+ return { valid: true };
+}
+
+// ==================== 网络错误处理 (任务 10.1) ====================
+async function fetchWithRetry(url, options = {}, retries = 2) {
+ for (let i = 0; i <= retries; i++) {
+ try {
+ const r = await fetch(url, options);
+ if (!r.ok && r.status >= 500 && i < retries) {
+ await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
+ continue;
+ }
+ return r;
+ } catch (e) {
+ if (i === retries) throw e;
+ await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
+ }
+ }
+}
+
+// ==================== 全局进度条组件 (任务 18.1) ====================
+const GlobalProgressBar = {
+ pollTimer: null,
+ isRefreshing: false,
+
+ // 显示进度条
+ show(total) {
+ this.isRefreshing = true;
+ const bar = $('#globalProgressBar');
+ if (bar) {
+ bar.classList.add('active');
+ // 重置显示
+ $('#globalProgressTitle').textContent = '正在刷新额度...';
+ $('#globalProgressCompleted').textContent = '0';
+ $('#globalProgressTotal').textContent = total || '0';
+ $('#globalProgressSuccess').textContent = '0';
+ $('#globalProgressFailed').textContent = '0';
+ $('#globalProgressFill').style.width = '0%';
+ $('#globalProgressFill').classList.remove('complete');
+ $('#globalProgressCurrent').textContent = '准备中...';
+ $('#globalProgressClose').style.display = 'none';
+ // 显示 spinner
+ const spinner = bar.querySelector('.spinner');
+ if (spinner) spinner.style.display = 'inline-block';
+ }
+ // 禁用刷新按钮
+ this.updateRefreshButton(true);
+ },
+
+ // 更新进度
+ update(progress) {
+ if (!progress) return;
+
+ const completed = progress.completed || 0;
+ const total = progress.total || 0;
+ const success = progress.success || 0;
+ const failed = progress.failed || 0;
+ const current = progress.current_account || '';
+ const isComplete = progress.status === 'completed' || progress.status === 'idle';
+
+ // 更新数字
+ $('#globalProgressCompleted').textContent = completed;
+ $('#globalProgressTotal').textContent = total;
+ $('#globalProgressSuccess').textContent = success;
+ $('#globalProgressFailed').textContent = failed;
+
+ // 更新进度条
+ const percent = total > 0 ? (completed / total * 100) : 0;
+ const fill = $('#globalProgressFill');
+ if (fill) {
+ fill.style.width = percent + '%';
+ if (isComplete) {
+ fill.classList.add('complete');
+ }
+ }
+
+ // 更新当前处理的账号
+ if (current) {
+ $('#globalProgressCurrent').textContent = '正在处理: ' + current;
+ } else if (isComplete) {
+ $('#globalProgressCurrent').textContent = `刷新完成: 成功 ${success} 个, 失败 ${failed} 个`;
+ }
+
+ // 完成后的处理
+ if (isComplete) {
+ this.isRefreshing = false;
+ $('#globalProgressTitle').textContent = '刷新完成';
+ $('#globalProgressClose').style.display = 'inline-block';
+ // 隐藏 spinner
+ const spinner = $('#globalProgressBar')?.querySelector('.spinner');
+ if (spinner) spinner.style.display = 'none';
+ // 恢复刷新按钮
+ this.updateRefreshButton(false);
+ // 刷新账号列表
+ loadAccounts();
+ loadAccountsEnhanced();
+ // 显示完成通知
+ if (failed > 0) {
+ Toast.warning(`刷新完成: 成功 ${success} 个, 失败 ${failed} 个`);
+ } else {
+ Toast.success(`刷新完成: 成功 ${success} 个`);
+ }
+ // 5秒后自动关闭进度条
+ setTimeout(() => this.hide(), 5000);
+ }
+ },
+
+ // 隐藏进度条
+ hide() {
+ const bar = $('#globalProgressBar');
+ if (bar) {
+ bar.classList.remove('active');
+ }
+ this.isRefreshing = false;
+ this.stopPolling();
+ this.updateRefreshButton(false);
+ },
+
+ // 开始轮询进度
+ startPolling() {
+ this.stopPolling();
+ this.pollTimer = setInterval(() => this.pollProgress(), 500);
+ },
+
+ // 停止轮询
+ stopPolling() {
+ if (this.pollTimer) {
+ clearInterval(this.pollTimer);
+ this.pollTimer = null;
+ }
+ },
+
+ // 轮询进度 API
+ async pollProgress() {
+ try {
+ const r = await fetch('/api/refresh/progress');
+ const d = await r.json();
+ if (d.ok) {
+ // 传入 progress 对象,如果没有则传入整个响应(兼容)
+ const progress = d.progress || d;
+ // 添加 status 字段用于判断完成状态
+ if (!d.is_refreshing && !progress.status) {
+ progress.status = 'completed';
+ }
+ this.update(progress);
+ // 如果完成则停止轮询
+ if (!d.is_refreshing || progress.status === 'completed' || progress.status === 'idle') {
+ this.stopPolling();
+ }
+ }
+ } catch (e) {
+ console.error('轮询进度失败:', e);
+ }
+ },
+
+ // 更新刷新按钮状态
+ updateRefreshButton(disabled) {
+ // 查找所有刷新额度按钮
+ const buttons = document.querySelectorAll('button');
+ buttons.forEach(btn => {
+ const text = btn.textContent;
+ const originalText = btn.dataset.originalText;
+ // 匹配"刷新额度"、"刷新全部额度"或已经变成"刷新中..."的按钮
+ if (text.includes('刷新额度') || text.includes('刷新全部额度') ||
+ text === '刷新中...' ||
+ (originalText && (originalText.includes('刷新额度') || originalText.includes('刷新全部额度')))) {
+ btn.disabled = disabled;
+ if (disabled) {
+ if (!btn.dataset.originalText) {
+ btn.dataset.originalText = text;
+ }
+ btn.textContent = '刷新中...';
+ } else if (btn.dataset.originalText) {
+ btn.textContent = btn.dataset.originalText;
+ delete btn.dataset.originalText;
+ }
+ }
+ });
+ }
+};
+
+// ==================== 进度轮询函数 (任务 18.2) ====================
+async function pollRefreshProgress() {
+ return GlobalProgressBar.pollProgress();
+}
+'''
+
+JS_SCRIPTS = JS_UTILS + JS_TABS + JS_STATUS + JS_DOCS + JS_STATS + JS_LOGS + JS_ACCOUNTS + JS_LOGIN + JS_FLOWS + JS_SETTINGS + JS_UI_COMPONENTS
+
+
+# ==================== 组装最终 HTML ====================
+HTML_PAGE = f'''
+
+
+
+
+Kiro API
+
+
+
+
+
+{HTML_BODY}
+
+
+
+
+'''
diff --git a/KiroProxy/legacy/kiro_proxy.py b/KiroProxy/legacy/kiro_proxy.py
new file mode 100644
index 0000000000000000000000000000000000000000..e53631997cefb9df187e74fdc93cd916c2269f8c
--- /dev/null
+++ b/KiroProxy/legacy/kiro_proxy.py
@@ -0,0 +1,313 @@
+#!/usr/bin/env python3
+"""
+Kiro API 反向代理服务器
+对外暴露 OpenAI 兼容接口,内部调用 Kiro/AWS Q API
+"""
+
+import json
+import uuid
+import os
+import httpx
+from fastapi import FastAPI, Request, HTTPException
+from fastapi.responses import StreamingResponse, JSONResponse
+import uvicorn
+from datetime import datetime
+from pathlib import Path
+import logging
+
+logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
+logger = logging.getLogger(__name__)
+
+app = FastAPI(title="Kiro API Proxy")
+
+# Kiro API 配置
+KIRO_API_URL = "https://q.us-east-1.amazonaws.com/generateAssistantResponse"
+TOKEN_PATH = Path.home() / ".aws/sso/cache/kiro-auth-token.json"
+MACHINE_ID = "fa41d5def91e29225c73f6ea8ee0941a87bd812aae5239e3dde72c3ba7603a26"
+
+def get_kiro_token() -> str:
+ """从本地文件读取 Kiro token"""
+ try:
+ with open(TOKEN_PATH) as f:
+ data = json.load(f)
+ return data.get("accessToken", "")
+ except Exception as e:
+ logger.error(f"读取 token 失败: {e}")
+ raise HTTPException(status_code=500, detail="无法读取 Kiro token")
+
+def build_kiro_headers(token: str) -> dict:
+ """构建 Kiro API 请求头"""
+ return {
+ "content-type": "application/json",
+ "x-amzn-codewhisperer-optout": "true",
+ "x-amzn-kiro-agent-mode": "vibe",
+ "x-amz-user-agent": f"aws-sdk-js/1.0.27 KiroIDE-0.8.0-{MACHINE_ID}",
+ "user-agent": f"aws-sdk-js/1.0.27 ua/2.1 os/linux lang/js md/nodejs api/codewhispererstreaming KiroIDE-0.8.0-{MACHINE_ID}",
+ "amz-sdk-invocation-id": str(uuid.uuid4()),
+ "amz-sdk-request": "attempt=1; max=3",
+ "Authorization": f"Bearer {token}",
+ }
+
+def build_kiro_request(messages: list, model: str, conversation_id: str = None) -> dict:
+ """将 OpenAI 格式转换为 Kiro 格式"""
+ if not conversation_id:
+ conversation_id = str(uuid.uuid4())
+
+ # 提取最后一条用户消息
+ user_content = ""
+ for msg in reversed(messages):
+ if msg.get("role") == "user":
+ user_content = msg.get("content", "")
+ break
+
+ return {
+ "conversationState": {
+ "conversationId": conversation_id,
+ "currentMessage": {
+ "userInputMessage": {
+ "content": user_content,
+ "modelId": model.replace("kiro-", ""), # 移除前缀
+ "origin": "AI_EDITOR",
+ "userInputMessageContext": {}
+ }
+ },
+ "chatTriggerType": "MANUAL"
+ }
+ }
+
+def parse_kiro_response(response_data: dict) -> str:
+ """解析 Kiro 响应,提取 AI 回复内容"""
+ try:
+ # Kiro 响应格式可能是流式的,需要解析
+ if isinstance(response_data, dict):
+ # 尝试多种可能的响应路径
+ if "generateAssistantResponseResponse" in response_data:
+ resp = response_data["generateAssistantResponseResponse"]
+ if "assistantResponseEvent" in resp:
+ event = resp["assistantResponseEvent"]
+ if "content" in event:
+ return event["content"]
+
+ # 直接返回文本内容
+ if "content" in response_data:
+ return response_data["content"]
+
+ if "message" in response_data:
+ return response_data["message"]
+
+ return json.dumps(response_data)
+ except Exception as e:
+ logger.error(f"解析响应失败: {e}")
+ return str(response_data)
+
+def parse_event_stream(raw_content: bytes) -> str:
+ """解析 AWS event-stream 格式的响应"""
+ try:
+ # 尝试直接解码为 UTF-8
+ try:
+ text = raw_content.decode('utf-8')
+ # 如果是纯 JSON
+ if text.startswith('{'):
+ data = json.loads(text)
+ return parse_kiro_response(data)
+ except:
+ pass
+
+ # AWS event-stream 格式解析
+ # 格式: [prelude (8 bytes)][headers][payload][message CRC (4 bytes)]
+ content_parts = []
+ pos = 0
+
+ while pos < len(raw_content):
+ if pos + 12 > len(raw_content):
+ break
+
+ # 读取 prelude: total_length (4 bytes) + headers_length (4 bytes) + prelude_crc (4 bytes)
+ total_length = int.from_bytes(raw_content[pos:pos+4], 'big')
+ headers_length = int.from_bytes(raw_content[pos+4:pos+8], 'big')
+
+ if total_length == 0 or total_length > len(raw_content) - pos:
+ break
+
+ # 跳过 prelude (12 bytes) 和 headers
+ payload_start = pos + 12 + headers_length
+ payload_end = pos + total_length - 4 # 减去 message CRC
+
+ if payload_start < payload_end:
+ payload = raw_content[payload_start:payload_end]
+ try:
+ # 尝试解析 payload 为 JSON
+ payload_text = payload.decode('utf-8')
+ if payload_text.strip():
+ payload_json = json.loads(payload_text)
+
+ # 提取文本内容
+ if "assistantResponseEvent" in payload_json:
+ event = payload_json["assistantResponseEvent"]
+ if "content" in event:
+ content_parts.append(event["content"])
+ elif "content" in payload_json:
+ content_parts.append(payload_json["content"])
+ elif "text" in payload_json:
+ content_parts.append(payload_json["text"])
+ else:
+ logger.info(f" Event: {payload_text[:200]}")
+ except Exception as e:
+ logger.debug(f"解析 payload 失败: {e}")
+
+ pos += total_length
+
+ if content_parts:
+ return "".join(content_parts)
+
+ # 如果解析失败,返回原始内容的十六进制表示用于调试
+ return f"[无法解析响应,原始数据: {raw_content[:500].hex()}]"
+
+ except Exception as e:
+ logger.error(f"解析 event-stream 失败: {e}")
+ return f"[解析错误: {e}]"
+
+@app.get("/")
+async def root():
+ """健康检查"""
+ token_exists = TOKEN_PATH.exists()
+ return {
+ "status": "ok",
+ "service": "Kiro API Proxy",
+ "token_available": token_exists,
+ "endpoints": {
+ "chat": "/v1/chat/completions",
+ "models": "/v1/models"
+ }
+ }
+
+@app.get("/v1/models")
+async def list_models():
+ """列出可用模型 (OpenAI 兼容)"""
+ return {
+ "object": "list",
+ "data": [
+ {"id": "kiro-claude-sonnet-4", "object": "model", "owned_by": "kiro"},
+ {"id": "kiro-claude-opus-4.5", "object": "model", "owned_by": "kiro"},
+ {"id": "claude-sonnet-4", "object": "model", "owned_by": "kiro"},
+ {"id": "claude-opus-4.5", "object": "model", "owned_by": "kiro"},
+ ]
+ }
+
+@app.post("/v1/chat/completions")
+async def chat_completions(request: Request):
+ """OpenAI 兼容的聊天接口"""
+ try:
+ body = await request.json()
+ except:
+ raise HTTPException(status_code=400, detail="Invalid JSON")
+
+ messages = body.get("messages", [])
+ model = body.get("model", "claude-sonnet-4")
+ stream = body.get("stream", False)
+
+ if not messages:
+ raise HTTPException(status_code=400, detail="messages is required")
+
+ # 获取 token
+ token = get_kiro_token()
+
+ # 构建请求
+ headers = build_kiro_headers(token)
+ kiro_body = build_kiro_request(messages, model)
+
+ logger.info(f"📤 发送请求到 Kiro API, model={model}")
+ logger.info(f" 消息: {messages[-1].get('content', '')[:100]}...")
+
+ try:
+ async with httpx.AsyncClient(timeout=60.0, verify=False) as client:
+ response = await client.post(
+ KIRO_API_URL,
+ headers=headers,
+ json=kiro_body
+ )
+
+ logger.info(f"📥 Kiro 响应状态: {response.status_code}")
+ logger.info(f" Content-Type: {response.headers.get('content-type')}")
+
+ if response.status_code != 200:
+ logger.error(f"Kiro API 错误: {response.text}")
+ raise HTTPException(
+ status_code=response.status_code,
+ detail=f"Kiro API error: {response.text}"
+ )
+
+ # 处理响应 - 可能是 event-stream 或 JSON
+ raw_content = response.content
+ logger.info(f" 响应大小: {len(raw_content)} bytes")
+ logger.info(f" 原始响应前200字节: {raw_content[:200]}")
+
+ content = parse_event_stream(raw_content)
+
+ logger.info(f" 回复: {content[:100]}...")
+
+ # 返回 OpenAI 兼容格式
+ return JSONResponse({
+ "id": f"chatcmpl-{uuid.uuid4().hex[:8]}",
+ "object": "chat.completion",
+ "created": int(datetime.now().timestamp()),
+ "model": model,
+ "choices": [{
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": content
+ },
+ "finish_reason": "stop"
+ }],
+ "usage": {
+ "prompt_tokens": 0,
+ "completion_tokens": 0,
+ "total_tokens": 0
+ }
+ })
+
+ except httpx.RequestError as e:
+ logger.error(f"请求失败: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+ except Exception as e:
+ logger.error(f"未知错误: {e}")
+ import traceback
+ traceback.print_exc()
+ raise HTTPException(status_code=500, detail=str(e))
+
+@app.get("/token/status")
+async def token_status():
+ """检查 token 状态"""
+ try:
+ with open(TOKEN_PATH) as f:
+ data = json.load(f)
+ expires_at = data.get("expiresAt", "unknown")
+ return {
+ "valid": True,
+ "expires_at": expires_at,
+ "path": str(TOKEN_PATH)
+ }
+ except Exception as e:
+ return {
+ "valid": False,
+ "error": str(e),
+ "path": str(TOKEN_PATH)
+ }
+
+if __name__ == "__main__":
+ print("""
+╔══════════════════════════════════════════════════════════════╗
+║ Kiro API 反向代理服务器 ║
+╠══════════════════════════════════════════════════════════════╣
+║ 端口: 8000 ║
+║ OpenAI 兼容接口: http://127.0.0.1:8000/v1/chat/completions ║
+╠══════════════════════════════════════════════════════════════╣
+║ 使用方法: ║
+║ curl http://127.0.0.1:8000/v1/chat/completions \\ ║
+║ -H "Content-Type: application/json" \\ ║
+║ -d '{"model":"claude-sonnet-4","messages":[{"role":"user",║
+║ "content":"Hello"}]}' ║
+╚══════════════════════════════════════════════════════════════╝
+ """)
+ uvicorn.run(app, host="0.0.0.0", port=8000)
diff --git a/KiroProxy/pyproject.toml b/KiroProxy/pyproject.toml
new file mode 100644
index 0000000000000000000000000000000000000000..e3df50f23a05549492ab6e4b4833df2b64fa100c
--- /dev/null
+++ b/KiroProxy/pyproject.toml
@@ -0,0 +1,39 @@
+[build-system]
+requires = ["setuptools>=68", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "kiroproxy"
+dynamic = ["version"]
+description = "Kiro IDE API reverse proxy server"
+readme = "README.md"
+requires-python = ">=3.10"
+license = { text = "MIT" }
+authors = [{ name = "petehsu" }]
+urls = { Homepage = "https://github.com/petehsu/KiroProxy" }
+dependencies = [
+ "fastapi>=0.100.0",
+ "uvicorn>=0.23.0",
+ "httpx>=0.24.0",
+ "requests>=2.31.0",
+ "tiktoken>=0.5.0",
+ "cbor2>=5.4.0",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=7.0.0",
+ "hypothesis>=6.0.0",
+]
+
+[project.scripts]
+kiro-proxy = "kiro_proxy.cli:main"
+
+[tool.setuptools.dynamic]
+version = { attr = "kiro_proxy.__version__" }
+
+[tool.setuptools.packages.find]
+include = ["kiro_proxy*"]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
diff --git a/KiroProxy/requirements.txt b/KiroProxy/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..3ff93ba28840f80c905482767e236ea0efef20d5
--- /dev/null
+++ b/KiroProxy/requirements.txt
@@ -0,0 +1,8 @@
+fastapi>=0.100.0
+uvicorn>=0.23.0
+httpx>=0.24.0
+requests>=2.31.0
+pytest>=7.0.0
+hypothesis>=6.0.0
+tiktoken>=0.5.0
+cbor2>=5.4.0
diff --git a/KiroProxy/run.py b/KiroProxy/run.py
new file mode 100644
index 0000000000000000000000000000000000000000..7a796e6cb29e867e508f0e67e1293408abec81a5
--- /dev/null
+++ b/KiroProxy/run.py
@@ -0,0 +1,14 @@
+#!/usr/bin/env python3
+"""Kiro API Proxy 启动脚本"""
+import sys
+
+if __name__ == "__main__":
+ # 如果有子命令参数,使用 CLI 模式
+ if len(sys.argv) > 1 and sys.argv[1] in ("accounts", "login", "status", "serve"):
+ from kiro_proxy.cli import main
+ main()
+ else:
+ # 兼容旧的启动方式: python run.py [port]
+ port = int(sys.argv[1]) if len(sys.argv) > 1 else 8080
+ from kiro_proxy.main import run
+ run(port)
diff --git a/KiroProxy/scripts/capture_kiro.py b/KiroProxy/scripts/capture_kiro.py
new file mode 100644
index 0000000000000000000000000000000000000000..00b4ce3ef30017c2f03cd19f99bc7ac0d264d2e1
--- /dev/null
+++ b/KiroProxy/scripts/capture_kiro.py
@@ -0,0 +1,139 @@
+#!/usr/bin/env python3
+"""
+Kiro IDE 请求抓取工具
+
+使用 mitmproxy 抓取 Kiro IDE 发送到 AWS 的请求。
+
+安装:
+ pip install mitmproxy
+
+使用方法:
+ 1. 运行此脚本: python capture_kiro.py
+ 2. 设置系统代理为 127.0.0.1:8888
+ 3. 安装 mitmproxy 的 CA 证书 (访问 http://mitm.it)
+ 4. 启动 Kiro IDE 并使用
+ 5. 查看 kiro_requests/ 目录下的抓取结果
+
+或者使用 mitmproxy 命令行:
+ mitmproxy --mode regular@8888 -s capture_kiro.py
+
+ 或者使用 mitmdump (无 UI):
+ mitmdump --mode regular@8888 -s capture_kiro.py
+"""
+
+import json
+import os
+from datetime import datetime
+from mitmproxy import http, ctx
+
+# 创建输出目录
+OUTPUT_DIR = "kiro_requests"
+os.makedirs(OUTPUT_DIR, exist_ok=True)
+
+# 计数器
+request_count = 0
+
+def request(flow: http.HTTPFlow) -> None:
+ """处理请求"""
+ global request_count
+
+ # 只抓取 Kiro/AWS 相关请求
+ if "q.us-east-1.amazonaws.com" not in flow.request.host:
+ return
+
+ request_count += 1
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+
+ # 保存请求
+ request_data = {
+ "timestamp": timestamp,
+ "method": flow.request.method,
+ "url": flow.request.url,
+ "headers": dict(flow.request.headers),
+ "body": None
+ }
+
+ # 解析请求体
+ if flow.request.content:
+ try:
+ request_data["body"] = json.loads(flow.request.content.decode('utf-8'))
+ except:
+ request_data["body_raw"] = flow.request.content.decode('utf-8', errors='replace')
+
+ # 保存到文件
+ filename = f"{OUTPUT_DIR}/{timestamp}_{request_count:04d}_request.json"
+ with open(filename, 'w', encoding='utf-8') as f:
+ json.dump(request_data, f, indent=2, ensure_ascii=False)
+
+ ctx.log.info(f"[Kiro] Captured request #{request_count}: {flow.request.method} {flow.request.path}")
+
+
+def response(flow: http.HTTPFlow) -> None:
+ """处理响应"""
+ # 只处理 Kiro/AWS 相关响应
+ if "q.us-east-1.amazonaws.com" not in flow.request.host:
+ return
+
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+
+ # 保存响应
+ response_data = {
+ "timestamp": timestamp,
+ "status_code": flow.response.status_code,
+ "headers": dict(flow.response.headers),
+ "body": None
+ }
+
+ # 响应体可能是 event-stream 格式
+ if flow.response.content:
+ try:
+ # 尝试解析为 JSON
+ response_data["body"] = json.loads(flow.response.content.decode('utf-8'))
+ except:
+ # 保存原始内容(可能是 event-stream)
+ response_data["body_raw_length"] = len(flow.response.content)
+ # 保存前 2000 字节用于调试
+ response_data["body_preview"] = flow.response.content[:2000].decode('utf-8', errors='replace')
+
+ # 保存到文件
+ filename = f"{OUTPUT_DIR}/{timestamp}_{request_count:04d}_response.json"
+ with open(filename, 'w', encoding='utf-8') as f:
+ json.dump(response_data, f, indent=2, ensure_ascii=False)
+
+ ctx.log.info(f"[Kiro] Captured response: {flow.response.status_code}")
+
+
+# 如果直接运行此脚本
+if __name__ == "__main__":
+ print("""
+╔══════════════════════════════════════════════════════════════════╗
+║ Kiro IDE 请求抓取工具 ║
+╠══════════════════════════════════════════════════════════════════╣
+║ ║
+║ 方法 1: 使用 mitmproxy (推荐) ║
+║ ─────────────────────────────────────────────────────────────── ║
+║ 1. 安装: pip install mitmproxy ║
+║ 2. 运行: mitmproxy -s capture_kiro.py ║
+║ 或: mitmdump -s capture_kiro.py ║
+║ 3. 设置 Kiro IDE 的代理为 127.0.0.1:8080 ║
+║ 4. 安装 CA 证书: 访问 http://mitm.it ║
+║ ║
+║ 方法 2: 使用 Burp Suite ║
+║ ─────────────────────────────────────────────────────────────── ║
+║ 1. 启动 Burp Suite ║
+║ 2. 设置代理监听 127.0.0.1:8080 ║
+║ 3. 导出 CA 证书并安装到系统 ║
+║ 4. 设置 Kiro IDE 的代理 ║
+║ ║
+║ 方法 3: 直接修改 Kiro IDE (最简单) ║
+║ ─────────────────────────────────────────────────────────────── ║
+║ 在 Kiro IDE 的设置中添加: ║
+║ "http.proxy": "http://127.0.0.1:8080" ║
+║ ║
+║ 或者设置环境变量: ║
+║ export HTTPS_PROXY=http://127.0.0.1:8080 ║
+║ export HTTP_PROXY=http://127.0.0.1:8080 ║
+║ export NODE_TLS_REJECT_UNAUTHORIZED=0 ║
+║ ║
+╚══════════════════════════════════════════════════════════════════╝
+""")
diff --git a/KiroProxy/scripts/debug_quota_info.py b/KiroProxy/scripts/debug_quota_info.py
new file mode 100644
index 0000000000000000000000000000000000000000..c2c2d0ae06895790bbdd5205f0783c657a573fb6
--- /dev/null
+++ b/KiroProxy/scripts/debug_quota_info.py
@@ -0,0 +1,54 @@
+"""调试额度信息获取"""
+import asyncio
+import json
+from kiro_proxy.core.state import ProxyState
+
+
+def debug_quota_info():
+ """调试额度信息获取"""
+
+ # 初始化状态管理器
+ state = ProxyState()
+
+ print("=== 调试账号额度信息 ===\n")
+
+ for account in state.accounts[:2]: # 只查看前两个账号
+ print(f"账号: {account.name} ({account.id})")
+
+ # 获取状态信息
+ status = account.get_status_info()
+
+ if "quota" in status and status["quota"]:
+ quota = status["quota"]
+ print(f" - 额度状态: {quota.get('balance_status', 'unknown')}")
+ print(f" - 已用/总额: {quota.get('current_usage', 0)} / {quota.get('usage_limit', 0)}")
+ print(f" - 剩余额度: {quota.get('balance', 0)}")
+ print(f" - 更新时间: {quota.get('updated_at', 'unknown')}")
+
+ # 检查重置时间字段
+ print(f" - 下次重置时间: {quota.get('next_reset_date', '未设置')}")
+ print(f" - 格式化重置日期: {quota.get('reset_date_text', '未设置')}")
+ print(f" - 免费试用过期: {quota.get('free_trial_expiry', '未设置')}")
+ print(f" - 格式化过期日期: {quota.get('trial_expiry_text', '未设置')}")
+ print(f" - 奖励过期列表: {quota.get('bonus_expiries', [])}")
+ print(f" - 生效奖励数: {quota.get('active_bonuses', 0)}")
+ else:
+ print(" - 无额度信息")
+ if status.get("quota", {}).get("error"):
+ print(f" - 错误: {status['quota']['error']}")
+
+ print()
+
+ # 模拟 API 响应
+ print("=== 模拟 Web API 响应 ===\n")
+
+ accounts_status = state.get_accounts_status()
+
+ # 只显示第一个账号的信息
+ if accounts_status:
+ first_account = accounts_status[0]
+ print(json.dumps(first_account, indent=2, ensure_ascii=False, default=str))
+
+
+if __name__ == "__main__":
+ debug_quota_info()
diff --git a/KiroProxy/scripts/get_models.py b/KiroProxy/scripts/get_models.py
new file mode 100644
index 0000000000000000000000000000000000000000..be99477cdb7e933457d33ab06ee372b1498cd1b2
--- /dev/null
+++ b/KiroProxy/scripts/get_models.py
@@ -0,0 +1,44 @@
+#!/usr/bin/env python3
+"""获取 Kiro 支持的模型列表"""
+
+import json
+import uuid
+import httpx
+from pathlib import Path
+
+TOKEN_PATH = Path.home() / ".aws/sso/cache/kiro-auth-token.json"
+MACHINE_ID = "fa41d5def91e29225c73f6ea8ee0941a87bd812aae5239e3dde72c3ba7603a26"
+MODELS_URL = "https://q.us-east-1.amazonaws.com/ListAvailableModels"
+
+def get_token():
+ with open(TOKEN_PATH) as f:
+ return json.load(f).get("accessToken", "")
+
+def get_models():
+ token = get_token()
+ headers = {
+ "content-type": "application/json",
+ "x-amz-user-agent": f"aws-sdk-js/1.0.27 KiroIDE-0.8.0-{MACHINE_ID}",
+ "amz-sdk-invocation-id": str(uuid.uuid4()),
+ "Authorization": f"Bearer {token}",
+ }
+
+ # 尝试不同的参数
+ params = {"origin": "AI_EDITOR"}
+
+ with httpx.Client(verify=False, timeout=30) as client:
+ resp = client.get(MODELS_URL, headers=headers, params=params)
+ print(f"Status: {resp.status_code}")
+ print(f"Headers: {dict(resp.headers)}")
+ print(f"\nRaw response ({len(resp.content)} bytes):")
+
+ # 尝试解析
+ try:
+ data = resp.json()
+ print(json.dumps(data, indent=2, ensure_ascii=False))
+ except:
+ # 可能是 event-stream 格式
+ print(resp.content[:2000])
+
+if __name__ == "__main__":
+ get_models()
diff --git a/KiroProxy/scripts/proxy_server.py b/KiroProxy/scripts/proxy_server.py
new file mode 100644
index 0000000000000000000000000000000000000000..e7be7422779f0d663e84a213a462484301b54a07
--- /dev/null
+++ b/KiroProxy/scripts/proxy_server.py
@@ -0,0 +1,135 @@
+#!/usr/bin/env python3
+"""
+Kiro IDE 反向代理测试服务器
+用于测试是否能成功拦截和转发 Kiro 的 API 请求
+"""
+
+from fastapi import FastAPI, Request, Response
+from fastapi.responses import JSONResponse, StreamingResponse
+import httpx
+import uvicorn
+import json
+import logging
+from datetime import datetime
+
+# 配置日志
+logging.basicConfig(
+ level=logging.INFO,
+ format='%(asctime)s - %(levelname)s - %(message)s'
+)
+logger = logging.getLogger(__name__)
+
+app = FastAPI(title="Kiro Reverse Proxy Test")
+
+# 原始 Kiro API 地址(如果需要转发到真实服务器)
+KIRO_API_BASE = "https://api.kiro.dev"
+
+# 记录所有请求
+request_log = []
+
+@app.middleware("http")
+async def log_requests(request: Request, call_next):
+ """记录所有进入的请求"""
+ body = await request.body()
+
+ log_entry = {
+ "timestamp": datetime.now().isoformat(),
+ "method": request.method,
+ "url": str(request.url),
+ "path": request.url.path,
+ "headers": dict(request.headers),
+ "body": body.decode('utf-8', errors='ignore')[:2000] if body else None
+ }
+
+ request_log.append(log_entry)
+ logger.info(f"📥 {request.method} {request.url.path}")
+ logger.info(f" Headers: {dict(request.headers)}")
+ if body:
+ logger.info(f" Body: {body.decode('utf-8', errors='ignore')[:500]}...")
+
+ response = await call_next(request)
+ return response
+
+@app.get("/")
+async def root():
+ """健康检查"""
+ return {"status": "ok", "message": "Kiro Proxy Server Running", "requests_logged": len(request_log)}
+
+@app.get("/logs")
+async def get_logs():
+ """查看所有记录的请求"""
+ return {"total": len(request_log), "requests": request_log[-50:]}
+
+@app.get("/clear")
+async def clear_logs():
+ """清空日志"""
+ request_log.clear()
+ return {"message": "Logs cleared"}
+
+# 模拟认证成功响应
+@app.api_route("/auth/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
+async def mock_auth(request: Request, path: str):
+ """模拟认证端点"""
+ logger.info(f"🔐 Auth request: {path}")
+ return JSONResponse({
+ "success": True,
+ "token": "mock-token-for-testing",
+ "expires_in": 3600
+ })
+
+# 模拟 AI 对话端点
+@app.post("/v1/chat/completions")
+async def mock_chat_completions(request: Request):
+ """模拟 OpenAI 兼容的聊天接口"""
+ body = await request.json()
+ logger.info(f"💬 Chat request: {json.dumps(body, ensure_ascii=False)[:500]}")
+
+ # 返回模拟响应
+ return JSONResponse({
+ "id": "chatcmpl-test",
+ "object": "chat.completion",
+ "created": int(datetime.now().timestamp()),
+ "model": "kiro-proxy-test",
+ "choices": [{
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": "🎉 反向代理测试成功!你的请求已被成功拦截。"
+ },
+ "finish_reason": "stop"
+ }],
+ "usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30}
+ })
+
+# 捕获所有其他请求
+@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"])
+async def catch_all(request: Request, path: str):
+ """捕获所有其他请求并记录"""
+ body = await request.body()
+
+ logger.info(f"🎯 Caught: {request.method} /{path}")
+
+ return JSONResponse({
+ "proxy_status": "intercepted",
+ "method": request.method,
+ "path": f"/{path}",
+ "message": "请求已被反向代理捕获",
+ "headers_received": dict(request.headers)
+ })
+
+if __name__ == "__main__":
+ print("""
+╔══════════════════════════════════════════════════════════════╗
+║ Kiro IDE 反向代理测试服务器 ║
+╠══════════════════════════════════════════════════════════════╣
+║ 端口: 8000 ║
+║ 查看日志: http://127.0.0.1:8000/logs ║
+║ 清空日志: http://127.0.0.1:8000/clear ║
+╠══════════════════════════════════════════════════════════════╣
+║ 使用方法: ║
+║ 1. 修改 Kiro 的 JS 源码,将 api.kiro.dev 替换为 127.0.0.1:8000 ║
+║ 2. 或者修改 /etc/hosts 添加: 127.0.0.1 api.kiro.dev ║
+║ 3. 启动 Kiro,观察此终端的日志输出 ║
+╚══════════════════════════════════════════════════════════════╝
+ """)
+ uvicorn.run(app, host="0.0.0.0", port=8000)
diff --git a/KiroProxy/scripts/test_kiro_proxy.py b/KiroProxy/scripts/test_kiro_proxy.py
new file mode 100644
index 0000000000000000000000000000000000000000..ec5017437c3bc0c7b4929d090be6d4b7eda5fe9b
--- /dev/null
+++ b/KiroProxy/scripts/test_kiro_proxy.py
@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+"""测试 Kiro 反向代理"""
+
+import requests
+import json
+
+PROXY_URL = "http://127.0.0.1:8000"
+
+def test_health():
+ print("1. 测试健康检查...")
+ r = requests.get(f"{PROXY_URL}/")
+ print(f" ✅ {r.json()}")
+
+def test_token():
+ print("\n2. 检查 Token 状态...")
+ r = requests.get(f"{PROXY_URL}/token/status")
+ data = r.json()
+ if data.get("valid"):
+ print(f" ✅ Token 有效,过期时间: {data.get('expires_at')}")
+ else:
+ print(f" ❌ Token 无效: {data.get('error')}")
+
+def test_models():
+ print("\n3. 列出可用模型...")
+ r = requests.get(f"{PROXY_URL}/v1/models")
+ models = r.json().get("data", [])
+ for m in models:
+ print(f" - {m['id']}")
+
+def test_chat():
+ print("\n4. 测试聊天接口...")
+ r = requests.post(
+ f"{PROXY_URL}/v1/chat/completions",
+ json={
+ "model": "claude-sonnet-4",
+ "messages": [
+ {"role": "user", "content": "说一句话测试"}
+ ]
+ },
+ timeout=60
+ )
+
+ if r.status_code == 200:
+ data = r.json()
+ content = data["choices"][0]["message"]["content"]
+ print(f" ✅ AI 回复: {content[:200]}...")
+ else:
+ print(f" ❌ 错误 {r.status_code}: {r.text}")
+
+if __name__ == "__main__":
+ print("=" * 50)
+ print("Kiro 反向代理测试")
+ print("=" * 50)
+
+ try:
+ test_health()
+ test_token()
+ test_models()
+ test_chat()
+ print("\n" + "=" * 50)
+ print("测试完成")
+ print("=" * 50)
+ except requests.exceptions.ConnectionError:
+ print("\n❌ 连接失败!请先启动代理服务器:")
+ print(" source venv/bin/activate")
+ print(" python kiro_proxy.py")
diff --git a/KiroProxy/scripts/test_proxy.py b/KiroProxy/scripts/test_proxy.py
new file mode 100644
index 0000000000000000000000000000000000000000..026b925778ead53f4faff28b4fc705c5d7a401fc
--- /dev/null
+++ b/KiroProxy/scripts/test_proxy.py
@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+"""测试反向代理是否正常工作"""
+
+import requests
+import json
+
+PROXY_URL = "http://127.0.0.1:8000"
+
+def test_health():
+ """测试健康检查"""
+ print("1. 测试健康检查...")
+ r = requests.get(f"{PROXY_URL}/")
+ print(f" ✅ {r.json()}")
+
+def test_chat():
+ """测试聊天接口"""
+ print("\n2. 测试聊天接口...")
+ r = requests.post(
+ f"{PROXY_URL}/v1/chat/completions",
+ json={
+ "model": "test",
+ "messages": [{"role": "user", "content": "Hello"}]
+ }
+ )
+ print(f" ✅ {r.json()['choices'][0]['message']['content']}")
+
+def test_catch_all():
+ """测试通用捕获"""
+ print("\n3. 测试任意路径捕获...")
+ r = requests.post(
+ f"{PROXY_URL}/api/v1/some/kiro/endpoint",
+ json={"test": "data"}
+ )
+ print(f" ✅ {r.json()['message']}")
+
+def test_auth():
+ """测试认证端点"""
+ print("\n4. 测试认证端点...")
+ r = requests.post(f"{PROXY_URL}/auth/login")
+ print(f" ✅ Token: {r.json()['token']}")
+
+def view_logs():
+ """查看日志"""
+ print("\n5. 查看捕获的请求日志...")
+ r = requests.get(f"{PROXY_URL}/logs")
+ data = r.json()
+ print(f" ✅ 共捕获 {data['total']} 个请求")
+
+if __name__ == "__main__":
+ print("=" * 50)
+ print("Kiro 反向代理测试")
+ print("=" * 50)
+
+ try:
+ test_health()
+ test_chat()
+ test_catch_all()
+ test_auth()
+ view_logs()
+ print("\n" + "=" * 50)
+ print("✅ 所有测试通过!反向代理工作正常")
+ print("=" * 50)
+ except requests.exceptions.ConnectionError:
+ print("\n❌ 连接失败!请先启动代理服务器:")
+ print(" python proxy_server.py")
diff --git a/KiroProxy/scripts/test_thinking.py b/KiroProxy/scripts/test_thinking.py
new file mode 100644
index 0000000000000000000000000000000000000000..04e7fde34270c2cbaf3146196ee09dfcfa090f16
--- /dev/null
+++ b/KiroProxy/scripts/test_thinking.py
@@ -0,0 +1,115 @@
+#!/usr/bin/env python3
+"""
+测试思考功能
+"""
+import asyncio
+import json
+import httpx
+
+async def test_thinking_feature():
+ """测试思考功能"""
+
+ # 测试数据
+ test_data = {
+ "model": "claude-sonnet-4.5",
+ "messages": [
+ {
+ "role": "user",
+ "content": "请解释什么是递归,并给出一个简单的例子。"
+ }
+ ],
+ "thinking": {
+ "thinking_type": "enabled",
+ "budget_tokens": 5000
+ },
+ "stream": True,
+ "max_tokens": 1000
+ }
+
+ print("发送思考功能测试请求...")
+ print(f"请求内容: {json.dumps(test_data, indent=2, ensure_ascii=False)}")
+ print("\n" + "="*60 + "\n")
+
+ try:
+ async with httpx.AsyncClient(timeout=60) as client:
+ async with client.stream(
+ "POST",
+ "http://localhost:8080/v1/messages",
+ headers={
+ "Content-Type": "application/json",
+ "x-api-key": "any",
+ "anthropic-version": "2023-06-01"
+ },
+ json=test_data
+ ) as response:
+
+ if response.status_code != 200:
+ print(f"错误: {response.status_code}")
+ print(await response.aread())
+ return
+
+ print("收到响应:\n")
+
+ thinking_content = []
+ text_content = []
+ current_block = None
+
+ async for line in response.aiter_lines():
+ if line.startswith("data: "):
+ data_str = line[6:]
+
+ if data_str == "[DONE]":
+ break
+
+ try:
+ data = json.loads(data_str)
+ event_type = data.get("type")
+
+ if event_type == "content_block_start":
+ block_type = data.get("content_block", {}).get("type")
+ current_block = block_type
+ print(f"\n[开始 {block_type} 块]")
+
+ elif event_type == "content_block_delta":
+ delta = data.get("delta", {})
+
+ if delta.get("type") == "thinking_delta":
+ thinking = delta.get("thinking", "")
+ thinking_content.append(thinking)
+ print(thinking, end="", flush=True)
+
+ elif delta.get("type") == "text_delta":
+ text = delta.get("text", "")
+ text_content.append(text)
+ print(text, end="", flush=True)
+
+ elif event_type == "content_block_stop":
+ print(f"\n[结束块]")
+ current_block = None
+
+ elif event_type == "message_stop":
+ print("\n\n[响应完成]")
+ break
+
+ elif event_type == "error":
+ print("\n\n[错误]")
+ print(json.dumps(data.get("error", data), ensure_ascii=False, indent=2))
+ break
+
+ except json.JSONDecodeError as e:
+ print(f"\n解析错误: {e}")
+ continue
+
+ print("\n" + "="*60)
+ print("\n思考内容汇总:")
+ print("".join(thinking_content))
+
+ print("\n" + "-"*40)
+ print("\n回答内容汇总:")
+ print("".join(text_content))
+
+ except Exception as e:
+ print(f"请求失败: {e}")
+
+if __name__ == "__main__":
+ asyncio.run(test_thinking_feature())
diff --git a/KiroProxy/test_smart_mapping.py b/KiroProxy/test_smart_mapping.py
new file mode 100644
index 0000000000000000000000000000000000000000..e16a4f44c2e99faf4e69523ea865453af1f62be2
--- /dev/null
+++ b/KiroProxy/test_smart_mapping.py
@@ -0,0 +1,196 @@
+#!/usr/bin/env python3
+"""测试智能模型映射功能"""
+
+import sys
+sys.path.append('.')
+
+from kiro_proxy.config import map_model_name, detect_model_tier, get_best_model_by_tier
+
+def test_tier_detection():
+ """测试等级检测功能"""
+ print("测试模型等级检测:")
+
+ test_cases = [
+ # Opus 等级 (最强)
+ ("claude-4-opus", "opus"),
+ ("gpt-o1-preview", "opus"),
+ ("gemini-1.5-pro", "opus"),
+ ("claude-3-opus-20240229", "opus"),
+ ("some-premium-model", "opus"),
+
+ # Sonnet 等级 (平衡)
+ ("claude-3.5-sonnet", "sonnet"),
+ ("gpt-4o", "sonnet"),
+ ("gemini-2.0-flash", "sonnet"),
+ ("claude-4-standard", "sonnet"),
+
+ # Haiku 等级 (快速)
+ ("claude-3-haiku", "haiku"),
+ ("gpt-4o-mini", "haiku"),
+ ("gpt-3.5-turbo", "haiku"),
+ ("claude-haiku-fast", "haiku"),
+
+ # 未知模型
+ ("unknown-model-xyz", "sonnet"), # 默认中等
+ ("", "sonnet"), # 空值默认
+ ]
+
+ for model, expected in test_cases:
+ result = detect_model_tier(model)
+ status = "OK" if result == expected else "FAIL"
+ print(f" {status} {model:<25} -> {result:<6} (期望: {expected})")
+
+def test_dynamic_mapping():
+ """测试动态模型映射(等级对等 + 智能降级)"""
+ print("\n测试动态模型映射(等级对等策略):")
+
+ # 模拟不同的可用模型场景
+ scenarios = [
+ {
+ "name": "全部可用",
+ "available": {"claude-sonnet-4.5", "claude-sonnet-4", "claude-haiku-4.5", "auto"}
+ },
+ {
+ "name": "缺少4.5版本",
+ "available": {"claude-sonnet-4", "claude-haiku-4.5", "auto"}
+ },
+ {
+ "name": "仅有Haiku",
+ "available": {"claude-haiku-4.5", "auto"}
+ },
+ {
+ "name": "仅有Sonnet-4",
+ "available": {"claude-sonnet-4", "auto"}
+ }
+ ]
+
+ test_models = [
+ ("claude-4-opus", "opus"), # 应该优先选择 sonnet-4.5
+ ("gpt-4o", "sonnet"), # 应该优先选择 sonnet-4.5
+ ("gpt-4o-mini", "haiku"), # 应该优先选择 haiku-4.5
+ ("unknown-future-model", "sonnet") # 未知模型,默认 sonnet-4.5
+ ]
+
+ for scenario in scenarios:
+ print(f"\n 场景: {scenario['name']}")
+ print(f" 可用模型: {scenario['available']}")
+
+ for model, expected_tier in test_models:
+ result = map_model_name(model, scenario['available'])
+ tier = detect_model_tier(model)
+ print(f" {model:<20} ({tier:<6}) -> {result}")
+
+def test_tier_mapping_logic():
+ """测试等级对等映射逻辑"""
+ print("\n测试等级对等映射逻辑:")
+
+ # 全部可用时的期望映射
+ full_available = {"claude-sonnet-4.5", "claude-sonnet-4", "claude-haiku-4.5", "auto"}
+
+ test_cases = [
+ # 格式: (输入模型, 期望等级, 期望输出模型)
+ ("claude-4-opus", "opus", "claude-sonnet-4.5"), # Opus -> 最强
+ ("gpt-4o", "sonnet", "claude-sonnet-4.5"), # Sonnet -> 高性能
+ ("gpt-4o-mini", "haiku", "claude-haiku-4.5"), # Haiku -> 快速
+ ("o1-preview", "opus", "claude-sonnet-4.5"), # O1 -> 最强
+ ("claude-3.5-sonnet", "sonnet", "claude-sonnet-4.5"), # Sonnet -> 高性能
+ ("gpt-3.5-turbo", "haiku", "claude-haiku-4.5"), # 3.5 -> 快速
+ ]
+
+ for model, expected_tier, expected_output in test_cases:
+ tier = detect_model_tier(model)
+ result = map_model_name(model, full_available)
+ tier_ok = "OK" if tier == expected_tier else "FAIL"
+ output_ok = "OK" if result == expected_output else "FAIL"
+ print(f" {tier_ok}/{output_ok} {model:<20} -> {tier:<6} -> {result}")
+ if tier != expected_tier:
+ print(f" 等级检测错误: 期望 {expected_tier}, 实际 {tier}")
+ if result != expected_output:
+ print(f" 映射错误: 期望 {expected_output}, 实际 {result}")
+
+def test_degradation_paths():
+ """测试降级路径"""
+ print("\n测试降级路径:")
+
+ degradation_scenarios = [
+ {
+ "name": "Opus降级测试",
+ "model": "claude-4-opus",
+ "scenarios": [
+ ({"claude-sonnet-4.5", "auto"}, "claude-sonnet-4.5"), # 首选可用
+ ({"claude-sonnet-4", "auto"}, "claude-sonnet-4"), # 降级到次强
+ ({"claude-haiku-4.5", "auto"}, "claude-haiku-4.5"), # 降级到快速
+ ({"auto"}, "auto"), # 最终回退
+ ]
+ },
+ {
+ "name": "Haiku降级测试",
+ "model": "gpt-4o-mini",
+ "scenarios": [
+ ({"claude-haiku-4.5", "auto"}, "claude-haiku-4.5"), # 首选可用
+ ({"claude-sonnet-4", "auto"}, "claude-sonnet-4"), # 降级到标准
+ ({"claude-sonnet-4.5", "auto"}, "claude-sonnet-4.5"), # 降级到高性能
+ ({"auto"}, "auto"), # 最终回退
+ ]
+ }
+ ]
+
+ for test_group in degradation_scenarios:
+ print(f"\n {test_group['name']}:")
+ model = test_group['model']
+ tier = detect_model_tier(model)
+
+ for available, expected in test_group['scenarios']:
+ result = map_model_name(model, available)
+ status = "OK" if result == expected else "FAIL"
+ print(f" {status} 可用:{available} -> {result} (期望:{expected})")
+
+def test_backward_compatibility():
+ """测试向后兼容性"""
+ print("\n测试向后兼容性:")
+
+ # 原有的精确映射应该仍然工作
+ legacy_tests = [
+ ("gpt-4o", "claude-sonnet-4"),
+ ("claude-3-5-sonnet-20241022", "claude-sonnet-4"),
+ ("o1-preview", "claude-sonnet-4.5"),
+ ("gemini-1.5-pro", "claude-sonnet-4.5"),
+ ]
+
+ for model, expected in legacy_tests:
+ result = map_model_name(model)
+ status = "OK" if result == expected else "FAIL"
+ print(f" {status} {model:<25} -> {result:<20} (期望: {expected})")
+
+def test_edge_cases():
+ """测试边界情况"""
+ print("\n测试边界情况:")
+
+ edge_cases = [
+ ("", "auto"), # 空字符串
+ (None, "auto"), # None值 (需要修改函数处理)
+ ("CLAUDE-4-OPUS", "claude-sonnet-4.5"), # 大写
+ ("gpt-4o-MINI-turbo", "claude-haiku-4.5"), # 混合大小写
+ ("claude_sonnet_4", "claude-sonnet-4"), # 下划线
+ ]
+
+ for model, expected in edge_cases:
+ try:
+ result = map_model_name(model or "")
+ tier = detect_model_tier(model or "")
+ status = "OK" if result == expected else "FAIL"
+ print(f" {status} {str(model):<25} ({tier}) -> {result}")
+ except Exception as e:
+ print(f" ERROR {str(model):<25} -> 错误: {e}")
+
+if __name__ == "__main__":
+ print("KiroProxy 智能模型映射测试(等级对等策略)\n")
+
+ test_tier_detection()
+ test_tier_mapping_logic()
+ test_degradation_paths()
+ test_dynamic_mapping()
+ test_backward_compatibility()
+ test_edge_cases()
+
+ print("\n测试完成!")
\ No newline at end of file
diff --git a/KiroProxy/tests/__init__.py b/KiroProxy/tests/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..31408c77a5cf48146b6007cddab371e5cc16f846
--- /dev/null
+++ b/KiroProxy/tests/__init__.py
@@ -0,0 +1 @@
+# 测试模块
diff --git a/KiroProxy/tests/test_account_management.py b/KiroProxy/tests/test_account_management.py
new file mode 100644
index 0000000000000000000000000000000000000000..2bb44bf90880622e23fc6299603a82b792b36ee6
--- /dev/null
+++ b/KiroProxy/tests/test_account_management.py
@@ -0,0 +1,527 @@
+"""账号管理增强功能属性测试
+
+测试覆盖:
+- Property 1: OAuth URL Generation Produces Valid PKCE Parameters
+- Property 2: Token Response Parsing Extracts All Required Fields
+- Property 3: Account Edit Validation and Persistence
+- Property 4: Import Validation Based on AuthMethod
+- Property 5: Batch Import Processes All Valid Entries
+- Property 6: Token Refresh Method Dispatch
+- Property 7: Token Refresh Updates Credentials
+- Property 8: Provider Field Persistence
+- Property 9: Compression State Tracking and Caching
+- Property 10: Progressive Compression Strategy
+"""
+import pytest
+import json
+import hashlib
+import base64
+import secrets
+from unittest.mock import Mock, AsyncMock, patch
+from datetime import datetime, timezone, timedelta
+
+
+# ==================== Property 1 & 2: Social Auth Tests ====================
+
+class TestSocialAuthOAuthURL:
+ """Property 1: OAuth URL Generation Produces Valid PKCE Parameters"""
+
+ def test_code_verifier_length(self):
+ """code_verifier 应该是 43-128 字符"""
+ from kiro_proxy.auth.device_flow import _generate_code_verifier
+ verifier = _generate_code_verifier()
+ assert 43 <= len(verifier) <= 128
+
+ def test_code_verifier_is_url_safe(self):
+ """code_verifier 应该只包含 URL 安全字符"""
+ from kiro_proxy.auth.device_flow import _generate_code_verifier
+ verifier = _generate_code_verifier()
+ # URL safe base64 字符集
+ valid_chars = set('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_')
+ assert all(c in valid_chars for c in verifier)
+
+ def test_code_challenge_is_sha256_of_verifier(self):
+ """code_challenge 应该是 code_verifier 的 SHA256 哈希"""
+ from kiro_proxy.auth.device_flow import _generate_code_verifier, _generate_code_challenge
+ verifier = _generate_code_verifier()
+ challenge = _generate_code_challenge(verifier)
+
+ # 手动计算验证
+ expected = base64.urlsafe_b64encode(
+ hashlib.sha256(verifier.encode()).digest()
+ ).rstrip(b'=').decode()
+
+ assert challenge == expected
+
+ def test_oauth_state_is_unique(self):
+ """每次生成的 state 应该是唯一的"""
+ from kiro_proxy.auth.device_flow import _generate_oauth_state
+ states = [_generate_oauth_state() for _ in range(100)]
+ assert len(set(states)) == 100
+
+ @pytest.mark.asyncio
+ async def test_start_social_auth_returns_valid_url(self):
+ """start_social_auth 应该返回有效的登录 URL"""
+ from kiro_proxy.auth.device_flow import start_social_auth
+
+ success, result = await start_social_auth("google")
+
+ assert success
+ assert "login_url" in result
+ assert "state" in result
+ assert "provider" in result
+ assert result["provider"] == "Google"
+
+ # 验证 URL 包含必要参数
+ url = result["login_url"]
+ assert "idp=Google" in url
+ assert "code_challenge=" in url
+ assert "code_challenge_method=S256" in url
+ assert "state=" in url
+ assert "redirect_uri=" in url
+
+ @pytest.mark.asyncio
+ async def test_start_social_auth_github(self):
+ """GitHub 登录应该正确设置 provider"""
+ from kiro_proxy.auth.device_flow import start_social_auth
+
+ success, result = await start_social_auth("github")
+
+ assert success
+ assert result["provider"] == "Github"
+ assert "idp=Github" in result["login_url"]
+
+
+class TestTokenResponseParsing:
+ """Property 2: Token Response Parsing Extracts All Required Fields"""
+
+ def test_credentials_from_file_extracts_all_fields(self):
+ """from_file 应该提取所有必要字段"""
+ from kiro_proxy.credential.types import KiroCredentials
+ import tempfile
+ import os
+
+ test_data = {
+ "accessToken": "test_access_token",
+ "refreshToken": "test_refresh_token",
+ "profileArn": "arn:aws:test",
+ "expiresAt": "2025-01-10T00:00:00Z",
+ "region": "us-west-2",
+ "authMethod": "social",
+ "provider": "Google",
+ "clientId": "test_client_id",
+ "clientSecret": "test_client_secret",
+ }
+
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
+ json.dump(test_data, f)
+ temp_path = f.name
+
+ try:
+ creds = KiroCredentials.from_file(temp_path)
+
+ assert creds.access_token == "test_access_token"
+ assert creds.refresh_token == "test_refresh_token"
+ assert creds.profile_arn == "arn:aws:test"
+ assert creds.region == "us-west-2"
+ assert creds.auth_method == "social"
+ assert creds.provider == "Google"
+ assert creds.client_id == "test_client_id"
+ assert creds.client_secret == "test_client_secret"
+ finally:
+ os.unlink(temp_path)
+
+ def test_credentials_to_dict_includes_provider(self):
+ """to_dict 应该包含 provider 字段"""
+ from kiro_proxy.credential.types import KiroCredentials
+
+ creds = KiroCredentials(
+ access_token="test",
+ refresh_token="test",
+ provider="Github"
+ )
+
+ data = creds.to_dict()
+ assert data["provider"] == "Github"
+
+ def test_credentials_to_dict_excludes_none_provider(self):
+ """to_dict 不应该包含 None 的 provider"""
+ from kiro_proxy.credential.types import KiroCredentials
+
+ creds = KiroCredentials(
+ access_token="test",
+ refresh_token="test",
+ provider=None
+ )
+
+ data = creds.to_dict()
+ assert "provider" not in data or data.get("provider") is None
+
+
+# ==================== Property 6 & 7: Token Refresh Tests ====================
+
+class TestTokenRefreshDispatch:
+ """Property 6: Token Refresh Method Dispatch"""
+
+ def test_social_auth_uses_social_refresh(self):
+ """social authMethod 应该使用 refresh_social_token"""
+ from kiro_proxy.credential.refresher import TokenRefresher
+ from kiro_proxy.credential.types import KiroCredentials
+
+ creds = KiroCredentials(
+ refresh_token="test_refresh_token_" + "x" * 100,
+ auth_method="social"
+ )
+ refresher = TokenRefresher(creds)
+
+ # 验证 URL
+ url = refresher.get_refresh_url()
+ assert "auth.desktop.kiro.dev/refreshToken" in url
+
+ def test_idc_auth_uses_oidc_refresh(self):
+ """idc authMethod 应该使用 OIDC 端点"""
+ from kiro_proxy.credential.refresher import TokenRefresher
+ from kiro_proxy.credential.types import KiroCredentials
+
+ creds = KiroCredentials(
+ refresh_token="test_refresh_token_" + "x" * 100,
+ auth_method="idc",
+ region="us-east-1"
+ )
+ refresher = TokenRefresher(creds)
+
+ url = refresher.get_refresh_url()
+ assert "oidc.us-east-1.amazonaws.com/token" in url
+
+ def test_validate_refresh_token_rejects_empty(self):
+ """空的 refresh_token 应该被拒绝"""
+ from kiro_proxy.credential.refresher import TokenRefresher
+ from kiro_proxy.credential.types import KiroCredentials
+
+ creds = KiroCredentials(refresh_token="")
+ refresher = TokenRefresher(creds)
+
+ valid, error = refresher.validate_refresh_token()
+ assert not valid
+ assert "为空" in error or "缺少" in error
+
+ def test_validate_refresh_token_rejects_truncated(self):
+ """截断的 refresh_token 应该被拒绝"""
+ from kiro_proxy.credential.refresher import TokenRefresher
+ from kiro_proxy.credential.types import KiroCredentials
+
+ creds = KiroCredentials(refresh_token="short_token...")
+ refresher = TokenRefresher(creds)
+
+ valid, error = refresher.validate_refresh_token()
+ assert not valid
+ assert "截断" in error
+
+
+class TestTokenRefreshUpdates:
+ """Property 7: Token Refresh Updates Credentials"""
+
+ def test_credentials_save_preserves_existing_data(self):
+ """save_to_file 应该保留现有数据"""
+ from kiro_proxy.credential.types import KiroCredentials
+ import tempfile
+ import os
+
+ # 创建初始文件
+ initial_data = {
+ "accessToken": "old_token",
+ "customField": "should_be_preserved"
+ }
+
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
+ json.dump(initial_data, f)
+ temp_path = f.name
+
+ try:
+ # 更新凭证
+ creds = KiroCredentials(
+ access_token="new_token",
+ refresh_token="new_refresh"
+ )
+ creds.save_to_file(temp_path)
+
+ # 验证
+ with open(temp_path) as f:
+ saved_data = json.load(f)
+
+ assert saved_data["accessToken"] == "new_token"
+ assert saved_data["refreshToken"] == "new_refresh"
+ assert saved_data["customField"] == "should_be_preserved"
+ finally:
+ os.unlink(temp_path)
+
+
+# ==================== Property 8: Provider Field Persistence ====================
+
+class TestProviderFieldPersistence:
+ """Property 8: Provider Field Persistence"""
+
+ def test_provider_field_roundtrip(self):
+ """provider 字段应该能正确保存和加载"""
+ from kiro_proxy.credential.types import KiroCredentials
+ import tempfile
+ import os
+
+ creds = KiroCredentials(
+ access_token="test",
+ refresh_token="test",
+ provider="Google",
+ auth_method="social"
+ )
+
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
+ temp_path = f.name
+
+ try:
+ creds.save_to_file(temp_path)
+ loaded = KiroCredentials.from_file(temp_path)
+
+ assert loaded.provider == "Google"
+ assert loaded.auth_method == "social"
+ finally:
+ os.unlink(temp_path)
+
+ def test_provider_in_status_info(self):
+ """get_status_info 应该包含 provider 字段"""
+ from kiro_proxy.core.account import Account
+ from kiro_proxy.credential.types import KiroCredentials
+ import tempfile
+ import os
+
+ # 创建测试凭证文件
+ test_data = {
+ "accessToken": "test",
+ "refreshToken": "test",
+ "provider": "Github",
+ "authMethod": "social"
+ }
+
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
+ json.dump(test_data, f)
+ temp_path = f.name
+
+ try:
+ account = Account(
+ id="test_id",
+ name="Test Account",
+ token_path=temp_path
+ )
+ account.load_credentials()
+
+ status = account.get_status_info()
+ assert status["provider"] == "Github"
+ finally:
+ os.unlink(temp_path)
+
+
+# ==================== Property 9 & 10: Compression Tests ====================
+
+class TestCompressionStateTracking:
+ """Property 9: Compression State Tracking and Caching"""
+
+ def test_hash_history_is_deterministic(self):
+ """相同历史应该产生相同哈希"""
+ from kiro_proxy.core.history_manager import HistoryManager
+
+ manager = HistoryManager()
+ history = [
+ {"userInputMessage": {"content": "Hello"}},
+ {"assistantResponseMessage": {"content": "Hi"}}
+ ]
+
+ hash1 = manager._hash_history(history)
+ hash2 = manager._hash_history(history)
+
+ assert hash1 == hash2
+
+ def test_hash_history_changes_with_content(self):
+ """不同历史应该产生不同哈希"""
+ from kiro_proxy.core.history_manager import HistoryManager
+
+ manager = HistoryManager()
+ # 使用不同长度的内容确保哈希不同
+ history1 = [{"userInputMessage": {"content": "Hello"}}]
+ history2 = [{"userInputMessage": {"content": "Hello World, this is longer"}}]
+
+ hash1 = manager._hash_history(history1)
+ hash2 = manager._hash_history(history2)
+
+ assert hash1 != hash2
+
+ @pytest.mark.asyncio
+ async def test_compression_cache_prevents_repeated_compression(self):
+ """压缩缓存应该防止重复压缩"""
+ from kiro_proxy.core.history_manager import HistoryManager
+
+ manager = HistoryManager()
+ history = [{"userInputMessage": {"content": "x" * 1000}} for _ in range(50)]
+
+ # 第一次压缩
+ result1, should_retry1 = await manager.handle_length_error_async(history, 0, None)
+
+ # 第二次压缩相同内容
+ result2, should_retry2 = await manager.handle_length_error_async(history, 0, None)
+
+ # 第二次应该检测到重复并跳过
+ # (由于缓存机制,第二次可能返回 False)
+
+
+class TestProgressiveCompression:
+ """Property 10: Progressive Compression Strategy"""
+
+ def test_max_retries_stops_compression(self):
+ """达到最大重试次数应该停止压缩"""
+ from kiro_proxy.core.history_manager import HistoryManager, HistoryConfig
+
+ config = HistoryConfig(max_retries=3)
+ manager = HistoryManager(config)
+ history = [{"userInputMessage": {"content": "test"}}]
+
+ # 超过最大重试次数
+ result, should_retry = manager.handle_length_error(history, 5)
+
+ assert not should_retry
+
+ def test_small_history_not_compressed(self):
+ """小于目标大小的历史不应该被压缩"""
+ from kiro_proxy.core.history_manager import HistoryManager
+
+ manager = HistoryManager()
+ history = [{"userInputMessage": {"content": "small"}}]
+
+ result, should_retry = manager.handle_length_error(history, 0)
+
+ # 小历史不需要压缩
+ assert len(result) == len(history)
+
+ @pytest.mark.asyncio
+ async def test_compression_reduces_size(self):
+ """压缩应该减少历史大小"""
+ from kiro_proxy.core.history_manager import HistoryManager
+
+ manager = HistoryManager()
+ # 创建大历史
+ history = [
+ {"userInputMessage": {"content": f"Message {i}: " + "x" * 500}}
+ for i in range(100)
+ ]
+
+ original_size = len(json.dumps(history))
+
+ # 模拟 API 调用
+ async def mock_api_caller(prompt):
+ return "Summary of conversation"
+
+ result, should_retry = await manager.handle_length_error_async(
+ history, 0, mock_api_caller
+ )
+
+ result_size = len(json.dumps(result))
+
+ # 压缩后应该更小
+ assert result_size < original_size
+
+
+# ==================== Property 3: Account Edit Tests ====================
+
+class TestAccountEditValidation:
+ """Property 3: Account Edit Validation and Persistence"""
+
+ def test_empty_name_not_updated(self):
+ """空名称不应该更新"""
+ # 这个测试需要模拟 API 调用
+ pass
+
+ def test_invalid_provider_rejected(self):
+ """无效的 provider 应该被拒绝"""
+ # 只允许 Google, Github, 或空
+ valid_providers = [None, "", "Google", "Github"]
+ invalid_providers = ["facebook", "twitter", "invalid"]
+
+ for p in valid_providers:
+ assert p in valid_providers
+
+ for p in invalid_providers:
+ assert p not in valid_providers
+
+
+# ==================== Property 4 & 5: Import Tests ====================
+
+class TestImportValidation:
+ """Property 4: Import Validation Based on AuthMethod"""
+
+ def test_idc_requires_client_credentials(self):
+ """IDC 认证应该需要 client_id 和 client_secret"""
+ # IDC 认证验证逻辑
+ auth_method = "idc"
+ client_id = ""
+ client_secret = ""
+
+ # 应该失败
+ is_valid = not (auth_method == "idc" and (not client_id or not client_secret))
+ assert not is_valid
+
+ def test_social_does_not_require_client_credentials(self):
+ """Social 认证不需要 client_id 和 client_secret"""
+ auth_method = "social"
+ client_id = ""
+ client_secret = ""
+
+ # 应该通过
+ is_valid = not (auth_method == "idc" and (not client_id or not client_secret))
+ assert is_valid
+
+ def test_refresh_token_required(self):
+ """refresh_token 是必填的"""
+ refresh_token = ""
+
+ is_valid = bool(refresh_token)
+ assert not is_valid
+
+
+class TestBatchImport:
+ """Property 5: Batch Import Processes All Valid Entries"""
+
+ def test_batch_import_skips_duplicates(self):
+ """批量导入应该跳过重复的 refresh_token"""
+ existing_tokens = {"token1", "token2"}
+ new_tokens = ["token1", "token3", "token4"]
+
+ imported = []
+ skipped = []
+
+ for token in new_tokens:
+ if token in existing_tokens:
+ skipped.append(token)
+ else:
+ imported.append(token)
+ existing_tokens.add(token)
+
+ assert len(imported) == 2
+ assert len(skipped) == 1
+ assert "token1" in skipped
+
+ def test_batch_import_continues_on_error(self):
+ """批量导入应该在单个错误后继续处理"""
+ accounts = [
+ {"refresh_token": "valid1"},
+ {"refresh_token": ""}, # 无效
+ {"refresh_token": "valid2"},
+ ]
+
+ success = 0
+ failed = 0
+
+ for acc in accounts:
+ if acc["refresh_token"]:
+ success += 1
+ else:
+ failed += 1
+
+ assert success == 2
+ assert failed == 1
diff --git a/KiroProxy/tests/test_account_selector.py b/KiroProxy/tests/test_account_selector.py
new file mode 100644
index 0000000000000000000000000000000000000000..12213ea5783bb7fbf654926f94e3179e72b8a122
--- /dev/null
+++ b/KiroProxy/tests/test_account_selector.py
@@ -0,0 +1,511 @@
+"""AccountSelector 属性测试和单元测试
+
+Property 4: 最少额度优先选择
+Property 5: 优先账号选择
+Property 6: 优先账号验证
+"""
+import json
+import os
+import time
+import tempfile
+from pathlib import Path
+from dataclasses import dataclass
+from typing import Optional, Set
+
+import pytest
+from hypothesis import given, strategies as st, settings, assume
+
+import sys
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from kiro_proxy.core.quota_cache import QuotaCache, CachedQuota
+from kiro_proxy.core.account_selector import AccountSelector, SelectionStrategy
+
+
+# ============== Mock Account 类 ==============
+
+@dataclass
+class MockAccount:
+ """模拟账号类,用于测试"""
+ id: str
+ name: str = ""
+ enabled: bool = True
+ request_count: int = 0
+ _available: bool = True
+
+ def is_available(self) -> bool:
+ return self.enabled and self._available
+
+
+# ============== 数据生成策略 ==============
+
+@st.composite
+def account_id_strategy(draw):
+ """生成有效的账号ID"""
+ return draw(st.text(
+ alphabet=st.characters(whitelist_categories=('L', 'N'), whitelist_characters='_-'),
+ min_size=1,
+ max_size=16
+ ))
+
+
+@st.composite
+def mock_account_strategy(draw, account_id: Optional[str] = None):
+ """生成模拟账号"""
+ if account_id is None:
+ account_id = draw(account_id_strategy())
+ return MockAccount(
+ id=account_id,
+ name=f"Account {account_id}",
+ enabled=draw(st.booleans()),
+ request_count=draw(st.integers(min_value=0, max_value=10000)),
+ _available=draw(st.booleans())
+ )
+
+
+@st.composite
+def accounts_with_quotas_strategy(draw, min_accounts=2, max_accounts=10):
+ """生成账号列表和对应的额度缓存"""
+ num_accounts = draw(st.integers(min_value=min_accounts, max_value=max_accounts))
+
+ accounts = []
+ quotas = {}
+
+ for i in range(num_accounts):
+ account_id = f"acc_{i}"
+ account = MockAccount(
+ id=account_id,
+ name=f"Account {i}",
+ enabled=True,
+ request_count=draw(st.integers(min_value=0, max_value=1000)),
+ _available=True
+ )
+ accounts.append(account)
+
+ # 生成额度信息
+ balance = draw(st.floats(min_value=0.0, max_value=1000.0, allow_nan=False, allow_infinity=False))
+ quotas[account_id] = CachedQuota(
+ account_id=account_id,
+ usage_limit=1000.0,
+ current_usage=1000.0 - balance,
+ balance=balance,
+ updated_at=time.time()
+ )
+
+ return accounts, quotas
+
+
+# ============== 默认策略:随机(避免单账号压力过大) ==============
+
+class TestDefaultRandomStrategy:
+ def test_default_strategy_is_random(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ cache_file = os.path.join(tmpdir, "quota_cache.json")
+ priority_file = os.path.join(tmpdir, "priority.json")
+
+ cache = QuotaCache(cache_file=cache_file)
+ selector = AccountSelector(quota_cache=cache, priority_file=priority_file)
+
+ assert selector.strategy == SelectionStrategy.RANDOM
+
+ def test_legacy_lowest_balance_migrates_to_random_when_no_priority(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ cache_file = os.path.join(tmpdir, "quota_cache.json")
+ priority_file = os.path.join(tmpdir, "priority.json")
+
+ Path(priority_file).write_text(
+ json.dumps(
+ {"version": "1.0", "priority_accounts": [], "strategy": "lowest_balance"},
+ ensure_ascii=False,
+ indent=2,
+ ),
+ encoding="utf-8",
+ )
+
+ cache = QuotaCache(cache_file=cache_file)
+ selector = AccountSelector(quota_cache=cache, priority_file=priority_file)
+
+ assert selector.strategy == SelectionStrategy.RANDOM
+ saved = json.loads(Path(priority_file).read_text(encoding="utf-8"))
+ assert saved.get("strategy") == SelectionStrategy.RANDOM.value
+
+ def test_random_strategy_avoids_consecutive_same_account(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ cache_file = os.path.join(tmpdir, "quota_cache.json")
+ priority_file = os.path.join(tmpdir, "priority.json")
+
+ cache = QuotaCache(cache_file=cache_file)
+ selector = AccountSelector(quota_cache=cache, priority_file=priority_file)
+
+ accounts = [
+ MockAccount(id="acc_1", _available=True, enabled=True),
+ MockAccount(id="acc_2", _available=True, enabled=True),
+ MockAccount(id="acc_3", _available=True, enabled=True),
+ ]
+
+ ids = []
+ for _ in range(30):
+ selected = selector.select(accounts)
+ assert selected is not None
+ ids.append(selected.id)
+
+ assert all(a != b for a, b in zip(ids, ids[1:]))
+
+
+# ============== Property 4: 最少额度优先选择 ==============
+# **Validates: Requirements 3.1, 3.3**
+
+class TestLowestBalanceSelection:
+ """Property 4: 最少额度优先选择测试"""
+
+ @given(data=st.data())
+ @settings(max_examples=100)
+ def test_selects_lowest_balance_account(self, data):
+ """
+ Property 4: 最少额度优先选择
+ *对于任意*可用账号列表(无优先账号配置时),选择器应返回剩余额度最少的账号。
+ 如果存在多个相同最少额度的账号,应返回请求次数最少的账号。
+
+ **Validates: Requirements 3.1, 3.3**
+ """
+ with tempfile.TemporaryDirectory() as tmpdir:
+ cache_file = os.path.join(tmpdir, "quota_cache.json")
+ priority_file = os.path.join(tmpdir, "priority.json")
+
+ cache = QuotaCache(cache_file=cache_file)
+ selector = AccountSelector(quota_cache=cache, priority_file=priority_file)
+ selector.strategy = SelectionStrategy.LOWEST_BALANCE
+
+ # 生成账号和额度
+ accounts, quotas = data.draw(accounts_with_quotas_strategy(min_accounts=2, max_accounts=5))
+
+ # 设置缓存
+ for account_id, quota in quotas.items():
+ cache.set(account_id, quota)
+
+ # 选择账号
+ selected = selector.select(accounts)
+
+ # 验证选择了余额最少的账号
+ assert selected is not None
+
+ selected_quota = quotas[selected.id]
+ for account in accounts:
+ if account.is_available():
+ other_quota = quotas[account.id]
+ # 选中的账号余额应该 <= 其他账号
+ if other_quota.balance < selected_quota.balance:
+ # 如果有更低余额的账号,测试失败
+ assert False, f"应该选择余额更低的账号 {account.id}"
+ elif other_quota.balance == selected_quota.balance:
+ # 余额相同时,请求数应该 <= 其他账号
+ assert selected.request_count <= account.request_count
+
+ def test_selects_lowest_balance_simple(self):
+ """简单场景:选择余额最少的账号"""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ cache_file = os.path.join(tmpdir, "quota_cache.json")
+ priority_file = os.path.join(tmpdir, "priority.json")
+
+ cache = QuotaCache(cache_file=cache_file)
+ selector = AccountSelector(quota_cache=cache, priority_file=priority_file)
+ selector.strategy = SelectionStrategy.LOWEST_BALANCE
+
+ # 创建账号
+ accounts = [
+ MockAccount(id="acc_1", request_count=10, _available=True, enabled=True),
+ MockAccount(id="acc_2", request_count=5, _available=True, enabled=True),
+ MockAccount(id="acc_3", request_count=20, _available=True, enabled=True),
+ ]
+
+ # 设置额度:acc_2 余额最少
+ cache.set("acc_1", CachedQuota(account_id="acc_1", balance=500.0, updated_at=time.time()))
+ cache.set("acc_2", CachedQuota(account_id="acc_2", balance=100.0, updated_at=time.time()))
+ cache.set("acc_3", CachedQuota(account_id="acc_3", balance=800.0, updated_at=time.time()))
+
+ selected = selector.select(accounts)
+ assert selected is not None
+ assert selected.id == "acc_2"
+
+ def test_same_balance_selects_least_requests(self):
+ """余额相同时选择请求数最少的账号"""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ cache_file = os.path.join(tmpdir, "quota_cache.json")
+ priority_file = os.path.join(tmpdir, "priority.json")
+
+ cache = QuotaCache(cache_file=cache_file)
+ selector = AccountSelector(quota_cache=cache, priority_file=priority_file)
+ selector.strategy = SelectionStrategy.LOWEST_BALANCE
+
+ accounts = [
+ MockAccount(id="acc_1", request_count=100, _available=True, enabled=True),
+ MockAccount(id="acc_2", request_count=50, _available=True, enabled=True),
+ MockAccount(id="acc_3", request_count=200, _available=True, enabled=True),
+ ]
+
+ # 所有账号余额相同
+ for acc in accounts:
+ cache.set(acc.id, CachedQuota(account_id=acc.id, balance=500.0, updated_at=time.time()))
+
+ selected = selector.select(accounts)
+ assert selected is not None
+ assert selected.id == "acc_2" # 请求数最少
+
+
+# ============== Property 5: 优先账号选择 ==============
+# **Validates: Requirements 3.2, 4.3, 4.4**
+
+class TestPriorityAccountSelection:
+ """Property 5: 优先账号选择测试"""
+
+ @given(data=st.data())
+ @settings(max_examples=100)
+ def test_priority_account_selected_first(self, data):
+ """
+ Property 5: 优先账号选择
+ *对于任意*可用账号列表和优先账号配置,如果优先账号列表中存在可用账号,
+ 选择器应按优先级顺序返回第一个可用的优先账号;
+ 如果所有优先账号都不可用,应回退到最少额度优先策略。
+
+ **Validates: Requirements 3.2, 4.3, 4.4**
+ """
+ with tempfile.TemporaryDirectory() as tmpdir:
+ cache_file = os.path.join(tmpdir, "quota_cache.json")
+ priority_file = os.path.join(tmpdir, "priority.json")
+
+ cache = QuotaCache(cache_file=cache_file)
+ selector = AccountSelector(quota_cache=cache, priority_file=priority_file)
+
+ # 生成账号
+ num_accounts = data.draw(st.integers(min_value=3, max_value=6))
+ accounts = []
+ for i in range(num_accounts):
+ account_id = f"acc_{i}"
+ accounts.append(MockAccount(
+ id=account_id,
+ enabled=True,
+ _available=True,
+ request_count=data.draw(st.integers(min_value=0, max_value=100))
+ ))
+ cache.set(account_id, CachedQuota(
+ account_id=account_id,
+ balance=data.draw(st.floats(min_value=100.0, max_value=1000.0, allow_nan=False, allow_infinity=False)),
+ updated_at=time.time()
+ ))
+
+ # 随机选择一些账号作为优先账号
+ priority_count = data.draw(st.integers(min_value=1, max_value=min(3, num_accounts)))
+ priority_ids = [accounts[i].id for i in range(priority_count)]
+
+ valid_ids = {acc.id for acc in accounts}
+ selector.set_priority_accounts(priority_ids, valid_ids)
+
+ # 选择账号
+ selected = selector.select(accounts)
+
+ # 验证选择了第一个可用的优先账号
+ assert selected is not None
+
+ # 找到第一个可用的优先账号
+ first_available_priority = None
+ for pid in priority_ids:
+ for acc in accounts:
+ if acc.id == pid and acc.is_available():
+ first_available_priority = acc
+ break
+ if first_available_priority:
+ break
+
+ if first_available_priority:
+ assert selected.id == first_available_priority.id
+
+ def test_priority_fallback_to_lowest_balance(self):
+ """优先账号不可用时回退到最少额度策略"""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ cache_file = os.path.join(tmpdir, "quota_cache.json")
+ priority_file = os.path.join(tmpdir, "priority.json")
+
+ cache = QuotaCache(cache_file=cache_file)
+ selector = AccountSelector(quota_cache=cache, priority_file=priority_file)
+ selector.strategy = SelectionStrategy.LOWEST_BALANCE
+
+ accounts = [
+ MockAccount(id="acc_1", _available=False, enabled=True), # 优先但不可用
+ MockAccount(id="acc_2", _available=True, enabled=True),
+ MockAccount(id="acc_3", _available=True, enabled=True),
+ ]
+
+ cache.set("acc_1", CachedQuota(account_id="acc_1", balance=1000.0, updated_at=time.time()))
+ cache.set("acc_2", CachedQuota(account_id="acc_2", balance=200.0, updated_at=time.time()))
+ cache.set("acc_3", CachedQuota(account_id="acc_3", balance=500.0, updated_at=time.time()))
+
+ # 设置 acc_1 为优先账号
+ selector.set_priority_accounts(["acc_1"], {"acc_1", "acc_2", "acc_3"})
+
+ selected = selector.select(accounts)
+
+ # 优先账号不可用,应该选择余额最少的 acc_2
+ assert selected is not None
+ assert selected.id == "acc_2"
+
+
+# ============== Property 6: 优先账号验证 ==============
+# **Validates: Requirements 4.2**
+
+class TestPriorityAccountValidation:
+ """Property 6: 优先账号验证测试"""
+
+ @given(
+ valid_ids=st.lists(account_id_strategy(), min_size=1, max_size=5, unique=True),
+ invalid_id=account_id_strategy()
+ )
+ @settings(max_examples=100)
+ def test_invalid_account_rejected(self, valid_ids: list, invalid_id: str):
+ """
+ Property 6: 优先账号验证
+ *对于任意*账号ID,设置为优先账号时,如果该账号不存在或未启用,
+ 操作应失败并返回错误;如果账号存在且已启用,操作应成功。
+
+ **Validates: Requirements 4.2**
+ """
+ # 确保 invalid_id 不在 valid_ids 中
+ assume(invalid_id not in valid_ids)
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ cache_file = os.path.join(tmpdir, "quota_cache.json")
+ priority_file = os.path.join(tmpdir, "priority.json")
+
+ cache = QuotaCache(cache_file=cache_file)
+ selector = AccountSelector(quota_cache=cache, priority_file=priority_file)
+
+ valid_set = set(valid_ids)
+
+ # 测试添加无效账号
+ success, msg = selector.add_priority_account(invalid_id, valid_account_ids=valid_set)
+ assert not success, "添加无效账号应该失败"
+
+ # 测试添加有效账号
+ valid_id = valid_ids[0]
+ success, msg = selector.add_priority_account(valid_id, valid_account_ids=valid_set)
+ assert success, "添加有效账号应该成功"
+
+ def test_set_priority_validates_all_accounts(self):
+ """设置优先账号列表时验证所有账号"""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ cache_file = os.path.join(tmpdir, "quota_cache.json")
+ priority_file = os.path.join(tmpdir, "priority.json")
+
+ cache = QuotaCache(cache_file=cache_file)
+ selector = AccountSelector(quota_cache=cache, priority_file=priority_file)
+
+ valid_ids = {"acc_1", "acc_2", "acc_3"}
+
+ # 包含无效账号的列表应该失败
+ success, msg = selector.set_priority_accounts(
+ ["acc_1", "invalid_acc"],
+ valid_account_ids=valid_ids
+ )
+ assert not success
+
+ # 全部有效的列表应该成功
+ success, msg = selector.set_priority_accounts(
+ ["acc_1", "acc_2"],
+ valid_account_ids=valid_ids
+ )
+ assert success
+
+
+# ============== 单元测试:空账号列表和边界情况 ==============
+# **Validates: Requirements 3.4**
+
+class TestEdgeCases:
+ """边界情况单元测试"""
+
+ def test_empty_account_list(self):
+ """空账号列表应返回 None"""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ cache_file = os.path.join(tmpdir, "quota_cache.json")
+ priority_file = os.path.join(tmpdir, "priority.json")
+
+ cache = QuotaCache(cache_file=cache_file)
+ selector = AccountSelector(quota_cache=cache, priority_file=priority_file)
+
+ selected = selector.select([])
+ assert selected is None
+
+ def test_all_accounts_unavailable(self):
+ """所有账号不可用时应返回 None"""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ cache_file = os.path.join(tmpdir, "quota_cache.json")
+ priority_file = os.path.join(tmpdir, "priority.json")
+
+ cache = QuotaCache(cache_file=cache_file)
+ selector = AccountSelector(quota_cache=cache, priority_file=priority_file)
+
+ accounts = [
+ MockAccount(id="acc_1", _available=False, enabled=True),
+ MockAccount(id="acc_2", _available=False, enabled=True),
+ ]
+
+ selected = selector.select(accounts)
+ assert selected is None
+
+ def test_remove_priority_account(self):
+ """移除优先账号"""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ cache_file = os.path.join(tmpdir, "quota_cache.json")
+ priority_file = os.path.join(tmpdir, "priority.json")
+
+ cache = QuotaCache(cache_file=cache_file)
+ selector = AccountSelector(quota_cache=cache, priority_file=priority_file)
+
+ selector.set_priority_accounts(["acc_1", "acc_2"], None)
+ assert "acc_1" in selector.get_priority_accounts()
+
+ success, _ = selector.remove_priority_account("acc_1")
+ assert success
+ assert "acc_1" not in selector.get_priority_accounts()
+
+ # 移除不存在的账号应该失败
+ success, _ = selector.remove_priority_account("acc_1")
+ assert not success
+
+ def test_reorder_priority_accounts(self):
+ """重新排序优先账号"""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ cache_file = os.path.join(tmpdir, "quota_cache.json")
+ priority_file = os.path.join(tmpdir, "priority.json")
+
+ cache = QuotaCache(cache_file=cache_file)
+ selector = AccountSelector(quota_cache=cache, priority_file=priority_file)
+
+ selector.set_priority_accounts(["acc_1", "acc_2", "acc_3"], None)
+
+ # 正确的重排序
+ success, _ = selector.reorder_priority(["acc_3", "acc_1", "acc_2"])
+ assert success
+ assert selector.get_priority_accounts() == ["acc_3", "acc_1", "acc_2"]
+
+ # 缺少账号的重排序应该失败
+ success, _ = selector.reorder_priority(["acc_3", "acc_1"])
+ assert not success
+
+ def test_priority_order(self):
+ """获取优先级顺序"""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ cache_file = os.path.join(tmpdir, "quota_cache.json")
+ priority_file = os.path.join(tmpdir, "priority.json")
+
+ cache = QuotaCache(cache_file=cache_file)
+ selector = AccountSelector(quota_cache=cache, priority_file=priority_file)
+
+ selector.set_priority_accounts(["acc_1", "acc_2", "acc_3"], None)
+
+ assert selector.get_priority_order("acc_1") == 1
+ assert selector.get_priority_order("acc_2") == 2
+ assert selector.get_priority_order("acc_3") == 3
+ assert selector.get_priority_order("acc_4") is None
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
diff --git a/KiroProxy/tests/test_model_mapping.py b/KiroProxy/tests/test_model_mapping.py
new file mode 100644
index 0000000000000000000000000000000000000000..625ccaee866f4f634422be91c5ec5285ca10949c
--- /dev/null
+++ b/KiroProxy/tests/test_model_mapping.py
@@ -0,0 +1,25 @@
+import pytest
+
+
+def test_map_model_name_downgrades_opus():
+ from kiro_proxy.config import map_model_name
+
+ assert map_model_name("claude-opus-4.5") == "claude-sonnet-4.5"
+ assert map_model_name("claude-3-opus-20240229") == "claude-sonnet-4.5"
+ assert map_model_name("claude-3-opus-latest") == "claude-sonnet-4.5"
+ assert map_model_name("claude-4-opus") == "claude-sonnet-4.5"
+ assert map_model_name("o1") == "claude-sonnet-4.5"
+ assert map_model_name("o1-preview") == "claude-sonnet-4.5"
+ assert map_model_name("opus") == "claude-sonnet-4.5"
+
+
+@pytest.mark.asyncio
+async def test_models_fallback_does_not_advertise_opus(monkeypatch):
+ from kiro_proxy.routers import protocols
+
+ monkeypatch.setattr(protocols.state, "get_available_account", lambda *args, **kwargs: None)
+
+ resp = await protocols.models()
+ model_ids = {m["id"] for m in resp.get("data", [])}
+ assert "claude-opus-4.5" not in model_ids
+
diff --git a/KiroProxy/tests/test_quota_cache.py b/KiroProxy/tests/test_quota_cache.py
new file mode 100644
index 0000000000000000000000000000000000000000..246181d3e4a60de3911110e724f8c1fccb9518f0
--- /dev/null
+++ b/KiroProxy/tests/test_quota_cache.py
@@ -0,0 +1,479 @@
+"""QuotaCache 属性测试和单元测试
+
+Property 1: 缓存存储完整性 - 存储后读取应返回完整数据
+Property 2: 缓存持久化往返 - 保存后加载应产生等价状态
+"""
+import os
+import time
+import tempfile
+from pathlib import Path
+
+import pytest
+from hypothesis import given, strategies as st, settings, assume
+
+# 添加项目路径
+import sys
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from kiro_proxy.core.quota_cache import (
+ QuotaCache, CachedQuota, DEFAULT_CACHE_MAX_AGE
+)
+
+
+# ============== 数据生成策略 ==============
+
+# 固定的时间戳范围,避免 hypothesis 的 flaky 问题
+FIXED_MAX_TIMESTAMP = 2000000000.0 # 约 2033 年
+
+
+@st.composite
+def valid_quota_strategy(draw):
+ """生成有效的 CachedQuota 数据"""
+ usage_limit = draw(st.floats(min_value=0.0, max_value=10000.0, allow_nan=False, allow_infinity=False))
+ current_usage = draw(st.floats(min_value=0.0, max_value=usage_limit, allow_nan=False, allow_infinity=False))
+ balance = usage_limit - current_usage
+ usage_percent = (current_usage / usage_limit * 100) if usage_limit > 0 else 0.0
+
+ free_trial_limit = draw(st.floats(min_value=0.0, max_value=1000.0, allow_nan=False, allow_infinity=False))
+ free_trial_usage = draw(st.floats(min_value=0.0, max_value=free_trial_limit, allow_nan=False, allow_infinity=False))
+
+ bonus_limit = draw(st.floats(min_value=0.0, max_value=500.0, allow_nan=False, allow_infinity=False))
+ bonus_usage = draw(st.floats(min_value=0.0, max_value=bonus_limit, allow_nan=False, allow_infinity=False))
+
+ return CachedQuota(
+ account_id=draw(st.text(alphabet=st.characters(whitelist_categories=('L', 'N'), whitelist_characters='_-'), min_size=1, max_size=32)),
+ usage_limit=usage_limit,
+ current_usage=current_usage,
+ balance=balance,
+ usage_percent=round(usage_percent, 2),
+ is_low_balance=balance < usage_limit * 0.2 if usage_limit > 0 else False,
+ subscription_title=draw(st.text(min_size=0, max_size=50)),
+ free_trial_limit=free_trial_limit,
+ free_trial_usage=free_trial_usage,
+ bonus_limit=bonus_limit,
+ bonus_usage=bonus_usage,
+ updated_at=draw(st.floats(min_value=0.0, max_value=FIXED_MAX_TIMESTAMP, allow_nan=False, allow_infinity=False)),
+ error=draw(st.one_of(st.none(), st.text(min_size=1, max_size=100)))
+ )
+
+
+@st.composite
+def account_id_strategy(draw):
+ """生成有效的账号ID"""
+ return draw(st.text(
+ alphabet=st.characters(whitelist_categories=('L', 'N'), whitelist_characters='_-'),
+ min_size=1,
+ max_size=32
+ ))
+
+
+# ============== Property 1: 缓存存储完整性 ==============
+# **Validates: Requirements 1.2, 2.3**
+
+class TestCacheStorageIntegrity:
+ """Property 1: 缓存存储完整性测试"""
+
+ @given(quota=valid_quota_strategy())
+ @settings(max_examples=100)
+ def test_set_then_get_returns_complete_data(self, quota: CachedQuota):
+ """
+ Property 1: 缓存存储完整性
+ *对于任意*有效的额度信息,当存储到 QuotaCache 后,
+ 读取该账号的缓存应返回包含所有必要字段的完整数据。
+
+ **Validates: Requirements 1.2, 2.3**
+ """
+ # 使用临时文件避免影响真实缓存
+ with tempfile.NamedTemporaryFile(suffix='.json', delete=False) as f:
+ cache_file = f.name
+
+ try:
+ cache = QuotaCache(cache_file=cache_file)
+
+ # 存储
+ cache.set(quota.account_id, quota)
+
+ # 读取
+ retrieved = cache.get(quota.account_id)
+
+ # 验证完整性
+ assert retrieved is not None, "缓存应该存在"
+ assert retrieved.account_id == quota.account_id, "account_id 应该一致"
+ assert retrieved.usage_limit == quota.usage_limit, "usage_limit 应该一致"
+ assert retrieved.current_usage == quota.current_usage, "current_usage 应该一致"
+ assert retrieved.balance == quota.balance, "balance 应该一致"
+ assert retrieved.updated_at == quota.updated_at, "updated_at 应该一致"
+ assert retrieved.error == quota.error, "error 应该一致"
+
+ finally:
+ # 清理临时文件
+ if os.path.exists(cache_file):
+ os.unlink(cache_file)
+
+ @given(quotas=st.lists(valid_quota_strategy(), min_size=1, max_size=10, unique_by=lambda q: q.account_id))
+ @settings(max_examples=50)
+ def test_multiple_accounts_stored_independently(self, quotas: list):
+ """多个账号的缓存应该独立存储"""
+ with tempfile.NamedTemporaryFile(suffix='.json', delete=False) as f:
+ cache_file = f.name
+
+ try:
+ cache = QuotaCache(cache_file=cache_file)
+
+ # 存储所有账号
+ for quota in quotas:
+ cache.set(quota.account_id, quota)
+
+ # 验证每个账号都能正确读取
+ for quota in quotas:
+ retrieved = cache.get(quota.account_id)
+ assert retrieved is not None
+ assert retrieved.account_id == quota.account_id
+ assert retrieved.balance == quota.balance
+
+ finally:
+ if os.path.exists(cache_file):
+ os.unlink(cache_file)
+
+
+# ============== Property 2: 缓存持久化往返 ==============
+# **Validates: Requirements 7.1, 7.2**
+
+class TestCachePersistenceRoundTrip:
+ """Property 2: 缓存持久化往返测试"""
+
+ @given(quotas=st.lists(valid_quota_strategy(), min_size=1, max_size=10, unique_by=lambda q: q.account_id))
+ @settings(max_examples=100)
+ def test_save_then_load_preserves_data(self, quotas: list):
+ """
+ Property 2: 缓存持久化往返
+ *对于任意*有效的 QuotaCache 状态,保存到文件后再加载,
+ 应产生等价的缓存状态(所有账号的额度信息保持一致)。
+
+ **Validates: Requirements 7.1, 7.2**
+ """
+ with tempfile.NamedTemporaryFile(suffix='.json', delete=False) as f:
+ cache_file = f.name
+
+ try:
+ # 创建并填充缓存
+ cache1 = QuotaCache(cache_file=cache_file)
+ for quota in quotas:
+ cache1.set(quota.account_id, quota)
+
+ # 保存到文件
+ success = cache1.save_to_file()
+ assert success, "保存应该成功"
+
+ # 创建新缓存实例并加载
+ cache2 = QuotaCache(cache_file=cache_file)
+
+ # 验证数据一致性
+ all_cache1 = cache1.get_all()
+ all_cache2 = cache2.get_all()
+
+ assert len(all_cache1) == len(all_cache2), "账号数量应该一致"
+
+ for account_id, quota1 in all_cache1.items():
+ quota2 = all_cache2.get(account_id)
+ assert quota2 is not None, f"账号 {account_id} 应该存在"
+ assert quota1.usage_limit == quota2.usage_limit
+ assert quota1.current_usage == quota2.current_usage
+ assert quota1.balance == quota2.balance
+ assert quota1.updated_at == quota2.updated_at
+ assert quota1.error == quota2.error
+
+ finally:
+ if os.path.exists(cache_file):
+ os.unlink(cache_file)
+
+ @given(quota=valid_quota_strategy())
+ @settings(max_examples=50)
+ def test_dict_roundtrip(self, quota: CachedQuota):
+ """CachedQuota 的字典序列化往返"""
+ # 转换为字典
+ quota_dict = quota.to_dict()
+
+ # 从字典恢复
+ restored = CachedQuota.from_dict(quota_dict)
+
+ # 验证一致性
+ assert restored.account_id == quota.account_id
+ assert restored.usage_limit == quota.usage_limit
+ assert restored.current_usage == quota.current_usage
+ assert restored.balance == quota.balance
+ assert restored.updated_at == quota.updated_at
+ assert restored.error == quota.error
+
+
+# ============== 单元测试:缓存过期检测 ==============
+# **Validates: Requirements 7.3**
+
+class TestCacheExpiration:
+ """缓存过期检测单元测试"""
+
+ def test_fresh_cache_not_stale(self):
+ """新缓存不应该过期"""
+ with tempfile.NamedTemporaryFile(suffix='.json', delete=False) as f:
+ cache_file = f.name
+
+ try:
+ cache = QuotaCache(cache_file=cache_file)
+ quota = CachedQuota(
+ account_id="test_account",
+ usage_limit=1000.0,
+ current_usage=500.0,
+ balance=500.0,
+ updated_at=time.time() # 当前时间
+ )
+ cache.set("test_account", quota)
+
+ assert not cache.is_stale("test_account"), "新缓存不应该过期"
+
+ finally:
+ if os.path.exists(cache_file):
+ os.unlink(cache_file)
+
+ def test_old_cache_is_stale(self):
+ """旧缓存应该过期"""
+ with tempfile.NamedTemporaryFile(suffix='.json', delete=False) as f:
+ cache_file = f.name
+
+ try:
+ cache = QuotaCache(cache_file=cache_file)
+ quota = CachedQuota(
+ account_id="test_account",
+ usage_limit=1000.0,
+ current_usage=500.0,
+ balance=500.0,
+ updated_at=time.time() - DEFAULT_CACHE_MAX_AGE - 1 # 超过过期时间
+ )
+ cache.set("test_account", quota)
+
+ assert cache.is_stale("test_account"), "旧缓存应该过期"
+
+ finally:
+ if os.path.exists(cache_file):
+ os.unlink(cache_file)
+
+ def test_nonexistent_account_is_stale(self):
+ """不存在的账号应该被视为过期"""
+ with tempfile.NamedTemporaryFile(suffix='.json', delete=False) as f:
+ cache_file = f.name
+
+ try:
+ cache = QuotaCache(cache_file=cache_file)
+ assert cache.is_stale("nonexistent"), "不存在的账号应该被视为过期"
+
+ finally:
+ if os.path.exists(cache_file):
+ os.unlink(cache_file)
+
+
+# ============== 单元测试:文件读写错误处理 ==============
+# **Validates: Requirements 7.3**
+
+class TestFileErrorHandling:
+ """文件读写错误处理单元测试"""
+
+ def test_load_nonexistent_file(self):
+ """加载不存在的文件应该返回 False"""
+ cache = QuotaCache(cache_file="/nonexistent/path/cache.json")
+ result = cache.load_from_file()
+ assert result is False
+
+ def test_load_invalid_json(self):
+ """加载无效 JSON 应该返回 False"""
+ with tempfile.NamedTemporaryFile(suffix='.json', delete=False, mode='w') as f:
+ f.write("invalid json {{{")
+ cache_file = f.name
+
+ try:
+ cache = QuotaCache(cache_file=cache_file)
+ # 构造函数会尝试加载,但应该处理错误
+ assert len(cache.get_all()) == 0
+
+ finally:
+ if os.path.exists(cache_file):
+ os.unlink(cache_file)
+
+ def test_remove_account(self):
+ """移除账号应该正常工作"""
+ with tempfile.NamedTemporaryFile(suffix='.json', delete=False) as f:
+ cache_file = f.name
+
+ try:
+ cache = QuotaCache(cache_file=cache_file)
+ quota = CachedQuota(
+ account_id="test_account",
+ usage_limit=1000.0,
+ updated_at=time.time()
+ )
+ cache.set("test_account", quota)
+ assert cache.get("test_account") is not None
+
+ cache.remove("test_account")
+ assert cache.get("test_account") is None
+
+ finally:
+ if os.path.exists(cache_file):
+ os.unlink(cache_file)
+
+ def test_clear_cache(self):
+ """清空缓存应该正常工作"""
+ with tempfile.NamedTemporaryFile(suffix='.json', delete=False) as f:
+ cache_file = f.name
+
+ try:
+ cache = QuotaCache(cache_file=cache_file)
+ for i in range(5):
+ quota = CachedQuota(
+ account_id=f"account_{i}",
+ usage_limit=1000.0,
+ updated_at=time.time()
+ )
+ cache.set(f"account_{i}", quota)
+
+ assert len(cache.get_all()) == 5
+
+ cache.clear()
+ assert len(cache.get_all()) == 0
+
+ finally:
+ if os.path.exists(cache_file):
+ os.unlink(cache_file)
+
+
+# ============== 单元测试:CachedQuota 辅助方法 ==============
+
+class TestCachedQuotaMethods:
+ """CachedQuota 辅助方法测试"""
+
+ def test_has_error(self):
+ """has_error 方法测试"""
+ quota_ok = CachedQuota(account_id="test", error=None)
+ quota_err = CachedQuota(account_id="test", error="Some error")
+
+ assert not quota_ok.has_error()
+ assert quota_err.has_error()
+
+ def test_is_exhausted(self):
+ """is_exhausted 属性测试"""
+ quota_ok = CachedQuota(account_id="test", balance=100.0, usage_limit=1000.0)
+ quota_zero = CachedQuota(account_id="test", balance=0.0, usage_limit=1000.0)
+ quota_negative = CachedQuota(account_id="test", balance=-10.0, usage_limit=1000.0)
+ quota_error = CachedQuota(account_id="test", balance=0.0, usage_limit=1000.0, error="Error")
+
+ assert not quota_ok.is_exhausted
+ assert quota_zero.is_exhausted
+ assert quota_negative.is_exhausted
+ assert not quota_error.is_exhausted # 有错误时不更新状态
+
+ def test_balance_status(self):
+ """balance_status 属性测试"""
+ # 正常状态 (>20%)
+ quota_normal = CachedQuota(account_id="test", balance=500.0, usage_limit=1000.0)
+ assert quota_normal.balance_status == "normal"
+ assert not quota_normal.is_low_balance
+ assert not quota_normal.is_exhausted
+
+ # 低额度状态 (0-20%)
+ quota_low = CachedQuota(account_id="test", balance=100.0, usage_limit=1000.0)
+ assert quota_low.balance_status == "low"
+ assert quota_low.is_low_balance
+ assert not quota_low.is_exhausted
+
+ # 无额度状态 (<=0)
+ quota_exhausted = CachedQuota(account_id="test", balance=0.0, usage_limit=1000.0)
+ assert quota_exhausted.balance_status == "exhausted"
+ assert not quota_exhausted.is_low_balance
+ assert quota_exhausted.is_exhausted
+
+ def test_is_available(self):
+ """is_available 方法测试"""
+ quota_ok = CachedQuota(account_id="test", balance=100.0, usage_limit=1000.0)
+ quota_exhausted = CachedQuota(account_id="test", balance=0.0, usage_limit=1000.0)
+ quota_error = CachedQuota(account_id="test", balance=100.0, error="Error")
+
+ assert quota_ok.is_available()
+ assert not quota_exhausted.is_available()
+ assert not quota_error.is_available()
+
+ def test_from_error(self):
+ """from_error 工厂方法测试"""
+ quota = CachedQuota.from_error("test_account", "Connection failed")
+
+ assert quota.account_id == "test_account"
+ assert quota.error == "Connection failed"
+ assert quota.has_error()
+ assert quota.updated_at > 0
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
+
+
+# ============== Property 10: 低额度与无额度区分 ==============
+# **Validates: Requirements 5.5, 5.6**
+
+class TestBalanceStatusDistinction:
+ """Property 10: 低额度与无额度区分测试"""
+
+ @given(
+ balance=st.floats(min_value=-100.0, max_value=1000.0, allow_nan=False, allow_infinity=False),
+ usage_limit=st.floats(min_value=100.0, max_value=1000.0, allow_nan=False, allow_infinity=False)
+ )
+ @settings(max_examples=100)
+ def test_balance_status_distinction(self, balance: float, usage_limit: float):
+ """
+ Property 10: 低额度与无额度区分
+ *对于任意*账号,当剩余额度大于0但低于总额度的20%时,应标记为"低额度"状态;
+ 当剩余额度为0或负数时,应标记为"无额度"状态。
+
+ **Validates: Requirements 5.5, 5.6**
+ """
+ quota = CachedQuota(
+ account_id="test_account",
+ balance=balance,
+ usage_limit=usage_limit,
+ updated_at=time.time()
+ )
+
+ remaining_percent = (balance / usage_limit) * 100 if usage_limit > 0 else 0
+
+ if balance <= 0:
+ # 无额度状态
+ assert quota.balance_status == "exhausted", f"余额 {balance} 应该是 exhausted 状态"
+ assert quota.is_exhausted, f"余额 {balance} 应该标记为 is_exhausted"
+ assert not quota.is_low_balance, f"余额 {balance} 不应该标记为 is_low_balance"
+ assert not quota.is_available(), f"余额 {balance} 不应该可用"
+ elif remaining_percent <= 20:
+ # 低额度状态
+ assert quota.balance_status == "low", f"余额 {balance}/{usage_limit} ({remaining_percent:.1f}%) 应该是 low 状态"
+ assert quota.is_low_balance, f"余额 {balance}/{usage_limit} 应该标记为 is_low_balance"
+ assert not quota.is_exhausted, f"余额 {balance} 不应该标记为 is_exhausted"
+ assert quota.is_available(), f"余额 {balance} 应该可用"
+ else:
+ # 正常状态
+ assert quota.balance_status == "normal", f"余额 {balance}/{usage_limit} ({remaining_percent:.1f}%) 应该是 normal 状态"
+ assert not quota.is_low_balance, f"余额 {balance}/{usage_limit} 不应该标记为 is_low_balance"
+ assert not quota.is_exhausted, f"余额 {balance} 不应该标记为 is_exhausted"
+ assert quota.is_available(), f"余额 {balance} 应该可用"
+
+ def test_boundary_values(self):
+ """边界值测试"""
+ # 正好 20%
+ quota_20 = CachedQuota(account_id="test", balance=200.0, usage_limit=1000.0)
+ assert quota_20.balance_status == "low"
+
+ # 刚好超过 20%
+ quota_21 = CachedQuota(account_id="test", balance=210.0, usage_limit=1000.0)
+ assert quota_21.balance_status == "normal"
+
+ # 正好 0
+ quota_0 = CachedQuota(account_id="test", balance=0.0, usage_limit=1000.0)
+ assert quota_0.balance_status == "exhausted"
+
+ # 负数
+ quota_neg = CachedQuota(account_id="test", balance=-10.0, usage_limit=1000.0)
+ assert quota_neg.balance_status == "exhausted"
diff --git a/KiroProxy/tests/test_quota_reset_time.py b/KiroProxy/tests/test_quota_reset_time.py
new file mode 100644
index 0000000000000000000000000000000000000000..40b77eea7d8774ce63c6ff092ba5d3ec93f7aea6
--- /dev/null
+++ b/KiroProxy/tests/test_quota_reset_time.py
@@ -0,0 +1,167 @@
+"""测试额度重置时间功能"""
+import asyncio
+import json
+from datetime import datetime, timezone, timedelta
+from kiro_proxy.core.usage import calculate_balance, UsageInfo
+
+
+def test_quota_reset_time():
+ """测试额度重置时间解析"""
+
+ # 模拟 API 响应数据
+ mock_response = {
+ "subscriptionInfo": {
+ "subscriptionTitle": "Kiro Pro"
+ },
+ "usageBreakdownList": [
+ {
+ "resourceType": "CREDIT",
+ "displayName": "Credits",
+ "usageLimitWithPrecision": 50.0,
+ "currentUsageWithPrecision": 25.0,
+ "freeTrialInfo": {
+ "freeTrialStatus": "ACTIVE",
+ "usageLimitWithPrecision": 500.0,
+ "currentUsageWithPrecision": 100.0,
+ "freeTrialExpiry": "2026-02-13T23:59:59Z"
+ },
+ "bonuses": [
+ {
+ "status": "ACTIVE",
+ "usageLimitWithPrecision": 100.0,
+ "currentUsageWithPrecision": 0.0,
+ "expiresAt": "2026-03-01T23:59:59Z"
+ },
+ {
+ "status": "ACTIVE",
+ "usageLimitWithPrecision": 50.0,
+ "currentUsageWithPrecision": 25.0,
+ "expiresAt": "2026-02-28T23:59:59Z"
+ }
+ ]
+ }
+ ],
+ "nextDateReset": "2026-02-01T00:00:00Z"
+ }
+
+ # 解析额度信息
+ usage_info = calculate_balance(mock_response)
+
+ # 验证结果
+ print("=== 额度信息解析结果 ===")
+ print(f"订阅类型: {usage_info.subscription_title}")
+ print(f"总额度: {usage_info.usage_limit}")
+ print(f"已用额度: {usage_info.current_usage}")
+ print(f"剩余额度: {usage_info.balance}")
+ print(f"使用率: {(usage_info.current_usage / usage_info.usage_limit * 100):.1f}%")
+ print(f"下次重置时间: {usage_info.next_reset_date}")
+ print(f"免费试用过期时间: {usage_info.free_trial_expiry}")
+ print(f"奖励过期时间列表: {usage_info.bonus_expiries}")
+
+ # 验证具体数值
+ assert usage_info.usage_limit == 700.0 # 50 + 500 + 100 + 50
+ assert usage_info.current_usage == 150.0 # 25 + 100 + 0 + 25
+ assert usage_info.balance == 550.0
+ assert usage_info.free_trial_limit == 500.0
+ assert usage_info.free_trial_usage == 100.0
+ assert usage_info.bonus_limit == 150.0 # 100 + 50
+ assert usage_info.bonus_usage == 25.0 # 0 + 25
+ assert usage_info.next_reset_date == "2026-02-01T00:00:00Z"
+ assert usage_info.free_trial_expiry == "2026-02-13T23:59:59Z"
+ assert len(usage_info.bonus_expiries) == 2
+
+ print("\n✅ 测试通过!")
+
+
+def test_quota_cache_with_reset_time():
+ """测试额度缓存的重置时间功能"""
+ from kiro_proxy.core.quota_cache import CachedQuota
+
+ # 创建 UsageInfo
+ usage_info = UsageInfo(
+ subscription_title="Kiro Pro",
+ usage_limit=100.0,
+ current_usage=50.0,
+ balance=50.0,
+ next_reset_date="2026-02-01T00:00:00Z",
+ free_trial_expiry="2026-02-13T23:59:59Z",
+ bonus_expiries=["2026-03-01T23:59:59Z", "2026-02-28T23:59:59Z"]
+ )
+
+ # 从 UsageInfo 创建 CachedQuota
+ cached_quota = CachedQuota.from_usage_info("test_account", usage_info)
+
+ # 验证转换结果
+ print("\n=== 缓存额度信息 ===")
+ print(f"账号ID: {cached_quota.account_id}")
+ print(f"下次重置时间: {cached_quota.next_reset_date}")
+ print(f"免费试用过期时间: {cached_quota.free_trial_expiry}")
+ print(f"奖励过期时间: {cached_quota.bonus_expiries}")
+
+ # 转换为字典并验证
+ quota_dict = cached_quota.to_dict()
+ assert quota_dict["next_reset_date"] == "2026-02-01T00:00:00Z"
+ assert quota_dict["free_trial_expiry"] == "2026-02-13T23:59:59Z"
+ assert len(quota_dict["bonus_expiries"]) == 2
+
+ # 从字典重建并验证
+ rebuilt_quota = CachedQuota.from_dict(quota_dict)
+ assert rebuilt_quota.next_reset_date == cached_quota.next_reset_date
+ assert rebuilt_quota.free_trial_expiry == cached_quota.free_trial_expiry
+ assert rebuilt_quota.bonus_expiries == cached_quota.bonus_expiries
+
+ print("\n✅ 缓存测试通过!")
+
+
+def test_account_status_info():
+ """测试账号状态信息中的重置时间"""
+ from kiro_proxy.core.account import Account
+ from kiro_proxy.core.quota_cache import CachedQuota, get_quota_cache
+
+ # 创建模拟账号
+ account = Account(
+ id="test_account",
+ name="测试账号",
+ token_path="/tmp/test_token.json"
+ )
+
+ # 创建缓存额度
+ cached_quota = CachedQuota(
+ account_id="test_account",
+ usage_limit=100.0,
+ current_usage=50.0,
+ balance=50.0,
+ next_reset_date="2026-02-01T00:00:00Z",
+ free_trial_expiry="2026-02-13T23:59:59Z",
+ bonus_expiries=["2026-03-01T23:59:59Z"]
+ )
+
+ # 设置缓存
+ quota_cache = get_quota_cache()
+ quota_cache.set("test_account", cached_quota)
+
+ # 获取状态信息
+ status_info = account.get_status_info()
+
+ # 验证重置时间信息
+ print("\n=== 账号状态信息 ===")
+ quota_info = status_info.get("quota")
+ if quota_info:
+ print(f"下次重置时间: {quota_info.get('next_reset_date')}")
+ print(f"格式化重置日期: {quota_info.get('reset_date_text')}")
+ print(f"免费试用过期时间: {quota_info.get('free_trial_expiry')}")
+ print(f"格式化过期日期: {quota_info.get('trial_expiry_text')}")
+ print(f"生效奖励数: {quota_info.get('active_bonuses')}")
+
+ assert quota_info["reset_date_text"] == "2026-02-01"
+ assert quota_info["trial_expiry_text"] == "2026-02-13"
+ assert quota_info["active_bonuses"] == 1
+
+ print("\n✅ 账号状态测试通过!")
+
+
+if __name__ == "__main__":
+ test_quota_reset_time()
+ test_quota_cache_with_reset_time()
+ test_account_status_info()
+ print("\n🎉 所有测试通过!额度重置时间功能已成功实现。")
diff --git a/KiroProxy/tests/test_quota_scheduler.py b/KiroProxy/tests/test_quota_scheduler.py
new file mode 100644
index 0000000000000000000000000000000000000000..a0d78c488914caa3eb2603a8a88628d7d92c0f2a
--- /dev/null
+++ b/KiroProxy/tests/test_quota_scheduler.py
@@ -0,0 +1,273 @@
+"""QuotaScheduler 属性测试和单元测试
+
+Property 3: 活跃账号判定
+Property 7: 额度耗尽检测
+Property 8: 缓存过期检测
+Property 9: 获取失败状态标记
+"""
+import os
+import time
+import tempfile
+from pathlib import Path
+from dataclasses import dataclass
+from typing import Optional
+
+import pytest
+from hypothesis import given, strategies as st, settings
+
+import sys
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from kiro_proxy.core.quota_cache import QuotaCache, CachedQuota, DEFAULT_CACHE_MAX_AGE
+from kiro_proxy.core.quota_scheduler import (
+ QuotaScheduler, ACTIVE_WINDOW_SECONDS
+)
+
+
+# ============== Property 3: 活跃账号判定 ==============
+# **Validates: Requirements 2.2**
+
+class TestActiveAccountDetermination:
+ """Property 3: 活跃账号判定测试"""
+
+ @given(
+ account_id=st.text(min_size=1, max_size=16, alphabet=st.characters(whitelist_categories=('L', 'N'))),
+ seconds_ago=st.floats(min_value=0.0, max_value=300.0, allow_nan=False, allow_infinity=False)
+ )
+ @settings(max_examples=100)
+ def test_active_account_determination(self, account_id: str, seconds_ago: float):
+ """
+ Property 3: 活跃账号判定
+ *对于任意*账号和任意时间戳,如果账号的最后使用时间在当前时间60秒内,
+ 则该账号应被判定为活跃账号;否则应被判定为非活跃账号。
+
+ **Validates: Requirements 2.2**
+ """
+ with tempfile.TemporaryDirectory() as tmpdir:
+ cache_file = os.path.join(tmpdir, "quota_cache.json")
+ cache = QuotaCache(cache_file=cache_file)
+ scheduler = QuotaScheduler(quota_cache=cache)
+
+ # 模拟在 seconds_ago 秒前使用账号
+ scheduler._active_accounts[account_id] = time.time() - seconds_ago
+
+ is_active = scheduler.is_active(account_id)
+
+ # 验证活跃判定
+ if seconds_ago < ACTIVE_WINDOW_SECONDS:
+ assert is_active, f"账号在 {seconds_ago:.1f} 秒前使用,应该是活跃的"
+ else:
+ assert not is_active, f"账号在 {seconds_ago:.1f} 秒前使用,不应该是活跃的"
+
+ def test_mark_active_updates_timestamp(self):
+ """标记活跃应该更新时间戳"""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ cache_file = os.path.join(tmpdir, "quota_cache.json")
+ cache = QuotaCache(cache_file=cache_file)
+ scheduler = QuotaScheduler(quota_cache=cache)
+
+ # 初始不活跃
+ assert not scheduler.is_active("test_account")
+
+ # 标记活跃
+ scheduler.mark_active("test_account")
+
+ # 现在应该活跃
+ assert scheduler.is_active("test_account")
+
+ def test_get_active_accounts(self):
+ """获取活跃账号列表"""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ cache_file = os.path.join(tmpdir, "quota_cache.json")
+ cache = QuotaCache(cache_file=cache_file)
+ scheduler = QuotaScheduler(quota_cache=cache)
+
+ # 设置一些账号
+ scheduler._active_accounts["active_1"] = time.time()
+ scheduler._active_accounts["active_2"] = time.time() - 30
+ scheduler._active_accounts["inactive"] = time.time() - 120
+
+ active = scheduler.get_active_accounts()
+
+ assert "active_1" in active
+ assert "active_2" in active
+ assert "inactive" not in active
+
+
+# ============== Property 7: 额度耗尽检测 ==============
+# **Validates: Requirements 2.4**
+
+class TestQuotaExhaustion:
+ """Property 7: 额度耗尽检测测试"""
+
+ @given(balance=st.floats(min_value=-100.0, max_value=100.0, allow_nan=False, allow_infinity=False))
+ @settings(max_examples=100)
+ def test_quota_exhaustion_detection(self, balance: float):
+ """
+ Property 7: 额度耗尽检测
+ *对于任意*账号,当其剩余额度为0或负数时,该账号应被标记为不可用状态。
+
+ **Validates: Requirements 2.4**
+ """
+ quota = CachedQuota(
+ account_id="test_account",
+ usage_limit=1000.0,
+ current_usage=1000.0 - balance,
+ balance=balance,
+ updated_at=time.time()
+ )
+
+ # is_exhausted 现在是属性而不是方法
+ is_exhausted = quota.is_exhausted
+
+ if balance <= 0:
+ assert is_exhausted, f"余额 {balance} 应该被判定为耗尽"
+ else:
+ assert not is_exhausted, f"余额 {balance} 不应该被判定为耗尽"
+
+ def test_error_quota_not_exhausted(self):
+ """有错误的额度不应该被判定为耗尽"""
+ quota = CachedQuota(
+ account_id="test_account",
+ balance=0.0,
+ usage_limit=1000.0,
+ error="Connection failed"
+ )
+
+ # 有错误时不更新状态
+ assert not quota.is_exhausted
+
+
+# ============== Property 8: 缓存过期检测 ==============
+# **Validates: Requirements 7.3**
+
+class TestCacheStaleDetection:
+ """Property 8: 缓存过期检测测试"""
+
+ @given(
+ age_seconds=st.floats(min_value=0.0, max_value=1000.0, allow_nan=False, allow_infinity=False),
+ max_age=st.integers(min_value=60, max_value=600)
+ )
+ @settings(max_examples=100, deadline=None)
+ def test_cache_stale_detection(self, age_seconds: float, max_age: int):
+ """
+ Property 8: 缓存过期检测
+ *对于任意*缓存记录和过期阈值(默认5分钟),如果缓存的更新时间距当前时间超过阈值,
+ 则该缓存应被判定为过期。
+
+ **Validates: Requirements 7.3**
+ """
+ with tempfile.TemporaryDirectory() as tmpdir:
+ cache_file = os.path.join(tmpdir, "quota_cache.json")
+ cache = QuotaCache(cache_file=cache_file)
+
+ quota = CachedQuota(
+ account_id="test_account",
+ balance=500.0,
+ updated_at=time.time() - age_seconds
+ )
+ cache.set("test_account", quota)
+
+ is_stale = cache.is_stale("test_account", max_age_seconds=max_age)
+
+ if age_seconds > max_age:
+ assert is_stale, f"缓存年龄 {age_seconds:.1f}s 超过阈值 {max_age}s,应该过期"
+ else:
+ assert not is_stale, f"缓存年龄 {age_seconds:.1f}s 未超过阈值 {max_age}s,不应该过期"
+
+ def test_default_max_age(self):
+ """默认过期时间为5分钟"""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ cache_file = os.path.join(tmpdir, "quota_cache.json")
+ cache = QuotaCache(cache_file=cache_file)
+
+ # 4分钟前的缓存不应该过期
+ quota1 = CachedQuota(
+ account_id="fresh",
+ balance=500.0,
+ updated_at=time.time() - 240
+ )
+ cache.set("fresh", quota1)
+ assert not cache.is_stale("fresh")
+
+ # 6分钟前的缓存应该过期
+ quota2 = CachedQuota(
+ account_id="stale",
+ balance=500.0,
+ updated_at=time.time() - 360
+ )
+ cache.set("stale", quota2)
+ assert cache.is_stale("stale")
+
+
+# ============== Property 9: 获取失败状态标记 ==============
+# **Validates: Requirements 1.3**
+
+class TestFetchFailureMarking:
+ """Property 9: 获取失败状态标记测试"""
+
+ @given(error_msg=st.text(min_size=1, max_size=100))
+ @settings(max_examples=50)
+ def test_error_marking(self, error_msg: str):
+ """
+ Property 9: 获取失败状态标记
+ *对于任意*账号,当额度获取失败时,该账号的缓存应包含错误信息,
+ 且账号状态应被标记为额度未知。
+
+ **Validates: Requirements 1.3**
+ """
+ quota = CachedQuota.from_error("test_account", error_msg)
+
+ assert quota.has_error(), "应该有错误标记"
+ assert quota.error == error_msg, "错误信息应该一致"
+ assert quota.account_id == "test_account"
+ assert quota.updated_at > 0, "应该有更新时间"
+
+ def test_error_quota_fields(self):
+ """错误状态的额度字段应该为默认值"""
+ quota = CachedQuota.from_error("test_account", "Connection timeout")
+
+ assert quota.usage_limit == 0.0
+ assert quota.current_usage == 0.0
+ assert quota.balance == 0.0
+ assert quota.has_error()
+
+
+# ============== 单元测试:调度器状态 ==============
+
+class TestSchedulerStatus:
+ """调度器状态测试"""
+
+ def test_initial_status(self):
+ """初始状态"""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ cache_file = os.path.join(tmpdir, "quota_cache.json")
+ cache = QuotaCache(cache_file=cache_file)
+ scheduler = QuotaScheduler(quota_cache=cache)
+
+ status = scheduler.get_status()
+
+ assert status["running"] is False
+ assert status["update_interval"] == 60
+ assert status["active_count"] == 0
+ assert status["last_full_refresh"] is None
+
+ def test_cleanup_inactive(self):
+ """清理不活跃账号"""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ cache_file = os.path.join(tmpdir, "quota_cache.json")
+ cache = QuotaCache(cache_file=cache_file)
+ scheduler = QuotaScheduler(quota_cache=cache)
+
+ # 添加一些账号
+ scheduler._active_accounts["recent"] = time.time()
+ scheduler._active_accounts["old"] = time.time() - (ACTIVE_WINDOW_SECONDS * 2 + 1) # 超过 2 * ACTIVE_WINDOW
+
+ scheduler.cleanup_inactive()
+
+ assert "recent" in scheduler._active_accounts
+ assert "old" not in scheduler._active_accounts
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
diff --git a/KiroProxy/tests/test_refresh_manager.py b/KiroProxy/tests/test_refresh_manager.py
new file mode 100644
index 0000000000000000000000000000000000000000..dfa1d96a3a0dd142a06f1e9d8ebfabbbe039e575
--- /dev/null
+++ b/KiroProxy/tests/test_refresh_manager.py
@@ -0,0 +1,689 @@
+"""RefreshManager 属性测试和单元测试
+
+Property 11: Token 过期检测与自动刷新
+Property 12: 刷新锁互斥
+Property 13: 异常后锁释放
+Property 15: 重试次数限制
+Property 16: 指数退避延迟
+Property 17: 429 错误特殊处理
+Property 20: 401 错误自动重试
+"""
+import os
+import time
+import asyncio
+import tempfile
+from pathlib import Path
+from dataclasses import dataclass
+from typing import Optional
+from datetime import datetime, timezone, timedelta
+from unittest.mock import Mock, AsyncMock, patch
+
+import pytest
+from hypothesis import given, strategies as st, settings
+
+import sys
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from kiro_proxy.core.refresh_manager import (
+ RefreshManager, RefreshConfig, RefreshProgress,
+ get_refresh_manager, reset_refresh_manager
+)
+
+
+# ============== 辅助类和函数 ==============
+
+@dataclass
+class MockCredentials:
+ """模拟凭证"""
+ expires_at: Optional[str] = None
+
+ def is_expired(self) -> bool:
+ if not self.expires_at:
+ return True
+ try:
+ expires = datetime.fromisoformat(self.expires_at.replace("Z", "+00:00"))
+ now = datetime.now(timezone.utc)
+ return expires <= now + timedelta(minutes=5)
+ except Exception:
+ return True
+
+ def is_expiring_soon(self, minutes: int = 10) -> bool:
+ if not self.expires_at:
+ return False
+ try:
+ expires = datetime.fromisoformat(self.expires_at.replace("Z", "+00:00"))
+ now = datetime.now(timezone.utc)
+ return expires < now + timedelta(minutes=minutes)
+ except Exception:
+ return False
+
+
+class MockAccount:
+ """模拟账号"""
+ def __init__(self, account_id: str, name: str = None, enabled: bool = True):
+ self.id = account_id
+ self.name = name or account_id
+ self.enabled = enabled
+ self.status = Mock()
+ self.status.value = "active"
+ self._credentials = None
+ self._refresh_result = (True, "Token 刷新成功")
+
+ def get_credentials(self):
+ return self._credentials
+
+ def set_credentials(self, creds):
+ self._credentials = creds
+
+ def set_refresh_result(self, success: bool, message: str):
+ self._refresh_result = (success, message)
+
+ async def refresh_token(self):
+ return self._refresh_result
+
+
+# ============== Property 11: Token 过期检测与自动刷新 ==============
+# **Validates: Requirements 12.1, 12.2, 17.2**
+
+class TestTokenExpirationDetection:
+ """Property 11: Token 过期检测与自动刷新测试"""
+
+ @given(
+ minutes_until_expiry=st.integers(min_value=-60, max_value=60)
+ )
+ @settings(max_examples=100)
+ def test_token_expiration_detection(self, minutes_until_expiry: int):
+ """
+ Property 11: Token 过期检测与自动刷新
+ *对于任意*账号和当前时间,如果 Token 过期时间距当前时间小于5分钟,
+ 则该账号应被判定为需要刷新 Token。
+
+ **Validates: Requirements 12.1, 12.2, 17.2**
+ """
+ manager = RefreshManager()
+ account = MockAccount("test_account")
+
+ # 设置过期时间
+ expires_at = (datetime.now(timezone.utc) + timedelta(minutes=minutes_until_expiry)).isoformat()
+ creds = MockCredentials(expires_at=expires_at)
+ account.set_credentials(creds)
+
+ should_refresh = manager.should_refresh_token(account)
+
+ # 默认配置是过期前5分钟刷新
+ if minutes_until_expiry <= 5:
+ assert should_refresh, f"Token 将在 {minutes_until_expiry} 分钟后过期,应该需要刷新"
+ else:
+ assert not should_refresh, f"Token 将在 {minutes_until_expiry} 分钟后过期,不应该需要刷新"
+
+ def test_no_credentials_needs_refresh(self):
+ """无凭证时应该需要刷新"""
+ manager = RefreshManager()
+ account = MockAccount("test_account")
+ account.set_credentials(None)
+
+ assert manager.should_refresh_token(account)
+
+ @pytest.mark.asyncio
+ async def test_refresh_token_if_needed_valid(self):
+ """Token 有效时不刷新"""
+ manager = RefreshManager()
+ account = MockAccount("test_account")
+
+ # 设置1小时后过期
+ expires_at = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
+ creds = MockCredentials(expires_at=expires_at)
+ account.set_credentials(creds)
+
+ success, message = await manager.refresh_token_if_needed(account)
+
+ assert success
+ assert "无需刷新" in message
+
+ @pytest.mark.asyncio
+ async def test_refresh_token_if_needed_expired(self):
+ """Token 过期时自动刷新"""
+ manager = RefreshManager()
+ account = MockAccount("test_account")
+
+ # 设置已过期
+ expires_at = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
+ creds = MockCredentials(expires_at=expires_at)
+ account.set_credentials(creds)
+ account.set_refresh_result(True, "刷新成功")
+
+ success, message = await manager.refresh_token_if_needed(account)
+
+ assert success
+ assert "刷新成功" in message
+
+
+# ============== Property 12: 刷新锁互斥 ==============
+# **Validates: Requirements 14.1, 14.2**
+
+class TestRefreshLockMutex:
+ """Property 12: 刷新锁互斥测试"""
+
+ @pytest.mark.asyncio
+ async def test_concurrent_refresh_blocked(self):
+ """
+ Property 12: 刷新锁互斥
+ *对于任意*两个并发的批量刷新请求,系统应只允许一个请求执行,
+ 另一个请求应被拒绝并返回当前进度信息。
+
+ **Validates: Requirements 14.1, 14.2**
+ """
+ manager = RefreshManager()
+
+ # 第一个请求获取锁
+ acquired1 = await manager.acquire_refresh_lock()
+ assert acquired1, "第一个请求应该成功获取锁"
+
+ # 第二个请求应该被拒绝
+ acquired2 = await manager.acquire_refresh_lock()
+ assert not acquired2, "第二个请求应该被拒绝"
+
+ # 释放锁
+ manager.release_refresh_lock()
+
+ # 现在应该可以获取锁
+ acquired3 = await manager.acquire_refresh_lock()
+ assert acquired3, "锁释放后应该可以获取"
+ manager.release_refresh_lock()
+
+ @pytest.mark.asyncio
+ async def test_is_refreshing_status(self):
+ """刷新状态正确反映"""
+ manager = RefreshManager()
+
+ assert not manager.is_refreshing()
+
+ # 模拟开始刷新
+ manager._start_refresh(5, "测试刷新")
+
+ assert manager.is_refreshing()
+
+ # 完成刷新
+ manager._finish_refresh("completed")
+
+ assert not manager.is_refreshing()
+
+
+# ============== Property 13: 异常后锁释放 ==============
+# **Validates: Requirements 14.5**
+
+class TestLockReleaseAfterException:
+ """Property 13: 异常后锁释放测试"""
+
+ @pytest.mark.asyncio
+ async def test_lock_released_after_exception(self):
+ """
+ Property 13: 异常后锁释放
+ *对于任意*刷新操作,如果操作异常终止,系统应自动释放锁。
+
+ **Validates: Requirements 14.5**
+ """
+ manager = RefreshManager()
+
+ # 创建会抛出异常的账号
+ account = MockAccount("test_account")
+
+ async def failing_quota_func(acc):
+ raise Exception("模拟异常")
+
+ # 执行刷新(应该捕获异常并释放锁)
+ result = await manager.refresh_all_with_token(
+ [account],
+ get_quota_func=failing_quota_func
+ )
+
+ # 锁应该已释放
+ assert not manager._async_lock.locked(), "异常后锁应该被释放"
+
+ # 状态应该是 error 或 completed
+ assert result.status in ("error", "completed")
+
+
+# ============== Property 15: 重试次数限制 ==============
+# **Validates: Requirements 15.1, 15.2, 15.5**
+
+class TestRetryLimit:
+ """Property 15: 重试次数限制测试"""
+
+ @given(max_retries=st.integers(min_value=0, max_value=5))
+ @settings(max_examples=20, deadline=None)
+ @pytest.mark.asyncio
+ async def test_retry_count_limit(self, max_retries: int):
+ """
+ Property 15: 重试次数限制
+ *对于任意*失败的刷新操作和配置的最大重试次数 N,
+ 系统应最多重试 N 次。
+
+ **Validates: Requirements 15.1, 15.2, 15.5**
+ """
+ config = RefreshConfig(max_retries=max_retries, retry_base_delay=0.01)
+ manager = RefreshManager(config=config)
+
+ call_count = 0
+
+ async def always_fail():
+ nonlocal call_count
+ call_count += 1
+ return False, "总是失败"
+
+ success, result = await manager.retry_with_backoff(always_fail)
+
+ # 应该调用 max_retries + 1 次(初始 + 重试)
+ expected_calls = max_retries + 1
+ assert call_count == expected_calls, f"应该调用 {expected_calls} 次,实际调用 {call_count} 次"
+ assert not success, "应该最终失败"
+
+
+# ============== Property 16: 指数退避延迟 ==============
+# **Validates: Requirements 15.3**
+
+class TestExponentialBackoff:
+ """Property 16: 指数退避延迟测试"""
+
+ @given(
+ attempt=st.integers(min_value=0, max_value=5),
+ base_delay=st.floats(min_value=0.1, max_value=2.0, allow_nan=False, allow_infinity=False)
+ )
+ @settings(max_examples=50)
+ def test_exponential_backoff_delay(self, attempt: int, base_delay: float):
+ """
+ Property 16: 指数退避延迟
+ *对于任意*重试操作和重试次数 i,第 i 次重试前的等待时间应为 base_delay * 2^i 秒。
+
+ **Validates: Requirements 15.3**
+ """
+ config = RefreshConfig(retry_base_delay=base_delay)
+ manager = RefreshManager(config=config)
+
+ # 计算预期延迟
+ expected_delay = base_delay * (2 ** attempt)
+
+ # 验证计算逻辑(这里我们验证公式)
+ actual_delay = config.retry_base_delay * (2 ** attempt)
+
+ assert abs(actual_delay - expected_delay) < 0.001, \
+ f"第 {attempt} 次重试延迟应为 {expected_delay:.3f}s,实际为 {actual_delay:.3f}s"
+
+
+# ============== Property 17: 429 错误特殊处理 ==============
+# **Validates: Requirements 15.7**
+
+class TestRateLimitHandling:
+ """Property 17: 429 错误特殊处理测试"""
+
+ def test_rate_limit_error_detection(self):
+ """
+ Property 17: 429 错误特殊处理
+ *对于任意*返回 429 限流错误的请求,系统应识别为限流错误。
+
+ **Validates: Requirements 15.7**
+ """
+ manager = RefreshManager()
+
+ # 测试各种 429 错误格式
+ assert manager._is_rate_limit_error("HTTP 429 Too Many Requests")
+ assert manager._is_rate_limit_error("Rate limit exceeded")
+ assert manager._is_rate_limit_error("请求过于频繁,请稍后重试")
+
+ # 非限流错误
+ assert not manager._is_rate_limit_error("HTTP 500 Internal Server Error")
+ assert not manager._is_rate_limit_error("Connection timeout")
+
+ @given(attempt=st.integers(min_value=0, max_value=5))
+ @settings(max_examples=20)
+ def test_rate_limit_longer_delay(self, attempt: int):
+ """429 错误应使用更长的等待时间"""
+ config = RefreshConfig(retry_base_delay=1.0)
+ manager = RefreshManager(config=config)
+
+ normal_delay = config.retry_base_delay * (2 ** attempt)
+ rate_limit_delay = manager._get_rate_limit_delay(attempt, config.retry_base_delay)
+
+ # 429 延迟应该是普通延迟的 3 倍
+ assert rate_limit_delay == normal_delay * 3, \
+ f"429 延迟应为普通延迟的 3 倍"
+
+
+# ============== Property 20: 401 错误自动重试 ==============
+# **Validates: Requirements 12.6**
+
+class TestAuthErrorRetry:
+ """Property 20: 401 错误自动重试测试"""
+
+ def test_auth_error_detection(self):
+ """
+ Property 20: 401 错误自动重试
+ 系统应识别 401 认证错误。
+
+ **Validates: Requirements 12.6**
+ """
+ manager = RefreshManager()
+
+ # 测试各种 401 错误格式
+ assert manager._is_auth_error("HTTP 401 Unauthorized")
+ assert manager._is_auth_error("凭证已过期或无效,需要重新登录")
+ assert manager._is_auth_error("Unauthorized access")
+
+ # 非认证错误
+ assert not manager._is_auth_error("HTTP 500 Internal Server Error")
+ assert not manager._is_auth_error("Connection timeout")
+
+ @pytest.mark.asyncio
+ async def test_auth_error_triggers_token_refresh(self):
+ """401 错误应触发 Token 刷新并重试"""
+ manager = RefreshManager()
+ account = MockAccount("test_account")
+ account.set_refresh_result(True, "刷新成功")
+
+ call_count = 0
+
+ async def fail_then_succeed():
+ nonlocal call_count
+ call_count += 1
+ if call_count == 1:
+ return False, "HTTP 401 Unauthorized"
+ return True, "成功"
+
+ success, result = await manager.execute_with_auth_retry(
+ account,
+ fail_then_succeed
+ )
+
+ assert success, "重试后应该成功"
+ assert call_count == 2, "应该调用两次(失败 + 重试)"
+
+
+# ============== 单元测试:配置管理 ==============
+
+class TestConfigManagement:
+ """配置管理测试"""
+
+ def test_default_config(self):
+ """默认配置值"""
+ config = RefreshConfig()
+
+ assert config.max_retries == 3
+ assert config.retry_base_delay == 1.0
+ assert config.concurrency == 3
+ assert config.token_refresh_before_expiry == 300
+ assert config.auto_refresh_interval == 60
+
+ def test_config_validation(self):
+ """配置验证"""
+ # 有效配置
+ config = RefreshConfig(max_retries=5, concurrency=10)
+ assert config.validate()
+
+ # 无效配置
+ with pytest.raises(ValueError):
+ RefreshConfig(max_retries=-1).validate()
+
+ with pytest.raises(ValueError):
+ RefreshConfig(retry_base_delay=0).validate()
+
+ with pytest.raises(ValueError):
+ RefreshConfig(concurrency=0).validate()
+
+ def test_update_config(self):
+ """更新配置"""
+ manager = RefreshManager()
+
+ manager.update_config(max_retries=5, concurrency=10)
+
+ assert manager.config.max_retries == 5
+ assert manager.config.concurrency == 10
+ # 其他值保持不变
+ assert manager.config.retry_base_delay == 1.0
+
+
+# ============== 单元测试:进度跟踪 ==============
+
+class TestProgressTracking:
+ """进度跟踪测试"""
+
+ def test_progress_creation(self):
+ """进度创建"""
+ progress = RefreshProgress(
+ total=10,
+ completed=5,
+ success=4,
+ failed=1
+ )
+
+ assert progress.progress_percent == 50.0
+ assert progress.is_running()
+ assert not progress.is_completed()
+
+ def test_progress_to_dict(self):
+ """进度转字典"""
+ progress = RefreshProgress(total=10)
+ d = progress.to_dict()
+
+ assert "total" in d
+ assert "completed" in d
+ assert "status" in d
+
+ def test_manager_progress_tracking(self):
+ """管理器进度跟踪"""
+ manager = RefreshManager()
+
+ # 开始刷新
+ manager._start_refresh(5, "测试")
+
+ progress = manager.get_progress()
+ assert progress is not None
+ assert progress.total == 5
+ assert progress.status == "running"
+
+ # 更新进度
+ manager._update_progress(current_account="acc_1", success=True)
+
+ progress = manager.get_progress()
+ assert progress.completed == 1
+ assert progress.success == 1
+
+ # 完成
+ manager._finish_refresh("completed")
+
+ progress = manager.get_progress()
+ assert progress.status == "completed"
+
+
+# ============== 单元测试:全局实例 ==============
+
+class TestGlobalInstance:
+ """全局实例测试"""
+
+ def test_singleton_pattern(self):
+ """单例模式"""
+ reset_refresh_manager()
+
+ manager1 = get_refresh_manager()
+ manager2 = get_refresh_manager()
+
+ assert manager1 is manager2
+
+ def test_reset_manager(self):
+ """重置管理器"""
+ manager1 = get_refresh_manager()
+ reset_refresh_manager()
+ manager2 = get_refresh_manager()
+
+ assert manager1 is not manager2
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
+
+
+# ============== Property 14: 跳过错误状态账号 ==============
+# **Validates: Requirements 14.6, 17.4**
+
+class TestSkipErrorAccounts:
+ """Property 14: 跳过错误状态账号测试"""
+
+ @pytest.mark.asyncio
+ async def test_skip_disabled_accounts(self):
+ """
+ Property 14: 跳过错误状态账号
+ *对于任意*批量刷新操作,已禁用的账号应被跳过。
+
+ **Validates: Requirements 14.6, 17.4**
+ """
+ manager = RefreshManager()
+
+ # 创建账号列表
+ enabled_account = MockAccount("enabled", enabled=True)
+ disabled_account = MockAccount("disabled", enabled=False)
+
+ call_count = 0
+
+ async def track_quota_func(acc):
+ nonlocal call_count
+ call_count += 1
+ return True, "成功"
+
+ result = await manager.refresh_all_with_token(
+ [enabled_account, disabled_account],
+ get_quota_func=track_quota_func,
+ skip_disabled=True
+ )
+
+ # 只有启用的账号被处理
+ assert result.total == 1, "只应处理启用的账号"
+
+ @pytest.mark.asyncio
+ async def test_skip_unhealthy_accounts(self):
+ """跳过不健康状态的账号"""
+ manager = RefreshManager()
+
+ healthy_account = MockAccount("healthy")
+ healthy_account.status.value = "active"
+
+ unhealthy_account = MockAccount("unhealthy")
+ unhealthy_account.status.value = "unhealthy"
+
+ result = await manager.refresh_all_with_token(
+ [healthy_account, unhealthy_account],
+ skip_error=True
+ )
+
+ assert result.total == 1, "只应处理健康的账号"
+
+
+# ============== Property 18: 定时器唯一性 ==============
+# **Validates: Requirements 17.6**
+
+class TestTimerUniqueness:
+ """Property 18: 定时器唯一性测试"""
+
+ @pytest.mark.asyncio
+ async def test_single_timer_running(self):
+ """
+ Property 18: 定时器唯一性
+ *对于任意*时刻,系统中应最多只有一个自动刷新定时器在运行。
+
+ **Validates: Requirements 17.6**
+ """
+ config = RefreshConfig(auto_refresh_interval=1)
+ manager = RefreshManager(config=config)
+
+ # 启动第一个定时器
+ await manager.start_auto_refresh()
+ task1 = manager._auto_refresh_task
+
+ assert manager.is_auto_refresh_running()
+
+ # 再次启动应该替换旧定时器
+ await manager.start_auto_refresh()
+ task2 = manager._auto_refresh_task
+
+ # 应该是不同的任务(旧的被取消)
+ assert task1 is not task2 or task1.cancelled()
+ assert manager.is_auto_refresh_running()
+
+ # 清理
+ await manager.stop_auto_refresh()
+ assert not manager.is_auto_refresh_running()
+
+ @pytest.mark.asyncio
+ async def test_stop_clears_timer(self):
+ """停止应该清除定时器"""
+ config = RefreshConfig(auto_refresh_interval=1)
+ manager = RefreshManager(config=config)
+
+ await manager.start_auto_refresh()
+ assert manager.is_auto_refresh_running()
+
+ await manager.stop_auto_refresh()
+ assert not manager.is_auto_refresh_running()
+ assert manager._auto_refresh_task is None
+
+
+# ============== Property 19: 刷新失败隔离 ==============
+# **Validates: Requirements 17.5**
+
+class TestRefreshFailureIsolation:
+ """Property 19: 刷新失败隔离测试"""
+
+ @pytest.mark.asyncio
+ async def test_single_failure_does_not_affect_others(self):
+ """
+ Property 19: 刷新失败隔离
+ *对于任意*批量刷新操作,单个账号的刷新失败不应影响其他账号的刷新。
+
+ **Validates: Requirements 17.5**
+ """
+ # 使用无重试配置
+ config = RefreshConfig(max_retries=0)
+ manager = RefreshManager(config=config)
+
+ # 创建账号
+ account1 = MockAccount("acc1")
+ account2 = MockAccount("acc2")
+ account3 = MockAccount("acc3")
+
+ processed_accounts = set()
+
+ async def track_and_fail_second(acc):
+ processed_accounts.add(acc.id)
+ if acc.id == "acc2":
+ return False, "模拟失败"
+ return True, "成功"
+
+ result = await manager.refresh_all_with_token(
+ [account1, account2, account3],
+ get_quota_func=track_and_fail_second
+ )
+
+ # 所有账号都应该被处理
+ assert len(processed_accounts) == 3, "所有账号都应该被尝试处理"
+ assert "acc1" in processed_accounts
+ assert "acc2" in processed_accounts
+ assert "acc3" in processed_accounts
+
+ # 结果应该反映成功和失败
+ assert result.success == 2
+ assert result.failed == 1
+
+
+# ============== 自动刷新状态测试 ==============
+
+class TestAutoRefreshStatus:
+ """自动刷新状态测试"""
+
+ def test_auto_refresh_status(self):
+ """获取自动刷新状态"""
+ config = RefreshConfig(auto_refresh_interval=30, token_refresh_before_expiry=600)
+ manager = RefreshManager(config=config)
+
+ status = manager.get_auto_refresh_status()
+
+ assert status["running"] is False
+ assert status["interval"] == 30
+ assert status["token_refresh_before_expiry"] == 600
diff --git a/KiroProxy/tests/test_thinking_config.py b/KiroProxy/tests/test_thinking_config.py
new file mode 100644
index 0000000000000000000000000000000000000000..dbd7fced3183c4885fb2c9b3483d1a81fe6679b7
--- /dev/null
+++ b/KiroProxy/tests/test_thinking_config.py
@@ -0,0 +1,114 @@
+from pathlib import Path
+import sys
+
+import pytest
+
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from kiro_proxy.core.thinking import (
+ ThinkingConfig,
+ build_thinking_prompt,
+ build_user_prompt_with_thinking,
+ extract_thinking_config_from_gemini_body,
+ extract_thinking_config_from_openai_body,
+ infer_thinking_from_anthropic_messages,
+ infer_thinking_from_gemini_contents,
+ infer_thinking_from_openai_messages,
+ infer_thinking_from_openai_responses_input,
+ normalize_thinking_config,
+)
+
+
+def test_normalize_thinking_config_defaults_to_disabled_unlimited():
+ cfg = normalize_thinking_config(None)
+ assert cfg == ThinkingConfig(False, None)
+
+
+@pytest.mark.parametrize(
+ "raw,expected",
+ [
+ (True, ThinkingConfig(True, None)),
+ ("enabled", ThinkingConfig(True, None)),
+ ({"type": "enabled"}, ThinkingConfig(True, None)),
+ ({"thinking_type": "enabled", "budget_tokens": 20000}, ThinkingConfig(True, 20000)),
+ ({"enabled": True, "budget_tokens": 0}, ThinkingConfig(True, None)),
+ ({"includeThoughts": True, "thinkingBudget": 1234}, ThinkingConfig(True, 1234)),
+ ({"type": "disabled", "budget_tokens": 9999}, ThinkingConfig(False, 9999)),
+ ],
+)
+def test_normalize_thinking_config_variants(raw, expected):
+ assert normalize_thinking_config(raw) == expected
+
+
+def test_extract_thinking_config_from_openai_body():
+ cfg, explicit = extract_thinking_config_from_openai_body({})
+ assert cfg == ThinkingConfig(False, None)
+ assert explicit is False
+
+ cfg, explicit = extract_thinking_config_from_openai_body({"thinking": {"type": "enabled"}})
+ assert cfg.enabled is True
+ assert explicit is True
+
+ cfg, explicit = extract_thinking_config_from_openai_body({"reasoning_effort": "high"})
+ assert cfg.enabled is True
+ assert cfg.budget_tokens is None
+ assert explicit is True
+
+ cfg, explicit = extract_thinking_config_from_openai_body({"reasoning": {"effort": "medium"}})
+ assert cfg == ThinkingConfig(True, 20000)
+ assert explicit is True
+
+
+def test_extract_thinking_config_from_gemini_body():
+ cfg, explicit = extract_thinking_config_from_gemini_body({})
+ assert cfg == ThinkingConfig(False, None)
+ assert explicit is False
+
+ cfg, explicit = extract_thinking_config_from_gemini_body(
+ {"generationConfig": {"thinkingConfig": {"includeThoughts": True, "thinkingBudget": 1234}}}
+ )
+ assert cfg == ThinkingConfig(True, 1234)
+ assert explicit is True
+
+
+def test_infer_thinking_from_payloads():
+ assert (
+ infer_thinking_from_anthropic_messages(
+ [{"role": "assistant", "content": [{"type": "thinking", "thinking": "x"}]}]
+ )
+ is True
+ )
+
+ assert infer_thinking_from_openai_messages(
+ [{"role": "assistant", "content": "AAA\nBBB"}]
+ )
+
+ assert infer_thinking_from_openai_responses_input(
+ [
+ {
+ "type": "message",
+ "role": "assistant",
+ "content": [{"type": "output_text", "text": "AAABBB"}],
+ }
+ ]
+ )
+
+ assert infer_thinking_from_gemini_contents(
+ [{"role": "model", "parts": [{"text": "AAA\nBBB"}]}]
+ )
+
+
+def test_thinking_prompts_include_ultrathink_and_budget_hint():
+ p1 = build_thinking_prompt("hi", budget_tokens=None)
+ assert "ULTRATHINK" in p1
+ assert "within" not in p1.lower()
+
+ p2 = build_thinking_prompt("hi", budget_tokens=123)
+ assert "ULTRATHINK" in p2
+ assert "123" in p2
+
+
+def test_build_user_prompt_with_thinking_wraps_and_forbids_disclosure():
+ prompt = build_user_prompt_with_thinking("hello", "secret reasoning")
+ assert "" in prompt and "" in prompt
+ assert "Do NOT reveal" in prompt
diff --git a/KiroProxy/tests/test_thinking_stream_processor.py b/KiroProxy/tests/test_thinking_stream_processor.py
new file mode 100644
index 0000000000000000000000000000000000000000..c55ef506302e0f2f9e892eb8c406bf8fb581895e
--- /dev/null
+++ b/KiroProxy/tests/test_thinking_stream_processor.py
@@ -0,0 +1,67 @@
+"""ThinkingStreamProcessor 单元测试
+
+覆盖 标签在流式分片中被拆分的场景,避免思维链泄露到 text 输出。
+"""
+
+from pathlib import Path
+import sys
+
+import pytest
+
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from kiro_proxy.handlers.anthropic import ThinkingStreamProcessor
+
+
+def _collect_events(chunks: list[str]) -> list[dict]:
+ processor = ThinkingStreamProcessor(thinking_enabled=True)
+ events: list[dict] = []
+ for chunk in chunks:
+ events.extend(processor.process_content(chunk))
+ events.extend(processor.finalize())
+ return events
+
+
+def _extract_text(events: list[dict]) -> str:
+ return "".join(
+ e["delta"]["text"]
+ for e in events
+ if e.get("type") == "content_block_delta"
+ and e.get("delta", {}).get("type") == "text_delta"
+ )
+
+
+def _extract_thinking(events: list[dict]) -> str:
+ return "".join(
+ e["delta"]["thinking"]
+ for e in events
+ if e.get("type") == "content_block_delta"
+ and e.get("delta", {}).get("type") == "thinking_delta"
+ )
+
+
+@pytest.mark.parametrize(
+ "chunks,expected_thinking,expected_text",
+ [
+ # 起始标签被拆分
+ (["AAABBB"], "AAA", "BBB"),
+ # 结束标签被拆分
+ (["AAABBB"], "AAA", "BBB"),
+ # 起始/结束标签都可能被拆分(跨多个分片)
+ (["AAABBB"], "AAA", "BBB"),
+ # 无 thinking 标签:文本应保持原样
+ (["Hello AAA"], "AAA", ""),
+ ],
+)
+def test_thinking_stream_processor_chunk_splitting(chunks, expected_thinking, expected_text):
+ events = _collect_events(chunks)
+ assert _extract_thinking(events) == expected_thinking
+ assert _extract_text(events) == expected_text
+
+ # 思考标签不应出现在 text 输出中
+ text = _extract_text(events)
+ assert "" not in text
+ assert "" not in text
+
diff --git a/run.bat b/run.bat
new file mode 100644
index 0000000000000000000000000000000000000000..794e7e87ade1a628b421f277a304cba7e63a90dd
--- /dev/null
+++ b/run.bat
@@ -0,0 +1,3 @@
+cd KiroProxy
+start http://127.0.0.1:6696
+python run.py 6696
\ No newline at end of file
步骤 1:打开登录链接
+步骤 2:完成授权后粘贴回调 URL
++ 授权完成后,浏览器会尝试打开
+kiro://链接。+ 如果提示"无法打开",请复制地址栏中的完整 URL 粘贴到下方。 +
可选:自动回调模式
+ +