From 15a0ea6b9451b329f1d1cc582d03d6954e47f5f5 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Fri, 17 Apr 2026 19:58:07 +0800 Subject: [PATCH 01/96] Enhance chat2api and ChatService with model fetching and error handling improvements. Added new endpoint for listing available models and implemented caching for model requests. Updated retry logic in utils to handle specific HTTP status codes more effectively. --- api/chat2api.py | 63 ++++++++++++-- api/models.py | 72 +++++++++++++++ chatgpt/ChatService.py | 171 ++++++++++++++++++++---------------- gateway/backend.py | 18 ++-- gateway/reverseProxy.py | 5 ++ handoff.md | 100 +++++++++++++++++++++ memory.md | 188 ++++++++++++++++++++++++++++++++++++++++ utils/retry.py | 36 +++++++- 8 files changed, 563 insertions(+), 90 deletions(-) create mode 100644 handoff.md create mode 100644 memory.md diff --git a/api/chat2api.py b/api/chat2api.py index 6892199f..ef5e518b 100644 --- a/api/chat2api.py +++ b/api/chat2api.py @@ -12,7 +12,7 @@ from chatgpt.ChatService import ChatService from chatgpt.authorization import refresh_all_tokens from utils.Logger import logger -from utils.configs import api_prefix, scheduled_refresh +from utils.configs import api_prefix, scheduled_refresh, history_disabled from utils.retry import async_retry scheduler = AsyncIOScheduler() @@ -44,9 +44,38 @@ async def to_send_conversation(request_data, req_token): async def process(request_data, req_token): chat_service = await to_send_conversation(request_data, req_token) - await chat_service.prepare_send_conversation() - res = await chat_service.send_conversation() - return chat_service, res + try: + await chat_service.prepare_send_conversation() + res = await chat_service.send_conversation() + return chat_service, res + except HTTPException as e: + await chat_service.close_client() + raise HTTPException(status_code=e.status_code, detail=e.detail) + except Exception as e: + await chat_service.close_client() + logger.error(f"Server error, {str(e)}") + raise HTTPException(status_code=500, detail="Server error") + + +def parse_bool_query(value, default): + if value is None: + return default + return str(value).lower() in ['true', '1', 't', 'y', 'yes'] + + +def format_models_response(model_slugs): + data = [] + for model_slug in sorted(model_slugs): + data.append({ + "id": model_slug, + "object": "model", + "created": 0, + "owned_by": "openai", + }) + return { + "object": "list", + "data": data, + } @app.post(f"/{api_prefix}/v1/chat/completions" if api_prefix else "/v1/chat/completions") @@ -76,6 +105,30 @@ async def send_conversation(request: Request, credentials: HTTPAuthorizationCred raise HTTPException(status_code=500, detail="Server error") +@app.get(f"/{api_prefix}/v1/models" if api_prefix else "/v1/models") +async def list_models(request: Request, credentials: HTTPAuthorizationCredentials = Security(security_scheme)): + chat_service = ChatService(credentials.credentials) + try: + await chat_service.resolve_auth_context() + chat_service.history_disabled = parse_bool_query( + request.query_params.get("history_disabled", request.query_params.get("history_and_training_disabled")), + history_disabled, + ) + request_account_id = request.headers.get("ChatGPT-Account-ID") or request.headers.get("Chatgpt-Account-Id") + if request_account_id: + chat_service.account_id = request_account_id + await chat_service.initialize_request_context() + model_slugs = await chat_service.fetch_available_models() + return JSONResponse(format_models_response(model_slugs), media_type="application/json") + except HTTPException as e: + raise HTTPException(status_code=e.status_code, detail=e.detail) + except Exception as e: + logger.error(f"Server error, {str(e)}") + raise HTTPException(status_code=500, detail="Server error") + finally: + await chat_service.close_client() + + @app.get(f"/{api_prefix}/tokens" if api_prefix else "/tokens", response_class=HTMLResponse) async def upload_html(request: Request): tokens_count = len(set(globals.token_list) - set(globals.error_token_list)) @@ -133,4 +186,4 @@ async def clear_seed_tokens(): with open(globals.CONVERSATION_MAP_FILE, "w", encoding="utf-8") as f: f.write("{}") logger.info(f"Seed token count: {len(globals.seed_map)}") - return {"status": "success", "seed_tokens_count": len(globals.seed_map)} \ No newline at end of file + return {"status": "success", "seed_tokens_count": len(globals.seed_map)} diff --git a/api/models.py b/api/models.py index 56665f03..1bf4844c 100644 --- a/api/models.py +++ b/api/models.py @@ -29,3 +29,75 @@ "gpt-4o-2024-05-13": ["fp_3aa7262c27"], "gpt-4o-mini-2024-07-18": ["fp_c9aa9c0491"] } + +MODEL_REQUEST_RULES = ( + ("o3-mini-high", "o3-mini-high"), + ("o3-mini-medium", "o3-mini-medium"), + ("o3-mini-low", "o3-mini-low"), + ("o3-mini", "o3-mini"), + ("o3", "o3"), + ("o1-preview", "o1-preview"), + ("o1-pro", "o1-pro"), + ("o1-mini", "o1-mini"), + ("o1", "o1"), + ("gpt-4.5o", "gpt-4.5o"), + ("gpt-4o-canmore", "gpt-4o-canmore"), + ("gpt-4o-mini", "gpt-4o-mini"), + ("gpt-4o", "gpt-4o"), + ("gpt-4-mobile", "gpt-4-mobile"), + ("gpt-4", "gpt-4"), + ("gpt-3.5", "text-davinci-002-render-sha"), + ("auto", "auto"), +) + + +def get_response_model(origin_model): + return model_proxy.get(origin_model, origin_model) + + +def match_model_family(origin_model, alias): + return origin_model == alias or origin_model.startswith(f"{alias}-") + + +def resolve_request_model(origin_model): + origin_model = (origin_model or "gpt-3.5-turbo-0125").strip() + base_model = origin_model + gizmo_id = None + + if "-gizmo-g-" in origin_model: + base_model, _, gizmo_suffix = origin_model.partition("-gizmo-") + gizmo_id = gizmo_suffix + elif origin_model.startswith("g-"): + gizmo_id = origin_model + base_model = "gpt-4o" + + for alias, target in MODEL_REQUEST_RULES: + if match_model_family(base_model, alias): + return target, gizmo_id, False + + return base_model, gizmo_id, True + + +def extract_model_slugs(models_payload): + slugs = set() + model_items = models_payload.get("models", []) + if isinstance(model_items, dict): + model_items = model_items.values() + + for model in model_items: + if not isinstance(model, dict): + continue + + for key in ("slug", "id", "model_slug"): + value = model.get(key) + if isinstance(value, str) and value: + slugs.add(value) + + nested_model = model.get("model") + if isinstance(nested_model, dict): + for key in ("slug", "id", "model_slug"): + value = nested_model.get(key) + if isinstance(value, str) and value: + slugs.add(value) + + return slugs diff --git a/chatgpt/ChatService.py b/chatgpt/ChatService.py index 7260de01..5ea729f1 100644 --- a/chatgpt/ChatService.py +++ b/chatgpt/ChatService.py @@ -2,13 +2,14 @@ import hashlib import json import random +import time import uuid from fastapi import HTTPException from starlette.concurrency import run_in_threadpool from api.files import get_image_size, get_file_extension, determine_file_use_case -from api.models import model_proxy +from api.models import extract_model_slugs, get_response_model, resolve_request_model from chatgpt.authorization import get_req_token, verify_token from chatgpt.chatFormat import api_messages_to_chat, stream_response, format_not_stream_response, head_process_response from chatgpt.chatLimit import check_is_limit, handle_request_limit @@ -29,10 +30,14 @@ auth_key, turnstile_solver_url, oai_language, + check_model, ) class ChatService: + available_model_cache = {} + available_model_cache_ttl = 300 + def __init__(self, origin_token=None): # self.user_agent = random.choice(user_agents_list) if user_agents_list else "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36" self.req_token = get_req_token(origin_token) @@ -40,8 +45,68 @@ def __init__(self, origin_token=None): self.s = None self.ss = None self.ws = None + self.dynamic_model = False + + def model_not_found(self): + return HTTPException( + status_code=404, + detail={ + "message": f"The model `{self.origin_model}` does not exist or you do not have access to it.", + "type": "invalid_request_error", + "param": None, + "code": "model_not_found", + }, + ) - async def set_dynamic_data(self, data): + def get_model_cache_key(self): + token = self.req_token.split(",")[0] if self.req_token else "anon" + token_hash = hashlib.sha256(token.encode()).hexdigest() + account_id = self.account_id or "default" + return f"{self.host_url}:{account_id}:{token_hash}" + + async def fetch_available_models(self): + cache_key = self.get_model_cache_key() + now = time.time() + cached = self.available_model_cache.get(cache_key) + if cached and now - cached["time"] < self.available_model_cache_ttl: + return cached["slugs"] + + url = f"{self.host_url}/backend-api/models?history_and_training_disabled={str(self.history_disabled).lower()}" + headers = self.base_headers.copy() + r = await self.s.get(url, headers=headers, timeout=10) + if r.status_code != 200: + detail = r.text + if "application/json" in r.headers.get("Content-Type", ""): + detail = r.json().get("detail", r.json()) + raise HTTPException(status_code=r.status_code, detail=detail) + + models_payload = r.json() + model_slugs = extract_model_slugs(models_payload) + self.available_model_cache[cache_key] = { + "time": now, + "slugs": model_slugs, + } + logger.info(f"Available upstream models: {len(model_slugs)}") + return model_slugs + + async def validate_model_access(self): + if self.gizmo_id: + return + + if not self.access_token: + if self.req_model != "text-davinci-002-render-sha": + raise self.model_not_found() + return + + if not (self.dynamic_model or check_model): + return + + available_models = await self.fetch_available_models() + if self.req_model not in available_models: + logger.error(f"Model {self.req_model} not found in upstream models") + raise self.model_not_found() + + async def resolve_auth_context(self): if self.req_token: req_len = len(self.req_token.split(",")) if req_len == 1: @@ -55,6 +120,7 @@ async def set_dynamic_data(self, data): self.access_token = None self.account_id = None + async def initialize_request_context(self): self.fp = get_fp(self.req_token).copy() self.proxy_url = self.fp.pop("proxy_url", None) self.impersonate = self.fp.pop("impersonate", "safari15_3") @@ -64,30 +130,11 @@ async def set_dynamic_data(self, data): logger.info(f"Request UA: {self.user_agent}") logger.info(f"Request impersonate: {self.impersonate}") - self.data = data - await self.set_model() - if enable_limit and self.req_token: - limit_response = await handle_request_limit(self.req_token, self.req_model) - if limit_response: - raise HTTPException(status_code=429, detail=limit_response) - - self.account_id = self.data.get('Chatgpt-Account-Id', self.account_id) - self.parent_message_id = self.data.get('parent_message_id') - self.conversation_id = self.data.get('conversation_id') - self.history_disabled = self.data.get('history_disabled', history_disabled) - - self.api_messages = self.data.get("messages", []) - self.prompt_tokens = 0 - self.max_tokens = self.data.get("max_tokens", 2147483647) - if not isinstance(self.max_tokens, int): - self.max_tokens = 2147483647 - - # self.proxy_url = random.choice(proxy_url_list) if proxy_url_list else None - self.host_url = random.choice(chatgpt_base_url_list) if chatgpt_base_url_list else "https://chatgpt.com" self.ark0se_token_url = random.choice(ark0se_token_url_list) if ark0se_token_url_list else None - session_id = hashlib.md5(self.req_token.encode()).hexdigest() + session_source = self.req_token or "no-auth" + session_id = hashlib.md5(session_source.encode()).hexdigest() proxy_url = self.proxy_url.replace("{}", session_id) if self.proxy_url else None self.s = Client(proxy=proxy_url, impersonate=self.impersonate) if sentinel_proxy_url_list: @@ -130,52 +177,36 @@ async def set_dynamic_data(self, data): if auth_key: self.base_headers['authkey'] = auth_key + async def set_dynamic_data(self, data): + await self.resolve_auth_context() + + self.data = data + await self.set_model() + + self.account_id = self.data.get('Chatgpt-Account-Id', self.account_id) + self.parent_message_id = self.data.get('parent_message_id') + self.conversation_id = self.data.get('conversation_id') + self.history_disabled = self.data.get('history_disabled', history_disabled) + + self.api_messages = self.data.get("messages", []) + self.prompt_tokens = 0 + self.max_tokens = self.data.get("max_tokens", 2147483647) + if not isinstance(self.max_tokens, int): + self.max_tokens = 2147483647 + + await self.initialize_request_context() await get_dpl(self) + await self.validate_model_access() + + if enable_limit and self.req_token: + limit_response = await handle_request_limit(self.req_token, self.req_model) + if limit_response: + raise HTTPException(status_code=429, detail=limit_response) async def set_model(self): self.origin_model = self.data.get("model", "gpt-3.5-turbo-0125") - self.resp_model = model_proxy.get(self.origin_model, self.origin_model) - if "gizmo" in self.origin_model or "g-" in self.origin_model: - self.gizmo_id = "g-" + self.origin_model.split("g-")[-1] - else: - self.gizmo_id = None - - if "o3-mini-high" in self.origin_model: - self.req_model = "o3-mini-high" - elif "o3-mini-medium" in self.origin_model: - self.req_model = "o3-mini-medium" - elif "o3-mini-low" in self.origin_model: - self.req_model = "o3-mini-low" - elif "o3-mini" in self.origin_model: - self.req_model = "o3-mini" - elif "o3" in self.origin_model: - self.req_model = "o3" - elif "o1-preview" in self.origin_model: - self.req_model = "o1-preview" - elif "o1-pro" in self.origin_model: - self.req_model = "o1-pro" - elif "o1-mini" in self.origin_model: - self.req_model = "o1-mini" - elif "o1" in self.origin_model: - self.req_model = "o1" - elif "gpt-4.5o" in self.origin_model: - self.req_model = "gpt-4.5o" - elif "gpt-4o-canmore" in self.origin_model: - self.req_model = "gpt-4o-canmore" - elif "gpt-4o-mini" in self.origin_model: - self.req_model = "gpt-4o-mini" - elif "gpt-4o" in self.origin_model: - self.req_model = "gpt-4o" - elif "gpt-4-mobile" in self.origin_model: - self.req_model = "gpt-4-mobile" - elif "gpt-4" in self.origin_model: - self.req_model = "gpt-4" - elif "gpt-3.5" in self.origin_model: - self.req_model = "text-davinci-002-render-sha" - elif "auto" in self.origin_model: - self.req_model = "auto" - else: - self.req_model = "gpt-4o" + self.resp_model = get_response_model(self.origin_model) + self.req_model, self.gizmo_id, self.dynamic_model = resolve_request_model(self.origin_model) async def get_chat_requirements(self): if conversation_only: @@ -194,15 +225,7 @@ async def get_chat_requirements(self): if self.persona != "chatgpt-paid": if self.req_model == "gpt-4" or self.req_model == "o1-preview": logger.error(f"Model {self.resp_model} not support for {self.persona}") - raise HTTPException( - status_code=404, - detail={ - "message": f"The model `{self.origin_model}` does not exist or you do not have access to it.", - "type": "invalid_request_error", - "param": None, - "code": "model_not_found", - }, - ) + raise self.model_not_found() turnstile = resp.get('turnstile', {}) turnstile_required = turnstile.get('required') diff --git a/gateway/backend.py b/gateway/backend.py index ee9e5fad..ffb7ab48 100644 --- a/gateway/backend.py +++ b/gateway/backend.py @@ -40,11 +40,15 @@ chatgpt_paths = ["c/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"] +def has_direct_access_token(token: str) -> bool: + return len(token) == 45 or token.startswith("eyJhbGciOi") + + @app.get("/backend-api/accounts/check/v4-2023-04-27") async def check_account(request: Request): - token = request.headers.get("Authorization").replace("Bearer ", "") + token = request.headers.get("Authorization", "").replace("Bearer ", "") check_account_response = await chatgpt_reverse_proxy(request, "backend-api/accounts/check/v4-2023-04-27") - if len(token) == 45 or token.startswith("eyJhbGciOi"): + if has_direct_access_token(token): return check_account_response else: check_account_str = check_account_response.body.decode('utf-8') @@ -62,7 +66,7 @@ async def check_account(request: Request): @app.get("/backend-api/gizmos/bootstrap") async def get_gizmos_bootstrap(request: Request): token = request.headers.get("Authorization", "").replace("Bearer ", "") - if len(token) == 45 or token.startswith("eyJhbGciOi"): + if has_direct_access_token(token): return await chatgpt_reverse_proxy(request, "backend-api/gizmos/bootstrap") else: return {"gizmos": []} @@ -71,7 +75,7 @@ async def get_gizmos_bootstrap(request: Request): @app.get("/backend-api/gizmos/pinned") async def get_gizmos_pinned(request: Request): token = request.headers.get("Authorization", "").replace("Bearer ", "") - if len(token) == 45 or token.startswith("eyJhbGciOi"): + if has_direct_access_token(token): return await chatgpt_reverse_proxy(request, "backend-api/gizmos/pinned") else: return {"items": [], "cursor": None} @@ -80,7 +84,7 @@ async def get_gizmos_pinned(request: Request): @app.get("/public-api/gizmos/discovery/recent") async def get_gizmos_discovery_recent(request: Request): token = request.headers.get("Authorization", "").replace("Bearer ", "") - if len(token) == 45 or token.startswith("eyJhbGciOi"): + if has_direct_access_token(token): return await chatgpt_reverse_proxy(request, "public-api/gizmos/discovery/recent") else: return { @@ -98,7 +102,7 @@ async def get_gizmos_discovery_recent(request: Request): @app.get("/backend-api/gizmos/snorlax/sidebar") async def get_gizmos_snorlax_sidebar(request: Request): token = request.headers.get("Authorization", "").replace("Bearer ", "") - if len(token) == 45 or token.startswith: + if has_direct_access_token(token): return await chatgpt_reverse_proxy(request, "backend-api/gizmos/snorlax/sidebar") else: return {"items": [], "cursor": None} @@ -107,7 +111,7 @@ async def get_gizmos_snorlax_sidebar(request: Request): @app.post("/backend-api/gizmos/snorlax/upsert") async def get_gizmos_snorlax_upsert(request: Request): token = request.headers.get("Authorization", "").replace("Bearer ", "") - if len(token) == 45 or token.startswith: + if has_direct_access_token(token): return await chatgpt_reverse_proxy(request, "backend-api/gizmos/snorlax/upsert") else: raise HTTPException(status_code=403, detail="Forbidden") diff --git a/gateway/reverseProxy.py b/gateway/reverseProxy.py index 510640e3..53d40ebc 100644 --- a/gateway/reverseProxy.py +++ b/gateway/reverseProxy.py @@ -323,8 +323,13 @@ async def chatgpt_reverse_proxy(request: Request, path: str): response = Response(content=content, headers=rheaders, status_code=r.status_code, background=background) return response + except HTTPException as e: + await client.close() + raise HTTPException(status_code=e.status_code, detail=e.detail) except Exception as e: await client.close() + logger.error(f"Reverse proxy failed for {path}: {str(e)}") + raise HTTPException(status_code=502, detail="Upstream request failed") except HTTPException as e: raise e except Exception as e: diff --git a/handoff.md b/handoff.md new file mode 100644 index 00000000..ad69d004 --- /dev/null +++ b/handoff.md @@ -0,0 +1,100 @@ +# 协作交接 + +最后更新:2026-04-17 +负责人:主调度线 + +## 使用规则 + +- 每条工作线开始前先读 `memory.md` 与本文件。 +- 任何工作线只修改自己认领的文件;跨线修改前先在本文件登记。 +- 不记录真实 Token、Cookie、代理账号、私有域名或任何敏感信息。 +- 不做第三方风控规避、指纹伪装增强、封控绕过、限额绕过。 +- 优先做稳定性、兼容性、可观测性、资源释放、退避、缓存、限流与错误语义收敛。 + +## 当前工作线 + +### 线路 A:模型解析与模型校验 + +- 负责人:现有工作线 +- 目标:收敛模型解析、缓存上游模型列表、统一 `model_not_found` 语义 +- 主要文件:`api/models.py`、`chatgpt/ChatService.py` +- 进入条件:修改前先确认 `/v1/models` 兼容面不会被破坏 +- 交付标准: + - 未登录默认模型路径稳定 + - `gizmo` 路径不回归 + - 未知模型名、`CHECK_MODEL=true` 行为可解释 + +### 线路 B:OpenAI 兼容 API + +- 负责人:现有工作线 +- 目标:收敛 `/v1/models`、`/v1/chat/completions` 的错误处理与响应形态 +- 主要文件:`api/chat2api.py` +- 依赖:线路 A +- 交付标准: + - 资源释放明确 + - `JSONResponse` / `StreamingResponse` 路径一致 + - `/v1/models` 返回结构固定 + +### 线路 C:网关反代稳定性 + +- 负责人:现有工作线 +- 目标:统一上游异常到网关错误语义,补足可观测性 +- 主要文件:`gateway/reverseProxy.py`、`gateway/backend.py` +- 依赖:线路 D +- 交付标准: + - 上游失败时错误码稳定 + - 客户端总能关闭 + - 日志能定位到路径与失败类型 + +### 线路 D:重试与低风险运行 + +- 负责人:现有工作线 +- 目标:控制重试范围、加入退避、避免雪崩式重复请求 +- 主要文件:`utils/retry.py`、`chatgpt/chatLimit.py` +- 依赖:线路 B、线路 C +- 交付标准: + - 只对可重试错误重试 + - 退避参数可配置 + - 不放大 4xx 语义错误 + +### 线路 E:主调度线新增 + +- 负责人:主调度线 +- 目标:统一任务优先级、合并交付、补充共享记忆、做安全边界把控 +- 主要文件:`memory.md`、`handoff.md` +- 交付标准: + - 每条线边界清晰 + - 交付顺序明确 + - 风险项被记录 + +## 当前优先级 + +1. 先稳定 A + B:确保模型解析与 `/v1/models` 不互相打架 +2. 再稳定 C + D:确保 502/超时/瞬时 5xx 不触发雪崩重试 +3. 最后处理 E:把回归结果和遗留风险回写到 `memory.md` + +## 主调度线判定标准 + +- 允许推进: + - 缓存、队列、限流、退避、熔断、日志、指标、错误码收敛 + - 兼容性修复、资源释放、重复逻辑收敛 + - 文档、配置、部署与运行时保护 +- 不允许推进: + - 规避第三方封控或风控的实现 + - 指纹伪装增强、设备伪装增强、账号轮换绕过限制 + - 任何降低第三方检测能力的专项设计 + +## 回归清单 + +- A/B 联合: + - `/v1/chat/completions` + - `/v1/models` + - 未登录默认模型 + - `gizmo` 模型 + - 未知模型名 +- C/D 联合: + - 上游超时 + - 上游 502 + - 上游瞬时 500/503 + - 非可重试 4xx + - 客户端关闭与日志输出 diff --git a/memory.md b/memory.md new file mode 100644 index 00000000..c186f8bd --- /dev/null +++ b/memory.md @@ -0,0 +1,188 @@ +# 共享记忆 + +最后更新:2026-04-17 +编码:UTF-8 with BOM(为 Windows 终端和编辑器兼容性显式设置) +维护方式:任何线路开始动手前先读本文件;完成一个可交付点后立即回写本文件。 + +## 主调度策略 + +- 主调度目标:先提升稳定性、兼容性、可观测性和资源控制,再考虑更大范围的架构调整。 +- 当前协作文档:高层事实记录在 `memory.md`,逐线交接记录在 `handoff.md`。 +- 短期优先级:A/B 联合稳定模型与 API 兼容,C/D 联合稳定反代与重试。 +- 中期优先级:统一请求上下文、错误映射合同、缓存/状态存储和结构化日志。 +- 长期方向:如果要继续降低运行风险,优先考虑迁移到官方 API/正式接入方案,而不是继续加重站点模拟、指纹伪装或其他高风险路径。 + +## 合规边界 + +- 可以做:缓存、限流、并发保护、退避、熔断、日志、指标、错误语义收敛、协议兼容修复、状态存储改造。 +- 不做:第三方封控规避、风控绕过、指纹伪装增强、令牌复用规避校验、账号轮换绕过限制。 +- 任何可能降低第三方检测能力的专项设计,都不进入当前优化范围。 + +## 目标 + +- 为当前仓库内的多条并行工作线提供统一上下文。 +- 记录事实、边界、风险和当前认领,减少重复实现与相互覆盖。 +- 本文件只记录协作信息,不存放任何密钥、Token、Cookie 或敏感配置值。 + +## 项目概览 + +- 项目类型:基于 FastAPI 的 ChatGPT -> OpenAI 兼容 API 代理,同时支持官网镜像网关能力。 +- 入口文件:`app.py` +- API 主入口:`api/chat2api.py` +- 核心会话编排:`chatgpt/ChatService.py` +- 模型映射与模型辅助逻辑:`api/models.py` +- 反向代理主逻辑:`gateway/reverseProxy.py` +- 重试策略:`utils/retry.py` +- 运行时配置:`utils/configs.py` +- 网关相关逻辑:`gateway/*.py` +- 模板与静态上下文:`templates/*` + +## 当前结构认知 + +### 事实 + +- `app.py` 负责创建 FastAPI 应用、注册 CORS,并根据 `ENABLE_GATEWAY` 决定是否加载 `gateway/*` 路由。 +- `api/chat2api.py` 暴露 `/v1/chat/completions`、`/v1/models`、`/tokens` 等接口,并负责调度 `ChatService`。 +- `chatgpt/ChatService.py` 负责请求上下文初始化、鉴权、模型选择、上游会话准备与发送。 +- `utils/configs.py` 负责读取环境变量;当前已存在 `CHECK_MODEL` 与 `HISTORY_DISABLED` 配置项。 +- `gateway/backend.py`、`gateway/v1.py`、`gateway/reverseProxy.py` 承担官网镜像侧的反向代理与响应修正。 +- `utils/retry.py` 是 API 路径上的通用重试入口,行为变化会影响多个调用链。 + +### 建议并行工作面 + +- 线路 A:模型解析、模型可用性校验、模型错误语义一致性。 +- 线路 B:OpenAI 兼容 API 路由、请求/响应格式稳定性。 +- 线路 C:官网镜像网关、反向代理行为、上游错误映射。 +- 线路 D:重试策略、瞬时故障恢复、异常放大控制。 +- 线路 E:配置、部署、文档与环境变量说明。 + +## 当前已纳入的本地进行中改动 + +### 线路 A:模型解析与上游模型校验 + +涉及文件:`api/models.py`、`chatgpt/ChatService.py` + +当前未提交改动的意图如下: + +- 抽取 `MODEL_REQUEST_RULES`,统一维护“外部模型名 -> 上游请求模型名”的映射规则。 +- 新增 `get_response_model(origin_model)`,统一响应模型名回写逻辑。 +- 新增 `resolve_request_model(origin_model)`,把请求模型解析、`gizmo` 识别、动态模型判定集中到一个地方。 +- 新增 `extract_model_slugs(models_payload)`,用于从上游 `/backend-api/models` 返回中提取可用模型集合。 +- 在 `ChatService` 中引入 `resolve_auth_context()` 与 `initialize_request_context()`,把原本耦合在 `set_dynamic_data()` 中的职责拆开。 +- 增加 `model_not_found()`、`fetch_available_models()`、`validate_model_access()`,统一 404 语义并增加模型可用性校验。 +- 增加按 `host_url + account_id + token_hash` 划分的模型缓存,TTL 为 300 秒。 + +当前判断: + +- 这是一次明显的“去重复 + 规则下沉 + 职责拆分”的重构,符合 DRY / SRP / KISS。 +- 该改动会直接影响模型选择、鉴权上下文初始化、上游请求前校验,属于核心链路改动。 + +### 线路 B:OpenAI 兼容 API 路由补强 + +涉及文件:`api/chat2api.py` + +当前未提交改动的意图如下: + +- 给 `process()` 增加异常关闭与统一错误处理,避免准备发送阶段异常时泄漏客户端资源。 +- 新增 `parse_bool_query()`,统一布尔查询参数解析。 +- 新增 `format_models_response()`,以 OpenAI 风格返回模型列表。 +- 新增 `/v1/models` 路由,复用 `ChatService.fetch_available_models()` 暴露上游模型列表。 +- 路由层显式接入 `history_disabled` 作为模型列表请求的默认值。 + +当前判断: + +- 这是围绕“模型可见性”补齐 OpenAI 兼容面的改动,和线路 A 存在中等耦合。 +- 如果后续继续扩展 `/v1/models` 兼容性,优先在该文件收口,不要把响应格式散落到 `ChatService`。 + +### 线路 C:官网镜像反向代理错误语义收敛 + +涉及文件:`gateway/reverseProxy.py`、`gateway/backend.py` + +当前未提交改动的意图如下: + +- 在内部请求块中单独捕获 `HTTPException`,关闭客户端后按原状态继续抛出。 +- 对未知异常增加日志 `Reverse proxy failed for {path}`。 +- 对通用异常统一返回 `502 Upstream request failed`,避免把底层异常直接泄露到调用方。 +- 修复 `gateway/backend.py` 中两处 `token.startswith` 被误写为方法对象判断的问题。 +- 修复 `check_account` 在缺少 `Authorization` 请求头时直接触发 500 的问题。 + +当前判断: + +- 这是一次“资源清理 + 错误语义收敛”的稳定性修正。 +- 该改动会直接影响网关错误码、观测性和调用方重试行为,属于网关主链路改动。 + +### 线路 D:重试策略收敛与退避 + +涉及文件:`utils/retry.py` + +当前未提交改动的意图如下: + +- 增加 `RETRYABLE_STATUS_CODES`,把可重试状态码限制为 `408/500/502/503/504`。 +- 增加 `get_retry_delay()`,采用指数退避,基础延迟 0.5 秒,最大 4 秒。 +- `async_retry()` 与 `retry()` 改为只在可重试状态码上重试,其余异常立即抛出。 +- 同步补充异步/同步两条路径的等待逻辑,避免无差别快速重试。 + +当前判断: + +- 这是一次“限制盲目重试 + 控制重试节奏”的通用基础设施改动,符合 KISS / YAGNI。 +- 该文件属于横切关注点,一旦继续修改,必须同步考虑 API 路径与网关路径的影响。 + +## 当前协作边界 + +### 已被占用/应谨慎操作的文件 + +- `api/models.py` +- `chatgpt/ChatService.py` +- `api/chat2api.py` +- `gateway/reverseProxy.py` +- `gateway/backend.py` +- `utils/retry.py` + +说明: + +- 上述文件均已有本地未提交改动;后续任何线路进入前都必须先读最新内容,再决定是否继续叠加修改。 +- 线路 A 与线路 B 通过 `/v1/models` 和 `fetch_available_models()` 形成耦合。 +- 线路 C 与线路 D 在“上游失败 -> 错误码 -> 是否重试”的路径上存在耦合。 + +### 推荐文件归属 + +- 模型相关:`api/models.py`、`chatgpt/ChatService.py` +- API 路由相关:`api/chat2api.py` +- 网关相关:`gateway/backend.py`、`gateway/v1.py`、`gateway/chatgpt.py`、`gateway/login.py`、`gateway/reverseProxy.py` +- 重试/容错相关:`utils/retry.py` +- 配置与部署:`utils/configs.py`、`.env.example`、`README.md`、`docker-compose*.yml`、`Dockerfile` + +## 当前风险与待验证项 + +- `README.md` 在当前 PowerShell 输出中出现乱码,推测是终端编码展示问题;不要基于控制台乱码直接改写文档内容。 +- `memory.md` 已按 UTF-8 with BOM 保存,以提高 Windows 本地打开时的稳定性;如果终端仍乱码,优先检查控制台输出编码,而不是重写文件内容。 +- `/v1/models` 依赖上游 `/backend-api/models`;需要验证不同账户态、代理态、异常态下的返回是否稳定。 +- `resolve_request_model()` 的默认回退策略已从“未知模型强制回退到 `gpt-4o`”转向“保留原模型并标记为动态模型”;这会影响未知模型名的兼容性,应重点回归。 +- `gizmo` 模型路径当前绕过模型可用性校验;如后续要增强校验,需要确认 GPTs 的真实上游约束。 +- `gateway/reverseProxy.py` 现在把通用异常统一映射为 502;需要确认上层调用链是否会因此触发新的重试分支。 +- `utils/retry.py` 现在只重试部分状态码;需要确认历史上依赖“所有 HTTPException 都重试”的调用场景是否存在行为变化。 +- 当前缓存 Key 使用 `host_url + account_id + token_hash`;如果后续多网关/多账户轮询策略变化,需要重新确认缓存粒度是否足够。 + +## 协作规则 + +- 开始工作前:先读本文件,再读自己要改的目标文件。 +- 开始修改时:先在“当前认领”中登记负责范围。 +- 修改完成后:同步更新“当前状态 / 风险 / 下一步”。 +- 不要在本文件记录敏感信息、真实 Token、代理账号、Cookie、私有域名。 +- 避免多条线同时修改同一文件;若不可避免,先在本文件明确主导线路。 + +## 当前认领 + +- 线路 A(模型解析与校验):进行中;主文件 `api/models.py`、`chatgpt/ChatService.py` +- 线路 B(API 路由与 `/v1/models`):进行中;主文件 `api/chat2api.py` +- 线路 C(网关反向代理错误语义):进行中;主文件 `gateway/reverseProxy.py` + 补充:`gateway/backend.py` 已纳入该线 +- 线路 D(重试策略与退避):进行中;主文件 `utils/retry.py` +- 线路 E(配置/部署/文档):未认领 + +## 下一步建议 + +- 先做一轮 A + B 联合回归:验证模型选择、未登录访问、`gizmo`、未知模型名、`CHECK_MODEL=true`、`/v1/models` 返回结构。 +- 再做一轮 C + D 联合回归:验证上游 502、超时、瞬时 500/503 时的反代错误码与重试次数是否符合预期。 +- 其余线路如需并行,优先避开上面 5 个已占用文件,先处理文档、部署或其他网关路由。 +- 如果后续要长期并行协作,建议新增 `handoff.md` 专门记录每条线的交接事项,本文件只保留高层事实。 diff --git a/utils/retry.py b/utils/retry.py index ac488d1a..36726906 100644 --- a/utils/retry.py +++ b/utils/retry.py @@ -1,8 +1,24 @@ +import asyncio +import time + from fastapi import HTTPException from utils.Logger import logger from utils.configs import retry_times +RETRYABLE_STATUS_CODES = {408, 500, 502, 503, 504} +BASE_RETRY_DELAY_SECONDS = 0.5 +MAX_RETRY_DELAY_SECONDS = 4 + + +def should_retry_http_exception(status_code): + return status_code in RETRYABLE_STATUS_CODES + + +def get_retry_delay(attempt): + delay = BASE_RETRY_DELAY_SECONDS * (2 ** attempt) + return min(delay, MAX_RETRY_DELAY_SECONDS) + async def async_retry(func, *args, max_retries=retry_times, **kwargs): for attempt in range(max_retries + 1): @@ -10,12 +26,18 @@ async def async_retry(func, *args, max_retries=retry_times, **kwargs): result = await func(*args, **kwargs) return result except HTTPException as e: - if attempt == max_retries: + should_retry = should_retry_http_exception(e.status_code) + if attempt == max_retries or not should_retry: logger.error(f"Throw an exception {e.status_code}, {e.detail}") if e.status_code == 500: raise HTTPException(status_code=500, detail="Server error") raise HTTPException(status_code=e.status_code, detail=e.detail) - logger.info(f"Retry {attempt + 1} status code {e.status_code}, {e.detail}. Retrying...") + delay = get_retry_delay(attempt) + logger.info( + f"Retry {attempt + 1} status code {e.status_code}, {e.detail}. " + f"Retrying in {delay:.1f}s..." + ) + await asyncio.sleep(delay) def retry(func, *args, max_retries=retry_times, **kwargs): @@ -24,9 +46,15 @@ def retry(func, *args, max_retries=retry_times, **kwargs): result = func(*args, **kwargs) return result except HTTPException as e: - if attempt == max_retries: + should_retry = should_retry_http_exception(e.status_code) + if attempt == max_retries or not should_retry: logger.error(f"Throw an exception {e.status_code}, {e.detail}") if e.status_code == 500: raise HTTPException(status_code=500, detail="Server error") raise HTTPException(status_code=e.status_code, detail=e.detail) - logger.error(f"Retry {attempt + 1} status code {e.status_code}, {e.detail}. Retrying...") + delay = get_retry_delay(attempt) + logger.error( + f"Retry {attempt + 1} status code {e.status_code}, {e.detail}. " + f"Retrying in {delay:.1f}s..." + ) + time.sleep(delay) From 78370bd3b2e90783d3ed981f345b2b14345479a6 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Sat, 18 Apr 2026 00:29:56 +0800 Subject: [PATCH 02/96] Update GitHub Actions workflows for Docker builds: upgraded checkout action to v4, modified version output handling, added tag existence check before tagging, and updated image names for Docker metadata. Also included permissions for write access to contents and packages. --- .github/workflows/build_docker_dev.yml | 31 +++++++++++++++++------ .github/workflows/build_docker_main.yml | 33 +++++++++++++++++++------ 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/.github/workflows/build_docker_dev.yml b/.github/workflows/build_docker_dev.yml index 4b6564db..6c771883 100644 --- a/.github/workflows/build_docker_dev.yml +++ b/.github/workflows/build_docker_dev.yml @@ -13,30 +13,38 @@ on: - '.github/workflows/build_docker_dev.yml' workflow_dispatch: +permissions: + contents: write + packages: write + jobs: main: runs-on: ubuntu-latest steps: - name: Check out the repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Read the version from version.txt id: get_version run: | version=$(cat version.txt) echo "Current version: v$version-dev" - echo "::set-output name=version::v$version-dev" + echo "version=v$version-dev" >> "$GITHUB_OUTPUT" - name: Commit and push version tag - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | version=${{ steps.get_version.outputs.version }} git config --local user.email "action@github.com" git config --local user.name "GitHub Action" - git tag "$version" - git push https://x-access-token:${GHCR_PAT}@github.com/lanqian528/chat2api.git "$version" + if git rev-parse "$version" >/dev/null 2>&1; then + echo "Tag $version already exists" + else + git tag "$version" + git push origin "$version" + fi - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -50,11 +58,20 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Docker meta id: meta uses: docker/metadata-action@v5 with: - images: lanqian528/chat2api + images: | + nanashiwang/chat2api + ghcr.io/${{ github.repository_owner }}/chat2api tags: | type=raw,value=latest-dev type=raw,value=${{ steps.get_version.outputs.version }} diff --git a/.github/workflows/build_docker_main.yml b/.github/workflows/build_docker_main.yml index 4c7c6386..af23d7e5 100644 --- a/.github/workflows/build_docker_main.yml +++ b/.github/workflows/build_docker_main.yml @@ -13,30 +13,38 @@ on: - '.github/workflows/build_docker_dev.yml' workflow_dispatch: +permissions: + contents: write + packages: write + jobs: main: runs-on: ubuntu-latest steps: - name: Check out the repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Read the version from version.txt id: get_version run: | version=$(cat version.txt) echo "Current version: v$version" - echo "::set-output name=version::v$version" + echo "version=v$version" >> "$GITHUB_OUTPUT" - name: Commit and push version tag - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | version=${{ steps.get_version.outputs.version }} git config --local user.email "action@github.com" git config --local user.name "GitHub Action" - git tag "$version" - git push https://x-access-token:${GHCR_PAT}@github.com/lanqian528/chat2api.git "$version" + if git rev-parse "$version" >/dev/null 2>&1; then + echo "Tag $version already exists" + else + git tag "$version" + git push origin "$version" + fi - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -50,11 +58,20 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Docker meta id: meta uses: docker/metadata-action@v5 with: - images: lanqian528/chat2api + images: | + nanashiwang/chat2api + ghcr.io/${{ github.repository_owner }}/chat2api tags: | type=raw,value=latest,enable={{is_default_branch}} type=raw,value=${{ steps.get_version.outputs.version }} @@ -67,4 +84,4 @@ jobs: file: Dockerfile push: true tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file + labels: ${{ steps.meta.outputs.labels }} From 472f869e1e45d6103ac5c4556fb37268bc51b618 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Sat, 18 Apr 2026 00:35:32 +0800 Subject: [PATCH 03/96] =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build_docker_dev.yml | 2 -- .github/workflows/build_docker_main.yml | 2 -- 2 files changed, 4 deletions(-) diff --git a/.github/workflows/build_docker_dev.yml b/.github/workflows/build_docker_dev.yml index 6c771883..87ce8e81 100644 --- a/.github/workflows/build_docker_dev.yml +++ b/.github/workflows/build_docker_dev.yml @@ -9,8 +9,6 @@ on: - 'docker-compose.yml' - 'docker-compose-warp.yml' - 'docs/**' - - '.github/workflows/build_docker_main.yml' - - '.github/workflows/build_docker_dev.yml' workflow_dispatch: permissions: diff --git a/.github/workflows/build_docker_main.yml b/.github/workflows/build_docker_main.yml index af23d7e5..90ece70d 100644 --- a/.github/workflows/build_docker_main.yml +++ b/.github/workflows/build_docker_main.yml @@ -9,8 +9,6 @@ on: - 'docker-compose.yml' - 'docker-compose-warp.yml' - 'docs/**' - - '.github/workflows/build_docker_main.yml' - - '.github/workflows/build_docker_dev.yml' workflow_dispatch: permissions: From 7ff13cb45e0255a597cb672d853a9cd32492186d Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Sat, 18 Apr 2026 00:39:01 +0800 Subject: [PATCH 04/96] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=9E=84=E9=80=A0?= =?UTF-8?q?=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build_docker_dev.yml | 12 ++++++------ .github/workflows/build_docker_main.yml | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build_docker_dev.yml b/.github/workflows/build_docker_dev.yml index 87ce8e81..3e1bc04b 100644 --- a/.github/workflows/build_docker_dev.yml +++ b/.github/workflows/build_docker_dev.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Check out the repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 @@ -45,19 +45,19 @@ jobs: fi - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -75,7 +75,7 @@ jobs: type=raw,value=${{ steps.get_version.outputs.version }} - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/build_docker_main.yml b/.github/workflows/build_docker_main.yml index 90ece70d..2b018d58 100644 --- a/.github/workflows/build_docker_main.yml +++ b/.github/workflows/build_docker_main.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Check out the repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 @@ -45,19 +45,19 @@ jobs: fi - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -75,7 +75,7 @@ jobs: type=raw,value=${{ steps.get_version.outputs.version }} - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm64 From 8dbbcff932c0c75387c374eff983b86114a7e6d4 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Sat, 18 Apr 2026 00:46:20 +0800 Subject: [PATCH 05/96] =?UTF-8?q?=E6=9E=84=E9=80=A0=E9=95=9C=E5=83=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build_docker_dev.yml | 48 +++++++++++++++++-------- .github/workflows/build_docker_main.yml | 48 +++++++++++++++++-------- 2 files changed, 68 insertions(+), 28 deletions(-) diff --git a/.github/workflows/build_docker_dev.yml b/.github/workflows/build_docker_dev.yml index 3e1bc04b..dad62987 100644 --- a/.github/workflows/build_docker_dev.yml +++ b/.github/workflows/build_docker_dev.yml @@ -50,12 +50,6 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - - name: Log in to Docker Hub - uses: docker/login-action@v4 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - name: Log in to GHCR uses: docker/login-action@v4 with: @@ -63,23 +57,49 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Docker meta - id: meta + - name: Docker meta for GHCR + id: meta_ghcr + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/chat2api + tags: | + type=raw,value=latest-dev + type=raw,value=${{ steps.get_version.outputs.version }} + + - name: Build and push GHCR image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + file: Dockerfile + push: true + tags: ${{ steps.meta_ghcr.outputs.tags }} + labels: ${{ steps.meta_ghcr.outputs.labels }} + + - name: Log in to Docker Hub + if: ${{ secrets.DOCKER_USERNAME != '' && secrets.DOCKER_PASSWORD != '' }} + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Docker meta for Docker Hub + if: ${{ secrets.DOCKER_USERNAME != '' && secrets.DOCKER_PASSWORD != '' }} + id: meta_dockerhub uses: docker/metadata-action@v5 with: - images: | - nanashiwang/chat2api - ghcr.io/${{ github.repository_owner }}/chat2api + images: nanashiwang/chat2api tags: | type=raw,value=latest-dev type=raw,value=${{ steps.get_version.outputs.version }} - - name: Build and push + - name: Build and push Docker Hub image + if: ${{ secrets.DOCKER_USERNAME != '' && secrets.DOCKER_PASSWORD != '' }} uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm64 file: Dockerfile push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + tags: ${{ steps.meta_dockerhub.outputs.tags }} + labels: ${{ steps.meta_dockerhub.outputs.labels }} diff --git a/.github/workflows/build_docker_main.yml b/.github/workflows/build_docker_main.yml index 2b018d58..c302998a 100644 --- a/.github/workflows/build_docker_main.yml +++ b/.github/workflows/build_docker_main.yml @@ -50,12 +50,6 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - - name: Log in to Docker Hub - uses: docker/login-action@v4 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - name: Log in to GHCR uses: docker/login-action@v4 with: @@ -63,23 +57,49 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Docker meta - id: meta + - name: Docker meta for GHCR + id: meta_ghcr + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/chat2api + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=${{ steps.get_version.outputs.version }} + + - name: Build and push GHCR image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + file: Dockerfile + push: true + tags: ${{ steps.meta_ghcr.outputs.tags }} + labels: ${{ steps.meta_ghcr.outputs.labels }} + + - name: Log in to Docker Hub + if: ${{ secrets.DOCKER_USERNAME != '' && secrets.DOCKER_PASSWORD != '' }} + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Docker meta for Docker Hub + if: ${{ secrets.DOCKER_USERNAME != '' && secrets.DOCKER_PASSWORD != '' }} + id: meta_dockerhub uses: docker/metadata-action@v5 with: - images: | - nanashiwang/chat2api - ghcr.io/${{ github.repository_owner }}/chat2api + images: nanashiwang/chat2api tags: | type=raw,value=latest,enable={{is_default_branch}} type=raw,value=${{ steps.get_version.outputs.version }} - - name: Build and push + - name: Build and push Docker Hub image + if: ${{ secrets.DOCKER_USERNAME != '' && secrets.DOCKER_PASSWORD != '' }} uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm64 file: Dockerfile push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + tags: ${{ steps.meta_dockerhub.outputs.tags }} + labels: ${{ steps.meta_dockerhub.outputs.labels }} From 57937faa9a49cd6d4f3097cd00c165c5bf4ab470 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Sat, 18 Apr 2026 00:47:56 +0800 Subject: [PATCH 06/96] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build_docker_dev.yml | 8 +++++--- .github/workflows/build_docker_main.yml | 8 +++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build_docker_dev.yml b/.github/workflows/build_docker_dev.yml index dad62987..f7126a25 100644 --- a/.github/workflows/build_docker_dev.yml +++ b/.github/workflows/build_docker_dev.yml @@ -18,6 +18,8 @@ permissions: jobs: main: runs-on: ubuntu-latest + env: + HAS_DOCKERHUB_CREDS: ${{ secrets.DOCKER_USERNAME != '' && secrets.DOCKER_PASSWORD != '' }} steps: - name: Check out the repository @@ -77,14 +79,14 @@ jobs: labels: ${{ steps.meta_ghcr.outputs.labels }} - name: Log in to Docker Hub - if: ${{ secrets.DOCKER_USERNAME != '' && secrets.DOCKER_PASSWORD != '' }} + if: ${{ env.HAS_DOCKERHUB_CREDS == 'true' }} uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Docker meta for Docker Hub - if: ${{ secrets.DOCKER_USERNAME != '' && secrets.DOCKER_PASSWORD != '' }} + if: ${{ env.HAS_DOCKERHUB_CREDS == 'true' }} id: meta_dockerhub uses: docker/metadata-action@v5 with: @@ -94,7 +96,7 @@ jobs: type=raw,value=${{ steps.get_version.outputs.version }} - name: Build and push Docker Hub image - if: ${{ secrets.DOCKER_USERNAME != '' && secrets.DOCKER_PASSWORD != '' }} + if: ${{ env.HAS_DOCKERHUB_CREDS == 'true' }} uses: docker/build-push-action@v6 with: context: . diff --git a/.github/workflows/build_docker_main.yml b/.github/workflows/build_docker_main.yml index c302998a..56138b17 100644 --- a/.github/workflows/build_docker_main.yml +++ b/.github/workflows/build_docker_main.yml @@ -18,6 +18,8 @@ permissions: jobs: main: runs-on: ubuntu-latest + env: + HAS_DOCKERHUB_CREDS: ${{ secrets.DOCKER_USERNAME != '' && secrets.DOCKER_PASSWORD != '' }} steps: - name: Check out the repository @@ -77,14 +79,14 @@ jobs: labels: ${{ steps.meta_ghcr.outputs.labels }} - name: Log in to Docker Hub - if: ${{ secrets.DOCKER_USERNAME != '' && secrets.DOCKER_PASSWORD != '' }} + if: ${{ env.HAS_DOCKERHUB_CREDS == 'true' }} uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Docker meta for Docker Hub - if: ${{ secrets.DOCKER_USERNAME != '' && secrets.DOCKER_PASSWORD != '' }} + if: ${{ env.HAS_DOCKERHUB_CREDS == 'true' }} id: meta_dockerhub uses: docker/metadata-action@v5 with: @@ -94,7 +96,7 @@ jobs: type=raw,value=${{ steps.get_version.outputs.version }} - name: Build and push Docker Hub image - if: ${{ secrets.DOCKER_USERNAME != '' && secrets.DOCKER_PASSWORD != '' }} + if: ${{ env.HAS_DOCKERHUB_CREDS == 'true' }} uses: docker/build-push-action@v6 with: context: . From c698ca3408deef02637e79178d2647ff28abd0fc Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Sat, 18 Apr 2026 12:13:16 +0800 Subject: [PATCH 07/96] =?UTF-8?q?=E6=9E=84=E9=80=A0=E5=8D=87=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build_docker_dev.yml | 21 ++++++++++++++++----- .github/workflows/build_docker_main.yml | 21 ++++++++++++++++----- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build_docker_dev.yml b/.github/workflows/build_docker_dev.yml index f7126a25..966d558b 100644 --- a/.github/workflows/build_docker_dev.yml +++ b/.github/workflows/build_docker_dev.yml @@ -18,8 +18,6 @@ permissions: jobs: main: runs-on: ubuntu-latest - env: - HAS_DOCKERHUB_CREDS: ${{ secrets.DOCKER_USERNAME != '' && secrets.DOCKER_PASSWORD != '' }} steps: - name: Check out the repository @@ -78,15 +76,28 @@ jobs: tags: ${{ steps.meta_ghcr.outputs.tags }} labels: ${{ steps.meta_ghcr.outputs.labels }} + - name: Detect Docker Hub credentials + id: dockerhub_creds + env: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + run: | + if [ -n "$DOCKER_USERNAME" ] && [ -n "$DOCKER_PASSWORD" ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "Docker Hub credentials not configured; skipping Docker Hub publish." + fi + - name: Log in to Docker Hub - if: ${{ env.HAS_DOCKERHUB_CREDS == 'true' }} + if: ${{ steps.dockerhub_creds.outputs.present == 'true' }} uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Docker meta for Docker Hub - if: ${{ env.HAS_DOCKERHUB_CREDS == 'true' }} + if: ${{ steps.dockerhub_creds.outputs.present == 'true' }} id: meta_dockerhub uses: docker/metadata-action@v5 with: @@ -96,7 +107,7 @@ jobs: type=raw,value=${{ steps.get_version.outputs.version }} - name: Build and push Docker Hub image - if: ${{ env.HAS_DOCKERHUB_CREDS == 'true' }} + if: ${{ steps.dockerhub_creds.outputs.present == 'true' }} uses: docker/build-push-action@v6 with: context: . diff --git a/.github/workflows/build_docker_main.yml b/.github/workflows/build_docker_main.yml index 56138b17..b833be79 100644 --- a/.github/workflows/build_docker_main.yml +++ b/.github/workflows/build_docker_main.yml @@ -18,8 +18,6 @@ permissions: jobs: main: runs-on: ubuntu-latest - env: - HAS_DOCKERHUB_CREDS: ${{ secrets.DOCKER_USERNAME != '' && secrets.DOCKER_PASSWORD != '' }} steps: - name: Check out the repository @@ -78,15 +76,28 @@ jobs: tags: ${{ steps.meta_ghcr.outputs.tags }} labels: ${{ steps.meta_ghcr.outputs.labels }} + - name: Detect Docker Hub credentials + id: dockerhub_creds + env: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + run: | + if [ -n "$DOCKER_USERNAME" ] && [ -n "$DOCKER_PASSWORD" ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "Docker Hub credentials not configured; skipping Docker Hub publish." + fi + - name: Log in to Docker Hub - if: ${{ env.HAS_DOCKERHUB_CREDS == 'true' }} + if: ${{ steps.dockerhub_creds.outputs.present == 'true' }} uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Docker meta for Docker Hub - if: ${{ env.HAS_DOCKERHUB_CREDS == 'true' }} + if: ${{ steps.dockerhub_creds.outputs.present == 'true' }} id: meta_dockerhub uses: docker/metadata-action@v5 with: @@ -96,7 +107,7 @@ jobs: type=raw,value=${{ steps.get_version.outputs.version }} - name: Build and push Docker Hub image - if: ${{ env.HAS_DOCKERHUB_CREDS == 'true' }} + if: ${{ steps.dockerhub_creds.outputs.present == 'true' }} uses: docker/build-push-action@v6 with: context: . From 18348e878ba223391ddd3d5d949f295a7e3a96c0 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Sun, 19 Apr 2026 21:01:18 +0800 Subject: [PATCH 08/96] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=89=8D=E6=AE=B5?= =?UTF-8?q?=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 4 +- chatgpt/fp.py | 15 +- chatgpt/refreshToken.py | 6 +- gateway/admin.py | 222 ++++++++++++++++++ gateway/share.py | 9 +- templates/account_proxy_bindings.html | 315 ++++++++++++++++++++++++++ templates/admin_login.html | 39 ++++ utils/configs.py | 2 + utils/globals.py | 11 + utils/routing.py | 216 ++++++++++++++++++ 10 files changed, 829 insertions(+), 10 deletions(-) create mode 100644 templates/account_proxy_bindings.html create mode 100644 templates/admin_login.html create mode 100644 utils/routing.py diff --git a/app.py b/app.py index 52f3f063..a145fd9e 100644 --- a/app.py +++ b/app.py @@ -37,13 +37,13 @@ from app import app import api.chat2api +import gateway.admin if enable_gateway: - import gateway.share import gateway.login import gateway.chatgpt import gateway.gpts - import gateway.admin + import gateway.share import gateway.v1 import gateway.backend else: diff --git a/chatgpt/fp.py b/chatgpt/fp.py index ae1003aa..02119e54 100644 --- a/chatgpt/fp.py +++ b/chatgpt/fp.py @@ -8,16 +8,23 @@ import utils.globals as globals from utils import configs +from utils.routing import get_bound_proxy def get_fp(req_token): fp = globals.fp_map.get(req_token, {}) + bound_proxy = get_bound_proxy(req_token) if fp and fp.get("user-agent") and fp.get("impersonate"): - if "proxy_url" in fp.keys() and (fp["proxy_url"] is None or fp["proxy_url"] not in configs.proxy_url_list): + if bound_proxy: + fp["proxy_url"] = bound_proxy + globals.fp_map[req_token] = fp + with open(globals.FP_FILE, "w", encoding="utf-8") as f: + json.dump(globals.fp_map, f, indent=4, ensure_ascii=False) + elif "proxy_url" in fp.keys() and (fp["proxy_url"] is None or fp["proxy_url"] not in configs.proxy_url_list): fp["proxy_url"] = random.choice(configs.proxy_url_list) if configs.proxy_url_list else None globals.fp_map[req_token] = fp with open(globals.FP_FILE, "w", encoding="utf-8") as f: - json.dump(globals.fp_map, f, indent=4) + json.dump(globals.fp_map, f, indent=4, ensure_ascii=False) if globals.impersonate_list and "impersonate" in fp.keys() and fp["impersonate"] not in globals.impersonate_list: fp["impersonate"] = random.choice(globals.impersonate_list) globals.fp_map[req_token] = fp @@ -44,7 +51,7 @@ def get_fp(req_token): fp = { "user-agent": ua.text if not configs.user_agents_list else random.choice(configs.user_agents_list), "impersonate": random.choice(globals.impersonate_list), - "proxy_url": random.choice(configs.proxy_url_list) if configs.proxy_url_list else None, + "proxy_url": bound_proxy or (random.choice(configs.proxy_url_list) if configs.proxy_url_list else None), "oai-device-id": str(uuid.uuid4()) } if ua.device == "desktop" and ua.browser in ("chrome", "edge"): @@ -57,5 +64,5 @@ def get_fp(req_token): else: globals.fp_map[req_token] = fp with open(globals.FP_FILE, "w", encoding="utf-8") as f: - json.dump(globals.fp_map, f, indent=4) + json.dump(globals.fp_map, f, indent=4, ensure_ascii=False) return fp diff --git a/chatgpt/refreshToken.py b/chatgpt/refreshToken.py index a580a75f..aebf1d2b 100644 --- a/chatgpt/refreshToken.py +++ b/chatgpt/refreshToken.py @@ -8,6 +8,7 @@ from utils.Client import Client from utils.Logger import logger from utils.configs import proxy_url_list +from utils.routing import get_bound_proxy import utils.globals as globals @@ -36,7 +37,10 @@ async def chat_refresh(refresh_token): "refresh_token": refresh_token } session_id = hashlib.md5(refresh_token.encode()).hexdigest() - proxy_url = random.choice(proxy_url_list).replace("{}", session_id) if proxy_url_list else None + bound_proxy = get_bound_proxy(refresh_token) + proxy_url = bound_proxy or (random.choice(proxy_url_list).replace("{}", session_id) if proxy_url_list else None) + if proxy_url: + proxy_url = proxy_url.replace("{}", session_id) client = Client(proxy=proxy_url) try: r = await client.post("https://auth0.openai.com/oauth/token", json=data, timeout=15) diff --git a/gateway/admin.py b/gateway/admin.py index e69de29b..3e8bb40c 100644 --- a/gateway/admin.py +++ b/gateway/admin.py @@ -0,0 +1,222 @@ +import time +from collections import defaultdict, deque + +from fastapi import HTTPException, Request +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse + +from app import app, templates +from utils.configs import admin_password, api_prefix, authorization_list +from utils.routing import ( + build_group_assignments, + get_dashboard_payload, + get_routing_config, + save_routing_config, + sync_bindings_to_fp, + update_single_binding, +) +import utils.globals as globals + +ADMIN_COOKIE_NAME = "admin_auth" +ADMIN_COOKIE_MAX_AGE = 8 * 60 * 60 +rate_limit_buckets = defaultdict(deque) +failed_login_buckets = defaultdict(deque) + + +def admin_login_path(): + if api_prefix: + return f"/{api_prefix}/admin/login" + return "/admin/login" + + +def get_admin_secrets(): + if admin_password: + return [admin_password] + return authorization_list + + +def get_client_key(request: Request): + forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip() + if forwarded: + return forwarded + if request.client: + return request.client.host + return "unknown" + + +def check_rate_limit(bucket_key, limit, window_seconds): + now = time.time() + bucket = rate_limit_buckets[bucket_key] + while bucket and now - bucket[0] > window_seconds: + bucket.popleft() + if len(bucket) >= limit: + raise HTTPException(status_code=429, detail="Too many admin requests") + bucket.append(now) + + +def record_failed_login(client_key): + now = time.time() + bucket = failed_login_buckets[client_key] + while bucket and now - bucket[0] > 600: + bucket.popleft() + bucket.append(now) + + +def ensure_login_not_locked(client_key): + now = time.time() + bucket = failed_login_buckets[client_key] + while bucket and now - bucket[0] > 600: + bucket.popleft() + if len(bucket) >= 5: + raise HTTPException(status_code=429, detail="Too many failed login attempts") + + +def is_admin_authorized(request: Request): + cookie_token = request.cookies.get(ADMIN_COOKIE_NAME, "") + header_token = request.headers.get("authorization", "").replace("Bearer ", "").strip() + token = header_token or cookie_token + return bool(token and token in get_admin_secrets()) + + +def require_admin_auth(request: Request): + if not get_admin_secrets(): + return + check_rate_limit(f"admin:{get_client_key(request)}", 120, 60) + if not is_admin_authorized(request): + raise HTTPException(status_code=401, detail="Admin authorization required") + + +async def routing_admin_login_page(request: Request): + check_rate_limit(f"admin-page:{get_client_key(request)}", 60, 60) + if is_admin_authorized(request): + return RedirectResponse(url=f"/{api_prefix}/admin/routing" if api_prefix else "/admin/routing", status_code=302) + return templates.TemplateResponse( + "admin_login.html", + { + "request": request, + "api_prefix": api_prefix, + }, + ) + + +async def routing_admin_login_submit(request: Request): + client_key = get_client_key(request) + ensure_login_not_locked(client_key) + check_rate_limit(f"admin-login:{client_key}", 10, 300) + form = await request.form() + password = (form.get("password") or "").strip() + if not password or password not in get_admin_secrets(): + record_failed_login(client_key) + return templates.TemplateResponse( + "admin_login.html", + { + "request": request, + "api_prefix": api_prefix, + "error": "授权码无效", + }, + status_code=401, + ) + + response = RedirectResponse( + url=f"/{api_prefix}/admin/routing" if api_prefix else "/admin/routing", + status_code=302, + ) + failed_login_buckets.pop(client_key, None) + response.set_cookie( + ADMIN_COOKIE_NAME, + value=password, + httponly=True, + samesite="lax", + max_age=ADMIN_COOKIE_MAX_AGE, + ) + return response + + +async def routing_admin_logout(request: Request): + response = RedirectResponse(url=admin_login_path(), status_code=302) + response.delete_cookie(ADMIN_COOKIE_NAME) + return response + + +async def routing_admin_page(request: Request): + check_rate_limit(f"admin-page:{get_client_key(request)}", 60, 60) + if get_admin_secrets() and not is_admin_authorized(request): + return RedirectResponse(url=admin_login_path(), status_code=302) + return templates.TemplateResponse( + "account_proxy_bindings.html", + { + "request": request, + "api_prefix": api_prefix, + }, + ) + + +async def routing_admin_data(request: Request): + require_admin_auth(request) + payload = get_dashboard_payload() + payload["routing_config"] = get_routing_config() + payload["proxy_options"] = get_routing_config().get("proxies", []) + return JSONResponse(payload) + + +async def routing_admin_save(request: Request): + require_admin_auth(request) + try: + body = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON body") + + proxies = body.get("proxies", []) + group_size = body.get("group_size", 25) + if not isinstance(proxies, list) or not proxies: + raise HTTPException(status_code=400, detail="proxies is required") + + result = build_group_assignments(list(globals.token_list), proxies, group_size) + save_routing_config(result) + sync_bindings_to_fp(result["bindings"]) + return JSONResponse( + { + "status": "success", + "message": "Routing config saved", + "summary": get_dashboard_payload()["summary"], + } + ) + + +async def routing_admin_bind_account(request: Request): + require_admin_auth(request) + try: + body = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON body") + + token = (body.get("token") or "").strip() + proxy_url = (body.get("proxy_url") or "").strip() + proxy_name = (body.get("proxy_name") or "").strip() + if not token or not proxy_url: + raise HTTPException(status_code=400, detail="token and proxy_url are required") + + config = get_routing_config() + if not proxy_name: + proxy = next((item for item in config.get("proxies", []) if item.get("proxy_url") == proxy_url), None) + proxy_name = proxy.get("name") if proxy else "Custom Proxy" + + binding = update_single_binding(token, proxy_name, proxy_url) + return JSONResponse({"status": "success", "binding": binding}) + + +app.add_api_route("/admin/routing", routing_admin_page, methods=["GET"], response_class=HTMLResponse) +app.add_api_route("/admin/routing/data", routing_admin_data, methods=["GET"]) +app.add_api_route("/admin/routing/save", routing_admin_save, methods=["POST"]) +app.add_api_route("/admin/login", routing_admin_login_page, methods=["GET"], response_class=HTMLResponse) +app.add_api_route("/admin/login", routing_admin_login_submit, methods=["POST"]) +app.add_api_route("/admin/logout", routing_admin_logout, methods=["POST"]) +app.add_api_route("/admin/routing/account-bind", routing_admin_bind_account, methods=["POST"]) + +if api_prefix: + app.add_api_route(f"/{api_prefix}/admin/routing", routing_admin_page, methods=["GET"], response_class=HTMLResponse) + app.add_api_route(f"/{api_prefix}/admin/routing/data", routing_admin_data, methods=["GET"]) + app.add_api_route(f"/{api_prefix}/admin/routing/save", routing_admin_save, methods=["POST"]) + app.add_api_route(f"/{api_prefix}/admin/login", routing_admin_login_page, methods=["GET"], response_class=HTMLResponse) + app.add_api_route(f"/{api_prefix}/admin/login", routing_admin_login_submit, methods=["POST"]) + app.add_api_route(f"/{api_prefix}/admin/logout", routing_admin_logout, methods=["POST"]) + app.add_api_route(f"/{api_prefix}/admin/routing/account-bind", routing_admin_bind_account, methods=["POST"]) diff --git a/gateway/share.py b/gateway/share.py index aaf910ed..d4c76992 100644 --- a/gateway/share.py +++ b/gateway/share.py @@ -16,6 +16,7 @@ from utils.Client import Client from utils.Logger import logger from utils.configs import proxy_url_list, chatgpt_base_url_list, authorization_list +from utils.routing import get_bound_proxy base_headers = { 'accept': '*/*', @@ -138,7 +139,7 @@ async def chatgpt_account_check(access_token): headers.update({"authorization": f"Bearer {access_token}"}) session_id = hashlib.md5(access_token.encode()).hexdigest() - proxy_url = random.choice(proxy_url_list).replace("{}", session_id) if proxy_url_list else None + proxy_url = proxy_url.replace("{}", session_id) if proxy_url else None client = Client(proxy=proxy_url, impersonate=impersonate) r = await client.get(f"{host_url}/backend-api/models?history_and_training_disabled=false", headers=headers, timeout=10) @@ -186,7 +187,10 @@ async def chatgpt_account_check(access_token): async def chatgpt_refresh(refresh_token): session_id = hashlib.md5(refresh_token.encode()).hexdigest() - proxy_url = random.choice(proxy_url_list).replace("{}", session_id) if proxy_url_list else None + bound_proxy = get_bound_proxy(refresh_token) + proxy_url = bound_proxy or (random.choice(proxy_url_list).replace("{}", session_id) if proxy_url_list else None) + if proxy_url: + proxy_url = proxy_url.replace("{}", session_id) client = Client(proxy=proxy_url) try: data = { @@ -253,4 +257,3 @@ async def refresh(request: Request): raise HTTPException(status_code=401, detail="Unauthorized") - diff --git a/templates/account_proxy_bindings.html b/templates/account_proxy_bindings.html new file mode 100644 index 00000000..da5f73ac --- /dev/null +++ b/templates/account_proxy_bindings.html @@ -0,0 +1,315 @@ + + + + + + Chat2API Admin + + + + +
+ + +
+
+
+
+

总览 Dashboard

+

固定代理分组后台,支持按组把账号批量绑定到指定出口。

+
+
+
最后发布:尚未发布
+ +
+
+
+
+ +
+
+
+

IP 负载概览

+
固定映射后会同步到 fp_map.json
+
+
+
+
+
+

告警中心

+
+
+
+
+ +
+
+
+
+

代理配置 IP Pool

+

一行一个代理,格式支持 名称 | socks5://host:port

+
+
+ +
+
+ + +
+
+ +
+
+
+
+ +
+
+
+

绑定规则 Routing

+

按照账号列表顺序分组,每组固定绑定一个代理。

+
+
+
+ + + + + + + + + + + +
分组绑定 IP账号数策略状态
+
+
+
+ +
+
+
+

账号管理 Accounts

+

查看账号当前绑定的代理与分组,异常账号会从错误列表中标出。

+
+
+
+ + + + + + + + + + + + + +
账号Token状态绑定 IP分组最近更新操作
+
+
+
+
+ + diff --git a/templates/admin_login.html b/templates/admin_login.html new file mode 100644 index 00000000..ea701552 --- /dev/null +++ b/templates/admin_login.html @@ -0,0 +1,39 @@ + + + + + + Admin Login + + + +
+
+

管理后台登录

+

优先使用 `ADMIN_PASSWORD`,未配置时回退到 `AUTHORIZATION`。

+
+ + {% if error %} +
+ {{ error }} +
+ {% endif %} + +
+
+ + +
+ +
+
+ + diff --git a/utils/configs.py b/utils/configs.py index b88c5be0..eb110730 100644 --- a/utils/configs.py +++ b/utils/configs.py @@ -21,6 +21,7 @@ def is_true(x): api_prefix = os.getenv('API_PREFIX', None) authorization = os.getenv('AUTHORIZATION', '').replace(' ', '') +admin_password = os.getenv('ADMIN_PASSWORD', None) chatgpt_base_url = os.getenv('CHATGPT_BASE_URL', 'https://chatgpt.com').replace(' ', '') auth_key = os.getenv('AUTH_KEY', None) x_sign = os.getenv('X_SIGN', None) @@ -79,6 +80,7 @@ def is_true(x): logger.info("------------------------- Security -------------------------") logger.info("API_PREFIX: " + str(api_prefix)) logger.info("AUTHORIZATION: " + str(authorization_list)) +logger.info("ADMIN_PASSWORD: " + str(bool(admin_password))) logger.info("AUTH_KEY: " + str(auth_key)) logger.info("------------------------- Request --------------------------") logger.info("CHATGPT_BASE_URL: " + str(chatgpt_base_url_list)) diff --git a/utils/globals.py b/utils/globals.py index fd4bf574..46b3a2ad 100644 --- a/utils/globals.py +++ b/utils/globals.py @@ -10,6 +10,7 @@ ERROR_TOKENS_FILE = os.path.join(DATA_FOLDER, "error_token.txt") WSS_MAP_FILE = os.path.join(DATA_FOLDER, "wss_map.json") FP_FILE = os.path.join(DATA_FOLDER, "fp_map.json") +ROUTING_CONFIG_FILE = os.path.join(DATA_FOLDER, "routing_config.json") SEED_MAP_FILE = os.path.join(DATA_FOLDER, "seed_map.json") CONVERSATION_MAP_FILE = os.path.join(DATA_FOLDER, "conversation_map.json") @@ -19,6 +20,7 @@ refresh_map = {} wss_map = {} fp_map = {} +routing_config = {} seed_map = {} conversation_map = {} impersonate_list = [ @@ -66,6 +68,15 @@ else: fp_map = {} +if os.path.exists(ROUTING_CONFIG_FILE): + with open(ROUTING_CONFIG_FILE, "r", encoding="utf-8") as f: + try: + routing_config = json.load(f) + except: + routing_config = {} +else: + routing_config = {} + if os.path.exists(SEED_MAP_FILE): with open(SEED_MAP_FILE, "r") as f: try: diff --git a/utils/routing.py b/utils/routing.py new file mode 100644 index 00000000..ea918dea --- /dev/null +++ b/utils/routing.py @@ -0,0 +1,216 @@ +import json +from datetime import datetime, timezone + +import utils.globals as globals +from utils.Logger import logger + + +def utc_now(): + return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + + +def mask_token(token): + if not token: + return "" + if len(token) <= 12: + return token + return f"{token[:6]}...{token[-4:]}" + + +def get_routing_config(): + config = globals.routing_config or {} + config.setdefault("proxies", []) + config.setdefault("groups", []) + config.setdefault("bindings", {}) + config.setdefault("updated_at", None) + return config + + +def save_routing_config(config): + config["updated_at"] = utc_now() + globals.routing_config = config + with open(globals.ROUTING_CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2, ensure_ascii=False) + + +def resolve_group_name(config, proxy_name, proxy_url): + for group in config.get("groups", []): + if group.get("proxy_url") == proxy_url: + return group.get("name") + return f"Group {proxy_name}" + + +def build_group_assignments(tokens, proxies, group_size): + group_size = max(int(group_size or 1), 1) + bindings = {} + groups = [] + normalized_proxies = [] + + for index, proxy in enumerate(proxies): + proxy_name = (proxy.get("name") or f"IP-{index + 1}").strip() + proxy_url = (proxy.get("proxy_url") or "").strip() + if not proxy_url: + continue + normalized_proxies.append({ + "id": f"proxy-{index + 1}", + "name": proxy_name, + "proxy_url": proxy_url, + }) + + for proxy_index, proxy in enumerate(normalized_proxies): + start = proxy_index * group_size + end = min(start + group_size, len(tokens)) + group_tokens = tokens[start:end] + if not group_tokens: + break + group_name = f"Group {chr(65 + proxy_index)}" + groups.append({ + "id": f"group-{proxy_index + 1}", + "name": group_name, + "proxy_name": proxy["name"], + "proxy_url": proxy["proxy_url"], + "size": len(group_tokens), + "strategy": "fixed", + "status": "enabled", + }) + for token in group_tokens: + bindings[token] = { + "group": group_name, + "proxy_name": proxy["name"], + "proxy_url": proxy["proxy_url"], + "updated_at": utc_now(), + } + + return { + "proxies": normalized_proxies, + "groups": groups, + "bindings": bindings, + } + + +def sync_bindings_to_fp(bindings): + changed = False + for token, binding in bindings.items(): + if not token: + continue + fp = globals.fp_map.get(token, {}) + proxy_url = binding.get("proxy_url") + if fp.get("proxy_url") != proxy_url: + fp["proxy_url"] = proxy_url + changed = True + if binding.get("group"): + fp["group"] = binding["group"] + if binding.get("proxy_name"): + fp["proxy_name"] = binding["proxy_name"] + fp["updated_at"] = binding.get("updated_at", utc_now()) + globals.fp_map[token] = fp + if changed: + logger.info("Routing bindings synced to fp_map.json") + with open(globals.FP_FILE, "w", encoding="utf-8") as f: + json.dump(globals.fp_map, f, indent=2, ensure_ascii=False) + + +def update_single_binding(token, proxy_name, proxy_url, group_name=None): + config = get_routing_config() + existing_proxy = next((item for item in config.get("proxies", []) if item.get("proxy_url") == proxy_url), None) + if not existing_proxy: + config.setdefault("proxies", []).append({ + "id": f"proxy-{len(config.get('proxies', [])) + 1}", + "name": proxy_name, + "proxy_url": proxy_url, + }) + + if not group_name: + group_name = resolve_group_name(config, proxy_name, proxy_url) + + config.setdefault("bindings", {})[token] = { + "group": group_name, + "proxy_name": proxy_name, + "proxy_url": proxy_url, + "updated_at": utc_now(), + } + save_routing_config(config) + sync_bindings_to_fp({token: config["bindings"][token]}) + return config["bindings"][token] + + +def get_bound_proxy(req_token): + binding = get_routing_config().get("bindings", {}).get(req_token) + if binding: + return binding.get("proxy_url") + return None + + +def get_dashboard_payload(): + config = get_routing_config() + bindings = config.get("bindings", {}) + proxies = config.get("proxies", []) + tokens = list(globals.token_list) + error_tokens = set(globals.error_token_list) + active_tokens = len([token for token in tokens if token not in error_tokens]) + grouped_rules = {} + + proxy_stats = [] + for proxy in proxies: + matched_tokens = [token for token, binding in bindings.items() if binding.get("proxy_url") == proxy["proxy_url"]] + bad_count = len([token for token in matched_tokens if token in error_tokens]) + rule_name = None + if matched_tokens: + rule_name = bindings[matched_tokens[0]].get("group") + grouped_rules[rule_name or proxy["name"]] = { + "name": rule_name or proxy["name"], + "proxy_name": proxy["name"], + "proxy_url": proxy["proxy_url"], + "size": len(matched_tokens), + "strategy": "fixed", + "status": "enabled", + } + proxy_stats.append({ + "name": proxy["name"], + "proxy_url": proxy["proxy_url"], + "accounts": len(matched_tokens), + "ok": len(matched_tokens) - bad_count, + "bad": bad_count, + "group": rule_name or "-", + }) + + accounts = [] + for index, token in enumerate(tokens, start=1): + binding = bindings.get(token, {}) + status = "异常" if token in error_tokens else "正常" + proxy_name = binding.get("proxy_name", "-") + group_name = binding.get("group", "-") + accounts.append({ + "id": f"acct-{index:03d}", + "token": token, + "token_masked": mask_token(token), + "status": status, + "proxy_name": proxy_name, + "group": group_name, + "updated_at": binding.get("updated_at") or globals.fp_map.get(token, {}).get("updated_at") or "-", + }) + + alerts = [] + if error_tokens: + alerts.append(f"当前有 {len(error_tokens)} 个异常账号,建议优先检查刷新状态。") + unbound_count = max(len(tokens) - len(bindings), 0) + if unbound_count: + alerts.append(f"还有 {unbound_count} 个账号未绑定固定代理。") + if not alerts: + alerts.append("当前未发现异常告警。") + + return { + "summary": { + "accounts_total": len(tokens), + "accounts_ok": active_tokens, + "accounts_bad": len(error_tokens), + "proxy_total": len(proxies), + "group_total": len(grouped_rules), + "bound_total": len(bindings), + }, + "ip_cards": proxy_stats, + "accounts": accounts, + "rules": list(grouped_rules.values()), + "alerts": alerts, + "updated_at": config.get("updated_at"), + } From b928a756b5766cd1d3568df923ff1ab0e1371bc9 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Sun, 19 Apr 2026 23:00:52 +0800 Subject: [PATCH 09/96] =?UTF-8?q?=E4=BE=A7=E8=BE=B9=E6=A0=8F=E5=8F=AF?= =?UTF-8?q?=E7=82=B9=E5=87=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- templates/account_proxy_bindings.html | 50 +++++++++++++++++++++------ 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/templates/account_proxy_bindings.html b/templates/account_proxy_bindings.html index da5f73ac..2ec1b4a5 100644 --- a/templates/account_proxy_bindings.html +++ b/templates/account_proxy_bindings.html @@ -26,6 +26,7 @@ const logoutButton = document.getElementById('logoutButton'); const statusText = document.getElementById('statusText'); let proxyOptions = []; + const navLinks = Array.from(document.querySelectorAll('[data-nav-target]')); function parseProxyLines(raw) { return raw @@ -191,28 +192,57 @@ form.submit(); }); + navLinks.forEach((link) => { + link.addEventListener('click', () => { + const targetId = link.dataset.navTarget; + const target = document.getElementById(targetId); + if (!target) return; + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); + }); + + const sectionObserver = new IntersectionObserver((entries) => { + const visibleEntry = entries + .filter((entry) => entry.isIntersecting) + .sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0]; + if (!visibleEntry) return; + navLinks.forEach((link) => { + const active = link.dataset.navTarget === visibleEntry.target.id; + link.classList.toggle('bg-slate-800', active); + link.classList.toggle('border-slate-700', !active); + link.classList.toggle('bg-slate-900/60', !active); + }); + }, { + rootMargin: '-25% 0px -55% 0px', + threshold: [0.2, 0.5, 0.8], + }); + + document.querySelectorAll('[data-admin-section]').forEach((section) => { + sectionObserver.observe(section); + }); + loadDashboard().catch((error) => { statusText.textContent = `加载失败: ${error.message}`; }); }); - +
-
+
-
+

总览 Dashboard

固定代理分组后台,支持按组把账号批量绑定到指定出口。

@@ -241,7 +271,7 @@

告警中心

-
+

代理配置 IP Pool

@@ -261,7 +291,7 @@

代理配置 IP Pool

-
+

绑定规则 Routing

@@ -285,7 +315,7 @@

绑定规则 Routing

-
+

账号管理 Accounts

From bf6d31da4e6cb1741bb1dadf429e81de0335f7b4 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Mon, 20 Apr 2026 00:01:33 +0800 Subject: [PATCH 10/96] =?UTF-8?q?1=E3=80=81=E5=A2=9E=E5=8A=A0=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E9=83=A8=E7=BD=B2=EF=BC=9B=202=E3=80=81=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E8=B4=A6=E5=8F=B7=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/chat2api.py | 2 + deploy/install.sh | 136 ++++++++++++++++++++++++++ gateway/admin.py | 45 +++++++++ templates/account_proxy_bindings.html | 54 ++++++++++ utils/bootstrap.py | 93 ++++++++++++++++++ utils/configs.py | 9 ++ 6 files changed, 339 insertions(+) create mode 100755 deploy/install.sh create mode 100644 utils/bootstrap.py diff --git a/api/chat2api.py b/api/chat2api.py index ef5e518b..a342d91c 100644 --- a/api/chat2api.py +++ b/api/chat2api.py @@ -11,6 +11,7 @@ from app import app, templates, security_scheme from chatgpt.ChatService import ChatService from chatgpt.authorization import refresh_all_tokens +from utils.bootstrap import initialize_from_env from utils.Logger import logger from utils.configs import api_prefix, scheduled_refresh, history_disabled from utils.retry import async_retry @@ -20,6 +21,7 @@ @app.on_event("startup") async def app_start(): + initialize_from_env() if scheduled_refresh: scheduler.add_job(id='refresh', func=refresh_all_tokens, trigger='cron', hour=3, minute=0, day='*/2', kwargs={'force_refresh': True}) diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100755 index 00000000..2a28b791 --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +set -euo pipefail + +DEFAULT_INSTALL_DIR="/opt/chat2api" +DEFAULT_IMAGE="ghcr.io/nanashiwang/chat2api:latest" +DEFAULT_PORT="60403" +DEFAULT_API_PREFIX="nanapi-2026-a1" +DEFAULT_GROUP_SIZE="25" + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +prompt() { + local var_name="$1" + local prompt_text="$2" + local default_value="${3:-}" + local secret="${4:-false}" + local current_value="" + if [[ "$secret" == "true" ]]; then + read -r -s -p "$prompt_text [$default_value]: " current_value + echo + else + read -r -p "$prompt_text [$default_value]: " current_value + fi + if [[ -z "$current_value" ]]; then + current_value="$default_value" + fi + printf -v "$var_name" '%s' "$current_value" +} + +yaml_escape() { + printf "%s" "$1" | sed "s/'/''/g" +} + +ensure_docker() { + if command_exists docker && docker compose version >/dev/null 2>&1; then + return + fi + + echo "Docker or docker compose plugin not found, installing..." + if ! command_exists curl; then + if command_exists apt-get; then + sudo apt-get update + sudo apt-get install -y curl + else + echo "curl is required to install Docker automatically." + exit 1 + fi + fi + + curl -fsSL https://get.docker.com | sudo sh + sudo systemctl enable docker + sudo systemctl start docker +} + +extract_first_proxy_url() { + local raw="$1" + local first="${raw%%,*}" + first="$(printf "%s" "$first" | xargs)" + if [[ "$first" == *"|"* ]]; then + first="${first#*|}" + fi + printf "%s" "$first" +} + +echo "== Chat2API one-click installer ==" +prompt INSTALL_DIR "Install directory" "$DEFAULT_INSTALL_DIR" +prompt IMAGE "Docker image" "$DEFAULT_IMAGE" +prompt PORT "Host port" "$DEFAULT_PORT" +prompt API_PREFIX "API prefix" "$DEFAULT_API_PREFIX" +prompt AUTHORIZATION "API authorization token" "sk-your-api-key" "true" +prompt ADMIN_PASSWORD "Admin password" "change-me-admin" "true" +prompt INIT_TOKENS "Initial tokens (comma separated)" "rt-xxx1,rt-xxx2" +prompt INIT_PROXIES "Initial proxies (comma separated, NAME|URL)" "IP-A|socks5://127.0.0.1:7890,IP-B|socks5://127.0.0.2:7890" +prompt INIT_GROUP_SIZE "Initial group size" "$DEFAULT_GROUP_SIZE" + +FIRST_PROXY_URL="$(extract_first_proxy_url "$INIT_PROXIES")" +prompt EXPORT_PROXY_URL "Export proxy URL" "$FIRST_PROXY_URL" + +mkdir -p "$INSTALL_DIR/data" +cd "$INSTALL_DIR" + +cat > docker-compose.yml <:$PORT/$API_PREFIX/admin/login" +echo "API endpoint: http://:$PORT/$API_PREFIX/v1/chat/completions" diff --git a/gateway/admin.py b/gateway/admin.py index 3e8bb40c..ee7d87a8 100644 --- a/gateway/admin.py +++ b/gateway/admin.py @@ -204,6 +204,49 @@ async def routing_admin_bind_account(request: Request): return JSONResponse({"status": "success", "binding": binding}) +async def routing_admin_import_accounts(request: Request): + require_admin_auth(request) + try: + body = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON body") + + text = (body.get("text") or "").strip() + if not text: + raise HTTPException(status_code=400, detail="text is required") + + incoming_tokens = [] + for line in text.splitlines(): + token = line.strip() + if token and not token.startswith("#"): + incoming_tokens.append(token) + + if not incoming_tokens: + raise HTTPException(status_code=400, detail="No valid tokens found") + + existing = set(globals.token_list) + added = [] + for token in incoming_tokens: + if token not in existing: + globals.token_list.append(token) + existing.add(token) + added.append(token) + + if added: + with open(globals.TOKENS_FILE, "a", encoding="utf-8") as f: + for token in added: + f.write(token + "\n") + + return JSONResponse( + { + "status": "success", + "added_count": len(added), + "skipped_count": len(incoming_tokens) - len(added), + "message": "账号已导入;如需固定分组,请重新发布绑定规则。", + } + ) + + app.add_api_route("/admin/routing", routing_admin_page, methods=["GET"], response_class=HTMLResponse) app.add_api_route("/admin/routing/data", routing_admin_data, methods=["GET"]) app.add_api_route("/admin/routing/save", routing_admin_save, methods=["POST"]) @@ -211,6 +254,7 @@ async def routing_admin_bind_account(request: Request): app.add_api_route("/admin/login", routing_admin_login_submit, methods=["POST"]) app.add_api_route("/admin/logout", routing_admin_logout, methods=["POST"]) app.add_api_route("/admin/routing/account-bind", routing_admin_bind_account, methods=["POST"]) +app.add_api_route("/admin/routing/accounts/import", routing_admin_import_accounts, methods=["POST"]) if api_prefix: app.add_api_route(f"/{api_prefix}/admin/routing", routing_admin_page, methods=["GET"], response_class=HTMLResponse) @@ -220,3 +264,4 @@ async def routing_admin_bind_account(request: Request): app.add_api_route(f"/{api_prefix}/admin/login", routing_admin_login_submit, methods=["POST"]) app.add_api_route(f"/{api_prefix}/admin/logout", routing_admin_logout, methods=["POST"]) app.add_api_route(f"/{api_prefix}/admin/routing/account-bind", routing_admin_bind_account, methods=["POST"]) + app.add_api_route(f"/{api_prefix}/admin/routing/accounts/import", routing_admin_import_accounts, methods=["POST"]) diff --git a/templates/account_proxy_bindings.html b/templates/account_proxy_bindings.html index 2ec1b4a5..063f044d 100644 --- a/templates/account_proxy_bindings.html +++ b/templates/account_proxy_bindings.html @@ -13,6 +13,7 @@ const saveApi = `${basePath}/admin/routing/save`; const logoutApi = `${basePath}/admin/logout`; const bindApi = `${basePath}/admin/routing/account-bind`; + const importAccountsApi = `${basePath}/admin/routing/accounts/import`; const summaryGrid = document.getElementById('summaryGrid'); const ipGrid = document.getElementById('ipGrid'); @@ -24,6 +25,11 @@ const groupSizeInput = document.getElementById('groupSizeInput'); const saveButton = document.getElementById('saveButton'); const logoutButton = document.getElementById('logoutButton'); + const importAccountsButton = document.getElementById('importAccountsButton'); + const importModal = document.getElementById('importAccountsModal'); + const importModalClose = document.getElementById('importModalClose'); + const importSubmitButton = document.getElementById('importSubmitButton'); + const importTextarea = document.getElementById('importAccountsTextarea'); const statusText = document.getElementById('statusText'); let proxyOptions = []; const navLinks = Array.from(document.querySelectorAll('[data-nav-target]')); @@ -192,6 +198,37 @@ form.submit(); }); + importAccountsButton.addEventListener('click', () => { + importModal.classList.remove('hidden'); + importModal.classList.add('flex'); + }); + + importModalClose.addEventListener('click', () => { + importModal.classList.add('hidden'); + importModal.classList.remove('flex'); + }); + + importSubmitButton.addEventListener('click', async () => { + const text = importTextarea.value.trim(); + if (!text) { + alert('请输入账号 token,一行一个'); + return; + } + const response = await fetch(importAccountsApi, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }), + }); + const result = await response.json(); + statusText.textContent = response.ok ? result.message : (result.detail || '导入失败'); + if (response.ok) { + importTextarea.value = ''; + importModal.classList.add('hidden'); + importModal.classList.remove('flex'); + await loadDashboard(); + } + }); + navLinks.forEach((link) => { link.addEventListener('click', () => { const targetId = link.dataset.navTarget; @@ -248,6 +285,7 @@

总览 Dashboard

最后发布:尚未发布
+
@@ -341,5 +379,21 @@

账号管理 Accounts

+ + diff --git a/utils/bootstrap.py b/utils/bootstrap.py new file mode 100644 index 00000000..9cc1763d --- /dev/null +++ b/utils/bootstrap.py @@ -0,0 +1,93 @@ +from utils.Logger import logger +from utils.configs import ( + init_apply_on_empty, + init_force, + init_group_size, + init_proxies, + init_tokens, +) +import utils.globals as globals +from utils.routing import build_group_assignments, save_routing_config, sync_bindings_to_fp + + +def _split_items(raw): + if not raw: + return [] + if "\n" in raw: + parts = raw.splitlines() + else: + parts = raw.split(",") + items = [] + for part in parts: + item = part.strip() + if item: + items.append(item) + return items + + +def _parse_proxies(raw): + proxies = [] + for index, item in enumerate(_split_items(raw), start=1): + if "|" in item: + name, proxy_url = item.split("|", 1) + name = name.strip() or f"IP-{index}" + proxy_url = proxy_url.strip() + else: + name = f"IP-{index}" + proxy_url = item + if proxy_url: + proxies.append({"name": name, "proxy_url": proxy_url}) + return proxies + + +def initialize_tokens(): + tokens = _split_items(init_tokens) + if not tokens: + return False + has_existing = bool(globals.token_list) + if has_existing and init_apply_on_empty and not init_force: + logger.info("Bootstrap tokens skipped: token.txt already populated") + return False + + seen = set() + normalized = [] + for token in tokens: + if token not in seen: + normalized.append(token) + seen.add(token) + + globals.token_list[:] = normalized + with open(globals.TOKENS_FILE, "w", encoding="utf-8") as f: + for token in normalized: + f.write(token + "\n") + logger.info(f"Bootstrap tokens initialized: {len(normalized)} accounts") + return True + + +def initialize_routing(): + proxies = _parse_proxies(init_proxies) + if not proxies: + return False + if not globals.token_list: + logger.info("Bootstrap routing skipped: no tokens available") + return False + + has_existing = bool(globals.routing_config.get("bindings")) + if has_existing and init_apply_on_empty and not init_force: + logger.info("Bootstrap routing skipped: routing_config.json already populated") + return False + + result = build_group_assignments(list(globals.token_list), proxies, init_group_size) + save_routing_config(result) + sync_bindings_to_fp(result["bindings"]) + logger.info( + f"Bootstrap routing initialized: {len(result['proxies'])} proxies, " + f"{len(result['bindings'])} bindings, group size {init_group_size}" + ) + return True + + +def initialize_from_env(): + changed_tokens = initialize_tokens() + changed_routing = initialize_routing() + return changed_tokens or changed_routing diff --git a/utils/configs.py b/utils/configs.py index eb110730..5a6e87e2 100644 --- a/utils/configs.py +++ b/utils/configs.py @@ -69,6 +69,11 @@ def is_true(x): auto_seed = is_true(os.getenv('AUTO_SEED', True)) force_no_history = is_true(os.getenv('FORCE_NO_HISTORY', False)) no_sentinel = is_true(os.getenv('NO_SENTINEL', False)) +init_tokens = os.getenv('INIT_TOKENS', '') +init_proxies = os.getenv('INIT_PROXIES', '') +init_group_size = int(os.getenv('INIT_GROUP_SIZE', 25)) +init_apply_on_empty = is_true(os.getenv('INIT_APPLY_ON_EMPTY', True)) +init_force = is_true(os.getenv('INIT_FORCE', False)) with open('version.txt') as f: version = f.read().strip() @@ -105,4 +110,8 @@ def is_true(x): logger.info("ENABLE_GATEWAY: " + str(enable_gateway)) logger.info("AUTO_SEED: " + str(auto_seed)) logger.info("FORCE_NO_HISTORY: " + str(force_no_history)) +logger.info("INIT_TOKENS: " + str(bool(init_tokens))) +logger.info("INIT_PROXIES: " + str(bool(init_proxies))) +logger.info("INIT_GROUP_SIZE: " + str(init_group_size)) +logger.info("INIT_FORCE: " + str(init_force)) logger.info("-" * 60) From 91894db2988678b05c7c410b93c34104515fc24f Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Mon, 20 Apr 2026 00:24:44 +0800 Subject: [PATCH 11/96] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=97=B6=E9=97=B4=EF=BC=8C=E6=B5=8B=E8=AF=95=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E5=9C=A8=E5=89=8D=E7=AB=AF=E6=9B=B4=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chatgpt/ChatService.py | 12 ++++++++++-- utils/configs.py | 4 ++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/chatgpt/ChatService.py b/chatgpt/ChatService.py index 5ea729f1..2e4c66e9 100644 --- a/chatgpt/ChatService.py +++ b/chatgpt/ChatService.py @@ -31,6 +31,8 @@ turnstile_solver_url, oai_language, check_model, + chat_requirements_timeout, + chat_request_timeout, ) @@ -217,7 +219,7 @@ async def get_chat_requirements(self): config = get_config(self.user_agent, self.req_token) p = get_requirements_token(config) data = {'p': p} - r = await self.ss.post(url, headers=headers, json=data, timeout=5) + r = await self.ss.post(url, headers=headers, json=data, timeout=chat_requirements_timeout) if r.status_code == 200: resp = r.json() @@ -372,7 +374,13 @@ async def send_conversation(self): try: url = f'{self.base_url}/conversation' stream = self.data.get("stream", False) - r = await self.s.post_stream(url, headers=self.chat_headers, json=self.chat_request, timeout=10, stream=True) + r = await self.s.post_stream( + url, + headers=self.chat_headers, + json=self.chat_request, + timeout=chat_request_timeout, + stream=True, + ) if r.status_code != 200: rtext = await r.atext() if "application/json" == r.headers.get("Content-Type", ""): diff --git a/utils/configs.py b/utils/configs.py index 5a6e87e2..1e223adc 100644 --- a/utils/configs.py +++ b/utils/configs.py @@ -53,6 +53,8 @@ def is_true(x): scheduled_refresh = is_true(os.getenv('SCHEDULED_REFRESH', False)) random_token = is_true(os.getenv('RANDOM_TOKEN', True)) oai_language = os.getenv('OAI_LANGUAGE', 'zh-CN') +chat_requirements_timeout = int(os.getenv('CHAT_REQUIREMENTS_TIMEOUT', 15)) +chat_request_timeout = int(os.getenv('CHAT_REQUEST_TIMEOUT', 30)) authorization_list = authorization.split(',') if authorization else [] chatgpt_base_url_list = chatgpt_base_url.split(',') if chatgpt_base_url else [] @@ -106,6 +108,8 @@ def is_true(x): logger.info("SCHEDULED_REFRESH: " + str(scheduled_refresh)) logger.info("RANDOM_TOKEN: " + str(random_token)) logger.info("OAI_LANGUAGE: " + str(oai_language)) +logger.info("CHAT_REQUIREMENTS_TIMEOUT: " + str(chat_requirements_timeout)) +logger.info("CHAT_REQUEST_TIMEOUT: " + str(chat_request_timeout)) logger.info("------------------------- Gateway --------------------------") logger.info("ENABLE_GATEWAY: " + str(enable_gateway)) logger.info("AUTO_SEED: " + str(auto_seed)) From 5cc5cd6e21db945e76e22795d7cc5ec92f5b8eb0 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Mon, 20 Apr 2026 00:30:54 +0800 Subject: [PATCH 12/96] =?UTF-8?q?1=E3=80=81=E5=89=8D=E7=AB=AF=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E6=B5=8B=E8=AF=95=E6=A0=B7=E4=BE=8B=202=E3=80=81?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=B8=80=E9=94=AE=E6=9B=B4=E6=96=B0=E5=BF=AB?= =?UTF-8?q?=E6=8D=B7=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/chat2api.sh | 88 +++++++++++++++++++++++++++ deploy/install.sh | 12 ++++ templates/account_proxy_bindings.html | 55 +++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 deploy/chat2api.sh diff --git a/deploy/chat2api.sh b/deploy/chat2api.sh new file mode 100644 index 00000000..95d4766d --- /dev/null +++ b/deploy/chat2api.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONFIG_FILE="/etc/chat2api.env" + +if [[ ! -f "$CONFIG_FILE" ]]; then + echo "Config file not found: $CONFIG_FILE" + echo "Please run deploy/install.sh first." + exit 1 +fi + +# shellcheck disable=SC1091 +source "$CONFIG_FILE" + +if [[ -z "${INSTALL_DIR:-}" ]]; then + echo "INSTALL_DIR is not set in $CONFIG_FILE" + exit 1 +fi + +cd "$INSTALL_DIR" + +run_compose() { + if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then + sudo docker compose "$@" + else + echo "docker compose is not available" + exit 1 + fi +} + +show_help() { + cat <<'EOF' +Usage: chat2api + +Commands: + update Pull latest image and recreate containers + restart Restart services + stop Stop services + start Start services + status Show compose status + logs Tail chat2api logs + admin Print admin login URL + api Print API base URL + path Print install directory + help Show this help +EOF +} + +command_name="${1:-help}" + +case "$command_name" in + update) + run_compose pull + run_compose up -d + ;; + restart) + run_compose restart + ;; + stop) + run_compose stop + ;; + start) + run_compose up -d + ;; + status) + run_compose ps + ;; + logs) + run_compose logs -f chat2api + ;; + admin) + echo "http://:${PORT}/${API_PREFIX}/admin/login" + ;; + api) + echo "http://:${PORT}/${API_PREFIX}/v1/chat/completions" + ;; + path) + echo "$INSTALL_DIR" + ;; + help|--help|-h) + show_help + ;; + *) + echo "Unknown command: $command_name" + show_help + exit 1 + ;; +esac diff --git a/deploy/install.sh b/deploy/install.sh index 2a28b791..749c41b2 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -127,6 +127,17 @@ EOF ensure_docker +sudo mkdir -p /etc +sudo tee /etc/chat2api.env >/dev/null <:$PORT/$API_PREFIX/admin/login" echo "API endpoint: http://:$PORT/$API_PREFIX/v1/chat/completions" +echo "Manage commands: chat2api status | chat2api logs | chat2api update" diff --git a/templates/account_proxy_bindings.html b/templates/account_proxy_bindings.html index 063f044d..161ddd74 100644 --- a/templates/account_proxy_bindings.html +++ b/templates/account_proxy_bindings.html @@ -31,6 +31,10 @@ const importSubmitButton = document.getElementById('importSubmitButton'); const importTextarea = document.getElementById('importAccountsTextarea'); const statusText = document.getElementById('statusText'); + const apiTestCommand = document.getElementById('apiTestCommand'); + const modelsTestCommand = document.getElementById('modelsTestCommand'); + const copyModelsButton = document.getElementById('copyModelsButton'); + const copyChatButton = document.getElementById('copyChatButton'); let proxyOptions = []; const navLinks = Array.from(document.querySelectorAll('[data-nav-target]')); @@ -124,6 +128,23 @@ `).join(''); } + function buildTestCommands(payload) { + const origin = window.location.origin; + const authPlaceholder = '请替换为你的 AUTHORIZATION'; + const model = (payload && payload.accounts && payload.accounts.length) ? 'gpt-5' : 'gpt-5'; + modelsTestCommand.textContent = `curl --location '${origin}${basePath}/v1/models' \\\n --header 'Authorization: Bearer ${authPlaceholder}'`; + apiTestCommand.textContent = `curl --location '${origin}${basePath}/v1/chat/completions' \\\n --header 'Content-Type: application/json' \\\n --header 'Authorization: Bearer ${authPlaceholder}' \\\n --data '{\n "model": "${model}",\n "messages": [\n {"role": "user", "content": "你好,请回复 test ok"}\n ],\n "stream": false\n}'`; + } + + async function copyText(content) { + try { + await navigator.clipboard.writeText(content); + statusText.textContent = '测试命令已复制'; + } catch (error) { + statusText.textContent = `复制失败: ${error.message}`; + } + } + function fillProxyInput(config) { const proxies = config.proxies || []; if (!proxies.length) return; @@ -140,6 +161,7 @@ renderRules(payload.rules || []); renderAlerts(payload.alerts || []); fillProxyInput(payload.routing_config || {}); + buildTestCommands(payload); updatedAt.textContent = payload.updated_at || '尚未发布'; } @@ -229,6 +251,14 @@ } }); + copyModelsButton.addEventListener('click', async () => { + await copyText(modelsTestCommand.textContent); + }); + + copyChatButton.addEventListener('click', async () => { + await copyText(apiTestCommand.textContent); + }); + navLinks.forEach((link) => { link.addEventListener('click', () => { const targetId = link.dataset.navTarget; @@ -308,6 +338,31 @@

告警中心

+
+
+
+

测试样例

+

一键复制模型探测和聊天测试命令;只需把授权码替换成你配置的 `AUTHORIZATION`。

+
+
+
+
+
+
模型列表测试
+ +
+

+                    
+
+
+
聊天接口测试
+ +
+

+                    
+
+
+
From af3f7f2fac6a27036749e555f0dd6945f3a6dbe1 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Mon, 20 Apr 2026 00:42:25 +0800 Subject: [PATCH 13/96] =?UTF-8?q?=E5=89=8D=E7=AB=AF=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/admin.py | 48 +++++++++++++++++++++++++++ templates/account_proxy_bindings.html | 35 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/gateway/admin.py b/gateway/admin.py index ee7d87a8..91a9b6df 100644 --- a/gateway/admin.py +++ b/gateway/admin.py @@ -5,6 +5,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from app import app, templates +from utils.Client import Client from utils.configs import admin_password, api_prefix, authorization_list from utils.routing import ( build_group_assignments, @@ -247,6 +248,51 @@ async def routing_admin_import_accounts(request: Request): ) +async def routing_admin_test_proxy(request: Request): + require_admin_auth(request) + try: + body = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON body") + + proxy_url = (body.get("proxy_url") or "").strip() + if not proxy_url: + raise HTTPException(status_code=400, detail="proxy_url is required") + + client = Client(proxy=proxy_url, timeout=15) + results = [] + try: + targets = [ + "https://chatgpt.com", + "https://auth0.openai.com", + ] + for target in targets: + try: + response = await client.get(target, timeout=10) + results.append({ + "target": target, + "ok": 200 <= response.status_code < 500, + "status_code": response.status_code, + }) + except Exception as exc: + results.append({ + "target": target, + "ok": False, + "error": str(exc), + }) + + overall_ok = all(item.get("ok") for item in results) + return JSONResponse( + { + "status": "success" if overall_ok else "partial", + "proxy_url": proxy_url, + "results": results, + } + ) + finally: + await client.close() + + app.add_api_route("/admin/routing", routing_admin_page, methods=["GET"], response_class=HTMLResponse) app.add_api_route("/admin/routing/data", routing_admin_data, methods=["GET"]) app.add_api_route("/admin/routing/save", routing_admin_save, methods=["POST"]) @@ -255,6 +301,7 @@ async def routing_admin_import_accounts(request: Request): app.add_api_route("/admin/logout", routing_admin_logout, methods=["POST"]) app.add_api_route("/admin/routing/account-bind", routing_admin_bind_account, methods=["POST"]) app.add_api_route("/admin/routing/accounts/import", routing_admin_import_accounts, methods=["POST"]) +app.add_api_route("/admin/routing/test-proxy", routing_admin_test_proxy, methods=["POST"]) if api_prefix: app.add_api_route(f"/{api_prefix}/admin/routing", routing_admin_page, methods=["GET"], response_class=HTMLResponse) @@ -265,3 +312,4 @@ async def routing_admin_import_accounts(request: Request): app.add_api_route(f"/{api_prefix}/admin/logout", routing_admin_logout, methods=["POST"]) app.add_api_route(f"/{api_prefix}/admin/routing/account-bind", routing_admin_bind_account, methods=["POST"]) app.add_api_route(f"/{api_prefix}/admin/routing/accounts/import", routing_admin_import_accounts, methods=["POST"]) + app.add_api_route(f"/{api_prefix}/admin/routing/test-proxy", routing_admin_test_proxy, methods=["POST"]) diff --git a/templates/account_proxy_bindings.html b/templates/account_proxy_bindings.html index 161ddd74..c86f67bf 100644 --- a/templates/account_proxy_bindings.html +++ b/templates/account_proxy_bindings.html @@ -14,6 +14,7 @@ const logoutApi = `${basePath}/admin/logout`; const bindApi = `${basePath}/admin/routing/account-bind`; const importAccountsApi = `${basePath}/admin/routing/accounts/import`; + const testProxyApi = `${basePath}/admin/routing/test-proxy`; const summaryGrid = document.getElementById('summaryGrid'); const ipGrid = document.getElementById('ipGrid'); @@ -31,6 +32,8 @@ const importSubmitButton = document.getElementById('importSubmitButton'); const importTextarea = document.getElementById('importAccountsTextarea'); const statusText = document.getElementById('statusText'); + const testProxyButton = document.getElementById('testProxyButton'); + const proxyTestResult = document.getElementById('proxyTestResult'); const apiTestCommand = document.getElementById('apiTestCommand'); const modelsTestCommand = document.getElementById('modelsTestCommand'); const copyModelsButton = document.getElementById('copyModelsButton'); @@ -136,6 +139,11 @@ apiTestCommand.textContent = `curl --location '${origin}${basePath}/v1/chat/completions' \\\n --header 'Content-Type: application/json' \\\n --header 'Authorization: Bearer ${authPlaceholder}' \\\n --data '{\n "model": "${model}",\n "messages": [\n {"role": "user", "content": "你好,请回复 test ok"}\n ],\n "stream": false\n}'`; } + function getFirstConfiguredProxy() { + const proxies = parseProxyLines(proxyInput.value); + return proxies.length ? proxies[0].proxy_url : ''; + } + async function copyText(content) { try { await navigator.clipboard.writeText(content); @@ -259,6 +267,31 @@ await copyText(apiTestCommand.textContent); }); + testProxyButton.addEventListener('click', async () => { + const proxyUrl = getFirstConfiguredProxy(); + if (!proxyUrl) { + proxyTestResult.textContent = '请先在代理配置中至少填写一条代理'; + return; + } + proxyTestResult.textContent = '测试中...'; + const response = await fetch(testProxyApi, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ proxy_url: proxyUrl }), + }); + const result = await response.json(); + if (!response.ok) { + proxyTestResult.textContent = result.detail || '代理测试失败'; + return; + } + proxyTestResult.textContent = (result.results || []).map((item) => { + if (item.ok) { + return `${item.target} -> OK (${item.status_code})`; + } + return `${item.target} -> FAIL (${item.error || item.status_code || 'unknown'})`; + }).join(' | '); + }); + navLinks.forEach((link) => { link.addEventListener('click', () => { const targetId = link.dataset.navTarget; @@ -370,8 +403,10 @@

测试样例

代理配置 IP Pool

一行一个代理,格式支持 名称 | socks5://host:port

+
+
From f6386b457e1a71987842ea0c503e517e1e3daa82 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Mon, 20 Apr 2026 01:32:47 +0800 Subject: [PATCH 14/96] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/admin.py | 37 ++++++++++++++++ templates/account_proxy_bindings.html | 63 +++++++++++++++++++-------- utils/routing.py | 30 +++++++++++++ 3 files changed, 111 insertions(+), 19 deletions(-) diff --git a/gateway/admin.py b/gateway/admin.py index 91a9b6df..7a051c6c 100644 --- a/gateway/admin.py +++ b/gateway/admin.py @@ -11,6 +11,7 @@ build_group_assignments, get_dashboard_payload, get_routing_config, + remove_account_binding, save_routing_config, sync_bindings_to_fp, update_single_binding, @@ -248,6 +249,40 @@ async def routing_admin_import_accounts(request: Request): ) +async def routing_admin_delete_account(request: Request): + require_admin_auth(request) + try: + body = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON body") + + token = (body.get("token") or "").strip() + if not token: + raise HTTPException(status_code=400, detail="token is required") + + if token not in globals.token_list: + raise HTTPException(status_code=404, detail="token not found") + + globals.token_list[:] = [item for item in globals.token_list if item != token] + with open(globals.TOKENS_FILE, "w", encoding="utf-8") as f: + for item in globals.token_list: + f.write(item + "\n") + + remove_account_binding(token) + if token in globals.error_token_list: + globals.error_token_list[:] = [item for item in globals.error_token_list if item != token] + with open(globals.ERROR_TOKENS_FILE, "w", encoding="utf-8") as f: + for item in globals.error_token_list: + f.write(item + "\n") + + return JSONResponse( + { + "status": "success", + "message": "账号已删除", + } + ) + + async def routing_admin_test_proxy(request: Request): require_admin_auth(request) try: @@ -301,6 +336,7 @@ async def routing_admin_test_proxy(request: Request): app.add_api_route("/admin/logout", routing_admin_logout, methods=["POST"]) app.add_api_route("/admin/routing/account-bind", routing_admin_bind_account, methods=["POST"]) app.add_api_route("/admin/routing/accounts/import", routing_admin_import_accounts, methods=["POST"]) +app.add_api_route("/admin/routing/accounts/delete", routing_admin_delete_account, methods=["POST"]) app.add_api_route("/admin/routing/test-proxy", routing_admin_test_proxy, methods=["POST"]) if api_prefix: @@ -312,4 +348,5 @@ async def routing_admin_test_proxy(request: Request): app.add_api_route(f"/{api_prefix}/admin/logout", routing_admin_logout, methods=["POST"]) app.add_api_route(f"/{api_prefix}/admin/routing/account-bind", routing_admin_bind_account, methods=["POST"]) app.add_api_route(f"/{api_prefix}/admin/routing/accounts/import", routing_admin_import_accounts, methods=["POST"]) + app.add_api_route(f"/{api_prefix}/admin/routing/accounts/delete", routing_admin_delete_account, methods=["POST"]) app.add_api_route(f"/{api_prefix}/admin/routing/test-proxy", routing_admin_test_proxy, methods=["POST"]) diff --git a/templates/account_proxy_bindings.html b/templates/account_proxy_bindings.html index c86f67bf..1114c4e9 100644 --- a/templates/account_proxy_bindings.html +++ b/templates/account_proxy_bindings.html @@ -14,6 +14,7 @@ const logoutApi = `${basePath}/admin/logout`; const bindApi = `${basePath}/admin/routing/account-bind`; const importAccountsApi = `${basePath}/admin/routing/accounts/import`; + const deleteAccountApi = `${basePath}/admin/routing/accounts/delete`; const testProxyApi = `${basePath}/admin/routing/test-proxy`; const summaryGrid = document.getElementById('summaryGrid'); @@ -107,7 +108,10 @@ ${row.group} ${row.updated_at || '-'} - +
+ + +
`).join(''); @@ -199,24 +203,45 @@ accountsTable.addEventListener('click', async (event) => { const button = event.target.closest('.bind-account-button'); - if (!button) return; - const token = button.dataset.token; - const select = accountsTable.querySelector(`.account-proxy-select[data-token="${token}"]`); - const proxyUrl = select.value; - const proxy = proxyOptions.find((item) => item.proxy_url === proxyUrl); - const response = await fetch(bindApi, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - token, - proxy_url: proxyUrl, - proxy_name: proxy ? proxy.name : 'Custom Proxy', - }), - }); - const result = await response.json(); - statusText.textContent = response.ok ? '账号绑定已更新' : (result.detail || '更新失败'); - if (response.ok) { - await loadDashboard(); + const deleteButton = event.target.closest('.delete-account-button'); + + if (button) { + const token = button.dataset.token; + const select = accountsTable.querySelector(`.account-proxy-select[data-token="${token}"]`); + const proxyUrl = select.value; + const proxy = proxyOptions.find((item) => item.proxy_url === proxyUrl); + const response = await fetch(bindApi, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token, + proxy_url: proxyUrl, + proxy_name: proxy ? proxy.name : 'Custom Proxy', + }), + }); + const result = await response.json(); + statusText.textContent = response.ok ? '账号绑定已更新' : (result.detail || '更新失败'); + if (response.ok) { + await loadDashboard(); + } + return; + } + + if (deleteButton) { + const token = deleteButton.dataset.token; + if (!window.confirm('确认删除该账号吗?这会同时移除固定绑定配置。')) { + return; + } + const response = await fetch(deleteAccountApi, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + const result = await response.json(); + statusText.textContent = response.ok ? result.message : (result.detail || '删除失败'); + if (response.ok) { + await loadDashboard(); + } } }); diff --git a/utils/routing.py b/utils/routing.py index ea918dea..f46be898 100644 --- a/utils/routing.py +++ b/utils/routing.py @@ -134,6 +134,36 @@ def update_single_binding(token, proxy_name, proxy_url, group_name=None): return config["bindings"][token] +def remove_account_binding(token): + config = get_routing_config() + removed_binding = config.get("bindings", {}).pop(token, None) + + grouped_rules = {} + for binding_token, binding in config.get("bindings", {}).items(): + group_name = binding.get("group") or binding.get("proxy_name") or "Ungrouped" + proxy_name = binding.get("proxy_name", "-") + proxy_url = binding.get("proxy_url", "") + rule = grouped_rules.setdefault(group_name, { + "id": f"group-{len(grouped_rules) + 1}", + "name": group_name, + "proxy_name": proxy_name, + "proxy_url": proxy_url, + "size": 0, + "strategy": "fixed", + "status": "enabled", + }) + rule["size"] += 1 + config["groups"] = list(grouped_rules.values()) + save_routing_config(config) + + if token in globals.fp_map: + globals.fp_map.pop(token, None) + with open(globals.FP_FILE, "w", encoding="utf-8") as f: + json.dump(globals.fp_map, f, indent=2, ensure_ascii=False) + + return removed_binding + + def get_bound_proxy(req_token): binding = get_routing_config().get("bindings", {}).get(req_token) if binding: From 858960cd857107fa3f8a4e130ed931e23ca3638f Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Mon, 20 Apr 2026 01:40:33 +0800 Subject: [PATCH 15/96] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=9B=B4=E6=96=B0token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chatgpt/refreshToken.py | 45 +++++++++++-- gateway/admin.py | 50 ++++++++++++++ templates/account_proxy_bindings.html | 96 ++++++++++++++++++++------- utils/routing.py | 46 +++++++++++++ 4 files changed, 209 insertions(+), 28 deletions(-) diff --git a/chatgpt/refreshToken.py b/chatgpt/refreshToken.py index aebf1d2b..7b130a88 100644 --- a/chatgpt/refreshToken.py +++ b/chatgpt/refreshToken.py @@ -12,6 +12,17 @@ import utils.globals as globals +def persist_refresh_map(): + with open(globals.REFRESH_MAP_FILE, "w", encoding="utf-8") as f: + json.dump(globals.refresh_map, f, indent=4, ensure_ascii=False) + + +def persist_error_tokens(): + with open(globals.ERROR_TOKENS_FILE, "w", encoding="utf-8") as f: + for token in globals.error_token_list: + f.write(token + "\n") + + async def rt2ac(refresh_token, force_refresh=False): if not force_refresh and (refresh_token in globals.refresh_map and int(time.time()) - globals.refresh_map.get(refresh_token, {}).get("timestamp", 0) < 5 * 24 * 60 * 60): access_token = globals.refresh_map[refresh_token]["token"] @@ -20,9 +31,21 @@ async def rt2ac(refresh_token, force_refresh=False): else: try: access_token = await chat_refresh(refresh_token) - globals.refresh_map[refresh_token] = {"token": access_token, "timestamp": int(time.time())} - with open(globals.REFRESH_MAP_FILE, "w") as f: - json.dump(globals.refresh_map, f, indent=4) + refresh_meta = globals.refresh_map.get(refresh_token, {}) + now = int(time.time()) + refresh_meta.update({ + "token": access_token, + "timestamp": now, + "last_success_at": now, + "last_error": "", + "last_error_at": 0, + "fail_count": 0, + }) + globals.refresh_map[refresh_token] = refresh_meta + if refresh_token in globals.error_token_list: + globals.error_token_list[:] = [item for item in globals.error_token_list if item != refresh_token] + persist_error_tokens() + persist_refresh_map() logger.info(f"refresh_token -> access_token with openai: {access_token}") return access_token except HTTPException as e: @@ -41,6 +64,9 @@ async def chat_refresh(refresh_token): proxy_url = bound_proxy or (random.choice(proxy_url_list).replace("{}", session_id) if proxy_url_list else None) if proxy_url: proxy_url = proxy_url.replace("{}", session_id) + refresh_meta = globals.refresh_map.get(refresh_token, {}) + refresh_meta["last_proxy"] = proxy_url or "" + globals.refresh_map[refresh_token] = refresh_meta client = Client(proxy=proxy_url) try: r = await client.post("https://auth0.openai.com/oauth/token", json=data, timeout=15) @@ -51,12 +77,21 @@ async def chat_refresh(refresh_token): if "invalid_grant" in r.text or "access_denied" in r.text: if refresh_token not in globals.error_token_list: globals.error_token_list.append(refresh_token) - with open(globals.ERROR_TOKENS_FILE, "a", encoding="utf-8") as f: - f.write(refresh_token + "\n") + persist_error_tokens() raise Exception(r.text) else: raise Exception(r.text[:300]) except Exception as e: + now = int(time.time()) + refresh_meta = globals.refresh_map.get(refresh_token, {}) + refresh_meta.update({ + "last_error": str(e)[:300], + "last_error_at": now, + "fail_count": int(refresh_meta.get("fail_count", 0)) + 1, + "last_proxy": proxy_url or "", + }) + globals.refresh_map[refresh_token] = refresh_meta + persist_refresh_map() logger.error(f"Failed to refresh access_token `{refresh_token}`: {str(e)}") raise HTTPException(status_code=500, detail=f"Failed to refresh access_token.") finally: diff --git a/gateway/admin.py b/gateway/admin.py index 7a051c6c..19d374b3 100644 --- a/gateway/admin.py +++ b/gateway/admin.py @@ -1,3 +1,4 @@ +import json import time from collections import defaultdict, deque @@ -9,6 +10,7 @@ from utils.configs import admin_password, api_prefix, authorization_list from utils.routing import ( build_group_assignments, + detect_token_type, get_dashboard_payload, get_routing_config, remove_account_binding, @@ -17,6 +19,7 @@ update_single_binding, ) import utils.globals as globals +from chatgpt.refreshToken import rt2ac ADMIN_COOKIE_NAME = "admin_auth" ADMIN_COOKIE_MAX_AGE = 8 * 60 * 60 @@ -79,6 +82,15 @@ def is_admin_authorized(request: Request): return bool(token and token in get_admin_secrets()) +def get_current_admin_token(request: Request): + cookie_token = request.cookies.get(ADMIN_COOKIE_NAME, "") + header_token = request.headers.get("authorization", "").replace("Bearer ", "").strip() + token = header_token or cookie_token + if token and token in get_admin_secrets(): + return token + return "" + + def require_admin_auth(request: Request): if not get_admin_secrets(): return @@ -148,6 +160,7 @@ async def routing_admin_page(request: Request): { "request": request, "api_prefix": api_prefix, + "admin_token": get_current_admin_token(request), }, ) @@ -269,6 +282,10 @@ async def routing_admin_delete_account(request: Request): f.write(item + "\n") remove_account_binding(token) + if token in globals.refresh_map: + globals.refresh_map.pop(token, None) + with open(globals.REFRESH_MAP_FILE, "w", encoding="utf-8") as f: + json.dump(globals.refresh_map, f, indent=4, ensure_ascii=False) if token in globals.error_token_list: globals.error_token_list[:] = [item for item in globals.error_token_list if item != token] with open(globals.ERROR_TOKENS_FILE, "w", encoding="utf-8") as f: @@ -283,6 +300,37 @@ async def routing_admin_delete_account(request: Request): ) +async def routing_admin_refresh_account(request: Request): + require_admin_auth(request) + try: + body = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON body") + + token = (body.get("token") or "").strip() + if not token: + raise HTTPException(status_code=400, detail="token is required") + if token not in globals.token_list: + raise HTTPException(status_code=404, detail="token not found") + if detect_token_type(token) != "RefreshToken": + raise HTTPException(status_code=400, detail="Only RefreshToken supports manual refresh") + + try: + access_token = await rt2ac(token, force_refresh=True) + except HTTPException as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) + + refresh_info = globals.refresh_map.get(token, {}) + return JSONResponse( + { + "status": "success", + "message": "RefreshToken 刷新成功", + "token_masked": f"{access_token[:6]}...{access_token[-4:]}" if access_token else "", + "refresh_updated_at": refresh_info.get("last_success_at", refresh_info.get("timestamp", 0)), + } + ) + + async def routing_admin_test_proxy(request: Request): require_admin_auth(request) try: @@ -337,6 +385,7 @@ async def routing_admin_test_proxy(request: Request): app.add_api_route("/admin/routing/account-bind", routing_admin_bind_account, methods=["POST"]) app.add_api_route("/admin/routing/accounts/import", routing_admin_import_accounts, methods=["POST"]) app.add_api_route("/admin/routing/accounts/delete", routing_admin_delete_account, methods=["POST"]) +app.add_api_route("/admin/routing/accounts/refresh", routing_admin_refresh_account, methods=["POST"]) app.add_api_route("/admin/routing/test-proxy", routing_admin_test_proxy, methods=["POST"]) if api_prefix: @@ -349,4 +398,5 @@ async def routing_admin_test_proxy(request: Request): app.add_api_route(f"/{api_prefix}/admin/routing/account-bind", routing_admin_bind_account, methods=["POST"]) app.add_api_route(f"/{api_prefix}/admin/routing/accounts/import", routing_admin_import_accounts, methods=["POST"]) app.add_api_route(f"/{api_prefix}/admin/routing/accounts/delete", routing_admin_delete_account, methods=["POST"]) + app.add_api_route(f"/{api_prefix}/admin/routing/accounts/refresh", routing_admin_refresh_account, methods=["POST"]) app.add_api_route(f"/{api_prefix}/admin/routing/test-proxy", routing_admin_test_proxy, methods=["POST"]) diff --git a/templates/account_proxy_bindings.html b/templates/account_proxy_bindings.html index 1114c4e9..18ee0db7 100644 --- a/templates/account_proxy_bindings.html +++ b/templates/account_proxy_bindings.html @@ -8,6 +8,7 @@ - +
-
+
+
-

总览 Dashboard

-

固定代理分组后台,支持按组把账号批量绑定到指定出口。

+

控制台总览

+

集中查看账号健康度、代理负载、告警和 API 测试样例。

最后发布:尚未发布
@@ -882,8 +908,8 @@

告警中心

-

测试样例

-

一键复制模型探测和聊天测试命令;已直接填入当前登录授权码。

+

接口测试样例

+

一键复制模型探测和聊天测试命令,方便你本地或服务器直接验证。

@@ -903,59 +929,83 @@

测试样例

+
-
-
-
+ +
+
+
From 9c7801336f9c56ee52d96a6bd594c2a47986178e Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Tue, 21 Apr 2026 01:02:12 +0800 Subject: [PATCH 23/96] =?UTF-8?q?=E6=96=B0=E5=A2=9EHarvester=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=EF=BC=9A=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E8=B4=A6=E5=8F=B7=E5=85=83=E6=95=B0=E6=8D=AE=E7=9A=84?= =?UTF-8?q?=E5=A2=9E=E5=88=A0=E6=94=B9=E6=9F=A5=E6=8E=A5=E5=8F=A3=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=89=B9=E9=87=8F=E5=AF=BC=E5=85=A5=E5=92=8C?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E4=B8=8A=E6=8A=A5=EF=BC=8C=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E7=9B=B8=E5=BA=94=E6=9B=B4=E6=96=B0=E4=BB=A5?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E7=94=A8=E6=88=B7=E4=BA=A4=E4=BA=92=E4=BD=93?= =?UTF-8?q?=E9=AA=8C=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/admin.py | 127 +++++++++ harvester/src/chat2api_client.py | 37 +++ harvester/src/harvest.py | 19 +- templates/account_proxy_bindings.html | 385 +++++++++++++++++++++++++- utils/globals.py | 2 + utils/harvester_meta.py | 209 ++++++++++++++ 6 files changed, 771 insertions(+), 8 deletions(-) create mode 100644 utils/harvester_meta.py diff --git a/gateway/admin.py b/gateway/admin.py index cab63bc4..aa502b7e 100644 --- a/gateway/admin.py +++ b/gateway/admin.py @@ -581,6 +581,123 @@ async def routing_admin_logs_download(request: Request): ) +# ============ Harvester 账号元数据 ============ + +async def routing_admin_harvester_list(request: Request): + """返回所有 Harvester 账号元数据及统计。""" + require_admin_auth(request) + from utils import harvester_meta + return JSONResponse({ + "accounts": harvester_meta.list_all(), + "stats": harvester_meta.stats(), + }) + + +async def routing_admin_harvester_upsert(request: Request): + """新增或编辑账号元数据(不含密码)。""" + require_admin_auth(request) + try: + body = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON body") + + email = (body.get("email") or "").strip() + note = (body.get("note") or "").strip() + proxy_name = (body.get("proxy_name") or "").strip() + if not email or "@" not in email: + raise HTTPException(status_code=400, detail="email 不合法") + + from utils import harvester_meta + try: + rec = harvester_meta.upsert(email, note=note, proxy_name=proxy_name) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + return JSONResponse({"status": "success", "account": rec}) + + +async def routing_admin_harvester_delete(request: Request): + """删除元数据(不动 token.txt)。""" + require_admin_auth(request) + try: + body = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON body") + email = (body.get("email") or "").strip() + if not email: + raise HTTPException(status_code=400, detail="email 不能为空") + from utils import harvester_meta + ok = harvester_meta.delete(email) + return JSONResponse({"status": "success" if ok else "not_found"}) + + +async def routing_admin_harvester_bulk_import(request: Request): + """批量导入 email 清单。接受两种输入: + 1) JSON: {"rows": [{"email":"...","note":"...","proxy_name":"..."}]} + 2) multipart: file= CSV 文件(表头 email,note,proxy_name) + """ + require_admin_auth(request) + rows = [] + content_type = request.headers.get("content-type", "") + + if "multipart/form-data" in content_type: + form = await request.form() + file = form.get("file") + if file is None: + raise HTTPException(status_code=400, detail="缺少 file 字段") + raw = (await file.read()).decode("utf-8", errors="replace") + import csv + import io + reader = csv.DictReader(io.StringIO(raw)) + for r in reader: + rows.append({ + "email": (r.get("email") or "").strip(), + "note": (r.get("note") or "").strip(), + "proxy_name": (r.get("proxy_name") or "").strip(), + }) + else: + try: + body = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="需要 JSON 或 CSV 文件") + rows = body.get("rows") or [] + if not isinstance(rows, list): + raise HTTPException(status_code=400, detail="rows 必须是数组") + + if not rows: + raise HTTPException(status_code=400, detail="没有可导入的行") + + from utils import harvester_meta + result = harvester_meta.bulk_upsert(rows) + return JSONResponse({"status": "success", **result, "total": len(rows)}) + + +async def routing_admin_harvester_report(request: Request): + """Harvester 采集成功/失败后回调此接口上报状态。""" + require_admin_auth(request) + try: + body = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON body") + + email = (body.get("email") or "").strip() + if not email: + raise HTTPException(status_code=400, detail="email 不能为空") + success = bool(body.get("success", True)) + rt_prefix = (body.get("rt_prefix") or "").strip() + error = (body.get("error") or "").strip() + imported_token = (body.get("imported_token") or "").strip() + + from utils import harvester_meta + rec = harvester_meta.report_harvest( + email=email, + rt_prefix=rt_prefix, + success=success, + error=error, + imported_token=imported_token, + ) + return JSONResponse({"status": "success", "account": rec}) + + app.add_api_route("/admin/routing", routing_admin_page, methods=["GET"], response_class=HTMLResponse) app.add_api_route("/admin/routing/data", routing_admin_data, methods=["GET"]) app.add_api_route("/admin/routing/save", routing_admin_save, methods=["POST"]) @@ -596,6 +713,11 @@ async def routing_admin_logs_download(request: Request): app.add_api_route("/admin/routing/test-proxy", routing_admin_test_proxy, methods=["POST"]) app.add_api_route("/admin/logs/tail", routing_admin_logs_tail, methods=["GET"]) app.add_api_route("/admin/logs/download", routing_admin_logs_download, methods=["GET"]) +app.add_api_route("/admin/harvester/accounts", routing_admin_harvester_list, methods=["GET"]) +app.add_api_route("/admin/harvester/accounts", routing_admin_harvester_upsert, methods=["POST"]) +app.add_api_route("/admin/harvester/accounts/delete", routing_admin_harvester_delete, methods=["POST"]) +app.add_api_route("/admin/harvester/accounts/bulk-import", routing_admin_harvester_bulk_import, methods=["POST"]) +app.add_api_route("/admin/harvester/report", routing_admin_harvester_report, methods=["POST"]) if api_prefix: app.add_api_route(f"/{api_prefix}/admin/routing", routing_admin_page, methods=["GET"], response_class=HTMLResponse) @@ -613,3 +735,8 @@ async def routing_admin_logs_download(request: Request): app.add_api_route(f"/{api_prefix}/admin/routing/test-proxy", routing_admin_test_proxy, methods=["POST"]) app.add_api_route(f"/{api_prefix}/admin/logs/tail", routing_admin_logs_tail, methods=["GET"]) app.add_api_route(f"/{api_prefix}/admin/logs/download", routing_admin_logs_download, methods=["GET"]) + app.add_api_route(f"/{api_prefix}/admin/harvester/accounts", routing_admin_harvester_list, methods=["GET"]) + app.add_api_route(f"/{api_prefix}/admin/harvester/accounts", routing_admin_harvester_upsert, methods=["POST"]) + app.add_api_route(f"/{api_prefix}/admin/harvester/accounts/delete", routing_admin_harvester_delete, methods=["POST"]) + app.add_api_route(f"/{api_prefix}/admin/harvester/accounts/bulk-import", routing_admin_harvester_bulk_import, methods=["POST"]) + app.add_api_route(f"/{api_prefix}/admin/harvester/report", routing_admin_harvester_report, methods=["POST"]) diff --git a/harvester/src/chat2api_client.py b/harvester/src/chat2api_client.py index edd76df6..3a29d323 100644 --- a/harvester/src/chat2api_client.py +++ b/harvester/src/chat2api_client.py @@ -95,3 +95,40 @@ async def import_token( raise RuntimeError("chat2api 后台未开启(未配置 ADMIN_PASSWORD)") r.raise_for_status() return r.json() + + async def report_harvest( + self, + email: str, + rt_prefix: str = "", + success: bool = True, + error: str = "", + imported_token: str = "", + ) -> None: + """把采集结果上报给 chat2api,更新 Harvester 看板的 last_rt_prefix / 状态。 + + 失败不抛异常,避免因元数据回传问题污染主流程。 + """ + url = self._url("/admin/harvester/report") + payload = { + "email": email, + "success": success, + "rt_prefix": rt_prefix, + "error": error, + "imported_token": imported_token, + } + try: + async with httpx.AsyncClient(timeout=10) as c: + r = await c.post( + url, + json=payload, + headers={ + "Cookie": self._cookie_header, + "Content-Type": "application/json", + }, + ) + if r.status_code != 200: + logger.warning( + f"report_harvest 返回 {r.status_code}: {r.text[:200]}" + ) + except Exception as e: + logger.warning(f"report_harvest 失败(忽略): {e}") diff --git a/harvester/src/harvest.py b/harvester/src/harvest.py index c13687e2..a78d6dcc 100644 --- a/harvester/src/harvest.py +++ b/harvester/src/harvest.py @@ -117,7 +117,14 @@ async def on_success(account: Account, token_set: TokenSet) -> None: proxy_name=account.proxy_name if proxy_url else "", proxy_url=proxy_url, ) - logger.info(f"[{account.masked_email()}] → chat2api import OK") + # 额外:上报给 Harvester 看板,更新 last_rt_prefix / 状态 + await client.report_harvest( + email=account.email, + rt_prefix=token_set.rt_prefix, + success=True, + imported_token=token_set.refresh_token, + ) + logger.info(f"[{account.masked_email()}] → chat2api import OK + reported") # 串行执行(并行留作未来增强) results: List[HarvestResult] = [] @@ -146,6 +153,16 @@ async def on_success(account: Account, token_set: TokenSet) -> None: else: banned = any(k in (last.error or "").lower() for k in ("banned", "blocked", "suspended")) state.mark_failure(acc.email, last.error, banned=banned) + # 非 export 模式下,把失败也上报给 chat2api 看板 + if client is not None: + try: + await client.report_harvest( + email=acc.email, + success=False, + error=last.error, + ) + except Exception: + pass # 汇总 ok_count = sum(1 for r in results if r.ok) diff --git a/templates/account_proxy_bindings.html b/templates/account_proxy_bindings.html index 3866401a..d1391383 100644 --- a/templates/account_proxy_bindings.html +++ b/templates/account_proxy_bindings.html @@ -21,6 +21,9 @@ const parseFileApi = `${basePath}/admin/routing/accounts/parse-file`; const logsTailApi = `${basePath}/admin/logs/tail`; const logsDownloadApi = `${basePath}/admin/logs/download`; + const harvAccountsApi = `${basePath}/admin/harvester/accounts`; + const harvDeleteApi = `${basePath}/admin/harvester/accounts/delete`; + const harvBulkApi = `${basePath}/admin/harvester/accounts/bulk-import`; const summaryGrid = document.getElementById('summaryGrid'); const ipGrid = document.getElementById('ipGrid'); @@ -80,6 +83,26 @@ const logsViewer = document.getElementById('logsViewer'); const logsStatus = document.getElementById('logsStatus'); const logsCapacityHint = document.getElementById('logsCapacityHint'); + // Harvester UI + const harvAccountsTable = document.getElementById('harvAccountsTable'); + const harvStats = document.getElementById('harvStats'); + const harvStatusText = document.getElementById('harvStatusText'); + const harvRefreshButton = document.getElementById('harvRefreshButton'); + const harvAddButton = document.getElementById('harvAddButton'); + const harvBulkImportButton = document.getElementById('harvBulkImportButton'); + const harvCopyAllButton = document.getElementById('harvCopyAllButton'); + const harvEditModal = document.getElementById('harvEditModal'); + const harvEditTitle = document.getElementById('harvEditTitle'); + const harvEditClose = document.getElementById('harvEditClose'); + const harvEditSubmit = document.getElementById('harvEditSubmit'); + const harvEmailInput = document.getElementById('harvEmailInput'); + const harvNoteInput = document.getElementById('harvNoteInput'); + const harvProxyInput = document.getElementById('harvProxyInput'); + const harvBulkModal = document.getElementById('harvBulkModal'); + const harvBulkClose = document.getElementById('harvBulkClose'); + const harvBulkSubmit = document.getElementById('harvBulkSubmit'); + const harvBulkFile = document.getElementById('harvBulkFile'); + const harvBulkJson = document.getElementById('harvBulkJson'); let proxyOptions = []; let accountsCache = []; let editingToken = ''; @@ -210,7 +233,7 @@ return proxies.length ? proxies[0].proxy_url : ''; } - async function copyText(content) { + async function copyText(content, button = null) { try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(content); @@ -231,9 +254,38 @@ throw new Error('浏览器不支持自动复制'); } } - statusText.textContent = '测试命令已复制'; + if (button) { + const originalText = button.dataset.originalText || button.textContent; + button.dataset.originalText = originalText; + button.textContent = '已复制'; + button.classList.add('bg-emerald-600', 'text-white', 'border-emerald-600'); + button.classList.remove('bg-white', 'text-slate-700'); + window.setTimeout(() => { + button.textContent = originalText; + button.classList.remove('bg-emerald-600', 'text-white', 'border-emerald-600'); + button.classList.add('bg-white', 'text-slate-700'); + }, 1600); + } + const copyToast = document.getElementById('copyToast'); + if (copyToast) { + copyToast.textContent = '测试命令已复制到剪贴板'; + copyToast.classList.remove('hidden'); + window.clearTimeout(copyToast._timer); + copyToast._timer = window.setTimeout(() => { + copyToast.classList.add('hidden'); + }, 1800); + } } catch (error) { - statusText.textContent = `复制失败: ${error.message}`; + const fallback = window.prompt('自动复制失败,请手动复制下面内容:', content); + const copyToast = document.getElementById('copyToast'); + if (copyToast) { + copyToast.textContent = fallback === null ? `复制失败: ${error.message}` : '已弹出手动复制窗口'; + copyToast.classList.remove('hidden'); + window.clearTimeout(copyToast._timer); + copyToast._timer = window.setTimeout(() => { + copyToast.classList.add('hidden'); + }, 2400); + } } } @@ -653,11 +705,11 @@ }); copyModelsButton.addEventListener('click', async () => { - await copyText(modelsTestCommand.textContent); + await copyText(modelsTestCommand.textContent, copyModelsButton); }); copyChatButton.addEventListener('click', async () => { - await copyText(apiTestCommand.textContent); + await copyText(apiTestCommand.textContent, copyChatButton); }); testProxyButton.addEventListener('click', async () => { @@ -798,6 +850,227 @@ logsStatus.textContent = logsAutoRefresh.checked ? '自动刷新已开启' : '已暂停自动刷新'; }); + // ========== Harvester 看板 ========== + let harvAccountsCache = []; + let harvEditingEmail = ''; + + function harvStatusChip(status) { + const map = { + fresh: ['bg-emerald-100 text-emerald-700', '新鲜'], + stale: ['bg-amber-100 text-amber-700', '即将过期'], + failed: ['bg-red-100 text-red-700', '失败/过期'], + pending: ['bg-slate-100 text-slate-700', '待采集'], + }; + const [cls, label] = map[status] || ['bg-slate-100 text-slate-700', status]; + return `${label}`; + } + + function harvFormatTs(ts) { + if (!ts) return '—'; + const d = new Date(ts * 1000); + const pad = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; + } + + function harvBuildCmd(email) { + // 一行命令,假设用户已 cd 到 harvester 并 source .venv + // 兼容:给出进入目录 + 激活 + 运行的完整三段 + const target = email ? `--only ${email}` : ''; + return `cd /Users/nanashiwang/Documents/Projects/chat2api/harvester && source .venv/bin/activate && python -m src.harvest ${target}`.trim(); + } + + async function harvCopyCmd(email) { + const cmd = harvBuildCmd(email); + try { + await navigator.clipboard.writeText(cmd); + harvStatusText.textContent = `已复制命令 → ${email || '全部'}`; + } catch (e) { + prompt('复制失败,请手工复制:', cmd); + } + } + + function harvRenderStats(stats) { + const cards = [ + ['总数', stats.total || 0, 'text-slate-900'], + ['新鲜', stats.fresh || 0, 'text-emerald-600'], + ['即将过期', stats.stale || 0, 'text-amber-600'], + ['失败/过期', stats.failed || 0, 'text-red-600'], + ['待采集', stats.pending || 0, 'text-slate-500'], + ]; + harvStats.innerHTML = cards.map(([l, v, c]) => ` +
+
${v}
+
${l}
+
+ `).join(''); + } + + function harvRenderTable(accounts) { + if (!accounts.length) { + harvAccountsTable.innerHTML = `暂无账号,点"新增账号"或"批量导入"`; + return; + } + harvAccountsTable.innerHTML = accounts.map((a) => ` + + ${escapeHtml(a.email)} + ${escapeHtml(a.note || '-')} + ${escapeHtml(a.proxy_name || '-')} + ${a.last_rt_prefix ? escapeHtml(a.last_rt_prefix) + '...' : '—'} + ${harvFormatTs(a.last_harvest_at)} + ${harvStatusChip(a.status)} + + + + + + + `).join(''); + } + + async function harvLoad() { + try { + const resp = await fetch(harvAccountsApi); + if (!resp.ok) { + harvStatusText.textContent = `加载失败:${resp.status}`; + return; + } + const data = await resp.json(); + harvAccountsCache = data.accounts || []; + harvRenderStats(data.stats || {}); + harvRenderTable(harvAccountsCache); + harvStatusText.textContent = `已加载 ${harvAccountsCache.length} 个账号 · ${new Date().toLocaleTimeString()}`; + } catch (e) { + harvStatusText.textContent = `网络错误:${e.message}`; + } + } + + function harvOpenEdit(email) { + const acc = harvAccountsCache.find((x) => x.email.toLowerCase() === (email || '').toLowerCase()); + harvEditingEmail = email || ''; + harvEditTitle.textContent = email ? '编辑账号' : '新增账号'; + harvEmailInput.value = acc ? acc.email : ''; + harvEmailInput.readOnly = Boolean(email); + harvNoteInput.value = acc ? (acc.note || '') : ''; + harvProxyInput.value = acc ? (acc.proxy_name || '') : ''; + harvEditModal.classList.remove('hidden'); + harvEditModal.classList.add('flex'); + } + + function harvCloseEdit() { + harvEditingEmail = ''; + harvEditModal.classList.add('hidden'); + harvEditModal.classList.remove('flex'); + } + + async function harvSaveEdit() { + const email = harvEmailInput.value.trim(); + if (!email || !email.includes('@')) { + alert('请输入合法的 email'); + return; + } + const payload = { + email, + note: harvNoteInput.value.trim(), + proxy_name: harvProxyInput.value.trim(), + }; + const resp = await fetch(harvAccountsApi, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(payload), + }); + const data = await resp.json(); + if (!resp.ok) { + alert(data.detail || '保存失败'); + return; + } + harvCloseEdit(); + await harvLoad(); + } + + async function harvDelete(email) { + if (!confirm(`确认删除 ${email} 的 Harvester 记录?\n(只删元数据,不动已导入的 token)`)) return; + const resp = await fetch(harvDeleteApi, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({email}), + }); + if (!resp.ok) { + const data = await resp.json().catch(() => ({})); + alert(data.detail || '删除失败'); + return; + } + await harvLoad(); + } + + async function harvBulkSubmitHandler() { + let body; + let headers = {}; + if (harvBulkFile.files && harvBulkFile.files[0]) { + const fd = new FormData(); + fd.append('file', harvBulkFile.files[0]); + body = fd; + } else { + const text = harvBulkJson.value.trim(); + if (!text) { + alert('请上传 CSV 文件或粘贴 JSON'); + return; + } + let rows; + try { + rows = JSON.parse(text); + if (!Array.isArray(rows)) throw new Error('需要数组'); + } catch (e) { + alert('JSON 无效:' + e.message); + return; + } + body = JSON.stringify({rows}); + headers = {'Content-Type': 'application/json'}; + } + const resp = await fetch(harvBulkApi, {method: 'POST', headers, body}); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) { + alert(data.detail || '导入失败'); + return; + } + alert(`导入完成:新增 ${data.added},更新 ${data.updated},总计 ${data.total}`); + harvBulkModal.classList.add('hidden'); + harvBulkModal.classList.remove('flex'); + harvBulkFile.value = ''; + harvBulkJson.value = ''; + await harvLoad(); + } + + // 事件绑定 + harvRefreshButton.addEventListener('click', harvLoad); + harvAddButton.addEventListener('click', () => harvOpenEdit('')); + harvEditClose.addEventListener('click', harvCloseEdit); + harvEditSubmit.addEventListener('click', harvSaveEdit); + harvCopyAllButton.addEventListener('click', () => harvCopyCmd('')); + harvBulkImportButton.addEventListener('click', () => { + harvBulkModal.classList.remove('hidden'); + harvBulkModal.classList.add('flex'); + }); + harvBulkClose.addEventListener('click', () => { + harvBulkModal.classList.add('hidden'); + harvBulkModal.classList.remove('flex'); + }); + harvBulkSubmit.addEventListener('click', harvBulkSubmitHandler); + + // 表格行操作委托 + harvAccountsTable.addEventListener('click', (e) => { + const target = e.target; + if (!(target instanceof HTMLElement)) return; + const cmdEmail = target.getAttribute('data-harv-cmd'); + const editEmail = target.getAttribute('data-harv-edit'); + const delEmail = target.getAttribute('data-harv-del'); + if (cmdEmail) return harvCopyCmd(cmdEmail); + if (editEmail) return harvOpenEdit(editEmail); + if (delEmail) return harvDelete(delEmail); + }); + + // 初始加载 + harvLoad(); + // 初始拉取 + 启动轮询 fetchLogsTail(true); startLogsPolling(); @@ -868,6 +1141,7 @@ + @@ -911,19 +1185,20 @@

告警中心

接口测试样例

一键复制模型探测和聊天测试命令,方便你本地或服务器直接验证。

+
模型列表测试
- +

                     
聊天接口测试
- +

                     
@@ -1031,6 +1306,44 @@

账号与令牌管理

+ + + + + + + + diff --git a/utils/globals.py b/utils/globals.py index 6f340ce6..234eb632 100644 --- a/utils/globals.py +++ b/utils/globals.py @@ -17,6 +17,8 @@ ANTIBAN_BUCKET_FILE = os.path.join(DATA_FOLDER, "antiban_bucket.json") ANTIBAN_GEO_FILE = os.path.join(DATA_FOLDER, "antiban_geo.json") ANTIBAN_DEAD_FILE = os.path.join(DATA_FOLDER, "antiban_dead.json") +# Harvester 账号元数据(不含密码,仅 email+note+proxy_name+采集历史) +HARVESTER_ACCOUNTS_FILE = os.path.join(DATA_FOLDER, "harvester_accounts.json") count = 0 token_list = [] diff --git a/utils/harvester_meta.py b/utils/harvester_meta.py new file mode 100644 index 00000000..98728070 --- /dev/null +++ b/utils/harvester_meta.py @@ -0,0 +1,209 @@ +"""Harvester 账号元数据:chat2api 后台与 Harvester 之间共享的"账号清单"。 + +只存**非敏感字段**: + - email + - note + - proxy_name + - last_rt_prefix (最近一次采集到的 rt 前缀 8 字符) + - last_harvest_at (unix ts) + - last_error (最近一次失败原因) + - fail_count + - imported_token (data/token.txt 中对应的完整 rt;用于 UI 查找) + +**绝不存密码 / TOTP secret**。密码始终在用户 Mac 本地 harvester/accounts.csv。 + +持久化文件:data/harvester_accounts.json +""" + +import json +import threading +import time +from pathlib import Path +from typing import Dict, List, Optional + +import utils.globals as globals +from utils.Logger import logger + + +_write_lock = threading.Lock() + +# 状态阈值(秒) +FRESH_WITHIN = 7 * 24 * 3600 # 最近 7 天内采集 → fresh +STALE_WITHIN = 45 * 24 * 3600 # 超过 7 天 <= 45 天 → stale +# 超过 45 天未采集或 last_error → failed + + +def _load() -> Dict: + path = Path(globals.HARVESTER_ACCOUNTS_FILE) + if not path.exists(): + return {"accounts": {}, "updated_at": 0} + try: + data = json.loads(path.read_text(encoding="utf-8")) + data.setdefault("accounts", {}) + return data + except (json.JSONDecodeError, OSError): + return {"accounts": {}, "updated_at": 0} + + +def _save(data: Dict) -> None: + data["updated_at"] = int(time.time()) + with _write_lock: + Path(globals.HARVESTER_ACCOUNTS_FILE).write_text( + json.dumps(data, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + + +def _email_key(email: str) -> str: + return (email or "").strip().lower() + + +def _compute_status(rec: Dict) -> str: + last_ts = rec.get("last_harvest_at", 0) or 0 + if rec.get("last_error") and not rec.get("last_rt_prefix"): + return "failed" + if not last_ts: + return "pending" + age = time.time() - last_ts + if age < FRESH_WITHIN: + return "fresh" + if age < STALE_WITHIN: + return "stale" + return "failed" + + +def list_all() -> List[Dict]: + """返回账号列表,按 email 排序,带计算字段 status。""" + data = _load() + items = [] + for email_lower, rec in data.get("accounts", {}).items(): + out = dict(rec) + out["email"] = rec.get("email", email_lower) + out["status"] = _compute_status(rec) + items.append(out) + items.sort(key=lambda x: x["email"].lower()) + return items + + +def get(email: str) -> Optional[Dict]: + key = _email_key(email) + if not key: + return None + data = _load() + rec = data.get("accounts", {}).get(key) + if not rec: + return None + out = dict(rec) + out["status"] = _compute_status(rec) + return out + + +def upsert(email: str, note: str = "", proxy_name: str = "") -> Dict: + """新增或更新账号元数据(只改 email/note/proxy_name,不动采集历史)。""" + key = _email_key(email) + if not key or "@" not in key: + raise ValueError("invalid email") + data = _load() + rec = data["accounts"].get(key, {"created_at": int(time.time())}) + rec["email"] = email.strip() + rec["note"] = (note or "").strip() + rec["proxy_name"] = (proxy_name or "").strip() + rec.setdefault("last_rt_prefix", "") + rec.setdefault("last_harvest_at", 0) + rec.setdefault("last_error", "") + rec.setdefault("fail_count", 0) + rec.setdefault("imported_token", "") + data["accounts"][key] = rec + _save(data) + return dict(rec) + + +def bulk_upsert(rows: List[Dict]) -> Dict[str, int]: + """批量导入 [{email, note?, proxy_name?}],返回 {added, updated}。""" + data = _load() + added = updated = 0 + for row in rows: + email = (row.get("email") or "").strip() + key = _email_key(email) + if not key or "@" not in key: + continue + existed = key in data["accounts"] + rec = data["accounts"].get(key, {"created_at": int(time.time())}) + rec["email"] = email + rec["note"] = (row.get("note") or rec.get("note", "")).strip() + rec["proxy_name"] = (row.get("proxy_name") or rec.get("proxy_name", "")).strip() + rec.setdefault("last_rt_prefix", "") + rec.setdefault("last_harvest_at", 0) + rec.setdefault("last_error", "") + rec.setdefault("fail_count", 0) + rec.setdefault("imported_token", "") + data["accounts"][key] = rec + if existed: + updated += 1 + else: + added += 1 + _save(data) + return {"added": added, "updated": updated} + + +def delete(email: str) -> bool: + """从元数据中删除账号(不影响 data/token.txt 中的 token)。""" + key = _email_key(email) + data = _load() + if key in data["accounts"]: + data["accounts"].pop(key) + _save(data) + return True + return False + + +def report_harvest( + email: str, + rt_prefix: str = "", + success: bool = True, + error: str = "", + imported_token: str = "", +) -> Dict: + """Harvester 成功/失败后上报。若 email 未知则自动创建。""" + key = _email_key(email) + if not key or "@" not in key: + raise ValueError("invalid email") + data = _load() + rec = data["accounts"].get(key, { + "email": email.strip(), + "note": "", + "proxy_name": "", + "created_at": int(time.time()), + }) + rec["email"] = email.strip() + now = int(time.time()) + if success: + rec["last_rt_prefix"] = (rt_prefix or "")[:12] + rec["last_harvest_at"] = now + rec["last_error"] = "" + rec["fail_count"] = 0 + if imported_token: + rec["imported_token"] = imported_token + else: + rec["last_error"] = (error or "unknown")[:200] + rec["last_error_at"] = now + rec["fail_count"] = int(rec.get("fail_count", 0)) + 1 + data["accounts"][key] = rec + _save(data) + logger.info( + f"[harvester-meta] report email={email} success={success} " + f"rt_prefix={rt_prefix[:8] if rt_prefix else ''}..." + ) + return dict(rec) + + +def stats() -> Dict: + """总览数据,供 UI 顶部卡片使用。""" + items = list_all() + return { + "total": len(items), + "fresh": sum(1 for x in items if x["status"] == "fresh"), + "stale": sum(1 for x in items if x["status"] == "stale"), + "failed": sum(1 for x in items if x["status"] == "failed"), + "pending": sum(1 for x in items if x["status"] == "pending"), + } From 908a9d311fca9bb109e6bcd08a164ff9b64d3ccc Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Tue, 21 Apr 2026 10:12:31 +0800 Subject: [PATCH 24/96] =?UTF-8?q?=E6=96=B0=E5=A2=9EHarvester=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=E5=99=A8=E7=99=BB=E5=BD=95=E5=8A=9F=E8=83=BD=EF=BC=9A?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0OAuth=20PKCE=E6=8E=88=E6=9D=83=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=EF=BC=8C=E5=8C=85=E6=8B=AC=E5=90=AF=E5=8A=A8=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E4=BC=9A=E8=AF=9D=E5=92=8C=E4=BA=A4=E6=8D=A2token?= =?UTF-8?q?=E7=9A=84API=EF=BC=8C=E5=89=8D=E7=AB=AF=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E7=9B=B8=E5=BA=94=E6=9B=B4=E6=96=B0=E4=BB=A5=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E4=BA=A4=E4=BA=92=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/admin.py | 194 ++++++++++++++++++++++++++ harvester/README.md | 24 ++++ templates/account_proxy_bindings.html | 175 ++++++++++++++++++++--- utils/oauth_session.py | 177 +++++++++++++++++++++++ 4 files changed, 554 insertions(+), 16 deletions(-) create mode 100644 utils/oauth_session.py diff --git a/gateway/admin.py b/gateway/admin.py index aa502b7e..85314a9c 100644 --- a/gateway/admin.py +++ b/gateway/admin.py @@ -698,6 +698,196 @@ async def routing_admin_harvester_report(request: Request): return JSONResponse({"status": "success", "account": rec}) +# ============ Harvester 浏览器登录(OAuth PKCE,用户在本地浏览器完成)============ + +async def routing_admin_harvester_authorize_start(request: Request): + """启动一次 OAuth 授权会话,返回 authorize_url 供前端展示给用户复制。""" + require_admin_auth(request) + try: + body = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON body") + + email = (body.get("email") or "").strip() + note = (body.get("note") or "").strip() + proxy_name = (body.get("proxy_name") or "").strip() + if not email or "@" not in email: + raise HTTPException(status_code=400, detail="email 不合法") + + from utils import oauth_session + try: + result = oauth_session.start_session(email, note=note, proxy_name=proxy_name) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + logger.info(f"[harvester-auth] start session for {email}") + return JSONResponse({"status": "success", **result}) + + +async def routing_admin_harvester_authorize_exchange(request: Request): + """用户粘贴浏览器地址栏的 com.openai.chat://...?code=X&state=Y 过来。 + + 步骤: + 1. pop session(验证 session_id 有效且未过期,一次性消费) + 2. 解析 callback URL 拿 code / state + 3. 校验 state 防 CSRF + 4. 用 verifier + code 调 Auth0 /oauth/token 换 refresh_token + 5. 调已有 routing_admin_import_accounts 的内部逻辑写入 chat2api 账号池 + 6. 通过 harvester_meta.report_harvest 更新看板 + """ + require_admin_auth(request) + try: + body = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON body") + + session_id = (body.get("session_id") or "").strip() + callback_url = (body.get("callback_url") or "").strip() + if not session_id or not callback_url: + raise HTTPException(status_code=400, detail="session_id 和 callback_url 必填") + + from urllib.parse import parse_qs, urlparse + from utils import harvester_meta, oauth_session + + sess = oauth_session.pop_session(session_id) + if sess is None: + raise HTTPException(status_code=404, detail="会话不存在或已过期,请重新开始") + + # 解析回调 URL + parsed = urlparse(callback_url) + qs = parse_qs(parsed.query) + + # error 优先 + if qs.get("error"): + err = qs.get("error", ["unknown"])[0] + desc = qs.get("error_description", [""])[0] + harvester_meta.report_harvest( + email=sess.email, success=False, error=f"{err}: {desc}"[:200] + ) + raise HTTPException(status_code=400, detail=f"OAuth 错误: {err} {desc}") + + code_list = qs.get("code") + state_list = qs.get("state") + if not code_list or not state_list: + harvester_meta.report_harvest( + email=sess.email, success=False, error="callback URL 缺 code/state" + ) + raise HTTPException(status_code=400, detail="回调 URL 中未找到 code 或 state") + + code = code_list[0] + returned_state = state_list[0] + if returned_state != sess.state: + harvester_meta.report_harvest( + email=sess.email, success=False, error="state mismatch (CSRF?)" + ) + raise HTTPException(status_code=400, detail="state 不匹配,可能是 CSRF 或会话错配") + + # 换 token + token_set = await _exchange_code_for_tokens(code, sess) + rt = token_set.get("refresh_token", "") + if not rt: + harvester_meta.report_harvest( + email=sess.email, success=False, error="Auth0 未返回 refresh_token" + ) + raise HTTPException(status_code=502, detail="Auth0 响应缺 refresh_token") + + rt_prefix = rt[:12] + + # 复用现有的 import_accounts 业务逻辑:这里为了避免构造假 request,直接调用底层 + try: + await _harvester_import_rt(sess, rt) + except Exception as e: + harvester_meta.report_harvest( + email=sess.email, success=False, error=f"import failed: {e}"[:200] + ) + raise HTTPException(status_code=500, detail=f"写入 chat2api 失败: {e}") + + # 更新看板 + harvester_meta.report_harvest( + email=sess.email, + rt_prefix=rt_prefix, + success=True, + imported_token=rt, + ) + logger.info(f"[harvester-auth] ✓ {sess.email} → rt_prefix={rt_prefix[:8]}...") + return JSONResponse({ + "status": "success", + "email": sess.email, + "rt_prefix": rt_prefix, + }) + + +async def _exchange_code_for_tokens(code: str, sess) -> dict: + """调用 Auth0 /oauth/token 换 refresh_token。""" + from utils.Client import Client + from utils import oauth_session as _oauth + + client_id, redirect_uri, _audience, _scope = _oauth._get_oauth_config() + data = { + "grant_type": "authorization_code", + "client_id": client_id, + "redirect_uri": redirect_uri, + "code": code, + "code_verifier": sess.verifier, + } + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "ChatGPT/1.2025.084 (iOS 17.5.1; iPhone15,3; build 1402)", + } + + # 复用现有 Client;关闭 impersonate 避免被 WAF 当浏览器(见 refreshToken.py 同样处理) + c = Client(impersonate=None) + try: + r = await c.post(_oauth.TOKEN_ENDPOINT, json=data, headers=headers, timeout=20) + raw = (r.text or "").strip() + if r.status_code != 200: + raise RuntimeError(f"Auth0 status={r.status_code} body={raw[:300]}") + import json as _json + payload = _json.loads(raw) + if "refresh_token" not in payload: + raise RuntimeError(f"Auth0 缺 refresh_token: keys={list(payload.keys())}") + return payload + finally: + await c.close() + + +async def _harvester_import_rt(sess, refresh_token: str) -> None: + """把 rt 写入 chat2api 账号池(复用现有 import 流程的底层调用)。""" + from utils.routing import ( + get_routing_config, + update_account_meta, + ) + # 加到 globals.token_list + token.txt + if refresh_token not in globals.token_list: + globals.token_list.append(refresh_token) + with open(globals.TOKENS_FILE, "a", encoding="utf-8") as f: + f.write(refresh_token + "\n") + + # 绑定代理 / 备注 + proxy_url = "" + proxy_name = sess.proxy_name or "" + if proxy_name: + cfg = get_routing_config() + match = next( + (p for p in cfg.get("proxies", []) if p.get("name") == proxy_name), + None, + ) + if match: + proxy_url = match.get("proxy_url", "") or "" + else: + logger.warning( + f"[harvester-auth] proxy_name='{proxy_name}' 未找到,rt 已导入但不绑定代理" + ) + note = f"{sess.email}" + (f" · {sess.note}" if sess.note else "") + update_account_meta( + refresh_token, + note=note, + group_name=None, + proxy_name=proxy_name if proxy_url else None, + proxy_url=proxy_url if proxy_url else None, + ) + + app.add_api_route("/admin/routing", routing_admin_page, methods=["GET"], response_class=HTMLResponse) app.add_api_route("/admin/routing/data", routing_admin_data, methods=["GET"]) app.add_api_route("/admin/routing/save", routing_admin_save, methods=["POST"]) @@ -718,6 +908,8 @@ async def routing_admin_harvester_report(request: Request): app.add_api_route("/admin/harvester/accounts/delete", routing_admin_harvester_delete, methods=["POST"]) app.add_api_route("/admin/harvester/accounts/bulk-import", routing_admin_harvester_bulk_import, methods=["POST"]) app.add_api_route("/admin/harvester/report", routing_admin_harvester_report, methods=["POST"]) +app.add_api_route("/admin/harvester/authorize/start", routing_admin_harvester_authorize_start, methods=["POST"]) +app.add_api_route("/admin/harvester/authorize/exchange", routing_admin_harvester_authorize_exchange, methods=["POST"]) if api_prefix: app.add_api_route(f"/{api_prefix}/admin/routing", routing_admin_page, methods=["GET"], response_class=HTMLResponse) @@ -740,3 +932,5 @@ async def routing_admin_harvester_report(request: Request): app.add_api_route(f"/{api_prefix}/admin/harvester/accounts/delete", routing_admin_harvester_delete, methods=["POST"]) app.add_api_route(f"/{api_prefix}/admin/harvester/accounts/bulk-import", routing_admin_harvester_bulk_import, methods=["POST"]) app.add_api_route(f"/{api_prefix}/admin/harvester/report", routing_admin_harvester_report, methods=["POST"]) + app.add_api_route(f"/{api_prefix}/admin/harvester/authorize/start", routing_admin_harvester_authorize_start, methods=["POST"]) + app.add_api_route(f"/{api_prefix}/admin/harvester/authorize/exchange", routing_admin_harvester_authorize_exchange, methods=["POST"]) diff --git a/harvester/README.md b/harvester/README.md index 7119dc49..99e20f4c 100644 --- a/harvester/README.md +++ b/harvester/README.md @@ -1,5 +1,29 @@ # Chat2API Harvester +> ⚠️ **DEPRECATED(已废弃,保留供参考)** +> +> 本 Playwright CLI 工具已被 chat2api 管理后台内置的"**🌐 浏览器登录**"功能取代。 +> +> 新方案优势: +> - 不用在本地装 Playwright / chromium(省 250MB) +> - 不用维护 `accounts.csv` 密码文件 +> - 不用本地 venv / Python 环境 +> - Arkose 挑战由真实浏览器处理,触发概率更低 +> - 完全 UI 驱动,一个账号 2 次粘贴完成 +> +> **迁移方法**: +> 1. 打开 `http://你的服务器:60403/{API_PREFIX}/admin/routing` +> 2. 左侧 → "账号采集 Harvester" +> 3. 点某账号行的 **[🌐 浏览器登录]** 按钮,按向导 2 次粘贴 URL 即可 +> +> 本目录代码仅作为历史参考保留。**无需运行本目录任何命令**。 +> +> 确认新方案无问题后可以直接 `rm -rf harvester/` 整个目录。 + +--- + +## (以下为原文档,仅供参考) + > 自家 ChatGPT 账号池 → RefreshToken 自动采集 → 写入 chat2api 本地 Python + Playwright 工具,用 iOS ChatGPT app 的 OAuth2 PKCE 流程,从自家账号登录并拿到 `rt_*` RefreshToken,自动通过管理后台 API 写入 chat2api 账号池。**一次人工辅助跑完,后续 2-3 个月由 chat2api 内建 `SCHEDULED_REFRESH` 接管刷新**。 diff --git a/templates/account_proxy_bindings.html b/templates/account_proxy_bindings.html index d1391383..0b1b9b8d 100644 --- a/templates/account_proxy_bindings.html +++ b/templates/account_proxy_bindings.html @@ -24,6 +24,8 @@ const harvAccountsApi = `${basePath}/admin/harvester/accounts`; const harvDeleteApi = `${basePath}/admin/harvester/accounts/delete`; const harvBulkApi = `${basePath}/admin/harvester/accounts/bulk-import`; + const harvAuthStartApi = `${basePath}/admin/harvester/authorize/start`; + const harvAuthExchangeApi = `${basePath}/admin/harvester/authorize/exchange`; const summaryGrid = document.getElementById('summaryGrid'); const ipGrid = document.getElementById('ipGrid'); @@ -90,7 +92,6 @@ const harvRefreshButton = document.getElementById('harvRefreshButton'); const harvAddButton = document.getElementById('harvAddButton'); const harvBulkImportButton = document.getElementById('harvBulkImportButton'); - const harvCopyAllButton = document.getElementById('harvCopyAllButton'); const harvEditModal = document.getElementById('harvEditModal'); const harvEditTitle = document.getElementById('harvEditTitle'); const harvEditClose = document.getElementById('harvEditClose'); @@ -103,6 +104,17 @@ const harvBulkSubmit = document.getElementById('harvBulkSubmit'); const harvBulkFile = document.getElementById('harvBulkFile'); const harvBulkJson = document.getElementById('harvBulkJson'); + // Harvester 浏览器登录 + const harvAuthModal = document.getElementById('harvAuthModal'); + const harvAuthTitle = document.getElementById('harvAuthTitle'); + const harvAuthClose = document.getElementById('harvAuthClose'); + const harvAuthCancel = document.getElementById('harvAuthCancel'); + const harvAuthUrlInput = document.getElementById('harvAuthUrlInput'); + const harvAuthCopyUrlButton = document.getElementById('harvAuthCopyUrlButton'); + const harvAuthCallbackInput = document.getElementById('harvAuthCallbackInput'); + const harvAuthSubmitButton = document.getElementById('harvAuthSubmitButton'); + const harvAuthSessionInfo = document.getElementById('harvAuthSessionInfo'); + const harvAuthErrorBox = document.getElementById('harvAuthErrorBox'); let proxyOptions = []; let accountsCache = []; let editingToken = ''; @@ -873,22 +885,107 @@ } function harvBuildCmd(email) { - // 一行命令,假设用户已 cd 到 harvester 并 source .venv - // 兼容:给出进入目录 + 激活 + 运行的完整三段 + // 历史兼容:保留给将来可能的 CLI 模式;当前 UI 不使用 const target = email ? `--only ${email}` : ''; - return `cd /Users/nanashiwang/Documents/Projects/chat2api/harvester && source .venv/bin/activate && python -m src.harvest ${target}`.trim(); + return `# 已废弃的 Harvester CLI 模式\ncd /Users/nanashiwang/Documents/Projects/chat2api/harvester && source .venv/bin/activate && python -m src.harvest ${target}`.trim(); } - async function harvCopyCmd(email) { - const cmd = harvBuildCmd(email); + // ========= 浏览器登录流程 ========= + let harvAuthCurrent = null; // {session_id, authorize_url, email, expires_in, started_at} + + async function harvStartAuth(email) { + const acc = harvAccountsCache.find((x) => x.email.toLowerCase() === email.toLowerCase()); + if (!acc) { + alert('账号不存在,请先"新增账号"'); + return; + } + const payload = { + email: acc.email, + note: acc.note || '', + proxy_name: acc.proxy_name || '', + }; + const resp = await fetch(harvAuthStartApi, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(payload), + }); + const data = await resp.json(); + if (!resp.ok) { + alert(data.detail || '启动授权失败'); + return; + } + harvAuthCurrent = { + session_id: data.session_id, + authorize_url: data.authorize_url, + email: acc.email, + expires_in: data.expires_in || 900, + started_at: Date.now(), + }; + harvAuthTitle.textContent = `浏览器登录 - ${acc.email}`; + harvAuthUrlInput.value = data.authorize_url; + harvAuthCallbackInput.value = ''; + harvAuthSessionInfo.textContent = `会话 ID: ${data.session_id.slice(0,8)}... · TTL ${Math.round(data.expires_in/60)} 分钟`; + harvAuthErrorBox.classList.add('hidden'); + harvAuthModal.classList.remove('hidden'); + harvAuthModal.classList.add('flex'); + } + + async function harvCopyAuthUrl() { + if (!harvAuthCurrent) return; try { - await navigator.clipboard.writeText(cmd); - harvStatusText.textContent = `已复制命令 → ${email || '全部'}`; + await navigator.clipboard.writeText(harvAuthCurrent.authorize_url); + harvAuthSessionInfo.textContent = '已复制授权 URL,请到本地浏览器粘贴登录 ↗'; + } catch (e) { + harvAuthUrlInput.select(); + document.execCommand('copy'); + harvAuthSessionInfo.textContent = '已选中 URL(请手动 Ctrl/Cmd+C)'; + } + } + + async function harvSubmitCallback() { + if (!harvAuthCurrent) return; + const callback = harvAuthCallbackInput.value.trim(); + if (!callback) { + alert('请粘贴浏览器登录完成后地址栏的 URL(以 com.openai.chat:// 开头)'); + return; + } + harvAuthSubmitButton.disabled = true; + harvAuthSubmitButton.textContent = '交换中...'; + harvAuthErrorBox.classList.add('hidden'); + try { + const resp = await fetch(harvAuthExchangeApi, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + session_id: harvAuthCurrent.session_id, + callback_url: callback, + }), + }); + const data = await resp.json(); + if (!resp.ok) { + harvAuthErrorBox.textContent = data.detail || '换取 token 失败'; + harvAuthErrorBox.classList.remove('hidden'); + return; + } + alert(`✅ 成功导入 ${data.email} (rt=${(data.rt_prefix || '').slice(0,8)}...)`); + harvCloseAuth(); + await harvLoad(); } catch (e) { - prompt('复制失败,请手工复制:', cmd); + harvAuthErrorBox.textContent = '网络错误:' + e.message; + harvAuthErrorBox.classList.remove('hidden'); + } finally { + harvAuthSubmitButton.disabled = false; + harvAuthSubmitButton.textContent = '完成'; } } + function harvCloseAuth() { + harvAuthCurrent = null; + harvAuthModal.classList.add('hidden'); + harvAuthModal.classList.remove('flex'); + harvAuthCallbackInput.value = ''; + } + function harvRenderStats(stats) { const cards = [ ['总数', stats.total || 0, 'text-slate-900'], @@ -919,7 +1016,7 @@ ${harvFormatTs(a.last_harvest_at)} ${harvStatusChip(a.status)} - + @@ -1045,7 +1142,6 @@ harvAddButton.addEventListener('click', () => harvOpenEdit('')); harvEditClose.addEventListener('click', harvCloseEdit); harvEditSubmit.addEventListener('click', harvSaveEdit); - harvCopyAllButton.addEventListener('click', () => harvCopyCmd('')); harvBulkImportButton.addEventListener('click', () => { harvBulkModal.classList.remove('hidden'); harvBulkModal.classList.add('flex'); @@ -1056,14 +1152,20 @@ }); harvBulkSubmit.addEventListener('click', harvBulkSubmitHandler); + // 授权弹窗 + harvAuthCopyUrlButton.addEventListener('click', harvCopyAuthUrl); + harvAuthClose.addEventListener('click', harvCloseAuth); + harvAuthCancel.addEventListener('click', harvCloseAuth); + harvAuthSubmitButton.addEventListener('click', harvSubmitCallback); + // 表格行操作委托 harvAccountsTable.addEventListener('click', (e) => { const target = e.target; if (!(target instanceof HTMLElement)) return; - const cmdEmail = target.getAttribute('data-harv-cmd'); + const authEmail = target.getAttribute('data-harv-auth'); const editEmail = target.getAttribute('data-harv-edit'); const delEmail = target.getAttribute('data-harv-del'); - if (cmdEmail) return harvCopyCmd(cmdEmail); + if (authEmail) return harvStartAuth(authEmail); if (editEmail) return harvOpenEdit(editEmail); if (delEmail) return harvDelete(delEmail); }); @@ -1312,14 +1414,13 @@

账号与令牌管理

账号采集 Harvester

- 管理 email 清单(服务器端不存密码),每 60-90 天在本地 Mac 跑一次 Harvester 补齐 RefreshToken。 - 点"复制命令"到本地 Terminal 粘贴执行即可。 + 服务器端不存密码。点账号行的"🌐 浏览器登录",把授权 URL 粘贴到本地浏览器完成登录, + 再把跳转后的 com.openai.chat:// URL 粘贴回来即可。

-
@@ -1534,5 +1635,47 @@

批量导入账号清单

+ + + diff --git a/utils/oauth_session.py b/utils/oauth_session.py new file mode 100644 index 00000000..2bc1917b --- /dev/null +++ b/utils/oauth_session.py @@ -0,0 +1,177 @@ +"""OAuth PKCE session 管理(纯内存 + TTL)。 + +用于 Harvester 的"浏览器登录"流程: + 1. start(email, ...) → 生成 PKCE + state + session_id,存入内存,返回 authorize_url + 2. 用户在真实浏览器完成 OAuth,拿到 com.openai.chat://...?code=X&state=Y 回调 + 3. exchange(session_id, callback_url) → 校验 state + 用 verifier 换 token + +设计: + - 内存 dict,不持久化(服务重启即失效,符合用户选择) + - 15 分钟 TTL(Auth0 的 code 也是 ~10 分钟过期) + - 线程安全(threading.Lock) + - 自动清理过期会话(每次写操作时机会性清理) +""" + +import base64 +import hashlib +import secrets +import threading +import time +from dataclasses import dataclass, field +from typing import Dict, Optional +from urllib.parse import urlencode + + +# 与 chatgpt/refreshToken.py 和 gateway/share.py 一致的 iOS client_id +# 可通过环境变量覆盖(同 chat_refresh) +_DEFAULT_CLIENT_ID = "pdlLIX2Y72MIl2rhLhTE9VV9bN905kBh" +_DEFAULT_REDIRECT_URI = "com.openai.chat://auth0.openai.com/ios/com.openai.chat/callback" +_DEFAULT_AUDIENCE = "https://api.openai.com/v1" +_DEFAULT_SCOPE = ( + "openid email profile offline_access model.request model.read " + "organization.read organization.write" +) + +AUTH_BASE = "https://auth0.openai.com/authorize" +TOKEN_ENDPOINT = "https://auth0.openai.com/oauth/token" + +SESSION_TTL_SECONDS = 15 * 60 + + +@dataclass +class OAuthSession: + session_id: str + verifier: str + challenge: str + state: str + email: str + note: str = "" + proxy_name: str = "" + created_at: int = field(default_factory=lambda: int(time.time())) + + def expired(self) -> bool: + return time.time() - self.created_at > SESSION_TTL_SECONDS + + +# ========================= 内存存储 ========================= + +_sessions: Dict[str, OAuthSession] = {} +_lock = threading.Lock() + + +def _base64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _gen_pkce_pair() -> tuple: + verifier = _base64url(secrets.token_bytes(32)) + challenge = _base64url(hashlib.sha256(verifier.encode("ascii")).digest()) + return verifier, challenge + + +def _gen_state() -> str: + return _base64url(secrets.token_bytes(16)) + + +def _gen_session_id() -> str: + return _base64url(secrets.token_bytes(12)) + + +def _gc_expired() -> None: + """机会性清理过期会话,调用方需已持锁。""" + now = time.time() + expired = [ + sid for sid, s in _sessions.items() + if now - s.created_at > SESSION_TTL_SECONDS + ] + for sid in expired: + _sessions.pop(sid, None) + + +# ========================= 对外 API ========================= + +def _get_oauth_config(): + """允许通过 configs 覆盖 client_id / redirect_uri 等。""" + try: + from utils import configs + client_id = getattr(configs, "openai_auth_client_id", None) or _DEFAULT_CLIENT_ID + redirect_uri = getattr(configs, "openai_auth_redirect_uri", None) or _DEFAULT_REDIRECT_URI + audience = getattr(configs, "openai_auth_audience", None) or _DEFAULT_AUDIENCE + scope = getattr(configs, "openai_auth_scope", None) or _DEFAULT_SCOPE + except Exception: + client_id = _DEFAULT_CLIENT_ID + redirect_uri = _DEFAULT_REDIRECT_URI + audience = _DEFAULT_AUDIENCE + scope = _DEFAULT_SCOPE + return client_id, redirect_uri, audience, scope + + +def start_session(email: str, note: str = "", proxy_name: str = "") -> Dict: + """生成一次性 OAuth 授权会话,返回前端需要的 URL + session_id。""" + if not email or "@" not in email: + raise ValueError("email 不合法") + + client_id, redirect_uri, audience, scope = _get_oauth_config() + + verifier, challenge = _gen_pkce_pair() + state = _gen_state() + session_id = _gen_session_id() + + sess = OAuthSession( + session_id=session_id, + verifier=verifier, + challenge=challenge, + state=state, + email=email.strip(), + note=(note or "").strip(), + proxy_name=(proxy_name or "").strip(), + ) + with _lock: + _gc_expired() + _sessions[session_id] = sess + + params = { + "client_id": client_id, + "audience": audience, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": scope, + "code_challenge": challenge, + "code_challenge_method": "S256", + "state": state, + "prompt": "login", + } + authorize_url = f"{AUTH_BASE}?{urlencode(params)}" + return { + "session_id": session_id, + "authorize_url": authorize_url, + "redirect_uri": redirect_uri, + "expires_in": SESSION_TTL_SECONDS, + } + + +def pop_session(session_id: str) -> Optional[OAuthSession]: + """取出并删除会话(一次性使用)。过期返回 None。""" + with _lock: + sess = _sessions.pop(session_id, None) + if not sess: + return None + if sess.expired(): + return None + return sess + + +def peek_session(session_id: str) -> Optional[OAuthSession]: + """不删除地查看会话(供调试)。""" + with _lock: + sess = _sessions.get(session_id) + if sess and sess.expired(): + return None + return sess + + +def stats() -> Dict: + with _lock: + _gc_expired() + total = len(_sessions) + return {"active_sessions": total, "ttl_seconds": SESSION_TTL_SECONDS} From 939533ccca4aaef39b66bc965dcd7420d97d3443 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Tue, 21 Apr 2026 14:57:24 +0800 Subject: [PATCH 25/96] =?UTF-8?q?=E6=96=B0=E5=A2=9ESessionToken=E6=94=AF?= =?UTF-8?q?=E6=8C=81=EF=BC=9A=E5=AE=9E=E7=8E=B0=E4=BB=8E=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=E7=B2=98=E8=B4=B4=E7=9A=84=5F=5FSecure-next-auth.sess?= =?UTF-8?q?ion-token=20cookie=E5=AF=BC=E5=85=A5=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E5=A2=9E=E5=BC=BAtoken=E9=AA=8C=E8=AF=81=E5=92=8C=E7=AE=A1?= =?UTF-8?q?=E7=90=86=EF=BC=8C=E5=89=8D=E7=AB=AF=E7=95=8C=E9=9D=A2=E7=9B=B8?= =?UTF-8?q?=E5=BA=94=E6=9B=B4=E6=96=B0=E4=BB=A5=E6=94=AF=E6=8C=81=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E4=BA=A4=E4=BA=92=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chatgpt/authorization.py | 23 +++- chatgpt/refreshToken.py | 168 ++++++++++++++++++++++---- gateway/admin.py | 128 ++++++++++++++++++-- templates/account_proxy_bindings.html | 140 +++++++++++++++++++-- utils/configs.py | 22 +++- utils/oauth_session.py | 41 ++++--- utils/routing.py | 3 + utils/token_parser.py | 15 ++- 8 files changed, 469 insertions(+), 71 deletions(-) diff --git a/chatgpt/authorization.py b/chatgpt/authorization.py index 259c8d1f..8436725f 100644 --- a/chatgpt/authorization.py +++ b/chatgpt/authorization.py @@ -6,7 +6,7 @@ import utils.configs as configs import utils.globals as globals -from chatgpt.refreshToken import rt2ac +from chatgpt.refreshToken import rt2ac, sess2ac from utils.Logger import logger @@ -54,6 +54,15 @@ async def verify_token(req_token): if req_token.startswith("eyJhbGciOi") or req_token.startswith("fk-"): access_token = req_token return access_token + # SessionToken:带 sess- 前缀的 chatgpt.com __Secure-next-auth.session-token + elif req_token.startswith("sess-"): + try: + if req_token in globals.error_token_list: + raise HTTPException(status_code=401, detail="Error SessionToken") + access_token = await sess2ac(req_token, force_refresh=False) + return access_token + except HTTPException as e: + raise HTTPException(status_code=e.status_code, detail=e.detail) # 识别 RefreshToken:老版 45 字符 或 新版 Auth0 'rt_' 前缀(长度 ≥ 60) elif (req_token.startswith("rt_") and len(req_token) >= 60) or len(req_token) == 45: try: @@ -70,11 +79,13 @@ async def verify_token(req_token): async def refresh_all_tokens(force_refresh=False): for token in list(set(globals.token_list) - set(globals.error_token_list)): - # 老版 45 字符 或 新版 rt_ 前缀 - if (token.startswith("rt_") and len(token) >= 60) or len(token) == 45: - try: + try: + if token.startswith("sess-"): + await asyncio.sleep(0.5) + await sess2ac(token, force_refresh=force_refresh) + elif (token.startswith("rt_") and len(token) >= 60) or len(token) == 45: await asyncio.sleep(0.5) await rt2ac(token, force_refresh=force_refresh) - except HTTPException: - pass + except HTTPException: + pass logger.info("All tokens refreshed.") diff --git a/chatgpt/refreshToken.py b/chatgpt/refreshToken.py index a1196a35..aa4554f7 100644 --- a/chatgpt/refreshToken.py +++ b/chatgpt/refreshToken.py @@ -2,6 +2,7 @@ import json import random import time +from urllib.parse import urlencode from fastapi import HTTPException @@ -10,6 +11,8 @@ from utils.configs import ( openai_auth_client_id, openai_auth_redirect_uri, + openai_auth_scope, + openai_auth_token_url, proxy_url_list, ) from utils.routing import get_bound_proxy @@ -56,20 +59,144 @@ async def rt2ac(refresh_token, force_refresh=False): raise HTTPException(status_code=e.status_code, detail=e.detail) +async def sess2ac(session_token, force_refresh=False): + """Session cookie → access_token。 + + `session_token` 是带 'sess-' 前缀的存储形态(外部传入时已剥除 or 保留都支持)。 + 缓存 8 分钟(session accessToken 寿命约 10-15 分钟)。 + """ + # 统一 key:带前缀的是存储形态,剥除后的是 cookie 真实值 + if session_token.startswith("sess-"): + storage_key = session_token + cookie_value = session_token[5:] + else: + storage_key = "sess-" + session_token + cookie_value = session_token + + # 缓存命中 + if (not force_refresh + and storage_key in globals.refresh_map + and int(time.time()) - globals.refresh_map.get(storage_key, {}).get("timestamp", 0) < 8 * 60): + cached = globals.refresh_map[storage_key].get("token") + if cached: + return cached + + try: + access_token = await fetch_session_access_token(cookie_value) + refresh_meta = globals.refresh_map.get(storage_key, {}) + now = int(time.time()) + refresh_meta.update({ + "token": access_token, + "timestamp": now, + "last_success_at": now, + "last_error": "", + "last_error_at": 0, + "fail_count": 0, + }) + globals.refresh_map[storage_key] = refresh_meta + if storage_key in globals.error_token_list: + globals.error_token_list[:] = [item for item in globals.error_token_list if item != storage_key] + persist_error_tokens() + persist_refresh_map() + logger.info(f"session_cookie -> access_token OK (key={storage_key[:12]}...)") + return access_token + except HTTPException as e: + raise HTTPException(status_code=e.status_code, detail=e.detail) + + +async def fetch_session_access_token(session_cookie): + """带 __Secure-next-auth.session-token cookie 访问 chatgpt.com/api/auth/session。 + + 返回响应 JSON 中的 accessToken 字段(JWT,调 chatgpt.com/backend-api 的 Bearer)。 + """ + session_id = hashlib.md5(session_cookie.encode()).hexdigest() + storage_key = "sess-" + session_cookie + bound_proxy = get_bound_proxy(storage_key) + proxy_url = bound_proxy or (random.choice(proxy_url_list).replace("{}", session_id) if proxy_url_list else None) + if proxy_url: + proxy_url = proxy_url.replace("{}", session_id) + refresh_meta = globals.refresh_map.get(storage_key, {}) + refresh_meta["last_proxy"] = proxy_url or "" + globals.refresh_map[storage_key] = refresh_meta + + # 注意:chatgpt.com/api/auth/session 是 NextAuth 端点,需要浏览器风格 UA + Accept + client = Client(proxy=proxy_url, impersonate="chrome124") + cookie_key = "__Secure-next-auth.session-token" + try: + r = await client.get( + "https://chatgpt.com/api/auth/session", + headers={ + "Accept": "application/json", + "Accept-Language": "en-US,en;q=0.9", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Cookie": f"{cookie_key}={session_cookie}", + }, + timeout=15, + ) + raw_text = (r.text or "").strip() + content_type = r.headers.get("content-type", "") + logger.info( + f"[sess2ac] key={storage_key[:12]}... status={r.status_code} " + f"ctype={content_type} body_len={len(raw_text)} proxy={'yes' if proxy_url else 'no'}" + ) + + if r.status_code != 200: + if storage_key not in globals.error_token_list and r.status_code in (401, 403): + globals.error_token_list.append(storage_key) + persist_error_tokens() + raise Exception( + f"chatgpt.com/api/auth/session status={r.status_code}: {raw_text[:200]}" + ) + if not raw_text: + raise Exception("chatgpt.com/api/auth/session 返回空响应;cookie 可能已失效") + + try: + payload = json.loads(raw_text) + except json.JSONDecodeError: + raise Exception(f"非 JSON 响应 ctype={content_type}: {raw_text[:200]}") + + # 未登录时 NextAuth 返回 {} 或 {"user": null} + access_token = payload.get("accessToken") or payload.get("access_token") + if not access_token: + if storage_key not in globals.error_token_list: + globals.error_token_list.append(storage_key) + persist_error_tokens() + raise Exception( + f"session cookie 无效或过期(response keys={list(payload.keys())})" + ) + return access_token + except Exception as e: + now = int(time.time()) + refresh_meta = globals.refresh_map.get(storage_key, {}) + refresh_meta.update({ + "last_error": str(e)[:300], + "last_error_at": now, + "fail_count": int(refresh_meta.get("fail_count", 0)) + 1, + "last_proxy": proxy_url or "", + }) + globals.refresh_map[storage_key] = refresh_meta + persist_refresh_map() + logger.error(f"[sess2ac] key={storage_key[:12]}... failed: {str(e)[:400]}") + raise HTTPException(status_code=500, detail=str(e)[:300]) + finally: + await client.close() + del client + + async def chat_refresh(refresh_token): - data = { - "client_id": openai_auth_client_id, + # 使用 Codex CLI 风格:application/x-www-form-urlencoded + auth.openai.com + # 老版 auth0.openai.com + iOS client_id 已返回 404 + form_body = urlencode({ "grant_type": "refresh_token", - "redirect_uri": openai_auth_redirect_uri, + "client_id": openai_auth_client_id, "refresh_token": refresh_token, - } - # Auth0 是 API 端点,必须走纯 API 风格调用: - # - impersonate=None 关闭 Safari/Chrome TLS 指纹模拟,避免被 WAF 判定为"浏览器调 API" - # - 显式带 Accept/Content-Type/User-Agent,让 Auth0 正确路由到 /oauth/token 而非 Universal Login 页 + "scope": openai_auth_scope, + }) headers = { "Accept": "application/json", - "Content-Type": "application/json", - "User-Agent": "ChatGPT/1.2025.084 (iOS 17.5.1; iPhone15,3; build 1402)", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "Codex_CLI/0.1.0", } session_id = hashlib.md5(refresh_token.encode()).hexdigest() bound_proxy = get_bound_proxy(refresh_token) @@ -82,51 +209,46 @@ async def chat_refresh(refresh_token): client = Client(proxy=proxy_url, impersonate=None) token_prefix = refresh_token[:8] try: - r = await client.post( - "https://auth0.openai.com/oauth/token", - json=data, - headers=headers, - timeout=15, - ) + r = await client.post(openai_auth_token_url, data=form_body, headers=headers, timeout=15) raw_text = (r.text or "").strip() content_type = r.headers.get("content-type", "") # 诊断日志:每次刷新都记录上游返回的关键元数据 logger.info( f"[chat_refresh] token={token_prefix}... status={r.status_code} " - f"ctype={content_type} body_len={len(raw_text)} proxy={'yes' if proxy_url else 'no'}" + f"ctype={content_type} body_len={len(raw_text)} proxy={'yes' if proxy_url else 'no'} " + f"endpoint={openai_auth_token_url}" ) # 200 路径:仍需防御解析 if r.status_code == 200: if not raw_text: - raise Exception("Auth0 returned empty body with status 200") + raise Exception("OpenAI returned empty body with status 200") try: payload = json.loads(raw_text) except json.JSONDecodeError: raise Exception( - f"Auth0 non-JSON response (status 200, ctype={content_type}): " + f"OpenAI non-JSON response (status 200, ctype={content_type}): " f"{raw_text[:200]}" ) if "access_token" not in payload: raise Exception( - f"Auth0 JSON missing access_token: keys={list(payload.keys())} " + f"OpenAI JSON missing access_token: keys={list(payload.keys())} " f"body={raw_text[:200]}" ) return payload["access_token"] # 非 200 路径:详细分流并记录 error_body_hint = raw_text[:300] if raw_text else "(empty body)" - if "invalid_grant" in raw_text or "access_denied" in raw_text: + if "invalid_grant" in raw_text or "access_denied" in raw_text or "refresh_token_expired" in raw_text: if refresh_token not in globals.error_token_list: globals.error_token_list.append(refresh_token) persist_error_tokens() raise Exception( - f"Auth0 rejected refresh_token (status {r.status_code}): {error_body_hint}. " - f"Hint: 若 token 以 'rt_' 开头,可能需要设置 OPENAI_AUTH_CLIENT_ID 环境变量为正确的 client_id。" + f"OpenAI rejected refresh_token (status {r.status_code}): {error_body_hint}" ) raise Exception( - f"Auth0 refresh failed (status {r.status_code}, ctype={content_type}): {error_body_hint}" + f"OpenAI refresh failed (status {r.status_code}, ctype={content_type}): {error_body_hint}" ) except Exception as e: now = int(time.time()) diff --git a/gateway/admin.py b/gateway/admin.py index 85314a9c..3165f08a 100644 --- a/gateway/admin.py +++ b/gateway/admin.py @@ -816,36 +816,142 @@ async def routing_admin_harvester_authorize_exchange(request: Request): }) +async def routing_admin_harvester_import_cookie(request: Request): + """从浏览器粘贴的 __Secure-next-auth.session-token cookie 导入账号。 + + 前端传递 {email, session_token, note?, proxy_name?}。 + 后端会: + 1. 加 'sess-' 前缀存到 token.txt + 2. 立即调 sess2ac 验证 cookie 有效性 + 3. 通过已有 update_account_meta 绑定代理/备注 + 4. 通过 harvester_meta.report_harvest 更新看板 + """ + require_admin_auth(request) + try: + body = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON body") + + email = (body.get("email") or "").strip() + session_token = (body.get("session_token") or "").strip() + note = (body.get("note") or "").strip() + proxy_name = (body.get("proxy_name") or "").strip() + + if not email or "@" not in email: + raise HTTPException(status_code=400, detail="email 不合法") + if not session_token: + raise HTTPException(status_code=400, detail="session_token 不能为空") + # 基础校验:cookie 通常是 base64url,极短不像真 token + if len(session_token) < 20: + raise HTTPException(status_code=400, detail="session_token 过短,不像有效 cookie") + # 去掉用户可能多复制的 cookie 名前缀 + for prefix in ( + "__Secure-next-auth.session-token=", + "__Host-next-auth.session-token=", + "next-auth.session-token=", + ): + if session_token.startswith(prefix): + session_token = session_token[len(prefix):] + + storage_key = "sess-" + session_token + + # 先验证 cookie 是否真能换出 access_token(短路失败) + from chatgpt.refreshToken import sess2ac + from utils import harvester_meta + from utils.routing import get_routing_config, update_account_meta + + try: + access_token = await sess2ac(storage_key, force_refresh=True) + except Exception as e: + harvester_meta.report_harvest( + email=email, success=False, error=f"cookie 验证失败: {e}" + ) + raise HTTPException(status_code=400, detail=f"Cookie 验证失败:{str(e)[:200]}") + + if not access_token: + raise HTTPException(status_code=400, detail="Cookie 有效但未拿到 access_token") + + # 写入 token 池 + if storage_key not in globals.token_list: + globals.token_list.append(storage_key) + with open(globals.TOKENS_FILE, "a", encoding="utf-8") as f: + f.write(storage_key + "\n") + + # 绑定代理 / 备注 + proxy_url = "" + if proxy_name: + cfg = get_routing_config() + match = next( + (p for p in cfg.get("proxies", []) if p.get("name") == proxy_name), + None, + ) + if match: + proxy_url = match.get("proxy_url", "") or "" + else: + logger.warning( + f"[harvester-cookie] proxy_name='{proxy_name}' 未找到" + ) + final_note = email + (f" · {note}" if note else "") + update_account_meta( + storage_key, + note=final_note, + group_name=None, + proxy_name=proxy_name if proxy_url else None, + proxy_url=proxy_url if proxy_url else None, + ) + + # 更新看板 + harvester_meta.report_harvest( + email=email, + rt_prefix=storage_key[:14], + success=True, + imported_token=storage_key, + ) + + logger.info(f"[harvester-cookie] ✓ {email} → access_token 已成功换出") + return JSONResponse({ + "status": "success", + "email": email, + "token_type": "SessionToken", + "access_token_preview": access_token[:16] + "...", + }) + + async def _exchange_code_for_tokens(code: str, sess) -> dict: - """调用 Auth0 /oauth/token 换 refresh_token。""" + """调用 OpenAI /oauth/token 换 refresh_token。 + + 使用 Codex CLI 风格:application/x-www-form-urlencoded。 + 端点从 oauth_session._get_oauth_config() 取(auth.openai.com/oauth/token)。 + """ + from urllib.parse import urlencode from utils.Client import Client from utils import oauth_session as _oauth - client_id, redirect_uri, _audience, _scope = _oauth._get_oauth_config() - data = { + client_id, redirect_uri, _audience, _scope, _auth_url, token_url = _oauth._get_oauth_config() + form = urlencode({ "grant_type": "authorization_code", "client_id": client_id, "redirect_uri": redirect_uri, "code": code, "code_verifier": sess.verifier, - } + }) headers = { "Accept": "application/json", - "Content-Type": "application/json", - "User-Agent": "ChatGPT/1.2025.084 (iOS 17.5.1; iPhone15,3; build 1402)", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "Codex_CLI/0.1.0", } - # 复用现有 Client;关闭 impersonate 避免被 WAF 当浏览器(见 refreshToken.py 同样处理) c = Client(impersonate=None) try: - r = await c.post(_oauth.TOKEN_ENDPOINT, json=data, headers=headers, timeout=20) + # curl_cffi 的 post 接受 data= 作为 form body + r = await c.post(token_url, data=form, headers=headers, timeout=20) raw = (r.text or "").strip() if r.status_code != 200: - raise RuntimeError(f"Auth0 status={r.status_code} body={raw[:300]}") + raise RuntimeError(f"OpenAI token status={r.status_code} body={raw[:300]}") import json as _json payload = _json.loads(raw) if "refresh_token" not in payload: - raise RuntimeError(f"Auth0 缺 refresh_token: keys={list(payload.keys())}") + raise RuntimeError(f"响应缺 refresh_token: keys={list(payload.keys())}") return payload finally: await c.close() @@ -910,6 +1016,7 @@ async def _harvester_import_rt(sess, refresh_token: str) -> None: app.add_api_route("/admin/harvester/report", routing_admin_harvester_report, methods=["POST"]) app.add_api_route("/admin/harvester/authorize/start", routing_admin_harvester_authorize_start, methods=["POST"]) app.add_api_route("/admin/harvester/authorize/exchange", routing_admin_harvester_authorize_exchange, methods=["POST"]) +app.add_api_route("/admin/harvester/import-cookie", routing_admin_harvester_import_cookie, methods=["POST"]) if api_prefix: app.add_api_route(f"/{api_prefix}/admin/routing", routing_admin_page, methods=["GET"], response_class=HTMLResponse) @@ -934,3 +1041,4 @@ async def _harvester_import_rt(sess, refresh_token: str) -> None: app.add_api_route(f"/{api_prefix}/admin/harvester/report", routing_admin_harvester_report, methods=["POST"]) app.add_api_route(f"/{api_prefix}/admin/harvester/authorize/start", routing_admin_harvester_authorize_start, methods=["POST"]) app.add_api_route(f"/{api_prefix}/admin/harvester/authorize/exchange", routing_admin_harvester_authorize_exchange, methods=["POST"]) + app.add_api_route(f"/{api_prefix}/admin/harvester/import-cookie", routing_admin_harvester_import_cookie, methods=["POST"]) diff --git a/templates/account_proxy_bindings.html b/templates/account_proxy_bindings.html index 0b1b9b8d..b3979ec0 100644 --- a/templates/account_proxy_bindings.html +++ b/templates/account_proxy_bindings.html @@ -26,6 +26,7 @@ const harvBulkApi = `${basePath}/admin/harvester/accounts/bulk-import`; const harvAuthStartApi = `${basePath}/admin/harvester/authorize/start`; const harvAuthExchangeApi = `${basePath}/admin/harvester/authorize/exchange`; + const harvImportCookieApi = `${basePath}/admin/harvester/import-cookie`; const summaryGrid = document.getElementById('summaryGrid'); const ipGrid = document.getElementById('ipGrid'); @@ -115,6 +116,15 @@ const harvAuthSubmitButton = document.getElementById('harvAuthSubmitButton'); const harvAuthSessionInfo = document.getElementById('harvAuthSessionInfo'); const harvAuthErrorBox = document.getElementById('harvAuthErrorBox'); + // Cookie 导入弹窗(主流程) + const harvCookieModal = document.getElementById('harvCookieModal'); + const harvCookieTitle = document.getElementById('harvCookieTitle'); + const harvCookieClose = document.getElementById('harvCookieClose'); + const harvCookieCancel = document.getElementById('harvCookieCancel'); + const harvCookieInput = document.getElementById('harvCookieInput'); + const harvCookieInfo = document.getElementById('harvCookieInfo'); + const harvCookieErrorBox = document.getElementById('harvCookieErrorBox'); + const harvCookieSubmit = document.getElementById('harvCookieSubmit'); let proxyOptions = []; let accountsCache = []; let editingToken = ''; @@ -1016,7 +1026,7 @@ ${harvFormatTs(a.last_harvest_at)} ${harvStatusChip(a.status)} - + @@ -1152,19 +1162,95 @@ }); harvBulkSubmit.addEventListener('click', harvBulkSubmitHandler); - // 授权弹窗 + // ===== Cookie 粘贴流程(主流程)===== + let harvCookieCurrent = null; // {email} + + function harvOpenCookie(email) { + const acc = harvAccountsCache.find((x) => x.email.toLowerCase() === email.toLowerCase()); + if (!acc) { + alert('账号不存在,请先"新增账号"'); + return; + } + harvCookieCurrent = {email: acc.email, note: acc.note || '', proxy_name: acc.proxy_name || ''}; + harvCookieTitle.textContent = `🍪 粘贴 ChatGPT Session Cookie - ${acc.email}`; + harvCookieInput.value = ''; + harvCookieInfo.textContent = '提示:cookie 值通常 500+ 字符,长段 base64;只复制 Value 列,不要带 name='; + harvCookieErrorBox.classList.add('hidden'); + harvCookieModal.classList.remove('hidden'); + harvCookieModal.classList.add('flex'); + setTimeout(() => harvCookieInput.focus(), 50); + } + + function harvCloseCookie() { + harvCookieCurrent = null; + harvCookieInput.value = ''; + harvCookieModal.classList.add('hidden'); + harvCookieModal.classList.remove('flex'); + } + + async function harvSubmitCookie() { + if (!harvCookieCurrent) return; + const val = harvCookieInput.value.trim(); + if (!val) { + alert('请粘贴 cookie 值'); + return; + } + if (val.length < 20) { + alert('cookie 看起来过短,请确认复制的是完整 Value'); + return; + } + harvCookieSubmit.disabled = true; + harvCookieSubmit.textContent = '验证中...'; + harvCookieErrorBox.classList.add('hidden'); + try { + const resp = await fetch(harvImportCookieApi, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + email: harvCookieCurrent.email, + session_token: val, + note: harvCookieCurrent.note, + proxy_name: harvCookieCurrent.proxy_name, + }), + }); + const data = await resp.json(); + if (!resp.ok) { + harvCookieErrorBox.textContent = data.detail || '导入失败'; + harvCookieErrorBox.classList.remove('hidden'); + return; + } + alert(`✅ 成功导入 ${data.email}\naccess_token 预览: ${data.access_token_preview || ''}`); + harvCloseCookie(); + await harvLoad(); + } catch (e) { + harvCookieErrorBox.textContent = '网络错误:' + e.message; + harvCookieErrorBox.classList.remove('hidden'); + } finally { + harvCookieSubmit.disabled = false; + harvCookieSubmit.textContent = '验证并导入'; + } + } + + // 授权弹窗(OAuth 路线,保留但不是主推) harvAuthCopyUrlButton.addEventListener('click', harvCopyAuthUrl); harvAuthClose.addEventListener('click', harvCloseAuth); harvAuthCancel.addEventListener('click', harvCloseAuth); harvAuthSubmitButton.addEventListener('click', harvSubmitCallback); + // Cookie 弹窗事件 + harvCookieClose.addEventListener('click', harvCloseCookie); + harvCookieCancel.addEventListener('click', harvCloseCookie); + harvCookieSubmit.addEventListener('click', harvSubmitCookie); + // 表格行操作委托 harvAccountsTable.addEventListener('click', (e) => { const target = e.target; if (!(target instanceof HTMLElement)) return; + const cookieEmail = target.getAttribute('data-harv-cookie'); const authEmail = target.getAttribute('data-harv-auth'); const editEmail = target.getAttribute('data-harv-edit'); const delEmail = target.getAttribute('data-harv-del'); + if (cookieEmail) return harvOpenCookie(cookieEmail); if (authEmail) return harvStartAuth(authEmail); if (editEmail) return harvOpenEdit(editEmail); if (delEmail) return harvDelete(delEmail); @@ -1414,8 +1500,8 @@

账号与令牌管理

账号采集 Harvester

- 服务器端不存密码。点账号行的"🌐 浏览器登录",把授权 URL 粘贴到本地浏览器完成登录, - 再把跳转后的 com.openai.chat:// URL 粘贴回来即可。 + 服务器端不存密码。点账号行的"🍪 粘贴 Cookie", + 把 chatgpt.com 浏览器里的 __Secure-next-auth.session-token 贴过来即可。

@@ -1650,7 +1736,7 @@

浏览器登录

  • 点下方 [复制 URL] 按钮
  • 到浏览器(Chrome / Safari / Firefox)新开标签页粘贴打开
  • 输入 OpenAI 邮箱 + 密码,完成 Arkose / 2FA(如有)
  • -
  • 登录后浏览器会跳到一个打不开的 URL(以 com.openai.chat:// 开头);弹窗"打开 ChatGPT"点取消,地址栏的 URL 会保留
  • +
  • 登录后浏览器会跳转到 http://localhost:1455/auth/callback?code=...,页面可能显示"无法访问",属于正常——地址栏的完整 URL 依然可见
  • 从地址栏完整复制该 URL,粘贴到下方输入框,点 [完成]
  • @@ -1664,8 +1750,8 @@

    浏览器登录

    - - + +
    --
    @@ -1677,5 +1763,45 @@

    浏览器登录

    + + + diff --git a/utils/configs.py b/utils/configs.py index 3a107613..d59f3fa7 100644 --- a/utils/configs.py +++ b/utils/configs.py @@ -81,15 +81,29 @@ def is_true(x): init_force = is_true(os.getenv('INIT_FORCE', False)) # ========================= OpenAI Auth0 凭据刷新 ========================= -# 默认 iOS app client_id(老版 45 字符 RefreshToken 适用) +# 默认 Codex CLI client_id(新版 OpenAI 登录流程,适用于 auth.openai.com 端点) +# 老版 iOS app client_id `pdlLIX2Y72MIl2rhLhTE9VV9bN905kBh` + auth0.openai.com 已失效(返回 404) openai_auth_client_id = os.getenv( 'OPENAI_AUTH_CLIENT_ID', - 'pdlLIX2Y72MIl2rhLhTE9VV9bN905kBh', + 'app_EMoamEEZ73f0CkXaXp7hrann', ) -# 默认 iOS 回调 URI +# 默认 localhost HTTP 回调(Codex CLI 风格,浏览器能正常识别) openai_auth_redirect_uri = os.getenv( 'OPENAI_AUTH_REDIRECT_URI', - 'com.openai.chat://auth0.openai.com/ios/com.openai.chat/callback', + 'http://localhost:1455/auth/callback', +) +# 新版 Authorize / Token 端点(去掉了 0) +openai_auth_authorize_url = os.getenv( + 'OPENAI_AUTH_AUTHORIZE_URL', + 'https://auth.openai.com/oauth/authorize', +) +openai_auth_token_url = os.getenv( + 'OPENAI_AUTH_TOKEN_URL', + 'https://auth.openai.com/oauth/token', +) +openai_auth_scope = os.getenv( + 'OPENAI_AUTH_SCOPE', + 'openid profile email offline_access', ) # ========================= Antiban (风控规避层) ========================= diff --git a/utils/oauth_session.py b/utils/oauth_session.py index 2bc1917b..3b0e71f4 100644 --- a/utils/oauth_session.py +++ b/utils/oauth_session.py @@ -22,18 +22,18 @@ from urllib.parse import urlencode -# 与 chatgpt/refreshToken.py 和 gateway/share.py 一致的 iOS client_id -# 可通过环境变量覆盖(同 chat_refresh) -_DEFAULT_CLIENT_ID = "pdlLIX2Y72MIl2rhLhTE9VV9bN905kBh" -_DEFAULT_REDIRECT_URI = "com.openai.chat://auth0.openai.com/ios/com.openai.chat/callback" +# 与 Codex CLI 一致的新版配置(claude-relay-service 已验证可用) +# 老版 iOS app (pdlLIX... + auth0.openai.com) 已失效,返回 404 +_DEFAULT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" +_DEFAULT_REDIRECT_URI = "http://localhost:1455/auth/callback" _DEFAULT_AUDIENCE = "https://api.openai.com/v1" -_DEFAULT_SCOPE = ( - "openid email profile offline_access model.request model.read " - "organization.read organization.write" -) +_DEFAULT_SCOPE = "openid profile email offline_access" +_DEFAULT_AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize" +_DEFAULT_TOKEN_ENDPOINT = "https://auth.openai.com/oauth/token" -AUTH_BASE = "https://auth0.openai.com/authorize" -TOKEN_ENDPOINT = "https://auth0.openai.com/oauth/token" +# 向后兼容:保留老常量名(TOKEN_ENDPOINT 被外部引用) +AUTH_BASE = _DEFAULT_AUTHORIZE_URL +TOKEN_ENDPOINT = _DEFAULT_TOKEN_ENDPOINT SESSION_TTL_SECONDS = 15 * 60 @@ -91,19 +91,23 @@ def _gc_expired() -> None: # ========================= 对外 API ========================= def _get_oauth_config(): - """允许通过 configs 覆盖 client_id / redirect_uri 等。""" + """允许通过 configs 覆盖 client_id / redirect_uri / endpoints。返回 7-tuple。""" try: from utils import configs client_id = getattr(configs, "openai_auth_client_id", None) or _DEFAULT_CLIENT_ID redirect_uri = getattr(configs, "openai_auth_redirect_uri", None) or _DEFAULT_REDIRECT_URI audience = getattr(configs, "openai_auth_audience", None) or _DEFAULT_AUDIENCE scope = getattr(configs, "openai_auth_scope", None) or _DEFAULT_SCOPE + authorize_url = getattr(configs, "openai_auth_authorize_url", None) or _DEFAULT_AUTHORIZE_URL + token_url = getattr(configs, "openai_auth_token_url", None) or _DEFAULT_TOKEN_ENDPOINT except Exception: client_id = _DEFAULT_CLIENT_ID redirect_uri = _DEFAULT_REDIRECT_URI audience = _DEFAULT_AUDIENCE scope = _DEFAULT_SCOPE - return client_id, redirect_uri, audience, scope + authorize_url = _DEFAULT_AUTHORIZE_URL + token_url = _DEFAULT_TOKEN_ENDPOINT + return client_id, redirect_uri, audience, scope, authorize_url, token_url def start_session(email: str, note: str = "", proxy_name: str = "") -> Dict: @@ -111,7 +115,7 @@ def start_session(email: str, note: str = "", proxy_name: str = "") -> Dict: if not email or "@" not in email: raise ValueError("email 不合法") - client_id, redirect_uri, audience, scope = _get_oauth_config() + client_id, redirect_uri, audience, scope, authorize_url, _token_url = _get_oauth_config() verifier, challenge = _gen_pkce_pair() state = _gen_state() @@ -130,21 +134,22 @@ def start_session(email: str, note: str = "", proxy_name: str = "") -> Dict: _gc_expired() _sessions[session_id] = sess + # Codex CLI 风格的 query 参数(额外参数让 OpenAI 返回 organization 信息) params = { + "response_type": "code", "client_id": client_id, - "audience": audience, "redirect_uri": redirect_uri, - "response_type": "code", "scope": scope, "code_challenge": challenge, "code_challenge_method": "S256", "state": state, - "prompt": "login", + "id_token_add_organizations": "true", + "codex_cli_simplified_flow": "true", } - authorize_url = f"{AUTH_BASE}?{urlencode(params)}" + full_url = f"{authorize_url}?{urlencode(params)}" return { "session_id": session_id, - "authorize_url": authorize_url, + "authorize_url": full_url, "redirect_uri": redirect_uri, "expires_in": SESSION_TTL_SECONDS, } diff --git a/utils/routing.py b/utils/routing.py index d0370adc..42b63959 100644 --- a/utils/routing.py +++ b/utils/routing.py @@ -21,12 +21,15 @@ def detect_token_type(token): """识别 token 类型。 规则: + - SessionToken: 以 'sess-' 开头(chatgpt.com 网页 session cookie,带前缀存储) - AccessToken: 以 'eyJhbGciOi' 或 'fk-' 开头(JWT / fakeopen token) - RefreshToken: 老版 45 字符;或新版 Auth0 'rt_' 前缀(长度通常 80-100+) - CustomToken: 其他 """ if not token: return "Unknown" + if token.startswith("sess-"): + return "SessionToken" if token.startswith("eyJhbGciOi") or token.startswith("fk-"): return "AccessToken" # 新版 Auth0 RefreshToken: rt_.,长度 ≥ 60 才算有效 diff --git a/utils/token_parser.py b/utils/token_parser.py index 68e23555..e076e171 100644 --- a/utils/token_parser.py +++ b/utils/token_parser.py @@ -35,14 +35,17 @@ def _classify(token: str) -> str: - """返回 'access' | 'refresh' | 'unknown'。 + """返回 'session' | 'access' | 'refresh' | 'unknown'。 与 utils/routing.detect_token_type 规则保持一致: + - session: 'sess-' 前缀(chatgpt.com 网页 session cookie,带前缀存储) - access: 'eyJhbGciOi' / 'fk-' 开头 - refresh: 'rt_' 前缀且长度 ≥ 60(新版 Auth0 格式)或 长度 45(老版) """ if not token: return "unknown" + if token.startswith("sess-"): + return "session" if token.startswith("eyJhbGciOi") or token.startswith("fk-"): return "access" if token.startswith("rt_") and len(token) >= 60: @@ -79,6 +82,7 @@ def _collect_from_json(obj, bucket: Set[str]) -> None: def _build_result(raw_tokens: Set[str], source: str, warnings: Optional[List[str]] = None) -> Dict: + session: List[str] = [] refresh: List[str] = [] access: List[str] = [] unknown: List[str] = [] @@ -87,24 +91,29 @@ def _build_result(raw_tokens: Set[str], source: str, warnings: Optional[List[str if not t: continue kind = _classify(t) - if kind == "refresh": + if kind == "session": + session.append(t) + elif kind == "refresh": refresh.append(t) elif kind == "access": access.append(t) else: unknown.append(t) + session_set = sorted(set(session)) refresh_set = sorted(set(refresh)) access_set = sorted(set(access)) unknown_set = sorted(set(unknown)) - total = len({*refresh_set, *access_set, *unknown_set}) + total = len({*session_set, *refresh_set, *access_set, *unknown_set}) return { + "session_tokens": session_set, "refresh_tokens": refresh_set, "access_tokens": access_set, "unknown": unknown_set, "stats": { + "session_count": len(session_set), "refresh_count": len(refresh_set), "access_count": len(access_set), "unknown_count": len(unknown_set), From efe5a3730329cb0f6ace988eb6ccd57982f64e63 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Tue, 21 Apr 2026 22:09:08 +0800 Subject: [PATCH 26/96] =?UTF-8?q?=E5=A2=9E=E5=BC=BASessionToken=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E5=8A=9F=E8=83=BD=EF=BC=9A=E6=94=AF=E6=8C=81=E5=A4=9A?= =?UTF-8?q?=E7=A7=8D=E6=A0=BC=E5=BC=8F=E7=9A=84NextAuth=20session=20cookie?= =?UTF-8?q?=E7=B2=98=E8=B4=B4=EF=BC=8C=E8=87=AA=E5=8A=A8=E8=AF=86=E5=88=AB?= =?UTF-8?q?=E5=B9=B6=E6=8B=BC=E6=8E=A5=E5=88=86=E7=89=87=EF=BC=8C=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E7=95=8C=E9=9D=A2=E6=9B=B4=E6=96=B0=E4=BB=A5=E6=8C=87?= =?UTF-8?q?=E5=AF=BC=E7=94=A8=E6=88=B7=E6=93=8D=E4=BD=9C=EF=BC=8C=E7=A1=AE?= =?UTF-8?q?=E4=BF=9D=E6=9C=89=E6=95=88=E6=80=A7=E9=AA=8C=E8=AF=81=E5=92=8C?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E6=8F=90=E7=A4=BA=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chatgpt/refreshToken.py | 45 ++++++++-- gateway/admin.py | 117 ++++++++++++++++++++------ templates/account_proxy_bindings.html | 28 +++--- 3 files changed, 146 insertions(+), 44 deletions(-) diff --git a/chatgpt/refreshToken.py b/chatgpt/refreshToken.py index aa4554f7..4ce36224 100644 --- a/chatgpt/refreshToken.py +++ b/chatgpt/refreshToken.py @@ -107,6 +107,10 @@ async def sess2ac(session_token, force_refresh=False): async def fetch_session_access_token(session_cookie): """带 __Secure-next-auth.session-token cookie 访问 chatgpt.com/api/auth/session。 + 支持两种 storage_key 格式: + - 单片:sess- + - 多片(JWE 超 4KB 被 NextAuth 分片):sess-|||||| + 返回响应 JSON 中的 accessToken 字段(JWT,调 chatgpt.com/backend-api 的 Bearer)。 """ session_id = hashlib.md5(session_cookie.encode()).hexdigest() @@ -119,9 +123,12 @@ async def fetch_session_access_token(session_cookie): refresh_meta["last_proxy"] = proxy_url or "" globals.refresh_map[storage_key] = refresh_meta - # 注意:chatgpt.com/api/auth/session 是 NextAuth 端点,需要浏览器风格 UA + Accept + # 按 NextAuth 协议组装 Cookie header + # 单片 → 一条 __Secure-next-auth.session-token= + # 多片 → 多条 __Secure-next-auth.session-token.0=xxx; .1=yyy; ... + cookie_header = _build_nextauth_cookie_header(session_cookie) + client = Client(proxy=proxy_url, impersonate="chrome124") - cookie_key = "__Secure-next-auth.session-token" try: r = await client.get( "https://chatgpt.com/api/auth/session", @@ -130,7 +137,7 @@ async def fetch_session_access_token(session_cookie): "Accept-Language": "en-US,en;q=0.9", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", - "Cookie": f"{cookie_key}={session_cookie}", + "Cookie": cookie_header, }, timeout=15, ) @@ -138,7 +145,8 @@ async def fetch_session_access_token(session_cookie): content_type = r.headers.get("content-type", "") logger.info( f"[sess2ac] key={storage_key[:12]}... status={r.status_code} " - f"ctype={content_type} body_len={len(raw_text)} proxy={'yes' if proxy_url else 'no'}" + f"ctype={content_type} body_len={len(raw_text)} " + f"proxy={'yes' if proxy_url else 'no'} chunks={cookie_header.count('session-token')}" ) if r.status_code != 200: @@ -163,7 +171,8 @@ async def fetch_session_access_token(session_cookie): globals.error_token_list.append(storage_key) persist_error_tokens() raise Exception( - f"session cookie 无效或过期(response keys={list(payload.keys())})" + f"session cookie 无效或过期(response keys={list(payload.keys())})。" + f"提示:NextAuth session token 可能分片,请确保同时提供 .0 和 .1(若存在)" ) return access_token except Exception as e: @@ -184,6 +193,32 @@ async def fetch_session_access_token(session_cookie): del client +# 分片分隔符(内部用,不可与 base64url 字符冲突) +SESS_CHUNK_SEPARATOR = "|||" +NEXTAUTH_COOKIE_NAME = "__Secure-next-auth.session-token" + + +def _build_nextauth_cookie_header(session_cookie: str) -> str: + """根据存储的 session_cookie 字符串,构造发给 chatgpt.com 的 Cookie header。 + + Args: + session_cookie: 已去除 'sess-' 前缀的原始值。 + - 单片:直接是 cookie value + - 多片:||||||... + + Returns: + Cookie header 字符串(NextAuth 规范分片格式) + """ + if SESS_CHUNK_SEPARATOR in session_cookie: + chunks = session_cookie.split(SESS_CHUNK_SEPARATOR) + return "; ".join( + f"{NEXTAUTH_COOKIE_NAME}.{i}={chunk}" + for i, chunk in enumerate(chunks) + if chunk.strip() + ) + return f"{NEXTAUTH_COOKIE_NAME}={session_cookie}" + + async def chat_refresh(refresh_token): # 使用 Codex CLI 风格:application/x-www-form-urlencoded + auth.openai.com # 老版 auth0.openai.com + iOS client_id 已返回 404 diff --git a/gateway/admin.py b/gateway/admin.py index 3165f08a..46c30c6e 100644 --- a/gateway/admin.py +++ b/gateway/admin.py @@ -817,14 +817,16 @@ async def routing_admin_harvester_authorize_exchange(request: Request): async def routing_admin_harvester_import_cookie(request: Request): - """从浏览器粘贴的 __Secure-next-auth.session-token cookie 导入账号。 + """从浏览器粘贴的 NextAuth session cookie 导入账号。 + + 支持 3 种粘贴格式(自动识别): + 1. 单一 cookie value: "eyJxxxxx..." + 2. 完整 cookie 串(多片): "__Secure-next-auth.session-token.0=xxx; __Secure-next-auth.session-token.1=yyy" + 3. 完整 document.cookie 字符串: "_ga=xxx; __Secure-next-auth.session-token.0=xxx; __Secure-next-auth.session-token.1=yyy; cf_clearance=xxx" + + 自动提取 `__Secure-next-auth.session-token.N` 的所有片段,按顺序拼接。 前端传递 {email, session_token, note?, proxy_name?}。 - 后端会: - 1. 加 'sess-' 前缀存到 token.txt - 2. 立即调 sess2ac 验证 cookie 有效性 - 3. 通过已有 update_account_meta 绑定代理/备注 - 4. 通过 harvester_meta.report_harvest 更新看板 """ require_admin_auth(request) try: @@ -833,30 +835,32 @@ async def routing_admin_harvester_import_cookie(request: Request): raise HTTPException(status_code=400, detail="Invalid JSON body") email = (body.get("email") or "").strip() - session_token = (body.get("session_token") or "").strip() + raw_input = (body.get("session_token") or "").strip() note = (body.get("note") or "").strip() proxy_name = (body.get("proxy_name") or "").strip() if not email or "@" not in email: raise HTTPException(status_code=400, detail="email 不合法") - if not session_token: + if not raw_input: raise HTTPException(status_code=400, detail="session_token 不能为空") - # 基础校验:cookie 通常是 base64url,极短不像真 token - if len(session_token) < 20: - raise HTTPException(status_code=400, detail="session_token 过短,不像有效 cookie") - # 去掉用户可能多复制的 cookie 名前缀 - for prefix in ( - "__Secure-next-auth.session-token=", - "__Host-next-auth.session-token=", - "next-auth.session-token=", - ): - if session_token.startswith(prefix): - session_token = session_token[len(prefix):] - - storage_key = "sess-" + session_token - - # 先验证 cookie 是否真能换出 access_token(短路失败) - from chatgpt.refreshToken import sess2ac + + # ----- 智能解析分片 cookie ----- + from chatgpt.refreshToken import SESS_CHUNK_SEPARATOR, sess2ac + session_cookie = _parse_session_cookie_input(raw_input) + if not session_cookie or len(session_cookie) < 20: + raise HTTPException( + status_code=400, + detail="未识别到有效的 session-token cookie。" + "请从浏览器 F12 → Application → Cookies 复制整段 Cookie 字符串粘贴。", + ) + + storage_key = "sess-" + session_cookie + chunk_count = session_cookie.count(SESS_CHUNK_SEPARATOR) + 1 if SESS_CHUNK_SEPARATOR in session_cookie else 1 + logger.info( + f"[harvester-cookie] parsed {chunk_count} chunk(s) for {email}" + ) + + # 验证 cookie 是否真能换出 access_token from utils import harvester_meta from utils.routing import get_routing_config, update_account_meta @@ -891,7 +895,7 @@ async def routing_admin_harvester_import_cookie(request: Request): logger.warning( f"[harvester-cookie] proxy_name='{proxy_name}' 未找到" ) - final_note = email + (f" · {note}" if note else "") + final_note = f"{email} [chunks={chunk_count}]" + (f" · {note}" if note else "") update_account_meta( storage_key, note=final_note, @@ -903,20 +907,79 @@ async def routing_admin_harvester_import_cookie(request: Request): # 更新看板 harvester_meta.report_harvest( email=email, - rt_prefix=storage_key[:14], + rt_prefix=f"sess({chunk_count})...", success=True, imported_token=storage_key, ) - logger.info(f"[harvester-cookie] ✓ {email} → access_token 已成功换出") + logger.info(f"[harvester-cookie] ✓ {email} → chunks={chunk_count}, access_token 已换出") return JSONResponse({ "status": "success", "email": email, "token_type": "SessionToken", + "chunks": chunk_count, "access_token_preview": access_token[:16] + "...", }) +def _parse_session_cookie_input(raw: str) -> str: + """从用户粘贴的原始输入中提取 NextAuth session-token,按分片顺序拼接。 + + 返回内部存储用的字符串: + - 单片:cookie 原值 + - 多片:chunk0|||chunk1|||chunk2... + """ + import re + from chatgpt.refreshToken import SESS_CHUNK_SEPARATOR + + txt = raw.strip() + # 去掉可能的 "Cookie:" 前缀 + if txt.lower().startswith("cookie:"): + txt = txt[7:].strip() + + # 情况 1:整段看起来就是一个 cookie 值(没有分号、没有 =) + if "=" not in txt and ";" not in txt: + return txt + + # 情况 2:解析 cookie 串,提取 __Secure-next-auth.session-token.N 所有片 + # 支持格式: `name=value; name2=value2; __Secure-next-auth.session-token.0=xxx; ...` + chunks = {} # {index: value} + single_match = None + # 按分号分段 + for part in re.split(r";\s*", txt): + if not part or "=" not in part: + continue + key, _, value = part.partition("=") + key = key.strip() + value = value.strip() + # 匹配 __Secure-next-auth.session-token.N 格式(分片) + m = re.match(r"^__Secure-next-auth\.session-token\.(\d+)$", key) + if m: + chunks[int(m.group(1))] = value + continue + # 匹配不带下标的(单片旧版或非标准格式) + if key in ("__Secure-next-auth.session-token", "__Host-next-auth.session-token", + "next-auth.session-token"): + single_match = value + + if chunks: + # 按下标排序,拼接 + ordered = [chunks[i] for i in sorted(chunks.keys())] + if len(ordered) == 1: + return ordered[0] + return SESS_CHUNK_SEPARATOR.join(ordered) + + if single_match: + return single_match + + # 都没匹配上——可能用户粘了裸 value 但里面带了 ; / = + # 此时保守处理:如果看起来像 JWE/base64 的长字符串(含 . 或 _),直接当单片 + if len(txt) >= 100 and re.match(r"^[A-Za-z0-9_\-\.=+/]+$", txt): + return txt + + return "" + + async def _exchange_code_for_tokens(code: str, sess) -> dict: """调用 OpenAI /oauth/token 换 refresh_token。 diff --git a/templates/account_proxy_bindings.html b/templates/account_proxy_bindings.html index b3979ec0..02f39a68 100644 --- a/templates/account_proxy_bindings.html +++ b/templates/account_proxy_bindings.html @@ -1773,25 +1773,29 @@

    🍪 粘贴 ChatGPT Sessi
    -
    操作步骤
    +
    操作步骤(推荐法:整段粘贴)
      -
    1. 在你的 Mac 浏览器登录 https://chatgpt.com(确保走代理能访问)
    2. -
    3. 登录成功后按 F12 打开开发者工具
    4. -
    5. 切到 Application 选项卡 → 左侧 Cookieshttps://chatgpt.com
    6. -
    7. 找到名为 __Secure-next-auth.session-token 的 cookie
    8. -
    9. 双击 Value 列,右键 → Copy value(注意不要复制成别的字段)
    10. -
    11. 粘贴到下方输入框,点 [验证并导入]
    12. +
    13. 在你的 Mac 浏览器登录 https://chatgpt.com(走代理)
    14. +
    15. F12 → Console → 输入 document.cookie 回车 → 右键 → Copy string contents
    16. +
    17. 粘贴到下方输入框,后端自动识别 __Secure-next-auth.session-token.0 / .1 / .2 全部分片
    18. +
    19. [验证并导入]
    20. +
    +
    或分段粘贴(繁琐但精确)
    +
      +
    1. Application → Cookies → https://chatgpt.com
    2. +
    3. 找到 __Secure-next-auth.session-token.0.1(甚至 .2)
    4. +
    5. 把它们全部name.0=value0; name.1=value1 格式拼接粘贴
    -
    - 💡 Cookie 通常是一段很长的 base64 字符串(500+ 字符),类似 eyJhbGciOi...xxx..xxx.xxx.xxx。 - 寿命约几个月,后端会自动用它换短期 access_token 调用 OpenAI。 +
    + ⚠️ 必须带上所有 .N 分片。NextAuth 的 JWE session token 超过 4KB 时被浏览器自动切成多段, + 只粘一段会导致解密失败,报错"session cookie 无效或过期"。
    - - + +
    --
    From f078cf41b15dd64ec6b5d1870a445cfd29f2e1f3 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Wed, 22 Apr 2026 01:49:45 +0800 Subject: [PATCH 27/96] =?UTF-8?q?=E6=96=B0=E5=A2=9EIP=E7=99=BD=E5=90=8D?= =?UTF-8?q?=E5=8D=95=E5=92=8C=E4=BF=A1=E4=BB=BB=E4=BB=A3=E7=90=86=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=9A=E5=AE=9E=E7=8E=B0=E7=AE=A1=E7=90=86=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E7=9A=84IP=E7=99=BD=E5=90=8D=E5=8D=95=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=EF=BC=8C=E5=A2=9E=E5=BC=BA=E5=AE=89=E5=85=A8=E6=80=A7?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E9=80=9A=E8=BF=87=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E5=8F=98=E9=87=8F=E9=85=8D=E7=BD=AE=EF=BC=8C=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E7=9B=B8=E5=BA=94=E6=9B=B4=E6=96=B0=E4=BB=A5?= =?UTF-8?q?=E7=A1=AE=E4=BF=9D=E6=9C=AA=E6=8E=88=E6=9D=83IP=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E8=AE=BF=E9=97=AE=E3=80=82=E5=90=8C=E6=97=B6=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BA=86SessionToken=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E5=A4=9A=E7=A7=8D?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E7=9A=84cookie=E7=B2=98=E8=B4=B4=EF=BC=8C?= =?UTF-8?q?=E7=A1=AE=E4=BF=9D=E6=9C=89=E6=95=88=E6=80=A7=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E5=92=8C=E9=94=99=E8=AF=AF=E6=8F=90=E7=A4=BA=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 55 +++++ deploy/.env.template | 29 +++ deploy/docker-compose.template.yml | 54 +++++ deploy/install.sh | 346 ++++++++++++++++++----------- docs/COOKIE_HARVEST.md | 231 +++++++++++++++++++ docs/FEATURES.md | 309 ++++++++++++++++++++++++++ docs/SECURITY.md | 176 +++++++++++++++ gateway/admin.py | 163 +++++++++++--- utils/antiban/bucket.py | 50 +++++ utils/configs.py | 8 + 10 files changed, 1262 insertions(+), 159 deletions(-) create mode 100644 deploy/.env.template create mode 100644 deploy/docker-compose.template.yml create mode 100644 docs/COOKIE_HARVEST.md create mode 100644 docs/FEATURES.md create mode 100644 docs/SECURITY.md diff --git a/README.md b/README.md index 106eb3a5..8eeff0d7 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,61 @@ 👮 配套用户管理端[Chat-Share](https://github.com/h88782481/Chat-Share)使用前需提前配置好环境变量(ENABLE_GATEWAY设置为True,AUTO_SEED设置为False) +--- + +## ✨ nanashiwang 分支新特性 + +> 本分支在上游基础上做了大量工程化增强,**适合生产部署**。完整说明见 [`docs/FEATURES.md`](docs/FEATURES.md)。 + +### 一键部署(零交互) + +```bash +curl -fsSL https://raw.githubusercontent.com/nanashiwang/chat2api/main/deploy/install.sh | bash +``` + +脚本自动:装 Docker → 下载 compose → 生成随机 ADMIN/API 密钥 → 启动 → 打印访问地址。 + +### 新增能力一览 + +| 能力 | 说明 | 文档 | +|---|---|---| +| 🛡️ **Antiban 风控层** | IP-账号粘性桶 / 账号冷却 / 地域一致性 / 熔断自愈 | [FEATURES#1](docs/FEATURES.md#1-antiban-风控规避层) | +| 🍪 **Harvester 采集** | UI 上粘贴 chatgpt.com session cookie 自动验证+导入 | [COOKIE_HARVEST](docs/COOKIE_HARVEST.md) | +| 📂 **文件上传导入** | .txt / .json 自动解析,预览后确认导入 | [FEATURES#3](docs/FEATURES.md#3-管理后台增强) | +| 📝 **系统日志 UI** | 实时轮询 / 级别筛选 / 关键字搜索 / 一键下载 | [FEATURES#4](docs/FEATURES.md#4-系统日志-ui) | +| 🔐 **安全加固** | IP 白名单 / HttpOnly / CSRF / 密码隔离 / CF 指引 | [SECURITY](docs/SECURITY.md) | +| 🔄 **UI 代理热加载** | 添加/删除代理即时生效,不需重启 | [FEATURES#3](docs/FEATURES.md#3-管理后台增强) | +| 🎯 **新版 Token 识别** | 支持 `rt_*` 新格式 + `sess-*` SessionToken + chat_refresh 现代化 | [FEATURES#6](docs/FEATURES.md#6-新版-token-支持) | + +### 核心运维流程 + +``` +1. 一键部署 (install.sh) + ↓ +2. 登录管理后台 (URL 从部署脚本最后输出) + ↓ +3. 配置 IP 白名单 (SECURITY.md) ← 强烈建议 + ↓ +4. (可选) 代理与路由 → 添加住宅代理 + ↓ +5. 账号采集 Harvester → 🍪 粘贴 Cookie ← 主流程 + ↓ +6. 开始使用 /v1/chat/completions + ↓ +7. Cookie 过期 (数月后) → 重抓并替换 +``` + +### Cookie 抓取快速链接 + +需要在**和 chat2api 出口 IP 相似的地区**登录 chatgpt.com 抓 cookie。完整指南(含 SSH 隧道技巧)见 [`docs/COOKIE_HARVEST.md`](docs/COOKIE_HARVEST.md)。 + +速抓命令: +```javascript +// 浏览器登录 chatgpt.com 后,F12 Console 执行 +document.cookie.split(';').filter(x=>x.includes('session-token')).join('; ') +``` + +--- ## 交流群 diff --git a/deploy/.env.template b/deploy/.env.template new file mode 100644 index 00000000..cbd25eb7 --- /dev/null +++ b/deploy/.env.template @@ -0,0 +1,29 @@ +# ============ 敏感凭据(勿提交到 git)============ +# 管理后台密码(请修改为强密码) +ADMIN_PASSWORD=CHANGE_ME_STRONG_ADMIN_PASSWORD + +# API 调用方凭据(你分发给客户端用) +AUTHORIZATION=CHANGE_ME_AUTHORIZATION_KEY + +# 访问路径前缀(避免被扫描器发现) +API_PREFIX=CHANGE_ME_API_PREFIX + +# ============ 运行参数 ============ +TZ=Asia/Shanghai +CHAT2API_PORT=60403 +CHAT2API_IMAGE=ghcr.io/nanashiwang/chat2api:latest + +# ============ 可选安全加固 ============ +# 管理后台 IP 白名单(推荐配置,多条用逗号,支持 CIDR) +# ADMIN_IP_WHITELIST=1.2.3.4,10.0.0.0/8 +# 是否信任反代的 X-Forwarded-For(在 Cloudflare/Nginx 后开启) +# ADMIN_TRUST_PROXY=true + +# ============ 可选:代理 ============ +# 如果服务器直连 OpenAI 有问题,配置代理 +# PROXY_URL=socks5://user:pass@gate.residential-proxy.com:10000 +# EXPORT_PROXY_URL=socks5://user:pass@gate.residential-proxy.com:10000 + +# ============ 可选:OAuth 覆盖(一般不用动) ============ +# OPENAI_AUTH_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann +# OPENAI_AUTH_TOKEN_URL=https://auth.openai.com/oauth/token diff --git a/deploy/docker-compose.template.yml b/deploy/docker-compose.template.yml new file mode 100644 index 00000000..6ae79864 --- /dev/null +++ b/deploy/docker-compose.template.yml @@ -0,0 +1,54 @@ +services: + chat2api: + image: ${CHAT2API_IMAGE:-ghcr.io/nanashiwang/chat2api:latest} + container_name: chat2api + restart: unless-stopped + pull_policy: always + env_file: + - .env + ports: + - '${CHAT2API_PORT:-60403}:5005' + volumes: + - ./data:/app/data + environment: + # ============ 基础(从 .env 读敏感项) ============ + TZ: '${TZ:-Asia/Shanghai}' + CHATGPT_BASE_URL: 'https://chatgpt.com' + + # ============ 功能开关 ============ + HISTORY_DISABLED: 'true' + RETRY_TIMES: '3' + RANDOM_TOKEN: 'false' + SCHEDULED_REFRESH: 'true' + ENABLE_LIMIT: 'true' + CHECK_MODEL: 'false' + UPLOAD_BY_URL: 'false' + OAI_LANGUAGE: 'zh-CN' + ENABLE_GATEWAY: 'false' + AUTO_SEED: 'true' + + # ============ Antiban 风控规避层 ============ + ENABLE_ANTIBAN: 'true' + STRICT_IP_BINDING: 'false' + BUCKET_MAX_ACCOUNTS_PER_IP: '30' + ACCOUNT_MIN_INTERVAL_SECONDS: '60' + ACCOUNT_COOLDOWN_JITTER: '0.3' + ACCOUNT_MAX_WAIT_SECONDS: '30' + IP_GEO_PROVIDER: 'ip-api' + CIRCUIT_429_COOLDOWN: '1800' + CIRCUIT_403_COOLDOWN: '3600' + CIRCUIT_BUCKET_HEAL_MINUTES: '30' + + # ============ 日志 ============ + LOG_BUFFER_SIZE: '3000' + + # ============ 冷启动初始化 ============ + INIT_APPLY_ON_EMPTY: 'true' + INIT_FORCE: 'false' + + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:5005/${API_PREFIX}/admin/login > /dev/null || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s diff --git a/deploy/install.sh b/deploy/install.sh index 749c41b2..33c82efb 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -1,148 +1,232 @@ #!/usr/bin/env bash +# ============================================================ +# chat2api 一键部署脚本 +# ============================================================ +# 使用方式(新机器): +# curl -fsSL https://raw.githubusercontent.com/nanashiwang/chat2api/main/deploy/install.sh | bash +# 或下载后: +# bash install.sh +# +# 本脚本会: +# 1. 自动安装 Docker(如缺) +# 2. 生成随机 ADMIN_PASSWORD / AUTHORIZATION / API_PREFIX +# 3. 下载 docker-compose 模板(不预设代理,代理请在 UI 里配) +# 4. 启动服务并打印访问信息 +# +# 自定义环境变量(可选,脚本启动前 export 即可): +# INSTALL_DIR 安装目录(默认 $HOME/chat2api) +# CHAT2API_PORT 监听端口(默认 60403) +# GITHUB_RAW 仓库 raw URL(默认官方) +# INTERACTIVE 设为 1 进入交互模式(询问密码/前缀) +# ============================================================ set -euo pipefail -DEFAULT_INSTALL_DIR="/opt/chat2api" -DEFAULT_IMAGE="ghcr.io/nanashiwang/chat2api:latest" -DEFAULT_PORT="60403" -DEFAULT_API_PREFIX="nanapi-2026-a1" -DEFAULT_GROUP_SIZE="25" - -command_exists() { - command -v "$1" >/dev/null 2>&1 +# ----- 颜色 ----- +C_RESET="\033[0m"; C_INFO="\033[1;34m"; C_OK="\033[1;32m" +C_WARN="\033[1;33m"; C_ERR="\033[1;31m" +log() { echo -e "${C_INFO}[*]${C_RESET} $*"; } +ok() { echo -e "${C_OK}[✓]${C_RESET} $*"; } +warn() { echo -e "${C_WARN}[!]${C_RESET} $*"; } +err() { echo -e "${C_ERR}[✗]${C_RESET} $*" >&2; } + +# ----- 配置默认值 ----- +INSTALL_DIR="${INSTALL_DIR:-$HOME/chat2api}" +GITHUB_RAW="${GITHUB_RAW:-https://raw.githubusercontent.com/nanashiwang/chat2api/main}" +CHAT2API_PORT="${CHAT2API_PORT:-60403}" +INTERACTIVE="${INTERACTIVE:-0}" + +# ----- sudo / root 判定 ----- +if [ "$(id -u)" -eq 0 ]; then + SUDO="" +else + if command -v sudo >/dev/null 2>&1; then + SUDO="sudo" + else + err "需要 root 权限或安装 sudo" + exit 1 + fi +fi + +# ----- 操作系统 ----- +if [ -f /etc/os-release ]; then + . /etc/os-release + OS_ID="${ID,,}" + OS_NAME="${PRETTY_NAME:-$ID}" +else + err "无法识别操作系统(缺 /etc/os-release)" + exit 1 +fi +ok "操作系统: $OS_NAME" + +# ----- 架构 ----- +ARCH="$(uname -m)" +case "$ARCH" in + x86_64|amd64) ARCH_DOCKER="amd64" ;; + aarch64|arm64) ARCH_DOCKER="arm64" ;; + *) err "不支持的架构: $ARCH"; exit 1 ;; +esac +ok "架构: $ARCH_DOCKER" + +# ----- 依赖工具 ----- +command -v curl >/dev/null 2>&1 || { + log "安装 curl" + case "$OS_ID" in + ubuntu|debian) $SUDO apt-get update -qq && $SUDO apt-get install -y -qq curl ;; + centos|rhel|rocky|almalinux) $SUDO yum install -y -q curl ;; + *) err "请先安装 curl"; exit 1 ;; + esac } -prompt() { - local var_name="$1" - local prompt_text="$2" - local default_value="${3:-}" - local secret="${4:-false}" - local current_value="" - if [[ "$secret" == "true" ]]; then - read -r -s -p "$prompt_text [$default_value]: " current_value - echo - else - read -r -p "$prompt_text [$default_value]: " current_value - fi - if [[ -z "$current_value" ]]; then - current_value="$default_value" - fi - printf -v "$var_name" '%s' "$current_value" -} +# ----- 安装 Docker ----- +if command -v docker >/dev/null 2>&1; then + ok "Docker 已安装: $(docker --version | head -1)" +else + log "Docker 未安装,使用官方脚本自动安装..." + curl -fsSL https://get.docker.com | $SUDO sh + $SUDO systemctl enable --now docker 2>/dev/null || true + ok "Docker 安装完成" +fi + +# ----- docker compose 插件 ----- +if ! docker compose version >/dev/null 2>&1; then + log "安装 docker compose 插件" + case "$OS_ID" in + ubuntu|debian) $SUDO apt-get install -y -qq docker-compose-plugin ;; + centos|rhel|rocky|almalinux) $SUDO yum install -y -q docker-compose-plugin ;; + *) err "docker compose 插件不可用,请手动装"; exit 1 ;; + esac +fi +ok "docker compose 可用: $(docker compose version --short 2>/dev/null || echo installed)" + +# ----- 目录 ----- +log "安装目录: $INSTALL_DIR" +mkdir -p "$INSTALL_DIR/data" +cd "$INSTALL_DIR" -yaml_escape() { - printf "%s" "$1" | sed "s/'/''/g" +# ----- 随机凭据生成 ----- +gen_random() { + local length="${1:-32}" + LC_ALL=C tr -dc 'A-Za-z0-9' /dev/null 2>&1; then - return - fi - - echo "Docker or docker compose plugin not found, installing..." - if ! command_exists curl; then - if command_exists apt-get; then - sudo apt-get update - sudo apt-get install -y curl +# ----- 下载 compose 模板 ----- +if [ -f docker-compose.yml ]; then + warn "docker-compose.yml 已存在,跳过下载" +else + log "下载 docker-compose.yml 模板" + if ! curl -fsSL "$GITHUB_RAW/deploy/docker-compose.template.yml" -o docker-compose.yml; then + err "下载失败,请检查 GITHUB_RAW 变量或网络" + exit 1 + fi + ok "模板下载完成" +fi + +# ----- 生成或复用 .env ----- +if [ -f .env ]; then + warn ".env 已存在,沿用现有配置" + set +u; . ./.env; set -u +else + log "生成随机凭据..." + if [ "$INTERACTIVE" = "1" ]; then + read -r -p "管理员密码 [留空自动生成 24 位]: " INPUT_ADMIN_PWD + ADMIN_PASSWORD="${INPUT_ADMIN_PWD:-$(gen_random 24)}" + read -r -p "API 密钥 [留空自动生成 sk-...]: " INPUT_AUTH + AUTHORIZATION="${INPUT_AUTH:-sk-$(gen_random 32)}" + read -r -p "API 路径前缀 [留空自动生成 api-...]: " INPUT_PREFIX + API_PREFIX="${INPUT_PREFIX:-api-$(gen_random 12)}" else - echo "curl is required to install Docker automatically." - exit 1 + ADMIN_PASSWORD="$(gen_random 24)" + AUTHORIZATION="sk-$(gen_random 32)" + API_PREFIX="api-$(gen_random 12)" fi - fi - - curl -fsSL https://get.docker.com | sudo sh - sudo systemctl enable docker - sudo systemctl start docker -} - -extract_first_proxy_url() { - local raw="$1" - local first="${raw%%,*}" - first="$(printf "%s" "$first" | xargs)" - if [[ "$first" == *"|"* ]]; then - first="${first#*|}" - fi - printf "%s" "$first" -} -echo "== Chat2API one-click installer ==" -prompt INSTALL_DIR "Install directory" "$DEFAULT_INSTALL_DIR" -prompt IMAGE "Docker image" "$DEFAULT_IMAGE" -prompt PORT "Host port" "$DEFAULT_PORT" -prompt API_PREFIX "API prefix" "$DEFAULT_API_PREFIX" -prompt AUTHORIZATION "API authorization token" "sk-your-api-key" "true" -prompt ADMIN_PASSWORD "Admin password" "change-me-admin" "true" -prompt INIT_TOKENS "Initial tokens (comma separated)" "rt-xxx1,rt-xxx2" -prompt INIT_PROXIES "Initial proxies (comma separated, NAME|URL)" "IP-A|socks5://127.0.0.1:7890,IP-B|socks5://127.0.0.2:7890" -prompt INIT_GROUP_SIZE "Initial group size" "$DEFAULT_GROUP_SIZE" + cat > .env < docker-compose.yml <&1 | grep -v "^$" || true +$SUDO docker compose up -d + +# ----- 健康检查 ----- +log "等待服务就绪..." +for i in $(seq 1 60); do + if curl -fsS "http://127.0.0.1:${CHAT2API_PORT}/${API_PREFIX}/admin/login" > /dev/null 2>&1; then + ok "服务就绪(第 ${i} 秒)" + break + fi + if [ "$i" -eq 60 ]; then + warn "服务 60 秒内未响应,检查日志: cd $INSTALL_DIR && docker compose logs" + fi + sleep 1 +done + +# ----- 公网 IP 获取 ----- +PUBLIC_IP="$(curl -fsSL --max-time 5 https://api.ipify.org 2>/dev/null || \ + curl -fsSL --max-time 5 https://ifconfig.me 2>/dev/null || \ + echo 'your-server-ip')" + +# ----- 结果摘要 ----- +cat <x.includes('session-token')).join('; ') + 复制结果粘到 UI) + 3. "代理与路由"(可选)→ 添加住宅代理 → 给账号绑定 + 4. 测试: curl -H "Authorization: Bearer ${AUTHORIZATION}" \\ + http://localhost:${CHAT2API_PORT}/${API_PREFIX}/v1/models + +🛡️ 安全加固(强烈建议): + - 配置 IP 白名单: + vim ${INSTALL_DIR}/.env + ADMIN_IP_WHITELIST=你的办公/家庭 IP + docker compose restart + - 或接入 Cloudflare 免费版隐藏真实 IP + - 详见: ${GITHUB_RAW}/docs/SECURITY.md + +📋 常用命令: + cd ${INSTALL_DIR} + docker compose logs -f # 实时日志 + docker compose restart # 重启 + docker compose pull && docker compose up -d # 升级 + docker compose down # 停止 + +============================================================ -ensure_docker - -sudo mkdir -p /etc -sudo tee /etc/chat2api.env >/dev/null <:$PORT/$API_PREFIX/admin/login" -echo "API endpoint: http://:$PORT/$API_PREFIX/v1/chat/completions" -echo "Manage commands: chat2api status | chat2api logs | chat2api update" diff --git a/docs/COOKIE_HARVEST.md b/docs/COOKIE_HARVEST.md new file mode 100644 index 00000000..4d8fd318 --- /dev/null +++ b/docs/COOKIE_HARVEST.md @@ -0,0 +1,231 @@ +# Cookie 抓取完整指南 + +> 本文教你从 ChatGPT 网页版抓取 **session-token cookie**,喂给 chat2api 使用。 +> 这是目前**唯一可持续**的获取 ChatGPT 网页 API 访问凭证的方式(auth0 OAuth 已废弃)。 + +--- + +## 核心原理 + +### 你抓的是什么? + +chatgpt.com 用 **NextAuth** 管理登录会话,用户登录后浏览器里会有一个加密 cookie: + +``` +__Secure-next-auth.session-token.0 = +__Secure-next-auth.session-token.1 = +[可能还有 .2 .3 ...] +``` + +为什么会分成 `.0 .1`?因为单个浏览器 cookie 上限约 4KB,而 NextAuth 的加密 session token 长度超过这个限制,就被**自动切片**存成多个 cookie。 + +chat2api 拿到 cookie 后,用它访问 `https://chatgpt.com/api/auth/session`,返回 JSON 中的 `accessToken`(JWT)就是调用 `/backend-api/conversation` 等接口的 Bearer token。 + +### 寿命对比 + +| 凭据 | 寿命 | 用途 | +|---|---|---| +| **session-token cookie** | **数月**(只要你不登出) | 长期凭证 → 存进 chat2api | +| accessToken (JWT) | **10-15 分钟** | 短命,chat2api 用 cookie 每次自动刷新 | + +**所以必须抓 cookie,不能只抓 accessToken**。 + +--- + +## 🎯 设备无关,IP 地区强相关 + +Cookie 本身不绑设备——JWE 里没有"浏览器指纹"。但 **OpenAI 对 IP 行为有监控**: + +``` +❌ 危险: + Windows 国内 IP 登录 → 拿 cookie → 贴到海外 VPS → chat2api 从美国 IP 调 + → OpenAI 看到"10 秒前在中国,现在在美国"→ 🚨 异地登录邮件 / 临时冻结 + +✅ 安全: + 登录拿 cookie 的 IP ≈ chat2api 运行的 IP(同国或同大洲) +``` + +--- + +## 🟢 方法 A:SSH 动态端口转发(推荐 ⭐⭐⭐⭐⭐) + +让你 Windows/Mac 浏览器**临时走云服务器出口**访问 chatgpt,使用 IP 和登录 IP 完全一致,零风险。 + +### 步骤 + +#### 1. 建立 SSH 隧道 + +**Windows**(需要 Git Bash / WSL / OpenSSH): +```bash +ssh -D 1080 -N -q root@你的云服务器IP +# 保持这个窗口开着,别关 +``` + +**Mac / Linux** Terminal 一样: +```bash +ssh -D 1080 -N -q root@你的云服务器IP +``` + +**PuTTY 用户**: +- Connection → SSH → Tunnels +- Source port: `1080` +- 勾选 `Dynamic` +- Add → 保存连接后开启 + +#### 2. 浏览器挂代理 + +**Chrome / Edge**: +1. 装扩展 [SwitchyOmega](https://chrome.google.com/webstore/detail/proxy-switchyomega) +2. 新建 profile: + - Protocol: `SOCKS5` + - Server: `127.0.0.1` + - Port: `1080` +3. 启用该 profile + +**Firefox**: +- 设置 → 网络设置 → 手动代理 → SOCKS 主机 `127.0.0.1:1080` → SOCKS v5 + +#### 3. 验证出口 IP + +新标签页访问 `https://ifconfig.me`,显示的应该是**你云服务器的 IP**。 + +#### 4. 登录 chatgpt.com 抓 cookie + +1. 访问 `https://chatgpt.com` +2. 登录(这次 OpenAI 看到的登录 IP = 云服务器 IP) +3. F12 → Console,输入: + ```javascript + document.cookie.split(';').filter(x=>x.includes('session-token')).join('; ') + ``` +4. 回车 → 右键复制输出 +5. 关掉 SSH 隧道或关 SwitchyOmega + +#### 5. 粘贴到 chat2api + +- 管理后台 → "账号采集 Harvester" → 新增账号 → 🍪 粘贴 Cookie +- 粘贴 → [验证并导入] → 成功 + +--- + +## 🟡 方法 B:Clash 挂海外节点 + +如果不方便 SSH,但 Clash 有和云服务器**同地区**的节点: + +1. Clash 切到云服务器同地区节点(日本 / 美国 / 新加坡) +2. 确认系统代理生效(访问 ifconfig.me 看 IP 地区对) +3. 按方法 A 的第 4-5 步抓 cookie + +**不如方法 A 精确**(仍是两个不同 IP,只是地区一致),但触发风控的概率大幅降低。 + +--- + +## 🔴 方法 C:直接在本地抓(国内 IP → 海外 VPS) + +- 第一次一定收到 OpenAI 异地登录警告邮件 +- 点"是我本人" → 后续可用 +- 不推荐但**可行** +- 适用场景:一次性测试账号,不介意邮件验证 + +--- + +## 正确粘贴格式 + +所有姿势都支持,后端自动识别: + +### 姿势 1:F12 Console 一行抓(最推荐) + +```javascript +document.cookie.split(';').filter(x=>x.includes('session-token')).join('; ') +``` + +输出: +``` + __Secure-next-auth.session-token.0=; __Secure-next-auth.session-token.1= +``` + +直接粘贴到 UI。 + +### 姿势 2:Application → Cookies 手工拼 Name=Value + +``` +__Secure-next-auth.session-token.0=;__Secure-next-auth.session-token.1= +``` + +### 姿势 3:只粘 Value(英文分号分隔) + +如果只复制 Value 列(没 name): + +``` +; +``` + +### 姿势 4:整段 document.cookie(含其他 cookie) + +直接 Console 输入 `document.cookie` 复制整串,后端自动过滤噪音 cookie。 + +--- + +## ⚠️ 常见错误 + +| 错误信息 | 原因 | 解决 | +|---|---|---| +| `'latin-1' codec can't encode character '\uff1b'` | 粘贴里含**中文全角分号 `;`** | 系统会自动归一化;如仍报错,用 [替换工具](https://www.baidu.com/s?wd=全角半角转换) 转半角 | +| `未识别到有效的 session-token cookie` | 只粘了值但长度不够 / 格式错乱 | 用姿势 1 重新抓 | +| `500: Failed to connect to 127.0.0.1 port 7890` | 云服务器想走 Clash 但代理在你 Mac | 清空 `PROXY_URL` 或在 UI "代理与路由" 配**可达的**代理 | +| `session cookie 无效或过期` | Cookie 真过期了,或少抓了 `.1` | F12 看看是不是有 `.1`,全部抓 | +| `401 / cf_chl_opt` | IP 触发 CF 挑战 | 换代理、换 VPS 地区、或等 15 分钟再试 | + +--- + +## 验证抓取成功 + +在 chat2api 后台点 "🍪 粘贴 Cookie" → 验证导入后,应看到: + +``` +✅ 成功导入 yours@example.com +access_token 预览: eyJhbGciOi...xxx +``` + +去"账号与令牌"页,新账号的"类型"列应显示 **SessionToken**。 + +测试真正能用: +```bash +curl -H "Authorization: Bearer $AUTHORIZATION" \ + -H "Content-Type: application/json" \ + -d '{"model":"gpt-4o","messages":[{"role":"user","content":"say hi"}]}' \ + http://your.server/your_prefix/v1/chat/completions +``` + +--- + +## Cookie 过期了怎么办 + +**Cookie 通常能用数月**。当你发现聊天失败时: + +1. 管理后台 → "系统日志" 看到 `[sess2ac] status=401` +2. 说明 cookie 失效,需要重抓 +3. 回到你 Windows 浏览器重复上面流程抓新 cookie +4. 管理后台 → 账号采集 → 对应邮箱点"编辑"或"🍪 粘贴 Cookie"(粘新的即可覆盖) + +chat2api 的 `SCHEDULED_REFRESH` 只能刷新 **access_token**(短 JWT),**不能**帮你刷新 cookie 本身——cookie 过期必须人工重抓。 + +--- + +## 账号生命周期管理 + +推荐节奏: + +``` + 首次导入 + ↓ + [持续数月] ────→ 自动续 access_token(每 8 分钟,chat2api 自己做) + ↓ + Cookie 过期 / 被强制登出 + ↓ + 管理后台发现异常 → 重抓 cookie → 更新 +``` + +如果你有 **多个账号池**(比如 20+),建议: +- 每月固定时间检查一次系统日志 +- 用 `docker compose logs chat2api | grep -E "401|sess2ac.*fail"` 批量看哪些失效 +- 一批一起重抓(SSH 隧道下同时开多个浏览器 Profile 登录) diff --git a/docs/FEATURES.md b/docs/FEATURES.md new file mode 100644 index 00000000..14ca53f1 --- /dev/null +++ b/docs/FEATURES.md @@ -0,0 +1,309 @@ +# 新功能总览(nanashiwang 分支) + +> 本文档汇总 nanashiwang 分支相比上游 `LanQian528/chat2api` 的增强功能。 +> 按能力分类,每节包含:功能简介 → 配置方式 → 使用方式。 + +--- + +## 目录 + +1. [Antiban 风控规避层](#1-antiban-风控规避层) +2. [Harvester 账号采集](#2-harvester-账号采集) +3. [管理后台增强](#3-管理后台增强) +4. [系统日志 UI](#4-系统日志-ui) +5. [安全加固](#5-安全加固) +6. [新版 Token 支持](#6-新版-token-支持) +7. [一键部署](#7-一键部署) + +--- + +## 1. Antiban 风控规避层 + +> 针对 OpenAI 对机房 IP / 批量登录 / 异常请求的风控,提供自动化保护。 + +### 功能 + +- **IP-账号粘性桶**:每个住宅 IP 绑定固定 N 个账号,账号**永不跨桶漂移**(OpenAI 对账号历史 IP 突变极敏感) +- **账号级冷却**:单账号两次请求间自动保持最小间隔(默认 60s)+ 抖动 +- **IP 地域一致性**:根据代理 IP 自动调整 `Accept-Language` / `timezone` 等 header +- **熔断自愈**:403/cf_chl_opt 自动降级 IP 桶、429 自动账号退避(指数 60→300→1800s) +- **指纹持久化**:每账号独立 UA + screen + cores,永不漂移 + +### 配置(`.env` 或 docker-compose environment) + +```yaml +ENABLE_ANTIBAN: 'true' # 总开关 +STRICT_IP_BINDING: 'true' # 严格 IP 绑定(有代理池时开) +BUCKET_MAX_ACCOUNTS_PER_IP: '5' # 每个 IP 容纳的账号数 +ACCOUNT_MIN_INTERVAL_SECONDS: '60' # Team/Plus 最小间隔 +FREE_ACCOUNT_MIN_INTERVAL_SECONDS: '180' # 免费账号最小间隔 +ACCOUNT_COOLDOWN_JITTER: '0.3' # 冷却抖动 ±30% +ACCOUNT_MAX_WAIT_SECONDS: '30' # 账号排队最长等待 +IP_GEO_PROVIDER: 'ip-api' # 地域查询提供商 +CIRCUIT_429_COOLDOWN: '1800' # 429 初始退避 +CIRCUIT_403_COOLDOWN: '3600' # 403 IP 桶冷冻时长 +CIRCUIT_BUCKET_HEAL_MINUTES: '30' # 桶自愈扫描间隔 +``` + +### 观察 + +启动日志会看到: +``` +[antiban] enabled | buckets=N accounts=M healthy=N degraded=0 +``` + +运行中:管理后台 → "代理与路由" 可看到每个桶的状态(healthy/degraded/dead)。 + +--- + +## 2. Harvester 账号采集 + +> 从浏览器 cookie 采集 ChatGPT 网页会话,一次导入后 chat2api 自动续期数月。 + +### 核心能力 + +| 能力 | 实现 | +|---|---| +| 可视化 email 清单 | 管理后台 → "账号采集 Harvester" 页面 | +| 一键粘贴 Cookie | 🍪 按钮 + 多种粘贴格式自动识别(整段 document.cookie / 裸 value / 分片 .0 .1 .2) | +| 全角分号归一化 | 用户粘贴里意外的 `;`(中文)自动转 `;`(英文) | +| 每账号状态看板 | fresh / stale / failed / pending 颜色标识 | +| 代理绑定 | CSV 里的 proxy_name 自动反查并绑定代理 | +| 批量导入 | 支持上传 CSV(email,note,proxy_name 三列)| +| 自动上报 | cookie 验证成功自动更新看板 last_rt_prefix | + +### 使用流程 + +详见 [`docs/COOKIE_HARVEST.md`](./COOKIE_HARVEST.md)。 + +1. 浏览器(走代理)登录 `https://chatgpt.com` +2. F12 Console: + ```javascript + document.cookie.split(';').filter(x=>x.includes('session-token')).join('; ') + ``` +3. 管理后台 → Harvester → 新增 email → 🍪 粘贴 Cookie → 完成 + +### 存储 + +- 元数据 `data/harvester_accounts.json`:email / note / proxy_name / last_rt_prefix +- **不存密码**,cookie 本身即凭证 +- 权限:建议挂载 `./data` 为 600,单机部署 + +--- + +## 3. 管理后台增强 + +### 分页式布局 + +之前的滚动锚点式 nav 改为真正页面切换: + +- 控制台总览 +- 账号与令牌 +- 代理与路由 +- 账号采集 Harvester(新) +- 运行日志(新) + +### 导入账号:支持文件上传 + 自动解析 + +- `.txt` 纯文本(每行 token,`#` 注释) +- `.json` 配置导出(递归扫描,识别 `refresh_token` / `access_token` 字段) +- 预览识别结果(SessionToken / RefreshToken / AccessToken 分桶) +- 复选后才写入(避免误操作) +- 2 MB 文件大小限制 + 后缀白名单 + +### 代理热加载 + +- UI "代理与路由" → 添加 / 编辑 / 删除 → **立即生效,无需重启** +- 保存后 antiban 桶自动重建(dead/orphaned 桶清理 + 未分配账号重分) +- 允许清空代理池(切到直连模式) + +--- + +## 4. 系统日志 UI + +> 把 `docker logs` 搬到 UI,方便远程运维。 + +### 功能 + +- **实时**:默认 3s 轮询增量(基于 `since_id`) +- **级别筛选**:ALL / DEBUG+ / INFO+ / WARNING+ / ERROR+ +- **关键字搜索**:400ms 防抖 +- **一键下载**:当前筛选结果 或 全部缓冲区 +- **自动滚底** / **自动刷新** 开关 +- **ANSI 颜色自动剥离**(日志里的 `\x1b[...]` 不会污染 UI) + +### 配置 + +```yaml +LOG_BUFFER_SIZE: '3000' # 内存环形缓冲条数,默认 2000 +``` + +### 使用 + +管理后台 → "运行日志" + +- `Ctrl+F` 前端查找支持 +- 想定位某次 chat_refresh 失败 → 关键字输入 `chat_refresh` 即可 + +--- + +## 5. 安全加固 + +详见 [`docs/SECURITY.md`](./SECURITY.md)。 + +### 已修复的历史漏洞 + +| # | 漏洞 | 修复 | +|---|---|---| +| P0 | ADMIN_PASSWORD 未配时回退到 AUTHORIZATION 或空放行 | 未配置 → 整个后台返回 503 | +| P1 | `gateway/gpts.py` 未登录访问触发 `len(None)` 崩溃 | 加判空,重定向登录页 | +| P2 | admin_token 被服务端注入前端 JS 绕开 HttpOnly | 移除注入,HttpOnly + SameSite=Strict + Secure | + +### 新增防护 + +| 防护 | 配置 | 效果 | +|---|---|---| +| 管理后台 IP 白名单 | `ADMIN_IP_WHITELIST=1.2.3.4,10.0.0.0/8` | 非白名单 IP 访问 `/admin/*` 直接 403 | +| 反代 IP 识别 | `ADMIN_TRUST_PROXY=true` | 走 CF/Nginx 后读真实 X-Forwarded-For | +| HttpOnly + SameSite=Strict | 默认 | 防 XSS 偷 admin cookie | +| 登录失败锁定 | 默认 | 同 IP 5 次错误锁 10 分钟 | +| 分级速率限制 | 默认 | 管理接口 120/min,登录接口 10/5min | + +### 推荐链路 + +``` +[公网用户] + ↓ +[Cloudflare 免费 WAF + 自动 HTTPS] + ↓ +[云服务器 UFW 只允许 CF IP 段 + 你的运维 IP] + ↓ +[chat2api IP 白名单 (ADMIN_IP_WHITELIST)] + ↓ +[ADMIN_PASSWORD 鉴权 + HttpOnly Cookie] + ↓ +[业务] +``` + +--- + +## 6. 新版 Token 支持 + +### 识别规则 + +`utils/routing.py::detect_token_type`: + +| 前缀/长度 | 类型 | 刷新机制 | +|---|---|---| +| `sess-*` | **SessionToken**(新)| 调 `chatgpt.com/api/auth/session`,8 分钟缓存 | +| `rt_*`(长度 ≥ 60)| RefreshToken(Auth0 新格式)| 调 `auth.openai.com/oauth/token` | +| 长度正好 45 | RefreshToken(老格式) | 调 `auth.openai.com/oauth/token` | +| `eyJhbGciOi*` | AccessToken (JWT) | 不刷新(2 小时后过期) | +| `fk-*` | AccessToken (fakeopen) | 不刷新 | +| 其他 | CustomToken | 直接透传 | + +### chat_refresh 现代化 + +- 端点从废弃的 `auth0.openai.com` 切到 **`auth.openai.com`** +- Content-Type 从 `application/json` → `application/x-www-form-urlencoded` +- User-Agent 从 iOS app → `Codex_CLI/0.1.0` +- 可通过环境变量完全覆盖: + ```yaml + OPENAI_AUTH_CLIENT_ID: 'app_EMoamEEZ73f0CkXaXp7hrann' + OPENAI_AUTH_TOKEN_URL: 'https://auth.openai.com/oauth/token' + OPENAI_AUTH_REDIRECT_URI: 'http://localhost:1455/auth/callback' + OPENAI_AUTH_SCOPE: 'openid profile email offline_access' + ``` + +### SessionToken 续期 + +每 8 分钟自动续一次 access_token。cookie 本身可用数月。流程: + +``` +请求进来 → verify_token 看 token 前缀 + ├── sess- → sess2ac() 查缓存 → 命中返回 / 失效重新调 /api/auth/session + ├── rt_ → rt2ac() 同上逻辑,调 auth.openai.com + └── eyJ → 直接用 +``` + +--- + +## 7. 一键部署 + +> 在新云服务器上一行命令完成部署。 + +### 使用 + +```bash +# 方式 1:远程一行启动 +curl -fsSL https://raw.githubusercontent.com/nanashiwang/chat2api/main/deploy/install.sh | bash + +# 方式 2:clone 后本地跑 +git clone https://github.com/nanashiwang/chat2api.git +cd chat2api/deploy +bash install.sh + +# 交互模式(让脚本问你密码/前缀) +INTERACTIVE=1 bash install.sh + +# 自定义安装目录 +INSTALL_DIR=/opt/chat2api bash install.sh +``` + +### 脚本做的事 + +1. 检测 OS(Ubuntu / Debian / CentOS / RHEL / Rocky / Alma)和架构(amd64 / arm64) +2. 自动装 Docker + docker-compose 插件(如缺) +3. 下载 `docker-compose.template.yml` 到安装目录 +4. 生成强随机凭据: + - `ADMIN_PASSWORD`:24 位字母数字 + - `AUTHORIZATION`:`sk-` + 32 位 + - `API_PREFIX`:`api-` + 12 位 +5. 写入 `.env`(chmod 600) +6. `docker compose up -d` +7. 等待健康检查通过 +8. 打印访问 URL + 凭据 + 下一步操作指引 + +### 凭据安全 + +- `.env` 永远不进镜像(通过 `env_file` 挂载) +- 部署完自动 `chmod 600` +- 终端打印一次后就只能从 `.env` 看 + +### 升级 + +```bash +cd ~/chat2api +docker compose pull && docker compose up -d +``` + +--- + +## 配置速查表 + +| 变量 | 默认 | 作用 | +|---|---|---| +| `ADMIN_PASSWORD` | (必填) | 管理后台登录密码 | +| `AUTHORIZATION` | (必填) | API 调用方 Bearer token | +| `API_PREFIX` | (必填) | URL 路径前缀,建议随机 | +| `ADMIN_IP_WHITELIST` | 空 | 管理后台 IP 白名单(逗号 / CIDR) | +| `ADMIN_TRUST_PROXY` | false | 走 CF/Nginx 时设 true 读 XFF | +| `ENABLE_ANTIBAN` | false | Antiban 风控层总开关 | +| `STRICT_IP_BINDING` | true | 账号严格绑定 IP | +| `SCHEDULED_REFRESH` | false | 每 4 天定时刷新 RT | +| `LOG_BUFFER_SIZE` | 2000 | 日志面板内存条数 | +| `OPENAI_AUTH_CLIENT_ID` | Codex CLI | OAuth client_id 覆盖 | +| `OPENAI_AUTH_TOKEN_URL` | auth.openai.com | token 端点覆盖 | + +--- + +## 常见问题速索引 + +| 问题 | 文档位置 | +|---|---| +| Cookie 怎么抓? | [COOKIE_HARVEST.md](./COOKIE_HARVEST.md) | +| 公网部署安全? | [SECURITY.md](./SECURITY.md) | +| chat_refresh 报 404 | 已修复,使用 `auth.openai.com` | +| 粘贴 cookie 报 latin-1 编码错 | 全角分号自动归一化,详见 COOKIE_HARVEST.md | +| 要不要用 Playwright 方案 | 已废弃,harvester/ 目录保留供参考 | +| 账号池怎么绑代理 | UI "代理与路由" → 编辑账号 → 选代理名 | diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 00000000..92a59805 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,176 @@ +# chat2api 生产环境安全加固指南 + +> 把管理后台暴露在公网是个严肃的安全问题。本文档给出分层防护建议。 + +## 威胁模型 + +| 威胁 | 描述 | 缓解层 | +|---|---|---| +| **暴力破解密码** | 攻击者拿字典撞 `ADMIN_PASSWORD` | 应用层(已有 5 次失败锁 10 分钟) | +| **DDoS / CC** | 刷登录接口打满带宽 | CF 边缘过滤 | +| **未授权访问** | 扫 80/443 端口发现后台入口 | CF + IP 白名单 | +| **XSS / CSRF** | 通过前端漏洞偷 admin cookie | HttpOnly cookie(已有) | +| **MITM** | 明文 HTTP 传输密码被窃听 | CF 自动 HTTPS | +| **配置泄露** | docker-compose.yml 里的密码入 git | 用 .env 文件(已推荐) | + +--- + +## 防护分层(按效果/成本排序) + +### 🥇 第 0 层:Cloudflare 免费版(10 分钟,0 代码) + +1. 把你服务器的域名(例如 `api.your-domain.com`)DNS 解析指向服务器 IP +2. 在 Cloudflare 把该域名 **接入**(免费套餐即可) +3. 开启 **橙色云朵 proxied** 图标 +4. SSL/TLS → 设为 `Full (strict)` +5. 防火墙规则 → 根据需要开启: + - **Country block**:只允许你的国家访问(可选) + - **Challenge (I'm Under Attack)**:被攻击时一键启用 + - **Rate limiting**:对 `/admin/login` 10 次/分钟限流 +6. 服务器侧 + - 防火墙只允许 Cloudflare IP 段连 443,其他全拒绝 + - CF 官方 IP 段:https://www.cloudflare.com/ips/ + +**效果**: +- 服务器真实 IP 隐藏 +- HTTPS 自动(Let's Encrypt 类似) +- CC 防护 +- WAF 基础规则 +- 访问日志更详细 + +如果配了 CF,在 chat2api 设置: +```yaml +environment: + ADMIN_TRUST_PROXY: 'true' # 信任 X-Forwarded-For(IP 白名单会读真实客户端 IP) +``` + +--- + +### 🥈 第 1 层:IP 白名单(5 行配置) + +只允许**你家宽带 IP + VPN 出口 IP**访问管理后台: + +```yaml +environment: + ADMIN_IP_WHITELIST: '1.2.3.4,5.6.7.0/24,2001:db8::/32' + ADMIN_TRUST_PROXY: 'true' # 如果走了 CF / Nginx 反代才开 +``` + +格式: +- 单 IP:`1.2.3.4` +- CIDR 子网:`192.168.1.0/24` +- IPv6:`2001:db8::1` 或 `2001:db8::/32` +- 多条逗号分隔 + +**行为**: +- 白名单**内** IP:正常看到登录页 +- 白名单**外** IP:所有 `/admin/*` 路径 **403**,连登录表单都看不到 + +**注意**: +- 如果你的 IP 是动态的(家用宽带),可用 DDNS + 脚本每小时更新 CIDR +- 调用 OpenAI API 的 `/v1/chat/completions` **不受白名单限制**(只保护后台) + +--- + +### 🥉 第 2 层:应用层(已默认开启,无需配置) + +| 功能 | 说明 | +|---|---| +| 登录失败锁定 | 同 IP 连续 5 次错误 → 10 分钟不能重试 | +| HttpOnly Cookie | 防 XSS 偷 admin token | +| SameSite=Strict | 防 CSRF | +| 速率限制 | 管理接口 120 次/分钟 | +| 密码隔离 | `ADMIN_PASSWORD` 必须独立,不再允许回退到 `AUTHORIZATION` | +| 分级错误响应 | 未配置 ADMIN_PASSWORD 时整个后台返回 503,而不是误放行 | + +--- + +## Cloudflare 配置详细步骤 + +### 1. 域名接入 CF + +1. 注册 cloudflare.com 免费账号 +2. Add Site → 输入你的域名 → Free 套餐 +3. CF 会给你两个 NS,到你的域名注册商把 NS 改成 CF 的 +4. 等待生效(10 分钟到 24 小时) + +### 2. DNS 记录 + +A 记录 `api.your-domain.com` → 你的服务器公网 IP +- **代理状态选 Proxied(橙色云)** + +### 3. SSL/TLS 配置 + +- SSL/TLS → Overview → 设为 `Full (strict)`(推荐) +- Edge Certificates → Always Use HTTPS: ON +- Edge Certificates → Min TLS Version: TLS 1.2 + +### 4. 防火墙规则(WAF → Custom rules) + +**规则 1:保护管理后台** +``` +Expression: (http.request.uri.path contains "/admin") and (ip.src ne 1.2.3.4) +Action: Block +``` + +**规则 2:限流登录接口** +``` +Expression: (http.request.uri.path eq "/your_prefix/admin/login") and (http.request.method eq "POST") +Action: Managed Challenge +Rate: 5 requests per 10 seconds +``` + +### 5. 服务器防火墙(UFW / iptables) + +只允许 Cloudflare IP 段 + 你自己的运维 IP: + +```bash +# Ubuntu UFW 示例 +sudo ufw default deny incoming +sudo ufw allow 22/tcp # SSH(建议改非默认端口) +# CF IPv4 段(复制自 https://www.cloudflare.com/ips-v4) +for ip in 173.245.48.0/20 103.21.244.0/22 103.22.200.0/22 \ + 103.31.4.0/22 141.101.64.0/18 108.162.192.0/18 \ + 190.93.240.0/20 188.114.96.0/20 197.234.240.0/22 \ + 198.41.128.0/17 162.158.0.0/15 104.16.0.0/13 \ + 104.24.0.0/14 172.64.0.0/13 131.0.72.0/22; do + sudo ufw allow from $ip to any port 60403 +done +sudo ufw enable +``` + +这样服务器 60403 端口只接受 CF 转发的流量,攻击者扫描你真实 IP 无法直连。 + +--- + +## 验证 + +配完后测试: + +```bash +# 1. 从白名单外 IP 访问 +curl -I https://api.your-domain.com/nanapi-2026-a1/admin/login +# 预期: 403 Forbidden (IP 白名单拦截) + +# 2. 从白名单 IP 访问 +curl -I https://api.your-domain.com/nanapi-2026-a1/admin/login +# 预期: 200 HTML 登录页 + +# 3. 服务器日志应能看到真实客户端 IP(ADMIN_TRUST_PROXY=true 生效) +docker compose logs chat2api --tail 20 | grep admin-ipwl +# 预期: [admin-ipwl] 拒绝 IP: xxx.xxx.xxx.xxx (而不是 CF 的 IP) + +# 4. 尝试直接用服务器 IP 访问(绕过 CF) +curl -I http://你的服务器IP:60403/ +# 预期: 超时或拒绝连接(UFW 拦截) +``` + +--- + +## 额外建议 + +- **备份 data/ 目录**:每天一次,放 S3 或另一台机器 +- **限制 SSH**:改端口 + 公钥登录 + Fail2ban +- **监控 /admin/logs**:出现大量 401 / 拒绝 IP 时立即警觉 +- **定期轮换 ADMIN_PASSWORD**:每 3 个月换一次 +- **审计 data/harvester_accounts.json**:发现不认识的 email 立即查 diff --git a/gateway/admin.py b/gateway/admin.py index 46c30c6e..87d886ee 100644 --- a/gateway/admin.py +++ b/gateway/admin.py @@ -1,3 +1,4 @@ +import ipaddress import json import time from collections import defaultdict, deque @@ -7,7 +8,7 @@ from app import app, templates from utils.Client import Client -from utils.configs import admin_password, api_prefix, authorization_list +from utils.configs import admin_password, admin_ip_whitelist, admin_trust_proxy, api_prefix, authorization_list from utils.Logger import logger from utils.routing import ( build_group_assignments, @@ -62,14 +63,60 @@ def get_admin_secrets(): def get_client_key(request: Request): - forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip() - if forwarded: - return forwarded + """获取客户端 IP。 + + 仅在 ADMIN_TRUST_PROXY=true 时读 X-Forwarded-For,否则用真实 TCP 连接 IP。 + 避免攻击者伪造 XFF 头绕过白名单。 + """ + if admin_trust_proxy: + forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip() + if forwarded: + return forwarded if request.client: return request.client.host return "unknown" +def _is_ip_whitelisted(request: Request) -> bool: + """检查客户端 IP 是否在白名单。空白名单 = 全放行。""" + if not admin_ip_whitelist: + return True + client_ip = get_client_key(request) + if client_ip in ("unknown", ""): + return False + try: + ip_obj = ipaddress.ip_address(client_ip) + except ValueError: + logger.warning(f"[admin-ipwl] 无法解析客户端 IP: {client_ip}") + return False + for rule in admin_ip_whitelist: + rule = rule.strip() + if not rule: + continue + try: + if "/" in rule: + if ip_obj in ipaddress.ip_network(rule, strict=False): + return True + else: + if client_ip == rule or ip_obj == ipaddress.ip_address(rule): + return True + except ValueError: + logger.warning(f"[admin-ipwl] 白名单规则无效: {rule}") + continue + return False + + +def require_ip_whitelist(request: Request): + """IP 白名单检查:不在白名单的直接 403,连登录页都看不到。""" + if not _is_ip_whitelisted(request): + client_ip = get_client_key(request) + logger.warning(f"[admin-ipwl] 拒绝 IP: {client_ip}") + raise HTTPException( + status_code=403, + detail="Forbidden: your IP is not whitelisted for admin backend", + ) + + def check_rate_limit(bucket_key, limit, window_seconds): now = time.time() bucket = rate_limit_buckets[bucket_key] @@ -114,7 +161,9 @@ def get_current_admin_token(request: Request): def require_admin_auth(request: Request): - # 安全要求:未配置 ADMIN_PASSWORD 时,后台接口一律拒绝(不再放行) + # 第一道:IP 白名单(若开启) + require_ip_whitelist(request) + # 第二道:未配置 ADMIN_PASSWORD 时,后台接口一律拒绝(不再放行) if not get_admin_secrets(): raise HTTPException( status_code=503, @@ -126,6 +175,7 @@ def require_admin_auth(request: Request): async def routing_admin_login_page(request: Request): + require_ip_whitelist(request) check_rate_limit(f"admin-page:{get_client_key(request)}", 60, 60) if not get_admin_secrets(): raise HTTPException( @@ -144,6 +194,7 @@ async def routing_admin_login_page(request: Request): async def routing_admin_login_submit(request: Request): + require_ip_whitelist(request) client_key = get_client_key(request) ensure_login_not_locked(client_key) check_rate_limit(f"admin-login:{client_key}", 10, 300) @@ -195,6 +246,7 @@ async def routing_admin_logout(request: Request): async def routing_admin_page(request: Request): + require_ip_whitelist(request) check_rate_limit(f"admin-page:{get_client_key(request)}", 60, 60) if not get_admin_secrets(): raise HTTPException( @@ -229,16 +281,35 @@ async def routing_admin_save(request: Request): proxies = body.get("proxies", []) group_size = body.get("group_size", 25) - if not isinstance(proxies, list) or not proxies: - raise HTTPException(status_code=400, detail="proxies is required") + if not isinstance(proxies, list): + raise HTTPException(status_code=400, detail="proxies must be a list") + # 允许清空代理池(直连模式):传 [] 时清理所有 bindings + if not proxies: + logger.info("[admin] clearing all proxies (direct mode)") + empty_config = { + "proxies": [], + "groups": [], + "bindings": {}, + "account_meta": get_routing_config().get("account_meta", {}), + } + save_routing_config(empty_config) + sync_bindings_to_fp({}) + else: + result = build_group_assignments(list(globals.token_list), proxies, group_size) + save_routing_config(result) + sync_bindings_to_fp(result["bindings"]) + + # 热同步 antiban 桶:不重启即生效 + try: + from utils.antiban import bucket as _bucket + _bucket.resync_from_routing() + except Exception as e: # pragma: no cover + logger.warning(f"[admin] antiban resync failed: {e}") - result = build_group_assignments(list(globals.token_list), proxies, group_size) - save_routing_config(result) - sync_bindings_to_fp(result["bindings"]) return JSONResponse( { "status": "success", - "message": "Routing config saved", + "message": "Routing config saved" if proxies else "Proxies cleared (direct mode)", "summary": get_dashboard_payload()["summary"], } ) @@ -263,6 +334,12 @@ async def routing_admin_bind_account(request: Request): proxy_name = proxy.get("name") if proxy else "Custom Proxy" binding = update_single_binding(token, proxy_name, proxy_url) + # 热同步 antiban 桶 + try: + from utils.antiban import bucket as _bucket + _bucket.resync_from_routing() + except Exception as e: + logger.warning(f"[admin] antiban resync failed: {e}") return JSONResponse({"status": "success", "binding": binding}) @@ -925,6 +1002,13 @@ async def routing_admin_harvester_import_cookie(request: Request): def _parse_session_cookie_input(raw: str) -> str: """从用户粘贴的原始输入中提取 NextAuth session-token,按分片顺序拼接。 + 支持 5 种粘贴姿势: + 1. 整段 document.cookie "_ga=x; __Secure-next-auth.session-token.0=v0; __Secure-next-auth.session-token.1=v1; ..." + 2. 只带 session 的 cookie 串 "__Secure-next-auth.session-token.0=v0; __Secure-next-auth.session-token.1=v1" + 3. 单片(老版) "__Secure-next-auth.session-token=value" + 4. 裸 value 分号拼接(F12 Application 逐个复制后拼) "value0;value1" + 5. 单个裸 value(未分片时) "eyJxxx.yyy.zzz..." + 返回内部存储用的字符串: - 单片:cookie 原值 - 多片:chunk0|||chunk1|||chunk2... @@ -933,38 +1017,56 @@ def _parse_session_cookie_input(raw: str) -> str: from chatgpt.refreshToken import SESS_CHUNK_SEPARATOR txt = raw.strip() - # 去掉可能的 "Cookie:" 前缀 + # 去掉 "Cookie:" 前缀 if txt.lower().startswith("cookie:"): txt = txt[7:].strip() - # 情况 1:整段看起来就是一个 cookie 值(没有分号、没有 =) + # 归一化全角标点(用户可能用输入法不小心敲成全角) + txt = txt.translate(str.maketrans({ + "\uff1b": ";", # ; 全角分号 + "\uff1d": "=", # = 全角等号 + "\u3000": " ", #   全角空格 + "\uff1a": ":", # : 全角冒号 + })) + + # 情况 1:整段只是一个裸值(无 ; 无 =) if "=" not in txt and ";" not in txt: return txt - # 情况 2:解析 cookie 串,提取 __Secure-next-auth.session-token.N 所有片 - # 支持格式: `name=value; name2=value2; __Secure-next-auth.session-token.0=xxx; ...` - chunks = {} # {index: value} - single_match = None - # 按分号分段 + indexed_chunks = {} # {.N 下标: value}(带 name.N) + single_match = None # 带 name 的单片 + positional_chunks = [] # 没 name 的裸 value(按顺序收集) + for part in re.split(r";\s*", txt): - if not part or "=" not in part: + part = part.strip() + if not part: + continue + + if "=" not in part: + # 没 name 的裸 value:只接受看起来像 JWE/base64url 的长片段 + # 避免把 cookie 噪音碎片误吞 + if len(part) >= 50 and re.match(r"^[A-Za-z0-9_\-\.=+/]+$", part): + positional_chunks.append(part) continue + key, _, value = part.partition("=") key = key.strip() value = value.strip() - # 匹配 __Secure-next-auth.session-token.N 格式(分片) + # 带下标的 __Secure-next-auth.session-token.N m = re.match(r"^__Secure-next-auth\.session-token\.(\d+)$", key) if m: - chunks[int(m.group(1))] = value + indexed_chunks[int(m.group(1))] = value continue - # 匹配不带下标的(单片旧版或非标准格式) - if key in ("__Secure-next-auth.session-token", "__Host-next-auth.session-token", + # 不带下标的单片(老版或非分片情况) + if key in ("__Secure-next-auth.session-token", + "__Host-next-auth.session-token", "next-auth.session-token"): single_match = value - if chunks: - # 按下标排序,拼接 - ordered = [chunks[i] for i in sorted(chunks.keys())] + # 优先级:带 name 的下标 chunk > 带 name 的单片 > 裸 value 顺序拼接 > 整段兜底 + + if indexed_chunks: + ordered = [indexed_chunks[i] for i in sorted(indexed_chunks.keys())] if len(ordered) == 1: return ordered[0] return SESS_CHUNK_SEPARATOR.join(ordered) @@ -972,8 +1074,13 @@ def _parse_session_cookie_input(raw: str) -> str: if single_match: return single_match - # 都没匹配上——可能用户粘了裸 value 但里面带了 ; / = - # 此时保守处理:如果看起来像 JWE/base64 的长字符串(含 . 或 _),直接当单片 + # 姿势 4:裸 value 用分号拼接的情况 + if positional_chunks: + if len(positional_chunks) == 1: + return positional_chunks[0] + return SESS_CHUNK_SEPARATOR.join(positional_chunks) + + # 兜底:整段看起来是合法 JWE(长,字符集对) if len(txt) >= 100 and re.match(r"^[A-Za-z0-9_\-\.=+/]+$", txt): return txt diff --git a/utils/antiban/bucket.py b/utils/antiban/bucket.py index fa8d6a1c..369093bb 100644 --- a/utils/antiban/bucket.py +++ b/utils/antiban/bucket.py @@ -234,3 +234,53 @@ def bulk_assign(tokens: List[str]) -> Dict[str, int]: skipped += 1 logger.info(f"[antiban] bulk_assign result: assigned={assigned} skipped={skipped}") return {"assigned": assigned, "skipped": skipped} + + +def resync_from_routing() -> Dict[str, int]: + """热同步:routing_config.json 改动后调用,重建桶索引并重新分配未绑定账号。 + + 清除已不存在的代理对应的桶(保留桶历史 metadata 但标记为 dead)。 + """ + if not configs.enable_antiban: + return {"synced": 0} + _ensure_structure() + + # 1. 从 routing 重新导入 bindings + _sync_from_routing() + + # 2. 清理:routing 里已不存在的 proxy_url 对应的桶标记为 dead + routing = get_routing_config() + valid_proxy_urls = {p.get("proxy_url") for p in routing.get("proxies", []) if p.get("proxy_url")} + dead_count = 0 + for bucket_id, bucket in globals.antiban_bucket["buckets"].items(): + if bucket.get("proxy_url") not in valid_proxy_urls: + if bucket.get("status") != "dead": + bucket["status"] = "dead" + dead_count += 1 + + # 3. 清理孤儿 account_index(对应的桶已消失) + orphaned = [tk for tk, bid in globals.antiban_bucket["account_index"].items() + if bid not in globals.antiban_bucket["buckets"]] + for tk in orphaned: + globals.antiban_bucket["account_index"].pop(tk, None) + + # 4. 已存在 token 但未在任何桶 → 尝试重新分配到 healthy 桶 + reassigned = 0 + for token in list(globals.token_list): + if not token: + continue + if globals.antiban_bucket["account_index"].get(token): + continue + if assign_account(token): + reassigned += 1 + + _persist() + logger.info( + f"[antiban] resync_from_routing: " + f"dead_buckets={dead_count}, orphaned={len(orphaned)}, reassigned={reassigned}" + ) + return { + "dead_buckets": dead_count, + "orphaned": len(orphaned), + "reassigned": reassigned, + } diff --git a/utils/configs.py b/utils/configs.py index d59f3fa7..ce866fc6 100644 --- a/utils/configs.py +++ b/utils/configs.py @@ -22,6 +22,13 @@ def is_true(x): api_prefix = os.getenv('API_PREFIX', None) authorization = os.getenv('AUTHORIZATION', '').replace(' ', '') admin_password = os.getenv('ADMIN_PASSWORD', None) +# 管理后台 IP 白名单(逗号分隔,支持单 IP / CIDR / 'trust_proxy') +# 空串 = 不启用(允许所有 IP 访问登录页;真正 API 仍受 ADMIN_PASSWORD 保护) +# 示例: ADMIN_IP_WHITELIST="1.2.3.4,10.0.0.0/8,192.168.1.0/24" +admin_ip_whitelist_raw = os.getenv('ADMIN_IP_WHITELIST', '').replace(' ', '') +admin_ip_whitelist = [x for x in admin_ip_whitelist_raw.split(',') if x] +# 是否信任 X-Forwarded-For 头(仅在 CF / Nginx 反代场景开启,否则可被伪造绕过) +admin_trust_proxy = os.getenv('ADMIN_TRUST_PROXY', '').lower() in ('true', '1', 'yes') chatgpt_base_url = os.getenv('CHATGPT_BASE_URL', 'https://chatgpt.com').replace(' ', '') auth_key = os.getenv('AUTH_KEY', None) x_sign = os.getenv('X_SIGN', None) @@ -142,6 +149,7 @@ def is_true(x): logger.info("API_PREFIX: " + str(api_prefix)) logger.info("AUTHORIZATION: " + str(authorization_list)) logger.info("ADMIN_PASSWORD: " + str(bool(admin_password))) +logger.info("ADMIN_IP_WHITELIST:" + (f" {len(admin_ip_whitelist)} rule(s) [{'trust_proxy' if admin_trust_proxy else 'no_proxy'}]" if admin_ip_whitelist else " (disabled)")) logger.info("AUTH_KEY: " + str(auth_key)) logger.info("------------------------- Request --------------------------") logger.info("CHATGPT_BASE_URL: " + str(chatgpt_base_url_list)) From 12fe9ba351b552b466c65429458e9678bfb14761 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Sun, 26 Apr 2026 22:10:18 +0800 Subject: [PATCH 28/96] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=A4=9A=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E5=A4=9A=E5=AE=9E=E4=BE=8B=E9=83=A8=E7=BD=B2=E6=96=B9?= =?UTF-8?q?=E6=A1=88=20B=20+=20=E7=BC=96=E6=8E=92=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=EF=BC=88Orchestrator=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deploy/multi/ 目录提供「一容器一账号」部署,每实例独立数据卷、独立 SOCKS5 代理、独立 AUTH/ADMIN_PWD/API_PREFIX;nginx 按 path 路由(/acc1/v1/…),watchtower label 模式自动更新。 orchestrator/ 是独立 FastAPI 服务(path /orchestrator/),单密码登录 + HMAC cookie + CSRF 双 cookie,提供: - 增删改账号实例(写 csv → 自动 docker compose up -d) - 单实例 启动/停止/重启 - 实时仪表盘(state、health、出口 IP、cookie 鲜度) - 凭证遮蔽默认显示,点击展开 + 写审计 - audit.jsonl 追加写所有 mutation 与 reveal 操作 generate.py 纯 stdlib 模板生成器,支持密钥幂等保留、孤儿 env 清理;空 CSV 时仅启动 orchestrator 用于面板内增账号。manage.sh 包装 init/apply/add/remove/list/status/logs/shell/secrets/orch-password/down 子命令。 不动 chat2api 应用代码,与原单实例 docker-compose.yml 并存,可分阶段迁移。 --- deploy/multi/.gitignore | 12 + deploy/multi/accounts.example.csv | 4 + deploy/multi/generate.py | 498 ++++++++++++++ deploy/multi/manage.sh | 226 +++++++ deploy/multi/orchestrator/Dockerfile | 26 + deploy/multi/orchestrator/app.py | 640 ++++++++++++++++++ deploy/multi/orchestrator/requirements.txt | 5 + deploy/multi/orchestrator/static/app.js | 271 ++++++++ deploy/multi/orchestrator/static/styles.css | 14 + .../orchestrator/templates/dashboard.html | 123 ++++ .../multi/orchestrator/templates/login.html | 39 ++ 11 files changed, 1858 insertions(+) create mode 100644 deploy/multi/.gitignore create mode 100644 deploy/multi/accounts.example.csv create mode 100755 deploy/multi/generate.py create mode 100755 deploy/multi/manage.sh create mode 100644 deploy/multi/orchestrator/Dockerfile create mode 100644 deploy/multi/orchestrator/app.py create mode 100644 deploy/multi/orchestrator/requirements.txt create mode 100644 deploy/multi/orchestrator/static/app.js create mode 100644 deploy/multi/orchestrator/static/styles.css create mode 100644 deploy/multi/orchestrator/templates/dashboard.html create mode 100644 deploy/multi/orchestrator/templates/login.html diff --git a/deploy/multi/.gitignore b/deploy/multi/.gitignore new file mode 100644 index 00000000..d2010efd --- /dev/null +++ b/deploy/multi/.gitignore @@ -0,0 +1,12 @@ +# 真实账号清单(含代理凭证),不入库 +accounts.csv + +# 生成产物(含密钥),不入库 +generated/ + +# 每实例数据目录(含 cookie / token),不入库 +data/ + +# 本地临时 +*.tmp +*.bak diff --git a/deploy/multi/accounts.example.csv b/deploy/multi/accounts.example.csv new file mode 100644 index 00000000..e879f727 --- /dev/null +++ b/deploy/multi/accounts.example.csv @@ -0,0 +1,4 @@ +slug,proxy_url,note +acc1,socks5://user1:pass1@residential1.example:8000,plus 主号 +acc2,socks5://user2:pass2@residential2.example:8000,plus 备号 +acc3,,免代理直连示例 diff --git a/deploy/multi/generate.py b/deploy/multi/generate.py new file mode 100755 index 00000000..f392777d --- /dev/null +++ b/deploy/multi/generate.py @@ -0,0 +1,498 @@ +#!/usr/bin/env python3 +"""chat2api 多实例部署生成器(KISS / YAGNI / DRY) + +输入:./accounts.csv [slug, proxy_url, note] +输出:./generated/docker-compose.yml + ./generated/nginx.conf + ./generated/env/.env + ./generated/secrets.txt (chmod 600) + ./data// (空目录,作为容器数据卷) + +设计要点: +1. 纯标准库(stdlib),无 jinja2 依赖 +2. 已有 env 文件中的 AUTHORIZATION/ADMIN_PASSWORD/API_PREFIX 复用,避免重生成导致客户端 key 失效 +3. 每实例独立 SOCKS5 代理(可空);空时 PROXY_URL 不写入 +4. 不映射 chat2api 实例的宿主端口,仅 nginx 暴露 60403 +""" +from __future__ import annotations + +import csv +import os +import re +import secrets as pysecrets +import string +import sys +from pathlib import Path +from typing import Iterator, NamedTuple + +ROOT = Path(__file__).resolve().parent +CSV_FILE = ROOT / "accounts.csv" +GEN_DIR = ROOT / "generated" +ENV_DIR = GEN_DIR / "env" +DATA_DIR = ROOT / "data" +SECRETS_FILE = GEN_DIR / "secrets.txt" +COMPOSE_FILE = GEN_DIR / "docker-compose.yml" +NGINX_FILE = GEN_DIR / "nginx.conf" +ORCH_ENV = GEN_DIR / "orch.env" + +SLUG_RE = re.compile(r"^[a-z0-9-]{1,16}$") +PROXY_RE = re.compile(r"^(socks5|socks5h|http|https)://[^\s]+$") + +NGINX_PORT = int(os.environ.get("CHAT2API_GATEWAY_PORT", "60403")) +CHAT2API_IMAGE = os.environ.get( + "CHAT2API_IMAGE", "ghcr.io/nanashiwang/chat2api:latest" +) +ORCH_ENABLED = os.environ.get("ORCH_ENABLED", "true").lower() != "false" + + +class Account(NamedTuple): + slug: str + proxy_url: str + note: str + auth: str + admin_password: str + api_prefix: str + + +# ---------- helpers ---------- + +def fail(msg: str) -> None: + sys.stderr.write(f"\033[1;31m[!]\033[0m {msg}\n") + sys.exit(1) + + +def info(msg: str) -> None: + sys.stdout.write(f"\033[1;34m[*]\033[0m {msg}\n") + + +def ok(msg: str) -> None: + sys.stdout.write(f"\033[1;32m[\u2713]\033[0m {msg}\n") + + +def gen_auth() -> str: + return "sk-" + pysecrets.token_hex(16) + + +def gen_admin_password() -> str: + alphabet = string.ascii_letters + string.digits + return "".join(pysecrets.choice(alphabet) for _ in range(24)) + + +def gen_api_prefix() -> str: + return "api-" + pysecrets.token_hex(4) + + +def parse_env_file(path: Path) -> dict[str, str]: + """简易 .env 解析:KEY=VALUE,忽略 # 注释与空行;不解析 quote。""" + if not path.exists(): + return {} + out: dict[str, str] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + k, v = line.split("=", 1) + out[k.strip()] = v.strip() + return out + + +def read_csv() -> list[tuple[str, str, str]]: + if not CSV_FILE.exists(): + fail(f"未找到 {CSV_FILE};请基于 accounts.example.csv 创建") + rows: list[tuple[str, str, str]] = [] + with CSV_FILE.open("r", encoding="utf-8", newline="") as f: + reader = csv.DictReader(f) + required = {"slug", "proxy_url", "note"} + if not reader.fieldnames or not required.issubset(reader.fieldnames): + fail(f"CSV 表头必须包含: {sorted(required)}") + seen: set[str] = set() + for i, row in enumerate(reader, start=2): + slug = (row.get("slug") or "").strip() + proxy = (row.get("proxy_url") or "").strip() + note = (row.get("note") or "").strip() + if not slug: + continue # 跳过空行 + if not SLUG_RE.match(slug): + fail(f"第 {i} 行 slug='{slug}' 不合法(需 [a-z0-9-]{{1,16}})") + if slug in seen: + fail(f"第 {i} 行 slug='{slug}' 重复") + seen.add(slug) + if proxy and not PROXY_RE.match(proxy): + fail(f"第 {i} 行 proxy_url='{proxy}' 不合法") + rows.append((slug, proxy, note)) + if not rows: + info("CSV 暂无账号,仅启动 orchestrator + nginx + watchtower(可在面板内增加)") + return rows + + +def resolve_secrets(slug: str) -> tuple[str, str, str]: + """已有 env 优先复用其密钥,否则生成新值。""" + env = parse_env_file(ENV_DIR / f"{slug}.env") + auth = env.get("AUTHORIZATION") or gen_auth() + pwd = env.get("ADMIN_PASSWORD") or gen_admin_password() + prefix = env.get("API_PREFIX") or gen_api_prefix() + return auth, pwd, prefix + + +def load_accounts() -> list[Account]: + out: list[Account] = [] + for slug, proxy, note in read_csv(): + auth, pwd, prefix = resolve_secrets(slug) + out.append(Account(slug, proxy, note, auth, pwd, prefix)) + return out + + +# ---------- renderers ---------- + +ENV_TEMPLATE = """\ +# 自动生成 — 由 generate.py 维护,请勿手工修改 +AUTHORIZATION={auth} +ADMIN_PASSWORD={admin_password} +API_PREFIX={api_prefix} +""" + + +def render_env(acc: Account) -> str: + body = ENV_TEMPLATE.format( + auth=acc.auth, + admin_password=acc.admin_password, + api_prefix=acc.api_prefix, + ) + if acc.proxy_url: + body += f"PROXY_URL={acc.proxy_url}\n" + return body + + +COMPOSE_HEADER = """\ +# 自动生成 — 由 generate.py 维护,请勿手工修改 +# 来源:deploy/multi/accounts.csv + +x-chat2api-common: &c2a-common + image: {image} + restart: unless-stopped + pull_policy: always + networks: [c2a-net] + labels: + com.centurylinklabs.watchtower.enable: 'true' + environment: + TZ: 'Asia/Shanghai' + CHATGPT_BASE_URL: 'https://chatgpt.com' + HISTORY_DISABLED: 'true' + SCHEDULED_REFRESH: 'true' + ENABLE_LIMIT: 'true' + OAI_LANGUAGE: 'zh-CN' + ENABLE_GATEWAY: 'false' + AUTO_SEED: 'true' + RANDOM_TOKEN: 'false' + RETRY_TIMES: '3' + ENABLE_ANTIBAN: 'true' + STRICT_IP_BINDING: 'true' + BUCKET_MAX_ACCOUNTS_PER_IP: '1' + ACCOUNT_MIN_INTERVAL_SECONDS: '60' + FREE_ACCOUNT_MIN_INTERVAL_SECONDS: '180' + ACCOUNT_COOLDOWN_JITTER: '0.3' + ACCOUNT_MAX_WAIT_SECONDS: '30' + IP_GEO_PROVIDER: 'ip-api' + CIRCUIT_429_COOLDOWN: '1800' + CIRCUIT_403_COOLDOWN: '3600' + CIRCUIT_BUCKET_HEAL_MINUTES: '30' + INIT_APPLY_ON_EMPTY: 'true' + LOG_BUFFER_SIZE: '3000' + +services: +""" + +COMPOSE_INSTANCE = """\ + chat2api-{slug}: + <<: *c2a-common + container_name: c2a-{slug} + env_file: + - ./env/{slug}.env + volumes: + - ../data/{slug}:/app/data + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:5005/$$API_PREFIX/admin/login > /dev/null || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + +""" + +COMPOSE_ORCHESTRATOR = """\ + orchestrator: + image: c2a-orchestrator:local + build: + context: ../orchestrator + container_name: c2a-orchestrator + restart: unless-stopped + networks: [c2a-net] + env_file: + - ./orch.env + environment: + MULTI_HOST_PATH: '${MULTI_HOST_PATH}' + ORCH_PORT: '8080' + TZ: 'Asia/Shanghai' + volumes: + - ../:/work + - /var/run/docker.sock:/var/run/docker.sock + labels: + com.centurylinklabs.watchtower.enable: 'true' + +""" + +COMPOSE_FOOTER = """\ + nginx: + image: nginx:alpine + container_name: c2a-nginx + restart: unless-stopped + ports: + - '{port}:80' + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + networks: [c2a-net] + depends_on: +{depends_on} + + watchtower: + image: containrrr/watchtower + container_name: c2a-watchtower + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock + command: --label-enable --cleanup --interval 300 + +networks: + c2a-net: + driver: bridge +""" + + +def render_compose(accounts: list[Account]) -> str: + services = "".join(COMPOSE_INSTANCE.format(slug=a.slug) for a in accounts) + depends_on_lines = [f" - chat2api-{a.slug}" for a in accounts] + if ORCH_ENABLED: + depends_on_lines.append(" - orchestrator") + depends_on = "\n".join(depends_on_lines) + body = COMPOSE_HEADER.format(image=CHAT2API_IMAGE) + services + if ORCH_ENABLED: + body += COMPOSE_ORCHESTRATOR + body += COMPOSE_FOOTER.format(port=NGINX_PORT, depends_on=depends_on) + return body + + +NGINX_HEADER = """\ +# 自动生成 — 由 generate.py 维护,请勿手工修改 +worker_processes auto; +events { worker_connections 1024; } + +http { + server_tokens off; + proxy_http_version 1.1; + client_max_body_size 50m; + + # SSE / 流式响应基线 + proxy_buffering off; + proxy_cache off; + proxy_set_header Connection ''; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + + # 简单访问日志(生产可换 json) + log_format upstream_log '$remote_addr - [$time_local] "$request" ' + '$status $body_bytes_sent upstream=$upstream_addr ' + 'rt=$request_time urt=$upstream_response_time'; + access_log /var/log/nginx/access.log upstream_log; + + server { + listen 80 default_server; + server_name _; + + location = / { + default_type text/plain; + return 200 "chat2api multi-instance gateway\\n"; + } + + location = /healthz { + default_type text/plain; + return 200 "ok\\n"; + } + +""" + +NGINX_ORCH_LOCATION = """\ + # ---- orchestrator (编排面板) ---- + location /orchestrator/ { + proxy_pass http://c2a-orchestrator:8080/; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + } + +""" + +NGINX_LOCATION = """\ + # ---- {slug} ({note}) ---- + location /{slug}/ {{ + proxy_pass http://c2a-{slug}:5005/{api_prefix}/; + }} + +""" + +NGINX_FOOTER = """\ + } +} +""" + + +def render_nginx(accounts: list[Account]) -> str: + locations = "" + if ORCH_ENABLED: + locations += NGINX_ORCH_LOCATION + locations += "".join( + NGINX_LOCATION.format( + slug=a.slug, + api_prefix=a.api_prefix, + note=a.note or "-", + ) + for a in accounts + ) + return NGINX_HEADER + locations + NGINX_FOOTER + + +SECRETS_HEADER = """\ +# chat2api 多实例访问凭证(自动生成) +# 字段:slug | path-base | AUTHORIZATION | ADMIN_PASSWORD | API_PREFIX | proxy +# 警告:含敏感数据,请妥善保管(已 chmod 600) + +""" + + +def render_secrets(accounts: list[Account]) -> str: + lines = [SECRETS_HEADER] + for a in accounts: + proxy = a.proxy_url or "-" + lines.append( + f"slug={a.slug}\n" + f" path = /{a.slug}/v1/...\n" + f" AUTH = {a.auth}\n" + f" ADMIN_PWD = {a.admin_password}\n" + f" API_PREFIX = {a.api_prefix}\n" + f" PROXY = {proxy}\n" + f" note = {a.note or '-'}\n\n" + ) + return "".join(lines) + + +# ---------- writer ---------- + +def write_file(path: Path, content: str, mode: int | None = None) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + if mode is not None: + path.chmod(mode) + + +def cleanup_orphan_envs(accounts: list[Account]) -> None: + """CSV 中已删除的 slug,env 文件应该清理。data/ 故意保留以避免误删。""" + if not ENV_DIR.exists(): + return + keep = {a.slug for a in accounts} + for env_file in ENV_DIR.glob("*.env"): + if env_file.stem in {"orch"} or env_file.stem in keep: + continue + info(f"清理孤儿 env: {env_file.name}") + env_file.unlink() + + +def ensure_orch_env() -> tuple[str, bool]: + """orchestrator 凭证:首次自动生成 24 位密码 + 64 字符 session secret。 + + 返回 (password, was_generated)。 + """ + if ORCH_ENV.exists(): + env = parse_env_file(ORCH_ENV) + pwd = env.get("ORCH_PASSWORD", "") + secret = env.get("ORCH_SESSION_SECRET", "") + if pwd and secret: + return pwd, False + pwd = gen_admin_password() + secret = pysecrets.token_hex(32) + body = ( + "# 自动生成 — orchestrator 凭证(generate.py 维护)\n" + f"ORCH_PASSWORD={pwd}\n" + f"ORCH_SESSION_SECRET={secret}\n" + ) + write_file(ORCH_ENV, body, mode=0o600) + return pwd, True + + +def parse_env_file(path: Path) -> dict[str, str]: + """简易 .env 解析,仅本模块用。""" + out: dict[str, str] = {} + if not path.exists(): + return out + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + out[k.strip()] = v.strip() + return out + + +def main() -> int: + accounts = load_accounts() + info(f"读到 {len(accounts)} 个账号") + + GEN_DIR.mkdir(parents=True, exist_ok=True) + ENV_DIR.mkdir(parents=True, exist_ok=True) + DATA_DIR.mkdir(parents=True, exist_ok=True) + + # 1. env files (per-instance) + for a in accounts: + write_file(ENV_DIR / f"{a.slug}.env", render_env(a), mode=0o600) + (DATA_DIR / a.slug).mkdir(exist_ok=True) + cleanup_orphan_envs(accounts) + + # 2. compose + write_file(COMPOSE_FILE, render_compose(accounts)) + + # 3. nginx + write_file(NGINX_FILE, render_nginx(accounts)) + + # 4. secrets + write_file(SECRETS_FILE, render_secrets(accounts), mode=0o600) + + # 5. orchestrator 凭证(首次生成时打印) + orch_first_pwd: str | None = None + if ORCH_ENABLED: + pwd, was_generated = ensure_orch_env() + if was_generated: + orch_first_pwd = pwd + + ok(f"compose: {COMPOSE_FILE}") + ok(f"nginx: {NGINX_FILE}") + ok(f"env dir: {ENV_DIR}") + ok(f"secrets: {SECRETS_FILE} (chmod 600)") + ok(f"网关端口: {NGINX_PORT} (可用 CHAT2API_GATEWAY_PORT 覆盖)") + if orch_first_pwd: + sys.stdout.write( + "\n" + "============================================================\n" + f" Orchestrator 首次访问密码:{orch_first_pwd}\n" + " 请立即记录!可通过 ./manage.sh orch-password 重置\n" + " 访问入口:http://:{port}/orchestrator/\n" + "============================================================\n" + .format(port=NGINX_PORT) + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/deploy/multi/manage.sh b/deploy/multi/manage.sh new file mode 100755 index 00000000..0d2dec62 --- /dev/null +++ b/deploy/multi/manage.sh @@ -0,0 +1,226 @@ +#!/usr/bin/env bash +# chat2api 多实例运维包装(KISS) +# 全部子命令幂等:底层都是 generate.py + docker compose +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$DIR" + +C_RESET="\033[0m"; C_INFO="\033[1;34m"; C_OK="\033[1;32m"; C_ERR="\033[1;31m" +log() { echo -e "${C_INFO}[*]${C_RESET} $*"; } +ok() { echo -e "${C_OK}[\u2713]${C_RESET} $*"; } +err() { echo -e "${C_ERR}[\u2717]${C_RESET} $*" >&2; } + +CSV="$DIR/accounts.csv" +EXAMPLE_CSV="$DIR/accounts.example.csv" +GEN_DIR="$DIR/generated" +COMPOSE="$GEN_DIR/docker-compose.yml" + +# orchestrator 必需:让容器内的 docker compose --project-directory 指向宿主路径 +export MULTI_HOST_PATH="$DIR" + +ensure_csv() { + if [ ! -f "$CSV" ]; then + if [ -f "$EXAMPLE_CSV" ]; then + cp "$EXAMPLE_CSV" "$CSV" + log "已复制 accounts.example.csv → accounts.csv,请编辑后再次运行" + exit 0 + fi + err "accounts.csv 不存在且无 example 可复制" + exit 1 + fi +} + +require_compose() { + if [ ! -f "$COMPOSE" ]; then + err "尚未生成,请先 ./manage.sh init" + exit 1 + fi +} + +dc() { + docker compose -f "$COMPOSE" "$@" +} + +cmd_apply() { + ensure_csv + log "生成配置..." + python3 "$DIR/generate.py" + if dc config --services 2>/dev/null | grep -qx orchestrator; then + log "构建 orchestrator 镜像..." + dc build orchestrator + fi + log "应用 docker compose..." + dc up -d --remove-orphans + ok "完成。运行 ./manage.sh secrets 查看凭证 / 编排面板访问入口" +} + +cmd_init() { + cmd_apply +} + +cmd_add() { + local slug="${1:-}" proxy="${2:-}" note="${3:-}" + if [ -z "$slug" ]; then + err "用法: ./manage.sh add [proxy_url] [note]" + exit 1 + fi + ensure_csv + if grep -q "^${slug}," "$CSV" 2>/dev/null; then + err "slug='$slug' 已存在于 accounts.csv" + exit 1 + fi + echo "${slug},${proxy},${note}" >> "$CSV" + ok "已追加到 accounts.csv: $slug" + cmd_apply +} + +cmd_remove() { + local slug="${1:-}" + if [ -z "$slug" ]; then + err "用法: ./manage.sh remove " + exit 1 + fi + ensure_csv + if ! grep -q "^${slug}," "$CSV"; then + err "slug='$slug' 不在 accounts.csv 中" + exit 1 + fi + log "停止并清理容器 c2a-${slug}..." + if [ -f "$COMPOSE" ]; then + dc stop "chat2api-${slug}" 2>/dev/null || true + dc rm -f "chat2api-${slug}" 2>/dev/null || true + fi + log "从 accounts.csv 移除..." + grep -v "^${slug}," "$CSV" > "$CSV.tmp" && mv "$CSV.tmp" "$CSV" + log "保留 data/${slug}/ 目录(如确认无用,请手动 rm -rf)" + cmd_apply +} + +cmd_list() { + require_compose + dc ps +} + +cmd_logs() { + local slug="${1:-}" tail="${2:-200}" + if [ -z "$slug" ]; then + err "用法: ./manage.sh logs [行数]" + exit 1 + fi + docker logs --tail "$tail" -f "c2a-${slug}" +} + +cmd_shell() { + local slug="${1:-}" + if [ -z "$slug" ]; then + err "用法: ./manage.sh shell " + exit 1 + fi + docker exec -it "c2a-${slug}" sh +} + +cmd_secrets() { + if [ ! -f "$GEN_DIR/secrets.txt" ]; then + err "secrets.txt 不存在,先 ./manage.sh init" + exit 1 + fi + cat "$GEN_DIR/secrets.txt" + if [ -f "$GEN_DIR/orch.env" ]; then + echo + echo "============ Orchestrator 编排面板 ============" + grep '^ORCH_PASSWORD=' "$GEN_DIR/orch.env" 2>/dev/null \ + | sed 's/^/ /' + local port="${CHAT2API_GATEWAY_PORT:-60403}" + echo " URL: http://:${port}/orchestrator/" + echo "===============================================" + fi +} + +cmd_orch_password() { + if [ ! -f "$GEN_DIR/orch.env" ]; then + err "orch.env 不存在;先 ./manage.sh init 生成" + exit 1 + fi + local new_pwd + new_pwd=$(python3 -c 'import secrets,string; print("".join(secrets.choice(string.ascii_letters+string.digits) for _ in range(24)))') + # 跨平台 sed -i:写到 tmp 再 mv + awk -v new="$new_pwd" '/^ORCH_PASSWORD=/{print "ORCH_PASSWORD="new; next} {print}' \ + "$GEN_DIR/orch.env" > "$GEN_DIR/orch.env.tmp" + mv "$GEN_DIR/orch.env.tmp" "$GEN_DIR/orch.env" + chmod 600 "$GEN_DIR/orch.env" + log "已更新 ORCH_PASSWORD,重启 orchestrator..." + dc restart orchestrator + ok "新密码:$new_pwd" + log "旧 cookie 已自动失效(SESSION_SECRET 未变,但密码变了),需重新登录" +} + +cmd_down() { + require_compose + dc down + ok "已停止所有实例(数据保留)" +} + +cmd_status() { + require_compose + log "容器状态:" + dc ps + echo + log "出口 IP 抽样(前 5 个实例):" + local count=0 + for s in $(awk -F, 'NR>1 && $1!="" {print $1}' "$CSV"); do + [ "$count" -ge 5 ] && break + printf " %-12s -> " "$s" + docker exec "c2a-${s}" curl -s --max-time 8 https://api.ipify.org 2>/dev/null \ + || echo "(unreachable)" + echo + count=$((count+1)) + done +} + +cmd_help() { + cat <<'EOF' +chat2api 多实例运维(一容器一账号) + +用法: + ./manage.sh init 首次部署(自动从 example 复制 csv) + ./manage.sh apply 编辑 accounts.csv 后重新应用 + ./manage.sh add [proxy] [note] 追加单个账号 + apply + ./manage.sh remove 移除单个账号 + apply(保留 data/) + ./manage.sh list 所有容器状态(docker compose ps) + ./manage.sh status 状态 + 抽样验证出口 IP + ./manage.sh logs [N] 跟随该实例日志(默认 200 行) + ./manage.sh shell 进入该实例容器 shell + ./manage.sh secrets 打印所有 AUTH / ADMIN_PWD(敏感) + ./manage.sh orch-password 重置编排面板密码并重启 orchestrator + ./manage.sh down 停止全部(数据保留) + ./manage.sh help 显示本帮助 + +环境变量(可选): + CHAT2API_GATEWAY_PORT nginx 对外端口(默认 60403) + CHAT2API_IMAGE 覆盖镜像(默认 ghcr.io/nanashiwang/chat2api:latest) + +文件: + accounts.csv 真实账号清单(敏感,git 忽略) + generated/ 生成产物(含密钥,git 忽略) + data// 每实例数据卷(含 cookie/token,git 忽略) +EOF +} + +cmd="${1:-help}" +shift || true +case "$cmd" in + init) cmd_init "$@" ;; + apply) cmd_apply "$@" ;; + add) cmd_add "$@" ;; + remove) cmd_remove "$@" ;; + list) cmd_list "$@" ;; + status) cmd_status "$@" ;; + logs) cmd_logs "$@" ;; + shell) cmd_shell "$@" ;; + secrets) cmd_secrets "$@" ;; + orch-password) cmd_orch_password "$@" ;; + down) cmd_down "$@" ;; + help|-h|--help) cmd_help ;; + *) err "未知命令: $cmd"; cmd_help; exit 1 ;; +esac diff --git a/deploy/multi/orchestrator/Dockerfile b/deploy/multi/orchestrator/Dockerfile new file mode 100644 index 00000000..42a65de7 --- /dev/null +++ b/deploy/multi/orchestrator/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.11-alpine + +# docker-cli 用于 subprocess 调 docker compose;curl 用于 healthcheck +RUN apk add --no-cache docker-cli docker-cli-compose curl tzdata \ + && rm -rf /var/cache/apk/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . +COPY templates ./templates +COPY static ./static + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + TZ=Asia/Shanghai \ + ORCH_PORT=8080 + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=15s \ + CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1 + +CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers"] diff --git a/deploy/multi/orchestrator/app.py b/deploy/multi/orchestrator/app.py new file mode 100644 index 00000000..29b88947 --- /dev/null +++ b/deploy/multi/orchestrator/app.py @@ -0,0 +1,640 @@ +"""chat2api 多实例编排面板 (Orchestrator) + +职责: +- 单密码登录(HMAC 签名 cookie + CSRF 双 cookie) +- 增删改账号实例(写 accounts.csv → 调 generate.py → docker compose up) +- 启停重启单实例 +- 状态仪表盘(docker inspect + exit IP 抽样 + cookie age) +- 操作审计(jsonl 追加写) + +所有 docker 调用走容器内 docker-cli + 挂入的 /var/run/docker.sock。 +所有 compose 操作必须 --project-directory $MULTI_HOST_PATH 让 daemon 用宿主路径解析 volumes。 +""" +from __future__ import annotations + +import csv +import io +import json +import logging +import os +import re +import secrets as pysecrets +import subprocess +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from fastapi import ( + Cookie, + Depends, + FastAPI, + Form, + HTTPException, + Query, + Request, + Response, + status, +) +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer +from pydantic import BaseModel, Field, field_validator + +# ---------- 配置 ---------- + +WORK = Path("/work") +HOST_PATH = os.environ.get("MULTI_HOST_PATH") or str(WORK) +COMPOSE_FILE_C = WORK / "generated" / "docker-compose.yml" # 容器内路径(读文件) +ACCOUNTS_CSV = WORK / "accounts.csv" +SECRETS_FILE = WORK / "generated" / "secrets.txt" +ORCH_ENV = WORK / "generated" / "orch.env" +DATA_DIR = WORK / "data" +AUDIT_FILE = WORK / "audit.jsonl" + +PASSWORD = (os.environ.get("ORCH_PASSWORD") or "").strip() +SESSION_SECRET = (os.environ.get("ORCH_SESSION_SECRET") or "").strip() +SESSION_MAX_AGE = 8 * 3600 # 8h +SESSION_COOKIE = "orch_session" +CSRF_COOKIE = "orch_csrf" + +if not PASSWORD or not SESSION_SECRET: + raise RuntimeError( + "ORCH_PASSWORD 与 ORCH_SESSION_SECRET 必须在 generated/orch.env 中设置" + ) + +SLUG_RE = re.compile(r"^[a-z0-9-]{1,16}$") +PROXY_RE = re.compile(r"^(socks5|socks5h|http|https)://[^\s]+$") + +LOG_LEVEL = os.environ.get("ORCH_LOG_LEVEL", "INFO").upper() +logging.basicConfig( + level=LOG_LEVEL, + format="%(asctime)s | %(levelname)s | %(message)s", +) +logger = logging.getLogger("orchestrator") + +serializer = URLSafeTimedSerializer(SESSION_SECRET, salt="orch-session-v1") + +app = FastAPI(title="chat2api Orchestrator", docs_url=None, redoc_url=None) +app.mount("/static", StaticFiles(directory="static"), name="static") +templates = Jinja2Templates(directory="templates") + +# ---------- 工具:subprocess + docker ---------- + +class DockerError(Exception): + pass + + +def run(cmd: list[str], timeout: int = 60) -> tuple[int, str, str]: + """同步执行命令,返回 (rc, stdout, stderr)。绝不抛 stderr 给前端原文(避免泄漏路径)。""" + logger.debug("run: %s", " ".join(cmd)) + try: + proc = subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout + ) + except subprocess.TimeoutExpired: + raise DockerError(f"命令超时({timeout}s):{cmd[0]}") + return proc.returncode, proc.stdout or "", proc.stderr or "" + + +def dc(*args: str, timeout: int = 180) -> tuple[int, str, str]: + """docker compose 包装,自动加 -f 与 --project-directory。""" + cmd = [ + "docker", "compose", + "-f", str(COMPOSE_FILE_C), + "--project-directory", HOST_PATH, + *args, + ] + return run(cmd, timeout=timeout) + + +def inspect(container: str) -> dict | None: + rc, out, _ = run(["docker", "inspect", container], timeout=10) + if rc != 0: + return None + try: + data = json.loads(out) + return data[0] if data else None + except json.JSONDecodeError: + return None + + +def regenerate_and_apply() -> None: + """写 csv 后必须调用:先 generate.py,再 docker compose up -d --remove-orphans。""" + rc, out, err = run( + ["python3", str(WORK / "generate.py")], timeout=30 + ) + if rc != 0: + logger.error("generate.py 失败:rc=%s out=%s err=%s", rc, out, err) + raise DockerError(f"配置生成失败:{(err or out)[:300]}") + + rc, out, err = dc("up", "-d", "--remove-orphans", timeout=240) + if rc != 0: + logger.error("compose up 失败:rc=%s err=%s", rc, err) + raise DockerError(f"docker compose 失败:{(err or out)[:300]}") + + +# ---------- 工具:CSV / env / secrets ---------- + +def read_accounts() -> list[dict[str, str]]: + if not ACCOUNTS_CSV.exists(): + return [] + out: list[dict[str, str]] = [] + with ACCOUNTS_CSV.open("r", encoding="utf-8", newline="") as f: + reader = csv.DictReader(f) + for row in reader: + slug = (row.get("slug") or "").strip() + if not slug: + continue + out.append({ + "slug": slug, + "proxy_url": (row.get("proxy_url") or "").strip(), + "note": (row.get("note") or "").strip(), + }) + return out + + +def write_accounts(rows: list[dict[str, str]]) -> None: + """原子写:tmp + rename。csv 头固定。""" + tmp = ACCOUNTS_CSV.with_suffix(".csv.tmp") + with tmp.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=["slug", "proxy_url", "note"]) + writer.writeheader() + for r in rows: + writer.writerow({ + "slug": r["slug"], + "proxy_url": r.get("proxy_url", ""), + "note": r.get("note", ""), + }) + tmp.replace(ACCOUNTS_CSV) + + +def read_env_file(path: Path) -> dict[str, str]: + if not path.exists(): + return {} + out: dict[str, str] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + out[k.strip()] = v.strip() + return out + + +def mask_proxy(url: str) -> str: + """socks5://user:pass@host:port → socks5://****@host:port""" + if not url: + return "" + m = re.match(r"^(\w+)://([^@/]+@)?(.+)$", url) + if not m: + return url + scheme, _, host = m.groups() + return f"{scheme}://****@{host}" if _ else f"{scheme}://{host}" + + +def mask_secret(s: str, head: int = 6, tail: int = 4) -> str: + if not s: + return "" + if len(s) <= head + tail + 3: + return "*" * len(s) + return f"{s[:head]}...{s[-tail:]}" + + +# ---------- 出口 IP 缓存 ---------- + +_exit_ip_cache: dict[str, tuple[str, float]] = {} +EXIT_IP_TTL = 60.0 + + +def get_exit_ip(slug: str, force: bool = False) -> str | None: + now = time.time() + if not force and slug in _exit_ip_cache: + ip, ts = _exit_ip_cache[slug] + if now - ts < EXIT_IP_TTL: + return ip + rc, out, _ = run( + ["docker", "exec", f"c2a-{slug}", "curl", "-s", "--max-time", "6", + "https://api.ipify.org"], + timeout=10, + ) + ip = out.strip() if rc == 0 and out.strip() else None + if ip: + _exit_ip_cache[slug] = (ip, now) + return ip + + +def get_cookie_last_success(slug: str) -> int | None: + """读 data/{slug}/refresh_map.json,返回最新 last_success_at(unix ts)。""" + p = DATA_DIR / slug / "refresh_map.json" + if not p.exists(): + return None + try: + data = json.loads(p.read_text(encoding="utf-8")) + if not isinstance(data, dict): + return None + ts_list = [ + int(v.get("last_success_at") or v.get("timestamp") or 0) + for v in data.values() + if isinstance(v, dict) + ] + ts_list = [t for t in ts_list if t > 0] + return max(ts_list) if ts_list else None + except Exception: + return None + + +# ---------- 审计 ---------- + +def audit(action: str, request: Request, ok: bool, **fields: Any) -> None: + rec = { + "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "actor": "admin", + "ip": request.client.host if request.client else "?", + "action": action, + "ok": ok, + **fields, + } + try: + with AUDIT_FILE.open("a", encoding="utf-8") as f: + f.write(json.dumps(rec, ensure_ascii=False) + "\n") + except OSError as e: + logger.error("audit write failed: %s", e) + + +def read_audit(limit: int = 200) -> list[dict]: + if not AUDIT_FILE.exists(): + return [] + lines = AUDIT_FILE.read_text(encoding="utf-8").splitlines() + out: list[dict] = [] + for line in reversed(lines[-limit * 2:]): + if not line.strip(): + continue + try: + out.append(json.loads(line)) + except json.JSONDecodeError: + continue + if len(out) >= limit: + break + return out + + +# ---------- 鉴权 ---------- + +def issue_session_token() -> str: + return serializer.dumps({"u": "admin"}) + + +def verify_session_token(token: str | None) -> bool: + if not token: + return False + try: + data = serializer.loads(token, max_age=SESSION_MAX_AGE) + return isinstance(data, dict) and data.get("u") == "admin" + except (BadSignature, SignatureExpired): + return False + + +def gen_csrf() -> str: + return pysecrets.token_hex(16) + + +def require_session( + request: Request, + orch_session: str | None = Cookie(default=None), +) -> None: + if not verify_session_token(orch_session): + raise HTTPException(status_code=401, detail="未登录") + + +def require_csrf(request: Request) -> None: + """双 cookie 模式:cookie orch_csrf 必须等于 header X-CSRF-Token。""" + cookie_val = request.cookies.get(CSRF_COOKIE) or "" + header_val = request.headers.get("x-csrf-token") or "" + if not cookie_val or not pysecrets.compare_digest(cookie_val, header_val): + raise HTTPException(status_code=403, detail="CSRF 校验失败") + + +# 登录速率限制(同 IP 60s 内最多 5 次失败) +_login_attempts: dict[str, list[float]] = {} + + +def check_login_rate(ip: str) -> bool: + now = time.time() + bucket = _login_attempts.setdefault(ip, []) + bucket[:] = [t for t in bucket if now - t < 60] + return len(bucket) < 5 + + +def record_login_failure(ip: str) -> None: + _login_attempts.setdefault(ip, []).append(time.time()) + + +# ---------- 路由:基础 ---------- + +@app.get("/healthz") +async def healthz() -> JSONResponse: + return JSONResponse({"ok": True}) + + +@app.get("/login", response_class=HTMLResponse) +async def login_page(request: Request) -> HTMLResponse: + return templates.TemplateResponse( + "login.html", {"request": request, "error": None} + ) + + +@app.post("/login") +async def login( + request: Request, + response: Response, + password: str = Form(...), +) -> Response: + ip = request.client.host if request.client else "?" + if not check_login_rate(ip): + audit("login", request, False, reason="rate_limited") + return templates.TemplateResponse( + "login.html", + {"request": request, "error": "尝试过多,请稍候再试"}, + status_code=429, + ) + if not pysecrets.compare_digest(password, PASSWORD): + record_login_failure(ip) + audit("login", request, False, reason="bad_password") + return templates.TemplateResponse( + "login.html", + {"request": request, "error": "密码错误"}, + status_code=401, + ) + + token = issue_session_token() + csrf = gen_csrf() + is_https = request.url.scheme == "https" or \ + request.headers.get("x-forwarded-proto") == "https" + resp = RedirectResponse(url="./", status_code=303) + resp.set_cookie( + SESSION_COOKIE, token, + max_age=SESSION_MAX_AGE, + httponly=True, samesite="strict", secure=is_https, path="/", + ) + resp.set_cookie( + CSRF_COOKIE, csrf, + max_age=SESSION_MAX_AGE, + httponly=False, samesite="strict", secure=is_https, path="/", + ) + audit("login", request, True) + return resp + + +@app.post("/logout") +async def logout(request: Request, _: None = Depends(require_session)) -> Response: + audit("logout", request, True) + resp = RedirectResponse(url="./login", status_code=303) + resp.delete_cookie(SESSION_COOKIE, path="/") + resp.delete_cookie(CSRF_COOKIE, path="/") + return resp + + +@app.get("/", response_class=HTMLResponse) +async def dashboard( + request: Request, + orch_session: str | None = Cookie(default=None), +) -> Response: + if not verify_session_token(orch_session): + return RedirectResponse(url="./login", status_code=303) + return templates.TemplateResponse("dashboard.html", {"request": request}) + + +# ---------- API: accounts CRUD ---------- + +class AccountIn(BaseModel): + slug: str + proxy_url: str = "" + note: str = "" + + @field_validator("slug") + @classmethod + def _slug(cls, v: str) -> str: + v = v.strip() + if not SLUG_RE.match(v): + raise ValueError("slug 不合法(需 [a-z0-9-]{1,16})") + return v + + @field_validator("proxy_url") + @classmethod + def _proxy(cls, v: str) -> str: + v = v.strip() + if v and not PROXY_RE.match(v): + raise ValueError("proxy_url 必须以 socks5/socks5h/http/https:// 开头") + return v + + +class AccountPatch(BaseModel): + proxy_url: str | None = None + note: str | None = None + + @field_validator("proxy_url") + @classmethod + def _proxy(cls, v: str | None) -> str | None: + if v is None: + return None + v = v.strip() + if v and not PROXY_RE.match(v): + raise ValueError("proxy_url 必须以 socks5/socks5h/http/https:// 开头") + return v + + +@app.get("/api/accounts", dependencies=[Depends(require_session)]) +async def api_list_accounts() -> JSONResponse: + rows = read_accounts() + out = [] + for r in rows: + slug_env = read_env_file(WORK / "generated" / "env" / f"{r['slug']}.env") + out.append({ + "slug": r["slug"], + "proxy_url_masked": mask_proxy(r["proxy_url"]), + "has_proxy": bool(r["proxy_url"]), + "note": r["note"], + "auth_masked": mask_secret(slug_env.get("AUTHORIZATION", "")), + "admin_pwd_masked": mask_secret(slug_env.get("ADMIN_PASSWORD", "")), + "api_prefix": slug_env.get("API_PREFIX", ""), + }) + return JSONResponse({"accounts": out}) + + +@app.post( + "/api/accounts", + dependencies=[Depends(require_session), Depends(require_csrf)], +) +async def api_add_account(payload: AccountIn, request: Request) -> JSONResponse: + rows = read_accounts() + if any(r["slug"] == payload.slug for r in rows): + raise HTTPException(status_code=409, detail=f"slug={payload.slug} 已存在") + rows.append(payload.model_dump()) + write_accounts(rows) + try: + regenerate_and_apply() + except DockerError as e: + # 回滚 csv + write_accounts([r for r in rows if r["slug"] != payload.slug]) + audit("add_account", request, False, slug=payload.slug, error=str(e)) + raise HTTPException(status_code=500, detail=str(e)) + audit("add_account", request, True, slug=payload.slug) + return JSONResponse({"ok": True, "slug": payload.slug}) + + +@app.patch( + "/api/accounts/{slug}", + dependencies=[Depends(require_session), Depends(require_csrf)], +) +async def api_patch_account( + slug: str, payload: AccountPatch, request: Request +) -> JSONResponse: + if not SLUG_RE.match(slug): + raise HTTPException(status_code=400, detail="slug 不合法") + rows = read_accounts() + target = next((r for r in rows if r["slug"] == slug), None) + if not target: + raise HTTPException(status_code=404, detail=f"slug={slug} 不存在") + if payload.proxy_url is not None: + target["proxy_url"] = payload.proxy_url + if payload.note is not None: + target["note"] = payload.note + write_accounts(rows) + try: + regenerate_and_apply() + except DockerError as e: + audit("patch_account", request, False, slug=slug, error=str(e)) + raise HTTPException(status_code=500, detail=str(e)) + audit("patch_account", request, True, slug=slug, fields=payload.model_dump(exclude_none=True)) + return JSONResponse({"ok": True, "slug": slug}) + + +@app.delete( + "/api/accounts/{slug}", + dependencies=[Depends(require_session), Depends(require_csrf)], +) +async def api_delete_account(slug: str, request: Request) -> JSONResponse: + if not SLUG_RE.match(slug): + raise HTTPException(status_code=400, detail="slug 不合法") + rows = read_accounts() + if not any(r["slug"] == slug for r in rows): + raise HTTPException(status_code=404, detail=f"slug={slug} 不存在") + write_accounts([r for r in rows if r["slug"] != slug]) + try: + regenerate_and_apply() + except DockerError as e: + audit("delete_account", request, False, slug=slug, error=str(e)) + raise HTTPException(status_code=500, detail=str(e)) + audit("delete_account", request, True, slug=slug, note="data/ 目录保留") + return JSONResponse({"ok": True, "slug": slug}) + + +# ---------- API: 实例运维 ---------- + +ALLOWED_OPS = {"start", "stop", "restart"} + + +@app.post( + "/api/instances/{slug}/{op}", + dependencies=[Depends(require_session), Depends(require_csrf)], +) +async def api_instance_op(slug: str, op: str, request: Request) -> JSONResponse: + if op not in ALLOWED_OPS: + raise HTTPException(status_code=400, detail=f"非法操作 {op}") + if not SLUG_RE.match(slug): + raise HTTPException(status_code=400, detail="slug 不合法") + if not any(r["slug"] == slug for r in read_accounts()): + raise HTTPException(status_code=404, detail=f"slug={slug} 不存在") + rc, out, err = dc(op, f"chat2api-{slug}", timeout=120) + success = rc == 0 + audit(f"instance_{op}", request, success, slug=slug, + err=(err or "")[:200] if not success else "") + if not success: + raise HTTPException(status_code=500, detail=(err or out)[:300]) + return JSONResponse({"ok": True, "slug": slug, "op": op}) + + +# ---------- API: 状态 ---------- + +@app.get("/api/status", dependencies=[Depends(require_session)]) +async def api_status() -> JSONResponse: + rows = read_accounts() + instances = [] + for r in rows: + slug = r["slug"] + info = inspect(f"c2a-{slug}") or {} + state = info.get("State", {}) + health = state.get("Health", {}).get("Status") if state else None + started_at = state.get("StartedAt") if state else None + uptime_seconds: int | None = None + if started_at: + try: + # docker 的 StartedAt 是 RFC3339 + 纳秒;切到 26 位再 fromisoformat + ts_str = started_at.replace("Z", "+00:00")[:26] + "+00:00" \ + if "." in started_at and "Z" in started_at else started_at.replace("Z", "+00:00") + dt = datetime.fromisoformat(ts_str) + uptime_seconds = int( + (datetime.now(timezone.utc) - dt).total_seconds() + ) + except (ValueError, TypeError): + uptime_seconds = None + instances.append({ + "slug": slug, + "container": f"c2a-{slug}", + "state": state.get("Status") if state else "absent", + "health": health or "n/a", + "started_at": started_at, + "uptime_seconds": uptime_seconds, + "proxy_masked": mask_proxy(r["proxy_url"]), + "has_proxy": bool(r["proxy_url"]), + "exit_ip": _exit_ip_cache.get(slug, (None, 0))[0], + "cookie_last_success_at": get_cookie_last_success(slug), + "note": r["note"], + }) + return JSONResponse({ + "instances": instances, + "server_time": int(time.time()), + }) + + +@app.get( + "/api/instances/{slug}/exit-ip", + dependencies=[Depends(require_session)], +) +async def api_exit_ip(slug: str, force: int = Query(0)) -> JSONResponse: + if not SLUG_RE.match(slug): + raise HTTPException(status_code=400, detail="slug 不合法") + ip = get_exit_ip(slug, force=bool(force)) + return JSONResponse({"slug": slug, "exit_ip": ip or ""}) + + +# ---------- API: 凭证查看(敏感) ---------- + +@app.get( + "/api/secrets/{slug}", + dependencies=[Depends(require_session), Depends(require_csrf)], +) +async def api_reveal_secret(slug: str, request: Request) -> JSONResponse: + if not SLUG_RE.match(slug): + raise HTTPException(status_code=400, detail="slug 不合法") + env = read_env_file(WORK / "generated" / "env" / f"{slug}.env") + if not env: + audit("reveal_secret", request, False, slug=slug, reason="not_found") + raise HTTPException(status_code=404, detail=f"slug={slug} 凭证不存在") + audit("reveal_secret", request, True, slug=slug) + return JSONResponse({ + "slug": slug, + "AUTHORIZATION": env.get("AUTHORIZATION", ""), + "ADMIN_PASSWORD": env.get("ADMIN_PASSWORD", ""), + "API_PREFIX": env.get("API_PREFIX", ""), + "PROXY_URL": env.get("PROXY_URL", ""), + }) + + +# ---------- API: 审计 ---------- + +@app.get("/api/audit", dependencies=[Depends(require_session)]) +async def api_audit(limit: int = Query(200, ge=1, le=2000)) -> JSONResponse: + return JSONResponse({"records": read_audit(limit)}) diff --git a/deploy/multi/orchestrator/requirements.txt b/deploy/multi/orchestrator/requirements.txt new file mode 100644 index 00000000..11e49ff4 --- /dev/null +++ b/deploy/multi/orchestrator/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.3 +uvicorn[standard]==0.32.0 +jinja2==3.1.4 +python-multipart==0.0.12 +itsdangerous==2.2.0 diff --git a/deploy/multi/orchestrator/static/app.js b/deploy/multi/orchestrator/static/app.js new file mode 100644 index 00000000..46e027c5 --- /dev/null +++ b/deploy/multi/orchestrator/static/app.js @@ -0,0 +1,271 @@ +// chat2api Orchestrator 前端 +// 原生 fetch + 简单 DOM 操作,无框架 + +const $ = (sel) => document.querySelector(sel); +const $$ = (sel) => Array.from(document.querySelectorAll(sel)); + +function getCookie(name) { + return document.cookie.split('; ') + .find(r => r.startsWith(name + '='))?.split('=')[1] || ''; +} + +function csrf() { + return getCookie('orch_csrf'); +} + +async function api(method, path, body) { + const opts = { method, headers: {} }; + if (body !== undefined) { + opts.headers['Content-Type'] = 'application/json'; + opts.body = JSON.stringify(body); + } + if (method !== 'GET') { + opts.headers['X-CSRF-Token'] = csrf(); + } + const r = await fetch('.' + path, opts); + if (r.status === 401) { + location.href = './login'; + return; + } + const data = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(data.detail || `HTTP ${r.status}`); + return data; +} + +function toast(msg, isErr = false) { + const el = $('#toast'); + el.textContent = msg; + el.classList.remove('hidden', 'bg-gray-900', 'bg-red-600'); + el.classList.add(isErr ? 'bg-red-600' : 'bg-gray-900'); + setTimeout(() => el.classList.add('hidden'), 3000); +} + +function fmtUptime(sec) { + if (sec == null) return '-'; + if (sec < 60) return sec + 's'; + if (sec < 3600) return Math.floor(sec / 60) + 'm'; + if (sec < 86400) return Math.floor(sec / 3600) + 'h'; + return Math.floor(sec / 86400) + 'd'; +} + +function fmtCookieAge(ts) { + if (!ts) return '未刷新'; + const age = Math.floor(Date.now() / 1000 - ts); + let txt, color; + if (age < 600) { txt = age + 's 前'; color = 'text-green-600'; } + else if (age < 3600) { txt = Math.floor(age / 60) + 'm 前'; color = 'text-green-600'; } + else if (age < 86400) { txt = Math.floor(age / 3600) + 'h 前'; color = 'text-yellow-600'; } + else { txt = Math.floor(age / 86400) + 'd 前'; color = 'text-red-600'; } + return `${txt}`; +} + +function healthBadge(state, health) { + if (state !== 'running') { + return `${state}`; + } + if (health === 'healthy') return `healthy`; + if (health === 'unhealthy') return `unhealthy`; + if (health === 'starting') return `starting`; + return `${health}`; +} + +function escapeHtml(s) { + return String(s ?? '').replace(/[&<>"']/g, c => ({ + '&':'&','<':'<','>':'>','"':'"',"'":''' + }[c])); +} + +function renderRows(instances) { + if (!instances.length) { + $('#tbody').innerHTML = '暂无账号,点击右上角「新增账号」开始'; + return; + } + $('#tbody').innerHTML = instances.map(it => ` + + ${escapeHtml(it.slug)} + ${escapeHtml(it.proxy_masked || '-')} + ${healthBadge(it.state, it.health)} + ${escapeHtml(it.exit_ip || '?')} + ${fmtUptime(it.uptime_seconds)} + ${fmtCookieAge(it.cookie_last_success_at)} + ${escapeHtml(it.note || '-')} + + + + + ${it.state === 'running' + ? `` + : ``} + + + + `).join(''); + + $$('#tbody button').forEach(btn => { + btn.addEventListener('click', () => onRowAction(btn.dataset)); + }); +} + +async function loadStatus() { + try { + const data = await api('GET', '/api/status'); + renderRows(data.instances); + $('#server-status').textContent = `共 ${data.instances.length} 个实例 · 服务器时间 ${new Date(data.server_time*1000).toLocaleTimeString()}`; + } catch (e) { + toast('加载状态失败:' + e.message, true); + } +} + +// ---------- 模态 ---------- + +let modalMode = 'add'; // 'add' | 'edit' +let modalSlug = ''; + +function openModal(mode, prefill = {}) { + modalMode = mode; + modalSlug = prefill.slug || ''; + $('#modal-title').textContent = mode === 'add' ? '新增账号' : `编辑 ${prefill.slug}`; + $('#f-slug').value = prefill.slug || ''; + $('#f-slug').disabled = mode === 'edit'; + $('#f-proxy').value = mode === 'edit' ? '' : (prefill.proxy_url || ''); + $('#f-proxy').placeholder = mode === 'edit' + ? `当前:${prefill.proxy || '(无)'}; 留空则不变` + : 'socks5://user:pass@host:port'; + $('#f-note').value = prefill.note || ''; + $('#modal-error').classList.add('hidden'); + $('#modal').classList.remove('hidden'); + $('#modal').classList.add('flex'); +} + +function closeModal() { + $('#modal').classList.add('hidden'); + $('#modal').classList.remove('flex'); +} + +$('#btn-add').addEventListener('click', () => openModal('add')); +$('#btn-cancel').addEventListener('click', closeModal); +$('#btn-refresh').addEventListener('click', () => loadStatus()); + +$('#modal-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const slug = $('#f-slug').value.trim(); + const proxy_url = $('#f-proxy').value.trim(); + const note = $('#f-note').value.trim(); + $('#btn-submit').disabled = true; + $('#btn-submit').textContent = '处理中...'; + try { + if (modalMode === 'add') { + await api('POST', '/api/accounts', { slug, proxy_url, note }); + toast('新增成功,等待容器启动...'); + } else { + const body = { note }; + if (proxy_url) body.proxy_url = proxy_url; + await api('PATCH', '/api/accounts/' + encodeURIComponent(modalSlug), body); + toast('编辑成功,正在重建...'); + } + closeModal(); + await loadStatus(); + } catch (e) { + $('#modal-error').textContent = e.message; + $('#modal-error').classList.remove('hidden'); + } finally { + $('#btn-submit').disabled = false; + $('#btn-submit').textContent = '保存'; + } +}); + +// ---------- 行操作 ---------- + +async function onRowAction({ action, slug, proxy, note }) { + if (action === 'edit') { + openModal('edit', { slug, proxy, note }); + return; + } + if (action === 'secret') { + await showSecret(slug); + return; + } + if (action === 'delete') { + if (!confirm(`确认删除 ${slug}?\n容器将被销毁,data/${slug}/ 会保留。`)) return; + try { + await api('DELETE', '/api/accounts/' + encodeURIComponent(slug)); + toast(`已删除 ${slug}`); + await loadStatus(); + } catch (e) { + toast('删除失败:' + e.message, true); + } + return; + } + if (['start', 'stop', 'restart'].includes(action)) { + try { + await api('POST', `/api/instances/${encodeURIComponent(slug)}/${action}`); + toast(`${action} ${slug} 已发出`); + setTimeout(loadStatus, 1500); + } catch (e) { + toast(`${action} 失败:` + e.message, true); + } + } +} + +// ---------- 凭证查看 ---------- + +async function showSecret(slug) { + if (!confirm(`查看 ${slug} 的明文凭证?\n该操作会写入审计日志。`)) return; + try { + const d = await api('GET', '/api/secrets/' + encodeURIComponent(slug)); + $('#secret-body').innerHTML = ` +
    slug: ${escapeHtml(d.slug)}
    +
    AUTHORIZATION: ${escapeHtml(d.AUTHORIZATION)}
    +
    ADMIN_PASSWORD: ${escapeHtml(d.ADMIN_PASSWORD)}
    +
    API_PREFIX: ${escapeHtml(d.API_PREFIX)}
    +
    PROXY_URL: ${escapeHtml(d.PROXY_URL || '(无)')}
    +
    + 调用示例:
    + curl http://<vps>:60403/${escapeHtml(d.slug)}/v1/chat/completions -H "Authorization: Bearer ${escapeHtml(d.AUTHORIZATION)}"
    + Admin 后台:/${escapeHtml(d.slug)}/admin/login +
    + `; + $('#modal-secret').classList.remove('hidden'); + $('#modal-secret').classList.add('flex'); + } catch (e) { + toast('获取凭证失败:' + e.message, true); + } +} + +$('#btn-close-secret').addEventListener('click', () => { + $('#modal-secret').classList.add('hidden'); + $('#modal-secret').classList.remove('flex'); + $('#secret-body').innerHTML = ''; // 立即清屏 +}); + +// ---------- 审计 ---------- + +$('#btn-audit').addEventListener('click', async () => { + try { + const d = await api('GET', '/api/audit?limit=200'); + $('#audit-body').innerHTML = d.records.map(r => ` + + ${escapeHtml(r.ts || '')} + ${escapeHtml(r.ip || '')} + ${escapeHtml(r.action || '')} + ${escapeHtml(r.slug || '-')} + ${r.ok ? '' : ''} + ${escapeHtml(JSON.stringify({...r, ts:undefined, ip:undefined, action:undefined, slug:undefined, ok:undefined, actor:undefined}).replace(/^\{\}$/, ''))} + + `).join(''); + $('#modal-audit').classList.remove('hidden'); + $('#modal-audit').classList.add('flex'); + } catch (e) { + toast('加载审计失败:' + e.message, true); + } +}); + +$('#btn-close-audit').addEventListener('click', () => { + $('#modal-audit').classList.add('hidden'); + $('#modal-audit').classList.remove('flex'); +}); + +// ---------- 启动 ---------- + +loadStatus(); +setInterval(loadStatus, 5000); diff --git a/deploy/multi/orchestrator/static/styles.css b/deploy/multi/orchestrator/static/styles.css new file mode 100644 index 00000000..033b82c5 --- /dev/null +++ b/deploy/multi/orchestrator/static/styles.css @@ -0,0 +1,14 @@ +/* 极简补丁,绝大多数样式由 Tailwind CDN 提供 */ +.row-action-btn { + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + border-radius: 0.25rem; + margin-right: 0.25rem; +} +.row-action-btn:hover { background: #f3f4f6; } +.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; } +.dot-healthy { background: #10b981; } +.dot-unhealthy { background: #ef4444; } +.dot-starting { background: #f59e0b; } +.dot-na { background: #9ca3af; } +.kbd-row { font-family: ui-monospace, SFMono-Regular, monospace; } diff --git a/deploy/multi/orchestrator/templates/dashboard.html b/deploy/multi/orchestrator/templates/dashboard.html new file mode 100644 index 00000000..45e519e1 --- /dev/null +++ b/deploy/multi/orchestrator/templates/dashboard.html @@ -0,0 +1,123 @@ + + + + + + chat2api Orchestrator + + + + + +
    +
    +
    +

    chat2api Orchestrator

    +

    加载中...

    +
    +
    + + +
    + +
    +
    +
    +
    + +
    +
    +

    实例列表

    + +
    + +
    + + + + + + + + + + + + + + + + +
    slug代理状态出口 IP运行时长cookie 鲜度备注操作
    加载中...
    +
    +
    + + + + + + + + + + + + + + + + diff --git a/deploy/multi/orchestrator/templates/login.html b/deploy/multi/orchestrator/templates/login.html new file mode 100644 index 00000000..a73ddc08 --- /dev/null +++ b/deploy/multi/orchestrator/templates/login.html @@ -0,0 +1,39 @@ + + + + + + Orchestrator 登录 + + + +
    +

    chat2api Orchestrator

    +

    多账号实例编排管理面板

    + +
    +
    + + +
    + {% if error %} +

    {{ error }}

    + {% endif %} + +
    + +

    + 密码忘记?SSH 服务器执行:./manage.sh orch-password +

    +
    + + From 98eb1bbd5b985ea322f80fbfae8ea5fae5ac7232 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Sun, 26 Apr 2026 22:14:29 +0800 Subject: [PATCH 29/96] =?UTF-8?q?=E5=88=87=E6=8D=A2=20watchtower=20?= =?UTF-8?q?=E5=88=B0=20nickfedor/watchtower=20fork=EF=BC=88containrrr=20?= =?UTF-8?q?=E4=BB=93=E5=BA=93=E5=B7=B2=20archived=EF=BC=8Clatest=20?= =?UTF-8?q?=E4=BB=8D=E6=98=AF=202023=20=E6=97=A7=E7=89=88=E4=B8=8E?= =?UTF-8?q?=E6=96=B0=20docker=20daemon=20=E4=B8=8D=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/multi/generate.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/deploy/multi/generate.py b/deploy/multi/generate.py index f392777d..adba913f 100755 --- a/deploy/multi/generate.py +++ b/deploy/multi/generate.py @@ -42,6 +42,9 @@ CHAT2API_IMAGE = os.environ.get( "CHAT2API_IMAGE", "ghcr.io/nanashiwang/chat2api:latest" ) +WATCHTOWER_IMAGE = os.environ.get( + "WATCHTOWER_IMAGE", "nickfedor/watchtower:latest" +) ORCH_ENABLED = os.environ.get("ORCH_ENABLED", "true").lower() != "false" @@ -257,7 +260,7 @@ def render_env(acc: Account) -> str: {depends_on} watchtower: - image: containrrr/watchtower + image: {watchtower_image} container_name: c2a-watchtower restart: unless-stopped volumes: @@ -279,7 +282,7 @@ def render_compose(accounts: list[Account]) -> str: body = COMPOSE_HEADER.format(image=CHAT2API_IMAGE) + services if ORCH_ENABLED: body += COMPOSE_ORCHESTRATOR - body += COMPOSE_FOOTER.format(port=NGINX_PORT, depends_on=depends_on) + body += COMPOSE_FOOTER.format(port=NGINX_PORT, depends_on=depends_on, watchtower_image=WATCHTOWER_IMAGE) return body From 3ffa926c90e620c7290ab5208dc58049fae56212 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Sun, 26 Apr 2026 22:29:20 +0800 Subject: [PATCH 30/96] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A4=9A=E5=AE=9E?= =?UTF-8?q?=E4=BE=8B=E9=83=A8=E7=BD=B2=E7=9A=84=E4=B8=A4=E4=B8=AA=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 路径解析:compose 文件原先用相对 generated/ 的路径(../data、./orch.env), manage.sh 默认 project_dir = compose 文件所在目录(generated/)能跑通, 但 orchestrator 容器内调 docker compose 时用 --project-directory $MULTI_HOST_PATH(= deploy/multi 宿主路径), 两者基准不同导致 env_file 找不到。 现统一所有路径相对 deploy/multi 根目录: - env_file: ./generated/env/{slug}.env, ./generated/orch.env - volumes: ./data/{slug}, ./generated/nginx.conf, ./:/work manage.sh dc() 函数加 --project-directory "$DIR" 与 orchestrator app.py 行为一致。 2. orch-password 子命令原用 `dc restart`,restart 不会重读 env_file, 改为 `dc up -d --force-recreate orchestrator` 强制 recreate 让新密码生效。 同时支持 ./manage.sh orch-password [pwd] 传入自定义密码。 --- deploy/multi/generate.py | 12 ++++++------ deploy/multi/manage.sh | 17 ++++++++++------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/deploy/multi/generate.py b/deploy/multi/generate.py index adba913f..52a6d12b 100755 --- a/deploy/multi/generate.py +++ b/deploy/multi/generate.py @@ -212,9 +212,9 @@ def render_env(acc: Account) -> str: <<: *c2a-common container_name: c2a-{slug} env_file: - - ./env/{slug}.env + - ./generated/env/{slug}.env volumes: - - ../data/{slug}:/app/data + - ./data/{slug}:/app/data healthcheck: test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:5005/$$API_PREFIX/admin/login > /dev/null || exit 1"] interval: 30s @@ -228,18 +228,18 @@ def render_env(acc: Account) -> str: orchestrator: image: c2a-orchestrator:local build: - context: ../orchestrator + context: ./orchestrator container_name: c2a-orchestrator restart: unless-stopped networks: [c2a-net] env_file: - - ./orch.env + - ./generated/orch.env environment: MULTI_HOST_PATH: '${MULTI_HOST_PATH}' ORCH_PORT: '8080' TZ: 'Asia/Shanghai' volumes: - - ../:/work + - ./:/work - /var/run/docker.sock:/var/run/docker.sock labels: com.centurylinklabs.watchtower.enable: 'true' @@ -254,7 +254,7 @@ def render_env(acc: Account) -> str: ports: - '{port}:80' volumes: - - ./nginx.conf:/etc/nginx/nginx.conf:ro + - ./generated/nginx.conf:/etc/nginx/nginx.conf:ro networks: [c2a-net] depends_on: {depends_on} diff --git a/deploy/multi/manage.sh b/deploy/multi/manage.sh index 0d2dec62..3b069714 100755 --- a/deploy/multi/manage.sh +++ b/deploy/multi/manage.sh @@ -39,7 +39,7 @@ require_compose() { } dc() { - docker compose -f "$COMPOSE" "$@" + docker compose -f "$COMPOSE" --project-directory "$DIR" "$@" } cmd_apply() { @@ -143,16 +143,19 @@ cmd_orch_password() { exit 1 fi local new_pwd - new_pwd=$(python3 -c 'import secrets,string; print("".join(secrets.choice(string.ascii_letters+string.digits) for _ in range(24)))') - # 跨平台 sed -i:写到 tmp 再 mv + if [ -n "${1:-}" ]; then + new_pwd="$1" + else + new_pwd=$(python3 -c 'import secrets,string; print("".join(secrets.choice(string.ascii_letters+string.digits) for _ in range(24)))') + fi awk -v new="$new_pwd" '/^ORCH_PASSWORD=/{print "ORCH_PASSWORD="new; next} {print}' \ "$GEN_DIR/orch.env" > "$GEN_DIR/orch.env.tmp" mv "$GEN_DIR/orch.env.tmp" "$GEN_DIR/orch.env" chmod 600 "$GEN_DIR/orch.env" - log "已更新 ORCH_PASSWORD,重启 orchestrator..." - dc restart orchestrator + log "已写入新 ORCH_PASSWORD,强制重建 orchestrator(让 env_file 重新加载)..." + dc up -d --force-recreate orchestrator ok "新密码:$new_pwd" - log "旧 cookie 已自动失效(SESSION_SECRET 未变,但密码变了),需重新登录" + log "提示:HMAC SESSION_SECRET 未变,旧 cookie 仍有效到 8h 过期;如需立刻全部失效,编辑 orch.env 改 ORCH_SESSION_SECRET 后再次重建" } cmd_down() { @@ -192,7 +195,7 @@ chat2api 多实例运维(一容器一账号) ./manage.sh logs [N] 跟随该实例日志(默认 200 行) ./manage.sh shell 进入该实例容器 shell ./manage.sh secrets 打印所有 AUTH / ADMIN_PWD(敏感) - ./manage.sh orch-password 重置编排面板密码并重启 orchestrator + ./manage.sh orch-password [pwd] 重置编排面板密码(不传则随机生成) ./manage.sh down 停止全部(数据保留) ./manage.sh help 显示本帮助 From 80cd251d3802636b927d1bd1b52c44ea359c11f3 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Sun, 26 Apr 2026 22:33:21 +0800 Subject: [PATCH 31/96] =?UTF-8?q?=E6=B7=B1=E5=B1=82=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=EF=BC=9A=E8=AE=A9=20orchestrator=20=E5=AE=B9=E5=99=A8=E5=86=85?= =?UTF-8?q?=E6=8C=82=E8=BD=BD=E7=82=B9=20=3D=20=E5=AE=BF=E4=B8=BB=20MULTI?= =?UTF-8?q?=5FHOST=5FPATH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bug 原因:docker compose 客户端在发送给 daemon 前会自己解析 env_file 并校验 其文件存在,使用 --project-directory 指定的路径。如果 client 在容器内、 daemon 在宿主,两边必须用同一字符串路径都能找到文件,否则 client 直接报错。 之前 ./:/work 让容器只能用 /work 访问,但 --project-directory 传的是宿主 路径 /root/chat2api/deploy/multi,client 验证 env_file 失败。 修复:用 ${MULTI_HOST_PATH}:${MULTI_HOST_PATH} 把宿主路径也作为容器内挂载点, 这样 client 与 daemon 都用 $MULTI_HOST_PATH 能找到同一文件。 orchestrator/app.py 同步把 WORK 从硬编码 /work 改为读 MULTI_HOST_PATH。 --- deploy/multi/generate.py | 5 ++++- deploy/multi/orchestrator/app.py | 10 +++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/deploy/multi/generate.py b/deploy/multi/generate.py index 52a6d12b..360fca8b 100755 --- a/deploy/multi/generate.py +++ b/deploy/multi/generate.py @@ -238,9 +238,12 @@ def render_env(acc: Account) -> str: MULTI_HOST_PATH: '${MULTI_HOST_PATH}' ORCH_PORT: '8080' TZ: 'Asia/Shanghai' + # 关键:用宿主路径作为容器内挂载点,让 docker compose 客户端 + # (在容器内运行)与 daemon(在宿主运行)能用同一路径找到 env_file 与 volumes volumes: - - ./:/work + - '${MULTI_HOST_PATH}:${MULTI_HOST_PATH}' - /var/run/docker.sock:/var/run/docker.sock + working_dir: '${MULTI_HOST_PATH}' labels: com.centurylinklabs.watchtower.enable: 'true' diff --git a/deploy/multi/orchestrator/app.py b/deploy/multi/orchestrator/app.py index 29b88947..3417cf69 100644 --- a/deploy/multi/orchestrator/app.py +++ b/deploy/multi/orchestrator/app.py @@ -44,9 +44,13 @@ # ---------- 配置 ---------- -WORK = Path("/work") -HOST_PATH = os.environ.get("MULTI_HOST_PATH") or str(WORK) -COMPOSE_FILE_C = WORK / "generated" / "docker-compose.yml" # 容器内路径(读文件) +# WORK 目录策略:让容器内 WORK 路径 = 宿主 deploy/multi 绝对路径, +# 因为 docker compose 客户端(在容器内)发命令前会先解析 env_file 并检查存在性, +# 必须用容器内能访问的路径;而 daemon(在宿主)解析 volumes 用宿主路径。 +# 通过 compose volume `${MULTI_HOST_PATH}:${MULTI_HOST_PATH}` 让两者重合。 +HOST_PATH = (os.environ.get("MULTI_HOST_PATH") or "/work").rstrip("/") +WORK = Path(HOST_PATH) +COMPOSE_FILE_C = WORK / "generated" / "docker-compose.yml" ACCOUNTS_CSV = WORK / "accounts.csv" SECRETS_FILE = WORK / "generated" / "secrets.txt" ORCH_ENV = WORK / "generated" / "orch.env" From ef2cd43f0bf8cf74553cfddfe576c9b7590d398a Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Sun, 26 Apr 2026 22:40:43 +0800 Subject: [PATCH 32/96] =?UTF-8?q?=E5=8E=BB=E6=8E=89=20orchestrator=20worki?= =?UTF-8?q?ng=5Fdir=20=E8=A6=86=E7=9B=96=EF=BC=9A=E4=BF=9D=E7=95=99=20Dock?= =?UTF-8?q?erfile=20WORKDIR=3D/app=20=E8=AE=A9=20uvicorn=20=E6=89=BE?= =?UTF-8?q?=E5=88=B0=20app=20=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/multi/generate.py | 1 - 1 file changed, 1 deletion(-) diff --git a/deploy/multi/generate.py b/deploy/multi/generate.py index 360fca8b..a20b09b0 100755 --- a/deploy/multi/generate.py +++ b/deploy/multi/generate.py @@ -243,7 +243,6 @@ def render_env(acc: Account) -> str: volumes: - '${MULTI_HOST_PATH}:${MULTI_HOST_PATH}' - /var/run/docker.sock:/var/run/docker.sock - working_dir: '${MULTI_HOST_PATH}' labels: com.centurylinklabs.watchtower.enable: 'true' From 40afd97d67f9021a1ff171a72b6058f799f26174 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Sun, 26 Apr 2026 22:52:46 +0800 Subject: [PATCH 33/96] =?UTF-8?q?healthcheck=20=E6=94=B9=E7=94=A8=20python?= =?UTF-8?q?=20TCP=20=E6=8E=A2=E6=B4=BB=205005=EF=BC=88chat2api=20=E9=95=9C?= =?UTF-8?q?=E5=83=8F=E4=B8=8D=E5=90=AB=20curl=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/multi/generate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deploy/multi/generate.py b/deploy/multi/generate.py index a20b09b0..a7b9ed04 100755 --- a/deploy/multi/generate.py +++ b/deploy/multi/generate.py @@ -216,7 +216,8 @@ def render_env(acc: Account) -> str: volumes: - ./data/{slug}:/app/data healthcheck: - test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:5005/$$API_PREFIX/admin/login > /dev/null || exit 1"] + # 镜像不含 curl,用 python TCP 探活 5005 端口(最简存活检查) + test: ["CMD", "python", "-c", "import socket; s=socket.socket(); s.settimeout(3); s.connect(('127.0.0.1',5005)); s.close()"] interval: 30s timeout: 10s retries: 3 From fd91e187d28fcbbb833cb950d003d14554e5106e Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Sun, 26 Apr 2026 22:54:39 +0800 Subject: [PATCH 34/96] =?UTF-8?q?=E6=96=B0=E5=A2=9E/=E5=88=A0=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E5=90=8E=E4=B8=BB=E5=8A=A8=20nginx=20reload=EF=BC=88c?= =?UTF-8?q?ompose=20=E4=B8=8D=E4=BC=9A=E5=9B=A0=20nginx.conf=20=E6=94=B9?= =?UTF-8?q?=E5=8A=A8=E9=87=8D=E5=90=AF=20nginx=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/multi/manage.sh | 6 ++++++ deploy/multi/orchestrator/app.py | 10 +++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/deploy/multi/manage.sh b/deploy/multi/manage.sh index 3b069714..4b76bb8e 100755 --- a/deploy/multi/manage.sh +++ b/deploy/multi/manage.sh @@ -52,6 +52,12 @@ cmd_apply() { fi log "应用 docker compose..." dc up -d --remove-orphans + # nginx.conf 变化时 compose 不会重启 nginx,主动 reload + if docker ps --format '{{.Names}}' | grep -qx c2a-nginx; then + docker exec c2a-nginx nginx -s reload 2>/dev/null \ + && log "nginx reload OK" \ + || log "nginx reload 失败(首次启动可忽略)" + fi ok "完成。运行 ./manage.sh secrets 查看凭证 / 编排面板访问入口" } diff --git a/deploy/multi/orchestrator/app.py b/deploy/multi/orchestrator/app.py index 3417cf69..fd852ec2 100644 --- a/deploy/multi/orchestrator/app.py +++ b/deploy/multi/orchestrator/app.py @@ -125,7 +125,8 @@ def inspect(container: str) -> dict | None: def regenerate_and_apply() -> None: - """写 csv 后必须调用:先 generate.py,再 docker compose up -d --remove-orphans。""" + """写 csv 后必须调用:先 generate.py,再 docker compose up -d --remove-orphans, + 再 nginx -s reload(compose 不会因 nginx.conf 改动重启 nginx)。""" rc, out, err = run( ["python3", str(WORK / "generate.py")], timeout=30 ) @@ -138,6 +139,13 @@ def regenerate_and_apply() -> None: logger.error("compose up 失败:rc=%s err=%s", rc, err) raise DockerError(f"docker compose 失败:{(err or out)[:300]}") + # nginx.conf 变化必须 reload,不然新 location 不生效 + rc, out, err = run( + ["docker", "exec", "c2a-nginx", "nginx", "-s", "reload"], timeout=10 + ) + if rc != 0: + logger.warning("nginx reload 失败(非致命):%s", (err or out)[:200]) + # ---------- 工具:CSV / env / secrets ---------- From 6915761cbeefa82fa8142002349e239601c18308 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Sun, 26 Apr 2026 23:10:21 +0800 Subject: [PATCH 35/96] =?UTF-8?q?=E5=89=8D=E7=AB=AF=E6=89=80=E6=9C=89?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E9=83=BD=E5=B8=A6=20X-CSRF-Token=20=E5=A4=B4?= =?UTF-8?q?=EF=BC=88=E4=BF=AE=E5=A4=8D=20GET=20/api/secrets=20reveal=20?= =?UTF-8?q?=E6=8A=A5=20CSRF=20=E6=A0=A1=E9=AA=8C=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/multi/orchestrator/static/app.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/deploy/multi/orchestrator/static/app.js b/deploy/multi/orchestrator/static/app.js index 46e027c5..67046d0e 100644 --- a/deploy/multi/orchestrator/static/app.js +++ b/deploy/multi/orchestrator/static/app.js @@ -19,9 +19,9 @@ async function api(method, path, body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } - if (method !== 'GET') { - opts.headers['X-CSRF-Token'] = csrf(); - } + // 所有请求都带 CSRF 头:少数 GET(如 /api/secrets/{slug} reveal)也走 CSRF 校验 + const c = csrf(); + if (c) opts.headers['X-CSRF-Token'] = c; const r = await fetch('.' + path, opts); if (r.status === 401) { location.href = './login'; From cd51b0044dbbe46b8c06129771a2e3a001c5e131 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Sun, 26 Apr 2026 23:18:47 +0800 Subject: [PATCH 36/96] =?UTF-8?q?=E5=87=AD=E8=AF=81=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=E5=8E=BB=E6=8E=89=20API=5FPREFIX=20=E6=98=BE=E7=A4=BA=EF=BC=8C?= =?UTF-8?q?=E5=BC=BA=E5=8C=96=E5=AF=B9=E5=A4=96=E5=8F=AF=E7=82=B9=20admin?= =?UTF-8?q?=20URL=20=E4=B8=8E=20v1=20=E8=B0=83=E7=94=A8=E7=A4=BA=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/multi/orchestrator/static/app.js | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/deploy/multi/orchestrator/static/app.js b/deploy/multi/orchestrator/static/app.js index 67046d0e..0e16912e 100644 --- a/deploy/multi/orchestrator/static/app.js +++ b/deploy/multi/orchestrator/static/app.js @@ -213,16 +213,25 @@ async function showSecret(slug) { if (!confirm(`查看 ${slug} 的明文凭证?\n该操作会写入审计日志。`)) return; try { const d = await api('GET', '/api/secrets/' + encodeURIComponent(slug)); + const origin = location.origin; // e.g. http://107.172.96.31:60403 + const adminUrl = `${origin}/${d.slug}/admin/login`; + const apiUrl = `${origin}/${d.slug}/v1/chat/completions`; $('#secret-body').innerHTML = ` +
    仅展示用户侧需要的凭证;后端 API_PREFIX 由 nginx 自动改写,不应直接访问。
    slug: ${escapeHtml(d.slug)}
    AUTHORIZATION: ${escapeHtml(d.AUTHORIZATION)}
    ADMIN_PASSWORD: ${escapeHtml(d.ADMIN_PASSWORD)}
    -
    API_PREFIX: ${escapeHtml(d.API_PREFIX)}
    PROXY_URL: ${escapeHtml(d.PROXY_URL || '(无)')}
    -
    - 调用示例:
    - curl http://<vps>:60403/${escapeHtml(d.slug)}/v1/chat/completions -H "Authorization: Bearer ${escapeHtml(d.AUTHORIZATION)}"
    - Admin 后台:/${escapeHtml(d.slug)}/admin/login +
    +
    +
    ① Admin 后台(粘 cookie 用)
    + ${escapeHtml(adminUrl)} +
    用上面 ADMIN_PASSWORD 登录
    +
    +
    +
    ② API 调用示例
    + curl ${escapeHtml(apiUrl)} -H "Authorization: Bearer ${escapeHtml(d.AUTHORIZATION)}" -H "Content-Type: application/json" -d '{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}' +
    `; $('#modal-secret').classList.remove('hidden'); From 53ac4844f35f6cc20e406d9fd86ab5ff93b1a19c Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Sun, 26 Apr 2026 23:23:08 +0800 Subject: [PATCH 37/96] =?UTF-8?q?nginx=20=E5=8F=8D=E5=90=91=E6=94=B9?= =?UTF-8?q?=E5=86=99=20chat2api=20=E7=A1=AC=E7=BC=96=E7=A0=81=20API=5FPREF?= =?UTF-8?q?IX=20=E5=88=B0=E5=AF=B9=E5=A4=96=20slug=20=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chat2api 内部把 API_PREFIX 烧进 redirect Location 头、Set-Cookie Path、 HTML/JS 链接,经 nginx 反代后浏览器看到的是 /api-XXX/... 而非 /{slug}/..., 导致登录后 302 跳转 404、cookie path 不匹配等问题。 每个 location 增加: - proxy_redirect 改 Location 头 - proxy_cookie_path 改 Set-Cookie Path - sub_filter(强制无 gzip)改响应体里硬编码的链接 --- deploy/multi/generate.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/deploy/multi/generate.py b/deploy/multi/generate.py index a7b9ed04..e1015890 100755 --- a/deploy/multi/generate.py +++ b/deploy/multi/generate.py @@ -346,6 +346,15 @@ def render_compose(accounts: list[Account]) -> str: # ---- {slug} ({note}) ---- location /{slug}/ {{ proxy_pass http://c2a-{slug}:5005/{api_prefix}/; + # chat2api 内部硬编码 API_PREFIX 到 redirect / cookie / HTML 链接里, + # 经 nginx 反代后必须把 /{api_prefix}/ 改回 /{slug}/,否则用户登录后跳转 404 + proxy_redirect ~^/{api_prefix}/(.*)$ /{slug}/$1; + proxy_cookie_path /{api_prefix} /{slug}; + # sub_filter 改写响应体内的硬编码链接(CSS/JS/HTML);强制无 gzip 才能改 + proxy_set_header Accept-Encoding ""; + sub_filter "/{api_prefix}/" "/{slug}/"; + sub_filter_once off; + sub_filter_types text/html text/css application/javascript application/json; }} """ From b03c9d46186379b90f3a6704e6328a5530ef0679 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Sun, 26 Apr 2026 23:25:24 +0800 Subject: [PATCH 38/96] =?UTF-8?q?proxy=5Fredirect=20=E7=94=A8=20$scheme://?= =?UTF-8?q?$http=5Fhost=20=E4=BF=9D=E7=95=99=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E7=AB=AF=E5=8F=A3=EF=BC=88=E4=BF=AE=2060403=20=E4=B8=A2?= =?UTF-8?q?=E5=A4=B1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/multi/generate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deploy/multi/generate.py b/deploy/multi/generate.py index e1015890..f5492781 100755 --- a/deploy/multi/generate.py +++ b/deploy/multi/generate.py @@ -348,7 +348,8 @@ def render_compose(accounts: list[Account]) -> str: proxy_pass http://c2a-{slug}:5005/{api_prefix}/; # chat2api 内部硬编码 API_PREFIX 到 redirect / cookie / HTML 链接里, # 经 nginx 反代后必须把 /{api_prefix}/ 改回 /{slug}/,否则用户登录后跳转 404 - proxy_redirect ~^/{api_prefix}/(.*)$ /{slug}/$1; + # 用 $scheme://$http_host 拼完整 URL(含客户端原始端口如 :60403) + proxy_redirect ~^/{api_prefix}/(.*)$ $scheme://$http_host/{slug}/$1; proxy_cookie_path /{api_prefix} /{slug}; # sub_filter 改写响应体内的硬编码链接(CSS/JS/HTML);强制无 gzip 才能改 proxy_set_header Accept-Encoding ""; From b462dc4e17d1c26cb87e49fe35a7c9529867f9d1 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Sun, 26 Apr 2026 23:46:54 +0800 Subject: [PATCH 39/96] =?UTF-8?q?nginx=20=E5=8F=8D=E5=90=91=20rewrite=20?= =?UTF-8?q?=E5=85=9C=E5=BA=95=EF=BC=9A=E6=8D=95=E8=8E=B7=20chat2api=20?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=20JS=20=E5=8A=A8=E6=80=81=E6=8B=BC=E5=87=BA?= =?UTF-8?q?=E7=9A=84=20/api-XXX/=20=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chat2api admin 页面里 JS 用变量动态拼路径(如 fetch(prefix+'/admin/logs/tail')), sub_filter 只能改静态字符串,无法改写运行时拼接,导致 admin 后台所有 AJAX 都打到 /api-XXX/... 被 nginx 当不存在 location 处理 → 404。 每个 slug 加 location ~ ^/{api_prefix}/(.*) 把请求 rewrite 回 /{slug}/, nginx 内部 last 重新匹配到 /{slug}/ location 正常转发。 --- deploy/multi/generate.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/deploy/multi/generate.py b/deploy/multi/generate.py index f5492781..f771e79a 100755 --- a/deploy/multi/generate.py +++ b/deploy/multi/generate.py @@ -358,6 +358,13 @@ def render_compose(accounts: list[Account]) -> str: sub_filter_types text/html text/css application/javascript application/json; }} + # 反向兜底:chat2api 前端 JS 在运行时动态拼接 /{api_prefix}/..., + # sub_filter 只改静态字符串改不到。用 location regex 把这种请求 + # 内部 rewrite 回 /{slug}/...,避免 404。 + location ~ ^/{api_prefix}/(.*)$ {{ + rewrite ^/{api_prefix}/(.*)$ /{slug}/$1 last; + }} + """ NGINX_FOOTER = """\ From c66a7cc48e3f0ec385a8c1db9a277b4041ecf35d Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Sun, 26 Apr 2026 23:48:16 +0800 Subject: [PATCH 40/96] =?UTF-8?q?=E5=85=9C=E5=BA=95=E4=BB=8E=20internal=20?= =?UTF-8?q?rewrite=20=E6=94=B9=E6=88=90=20307=20=E5=A4=96=E9=83=A8?= =?UTF-8?q?=E9=87=8D=E5=AE=9A=E5=90=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal rewrite 的问题:浏览器登录后 admin_auth cookie path=/{slug}/admin, 浏览器看到请求 URL 是 /api-XXX/admin/... 时(path 不匹配)不会发送 cookie, nginx rewrite 后到达 chat2api 仍是 401。 307 让浏览器先跳到 /{slug}/admin/... 再发请求,cookie path 匹配会自动携带。 307 保留 method/body,POST 表单提交不会被降级成 GET。 --- deploy/multi/generate.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/deploy/multi/generate.py b/deploy/multi/generate.py index f771e79a..3873a0d0 100755 --- a/deploy/multi/generate.py +++ b/deploy/multi/generate.py @@ -359,10 +359,11 @@ def render_compose(accounts: list[Account]) -> str: }} # 反向兜底:chat2api 前端 JS 在运行时动态拼接 /{api_prefix}/..., - # sub_filter 只改静态字符串改不到。用 location regex 把这种请求 - # 内部 rewrite 回 /{slug}/...,避免 404。 + # sub_filter 只改静态字符串改不到。用 307 让浏览器跳到 /{slug}/... + # (而非 internal rewrite),保证浏览器按新 URL 的 cookie path 发送 admin_auth。 + # 307 保留 method/body,POST/PATCH 不会变 GET。 location ~ ^/{api_prefix}/(.*)$ {{ - rewrite ^/{api_prefix}/(.*)$ /{slug}/$1 last; + return 307 $scheme://$http_host/{slug}/$1$is_args$args; }} """ From f76ad2b4114e77824b9fc54cf4d119fdf66ec3d2 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Mon, 27 Apr 2026 00:04:26 +0800 Subject: [PATCH 41/96] =?UTF-8?q?=E9=BB=98=E8=AE=A4=E5=85=B3=E9=97=AD=20an?= =?UTF-8?q?tiban=20=E6=A1=B6=E6=9C=BA=E5=88=B6=EF=BC=88=E4=B8=80=E5=AE=B9?= =?UTF-8?q?=E5=99=A8=E4=B8=80=E8=B4=A6=E5=8F=B7=E5=B7=B2=E7=89=A9=E7=90=86?= =?UTF-8?q?=E9=9A=94=E7=A6=BB=EF=BC=8C=E6=A1=B6=E9=80=BB=E8=BE=91=E5=8F=8D?= =?UTF-8?q?=E8=80=8C=E6=8B=A6=E6=88=AA=E8=AF=B7=E6=B1=82=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit antiban 桶是为多账号共享一容器做 IP 隔离设计的;新架构每容器只跑一个账号, 桶机制冗余。STRICT_IP_BINDING=true 会拒绝没绑桶的账号请求,下游又没正确 处理 'no healthy bucket' 状态导致 dict.encode 异常。改默认为 false。 需要 antiban 的用户可显式覆盖 ENABLE_ANTIBAN=true。 --- deploy/multi/generate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy/multi/generate.py b/deploy/multi/generate.py index 3873a0d0..a6802f1c 100755 --- a/deploy/multi/generate.py +++ b/deploy/multi/generate.py @@ -190,8 +190,8 @@ def render_env(acc: Account) -> str: AUTO_SEED: 'true' RANDOM_TOKEN: 'false' RETRY_TIMES: '3' - ENABLE_ANTIBAN: 'true' - STRICT_IP_BINDING: 'true' + ENABLE_ANTIBAN: 'false' + STRICT_IP_BINDING: 'false' BUCKET_MAX_ACCOUNTS_PER_IP: '1' ACCOUNT_MIN_INTERVAL_SECONDS: '60' FREE_ACCOUNT_MIN_INTERVAL_SECONDS: '180' From a69b144e9de5edc18fcb5dd36834bc4b23df3ddf Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Mon, 4 May 2026 22:26:50 +0800 Subject: [PATCH 42/96] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=E5=BF=AB=E6=8D=B7=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 0 -> 8196 bytes README.md | 24 ++++++++-------- deploy/install-command.sh | 27 ++++++++++++------ deploy/install.sh | 56 ++++++++++++++++++++++++++++++++++---- docs/FEATURES.md | 10 ++++--- 5 files changed, 86 insertions(+), 31 deletions(-) create mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..4bef45a189a2a32e35783374ccdb410b06578065 GIT binary patch literal 8196 zcmeHMziSjh82u(Wy$ga|8PH-41X4sHm0-1ovk)SP0Sl}96_dj|?#N!0Sm-vfw-IX* zD;q8R2PBGEh^-(bO~fw66&4D<`H|cC_Q$mb^9{_rWxhB2y?r~(-0TsNTHb0e5zP`& z1yy7944Ryx`s`k!OSDcM8nW~Iohy5Otkz|B< z3@cGm%wl@%Vlzz*8c>JpxHSzOF5>5}jvl|f_}SziN0YqSD9`7fzlA4n&=BqqQtqoa z2j=-1!_U9}aCG6j&0nSzE74KRVtVXidYam_1|?nU(>8NKg(Ik8abL^sy`8)Bc0Qyy ziIO4?)1mf|2J^8E1ucr0hPj2`e$3Cm|6y_a(0VZ*+v_K}^WrS;TwUia<2~rNu|^Mo zDF=3=EW_7u_Q~@`!!=U^~{X1zVSC!{`MlD/dev/null 2>&1; then + SUDO="sudo" +else + echo "需要 root 权限或安装 sudo" + exit 1 +fi + prompt() { local var_name="$1" local prompt_text="$2" @@ -17,8 +26,8 @@ prompt() { printf -v "$var_name" '%s' "$current_value" } -yaml_escape() { - printf "%s" "$1" | sed "s/'/''/g" +shell_escape() { + printf "%s" "$1" | sed "s/'/'\\\\''/g" } echo "== Install chat2api manage command ==" @@ -32,14 +41,14 @@ if [[ ! -f "$INSTALL_DIR/docker-compose.yml" && ! -f "$INSTALL_DIR/compose.yml" fi SCRIPT_SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -sudo mkdir -p /etc -sudo tee /etc/chat2api.env >/dev/null </dev/null </dev/null && pwd || true)" # ----- sudo / root 判定 ----- if [ "$(id -u)" -eq 0 ]; then @@ -110,6 +111,41 @@ gen_random() { echo } +shell_escape() { + printf "%s" "$1" | sed "s/'/'\\\\''/g" +} + +install_manage_command() { + local script_src tmp_script + + script_src="${SCRIPT_SOURCE_DIR}/chat2api.sh" + + $SUDO mkdir -p /etc || return 1 + if ! $SUDO tee /etc/chat2api.env >/dev/null <&1 | grep -v "^$" || true $SUDO docker compose up -d +# ----- 宿主管理命令 ----- +log "安装管理命令..." +if install_manage_command; then + ok "管理命令已安装: chat2api update" +else + warn "管理命令安装失败;服务已启动,可先用 docker compose pull && docker compose up -d 更新" +fi + # ----- 健康检查 ----- log "等待服务就绪..." for i in $(seq 1 60); do @@ -216,16 +260,16 @@ ${C_OK}✅ chat2api 部署完成${C_RESET} - 配置 IP 白名单: vim ${INSTALL_DIR}/.env ADMIN_IP_WHITELIST=你的办公/家庭 IP - docker compose restart + chat2api restart - 或接入 Cloudflare 免费版隐藏真实 IP - 详见: ${GITHUB_RAW}/docs/SECURITY.md 📋 常用命令: - cd ${INSTALL_DIR} - docker compose logs -f # 实时日志 - docker compose restart # 重启 - docker compose pull && docker compose up -d # 升级 - docker compose down # 停止 + chat2api status # 查看状态 + chat2api logs # 实时日志 + chat2api restart # 重启 + chat2api update # 升级 + chat2api stop # 停止 ============================================================ diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 14ca53f1..1fee5326 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -261,8 +261,9 @@ INSTALL_DIR=/opt/chat2api bash install.sh - `API_PREFIX`:`api-` + 12 位 5. 写入 `.env`(chmod 600) 6. `docker compose up -d` -7. 等待健康检查通过 -8. 打印访问 URL + 凭据 + 下一步操作指引 +7. 自动安装宿主管理命令 `chat2api` +8. 等待健康检查通过 +9. 打印访问 URL + 凭据 + 下一步操作指引 ### 凭据安全 @@ -273,10 +274,11 @@ INSTALL_DIR=/opt/chat2api bash install.sh ### 升级 ```bash -cd ~/chat2api -docker compose pull && docker compose up -d +chat2api update ``` +旧机器没有该命令时,重新跑一键部署脚本会沿用现有配置并补装。 + --- ## 配置速查表 From dc566641a64864eee4560c059d711421862d422c Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Tue, 5 May 2026 01:51:27 +0800 Subject: [PATCH 43/96] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/multi/orchestrator/static/app.js | 18 +++++- templates/account_proxy_bindings.html | 76 ++++++++----------------- 2 files changed, 40 insertions(+), 54 deletions(-) diff --git a/deploy/multi/orchestrator/static/app.js b/deploy/multi/orchestrator/static/app.js index 0e16912e..1509c270 100644 --- a/deploy/multi/orchestrator/static/app.js +++ b/deploy/multi/orchestrator/static/app.js @@ -28,10 +28,26 @@ async function api(method, path, body) { return; } const data = await r.json().catch(() => ({})); - if (!r.ok) throw new Error(data.detail || `HTTP ${r.status}`); + if (!r.ok) throw new Error(formatApiError(data.detail) || `HTTP ${r.status}`); return data; } +function formatApiError(detail) { + if (!detail) return ''; + if (typeof detail === 'string') return detail; + if (Array.isArray(detail)) { + return detail.map((item) => { + if (typeof item === 'string') return item; + const loc = Array.isArray(item.loc) ? item.loc.join('.') : ''; + return [loc, item.msg].filter(Boolean).join(': '); + }).join('\n'); + } + if (typeof detail === 'object') { + return detail.message || detail.msg || JSON.stringify(detail); + } + return String(detail); +} + function toast(msg, isErr = false) { const el = $('#toast'); el.textContent = msg; diff --git a/templates/account_proxy_bindings.html b/templates/account_proxy_bindings.html index 02f39a68..24144ce2 100644 --- a/templates/account_proxy_bindings.html +++ b/templates/account_proxy_bindings.html @@ -501,9 +501,10 @@ saveButton.addEventListener('click', async () => { const proxies = parseProxyLines(proxyInput.value); if (!proxies.length) { - alert('请至少配置一个代理'); + alert('请先填写当前账号要使用的代理'); return; } + const multipleProxyHint = proxies.length > 1 ? '检测到多条代理,已使用第一条绑定当前账号。' : ''; statusText.textContent = '保存中...'; const response = await fetch(saveApi, { method: 'POST', @@ -518,7 +519,7 @@ statusText.textContent = result.detail || '保存失败'; return; } - statusText.textContent = result.message; + statusText.textContent = multipleProxyHint || '当前代理已保存并发布'; await loadDashboard(); }); @@ -737,7 +738,7 @@ testProxyButton.addEventListener('click', async () => { const proxyUrl = getFirstConfiguredProxy(); if (!proxyUrl) { - proxyTestResult.textContent = '请先在代理配置中至少填写一条代理'; + proxyTestResult.textContent = '请先填写当前账号要使用的代理'; return; } proxyTestResult.textContent = '测试中...'; @@ -1398,68 +1399,37 @@

    接口测试样例

    -

    代理与路由

    -

    拆分为“代理池配置”和“规则发布”两个子页,方便分步管理。

    -
    -
    - - +

    当前实例代理

    +

    一容器一账号一出口,只需要维护当前账号使用的代理。

    +
    保存后自动绑定到当前账号
    -
    +
    -
    +
    -

    代理池配置

    -

    一行一个代理,格式支持 名称 | socks5h://host:port,远端域名解析建议优先使用 socks5h://

    +

    出口代理

    +

    建议填写一条代理,格式支持 名称 | socks5h://host:port

    - +
    - +
    -
    - -
    From 6a7b6909e59045d83e3430181c6d5c65870d271c Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Tue, 5 May 2026 12:47:52 +0800 Subject: [PATCH 44/96] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=AE=B9=E5=99=A8?= =?UTF-8?q?=E7=99=BB=E5=BD=95bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/multi/generate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/multi/generate.py b/deploy/multi/generate.py index a6802f1c..d95d21c8 100755 --- a/deploy/multi/generate.py +++ b/deploy/multi/generate.py @@ -186,7 +186,7 @@ def render_env(acc: Account) -> str: SCHEDULED_REFRESH: 'true' ENABLE_LIMIT: 'true' OAI_LANGUAGE: 'zh-CN' - ENABLE_GATEWAY: 'false' + ENABLE_GATEWAY: 'true' AUTO_SEED: 'true' RANDOM_TOKEN: 'false' RETRY_TIMES: '3' From d7f3b308f8bafc069c7aee582c5d77717fd103d8 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Tue, 5 May 2026 15:27:39 +0800 Subject: [PATCH 45/96] =?UTF-8?q?=E4=BF=AE=E6=94=B9bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/multi/generate.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/deploy/multi/generate.py b/deploy/multi/generate.py index d95d21c8..191100d4 100755 --- a/deploy/multi/generate.py +++ b/deploy/multi/generate.py @@ -236,13 +236,13 @@ def render_env(acc: Account) -> str: env_file: - ./generated/orch.env environment: - MULTI_HOST_PATH: '${MULTI_HOST_PATH}' + MULTI_HOST_PATH: '{host_path}' ORCH_PORT: '8080' TZ: 'Asia/Shanghai' # 关键:用宿主路径作为容器内挂载点,让 docker compose 客户端 # (在容器内运行)与 daemon(在宿主运行)能用同一路径找到 env_file 与 volumes volumes: - - '${MULTI_HOST_PATH}:${MULTI_HOST_PATH}' + - '{host_path}:{host_path}' - /var/run/docker.sock:/var/run/docker.sock labels: com.centurylinklabs.watchtower.enable: 'true' @@ -284,7 +284,7 @@ def render_compose(accounts: list[Account]) -> str: depends_on = "\n".join(depends_on_lines) body = COMPOSE_HEADER.format(image=CHAT2API_IMAGE) + services if ORCH_ENABLED: - body += COMPOSE_ORCHESTRATOR + body += COMPOSE_ORCHESTRATOR.format(host_path=str(ROOT).replace("'", "''")) body += COMPOSE_FOOTER.format(port=NGINX_PORT, depends_on=depends_on, watchtower_image=WATCHTOWER_IMAGE) return body From 54b8a4ad90548b599a085c97c0c2098a2aa3bf1e Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Tue, 5 May 2026 15:46:08 +0800 Subject: [PATCH 46/96] =?UTF-8?q?=E4=BC=98=E5=8C=96bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/multi/manage.sh | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/deploy/multi/manage.sh b/deploy/multi/manage.sh index 4b76bb8e..b7bbed2f 100755 --- a/deploy/multi/manage.sh +++ b/deploy/multi/manage.sh @@ -42,10 +42,22 @@ dc() { docker compose -f "$COMPOSE" --project-directory "$DIR" "$@" } +cleanup_renamed_containers() { + local ids + ids="$(docker ps -a --format '{{.ID}} {{.Names}}' \ + | awk '$2 ~ /^[0-9a-f]+_c2a-/ {print $1}')" + if [ -n "$ids" ]; then + log "清理上次重建残留容器..." + # shellcheck disable=SC2086 + docker rm -f $ids >/dev/null 2>&1 || true + fi +} + cmd_apply() { ensure_csv log "生成配置..." python3 "$DIR/generate.py" + cleanup_renamed_containers if dc config --services 2>/dev/null | grep -qx orchestrator; then log "构建 orchestrator 镜像..." dc build orchestrator From afb62b994559ecaadfa7c0b5cc7f41d98dc7dbb5 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Wed, 6 May 2026 23:50:13 +0800 Subject: [PATCH 47/96] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=B7=B3=E8=BD=AC?= =?UTF-8?q?=E5=AE=B9=E5=99=A8=E7=9A=84bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/multi/generate.py | 4 ++++ gateway/backend.py | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/deploy/multi/generate.py b/deploy/multi/generate.py index 191100d4..6e95dbc5 100755 --- a/deploy/multi/generate.py +++ b/deploy/multi/generate.py @@ -346,6 +346,10 @@ def render_compose(accounts: list[Account]) -> str: # ---- {slug} ({note}) ---- location /{slug}/ {{ proxy_pass http://c2a-{slug}:5005/{api_prefix}/; + # 把外部访问域名/协议传给 chat2api,避免页面里出现容器内网地址 + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; # chat2api 内部硬编码 API_PREFIX 到 redirect / cookie / HTML 链接里, # 经 nginx 反代后必须把 /{api_prefix}/ 改回 /{slug}/,否则用户登录后跳转 404 # 用 $scheme://$http_host 拼完整 URL(含客户端原始端口如 :60403) diff --git a/gateway/backend.py b/gateway/backend.py index ffb7ab48..1a4b037b 100644 --- a/gateway/backend.py +++ b/gateway/backend.py @@ -677,11 +677,19 @@ async def c_close(client, clients): @app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH", "TRACE"]) async def reverse_proxy(request: Request, path: str): token = request.headers.get("Authorization", "").replace("Bearer ", "") + normalized_path = "/" + path.lstrip("/") if len(token) != 45 and not token.startswith("eyJhbGciOi"): for banned_path in banned_paths: if re.match(banned_path, path): raise HTTPException(status_code=403, detail="Forbidden") + # 如果后台路径没有命中显式 admin 路由,说明 nginx / API_PREFIX 映射大概率错了。 + if "/admin/" in normalized_path or normalized_path.endswith("/admin"): + raise HTTPException( + status_code=404, + detail="Admin route mismatch: check API_PREFIX and nginx slug mapping.", + ) + for chatgpt_path in chatgpt_paths: if re.match(chatgpt_path, path): return await chatgpt_html(request) From cf9552a6d8996adb02886a8ffd6b6298b9e8d294 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Thu, 7 May 2026 14:30:22 +0800 Subject: [PATCH 48/96] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=AE=B9=E5=99=A8?= =?UTF-8?q?=E6=98=A0=E5=B0=84=E5=9C=B0=E5=9D=80=E9=94=99=E8=AF=AF=E7=9A=84?= =?UTF-8?q?bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/chat2api.sh | 112 ++++++++++++++++++++++++++++++++++----- deploy/multi/generate.py | 6 ++- deploy/multi/manage.sh | 94 ++++++++++++++++++++++++++++++++ 3 files changed, 198 insertions(+), 14 deletions(-) diff --git a/deploy/chat2api.sh b/deploy/chat2api.sh index cd2e679c..a2393641 100644 --- a/deploy/chat2api.sh +++ b/deploy/chat2api.sh @@ -29,7 +29,7 @@ detect_install_dir() { local dir for dir in "${candidates[@]}"; do - if [[ -f "$dir/docker-compose.yml" || -f "$dir/compose.yml" || -f "$dir/compose.yaml" ]]; then + if [[ -f "$dir/docker-compose.yml" || -f "$dir/compose.yml" || -f "$dir/compose.yaml" || -x "$dir/deploy/multi/manage.sh" ]]; then printf "%s\n" "$dir" return fi @@ -49,6 +49,14 @@ fi cd "$INSTALL_DIR" +is_multi_install() { + [[ -x "$INSTALL_DIR/deploy/multi/manage.sh" ]] +} + +run_multi_manage() { + (cd "$INSTALL_DIR/deploy/multi" && ./manage.sh "$@") +} + run_compose() { if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then sudo docker compose "$@" @@ -59,6 +67,28 @@ run_compose() { } show_help() { + if is_multi_install; then + cat <<'EOF' +Usage: chat2api + +Multi-instance commands: + update Re-generate config and recreate services + start Same as update + restart Same as update + stop Stop all multi-instance services + status Show multi-instance status and sampled egress IPs + verify Verify admin/tokens routing for all instances + logs Tail one instance logs + shell Enter one instance shell + secrets Print instance auth/admin secrets + admin Print orchestrator/admin entry hints + path Print install directory + help Show this help + +Any other command is passed through to: deploy/multi/manage.sh +EOF + return + fi cat <<'EOF' Usage: chat2api @@ -80,33 +110,85 @@ command_name="${1:-help}" case "$command_name" in update) - run_compose pull - run_compose up -d + if is_multi_install; then + run_multi_manage apply + else + run_compose pull + run_compose up -d + fi ;; restart) - run_compose restart + if is_multi_install; then + run_multi_manage apply + else + run_compose restart + fi ;; stop) - run_compose stop + if is_multi_install; then + run_multi_manage down + else + run_compose stop + fi ;; start) - run_compose up -d + if is_multi_install; then + run_multi_manage apply + else + run_compose up -d + fi ;; status) - run_compose ps + if is_multi_install; then + run_multi_manage status + else + run_compose ps + fi ;; logs) - run_compose logs -f chat2api + if is_multi_install; then + run_multi_manage logs "${@:2}" + else + run_compose logs -f chat2api + fi + ;; + shell) + if is_multi_install; then + run_multi_manage shell "${@:2}" + else + echo "shell command is only available in multi-instance mode" + exit 1 + fi + ;; + verify) + if is_multi_install; then + run_multi_manage verify + else + echo "verify command is only available in multi-instance mode" + exit 1 + fi + ;; + secrets) + if is_multi_install; then + run_multi_manage secrets + else + echo "secrets command is only available in multi-instance mode" + exit 1 + fi ;; admin) - if [[ -n "${PORT:-}" && -n "${API_PREFIX:-}" ]]; then + if is_multi_install; then + run_multi_manage secrets + elif [[ -n "${PORT:-}" && -n "${API_PREFIX:-}" ]]; then echo "http://:${PORT}/${API_PREFIX}/admin/login" else echo "PORT/API_PREFIX not found in /etc/chat2api.env" fi ;; api) - if [[ -n "${PORT:-}" && -n "${API_PREFIX:-}" ]]; then + if is_multi_install; then + run_multi_manage secrets + elif [[ -n "${PORT:-}" && -n "${API_PREFIX:-}" ]]; then echo "http://:${PORT}/${API_PREFIX}/v1/chat/completions" else echo "PORT/API_PREFIX not found in /etc/chat2api.env" @@ -119,8 +201,12 @@ case "$command_name" in show_help ;; *) - echo "Unknown command: $command_name" - show_help - exit 1 + if is_multi_install; then + run_multi_manage "$command_name" "${@:2}" + else + echo "Unknown command: $command_name" + show_help + exit 1 + fi ;; esac diff --git a/deploy/multi/generate.py b/deploy/multi/generate.py index 6e95dbc5..5d81e929 100755 --- a/deploy/multi/generate.py +++ b/deploy/multi/generate.py @@ -309,6 +309,8 @@ def render_compose(accounts: list[Account]) -> str: proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 600s; proxy_send_timeout 600s; + # Docker 内部 DNS。配合变量 proxy_pass,避免容器重建后 nginx 继续缓存旧 IP。 + resolver 127.0.0.11 ipv6=off valid=30s; # 简单访问日志(生产可换 json) log_format upstream_log '$remote_addr - [$time_local] "$request" ' @@ -345,7 +347,9 @@ def render_compose(accounts: list[Account]) -> str: NGINX_LOCATION = """\ # ---- {slug} ({note}) ---- location /{slug}/ {{ - proxy_pass http://c2a-{slug}:5005/{api_prefix}/; + set $chat2api_upstream c2a-{slug}:5005; + rewrite ^/{slug}/(.*)$ /{api_prefix}/$1 break; + proxy_pass http://$chat2api_upstream; # 把外部访问域名/协议传给 chat2api,避免页面里出现容器内网地址 proxy_set_header Host $http_host; proxy_set_header X-Forwarded-Host $http_host; diff --git a/deploy/multi/manage.sh b/deploy/multi/manage.sh index b7bbed2f..97a36cb5 100755 --- a/deploy/multi/manage.sh +++ b/deploy/multi/manage.sh @@ -15,6 +15,7 @@ CSV="$DIR/accounts.csv" EXAMPLE_CSV="$DIR/accounts.example.csv" GEN_DIR="$DIR/generated" COMPOSE="$GEN_DIR/docker-compose.yml" +REPO_ROOT="$(cd "$DIR/../.." && pwd)" # orchestrator 必需:让容器内的 docker compose --project-directory 指向宿主路径 export MULTI_HOST_PATH="$DIR" @@ -42,6 +43,21 @@ dc() { docker compose -f "$COMPOSE" --project-directory "$DIR" "$@" } +slugs() { + awk -F, 'NR>1 && $1!="" {print $1}' "$CSV" +} + +as_root() { + if [ "$(id -u)" -eq 0 ]; then + "$@" + elif command -v sudo >/dev/null 2>&1; then + sudo "$@" + else + err "需要 root 权限或已安装 sudo" + exit 1 + fi +} + cleanup_renamed_containers() { local ids ids="$(docker ps -a --format '{{.ID}} {{.Names}}' \ @@ -70,6 +86,7 @@ cmd_apply() { && log "nginx reload OK" \ || log "nginx reload 失败(首次启动可忽略)" fi + cmd_verify ok "完成。运行 ./manage.sh secrets 查看凭证 / 编排面板访问入口" } @@ -199,6 +216,79 @@ cmd_status() { done } +check_contains() { + local url="$1" needle="$2" tries="${3:-8}" body="" + local i + for i in $(seq 1 "$tries"); do + body="$(curl -fsS --max-time 15 "$url" 2>/dev/null || true)" + if printf '%s' "$body" | grep -q "$needle"; then + return 0 + fi + sleep 1 + done + printf '%s' "$body" + return 1 +} + +cmd_verify() { + require_compose + ensure_csv + local port="${CHAT2API_GATEWAY_PORT:-60403}" + local failed=0 slug env_prefix container_prefix nginx_block admin_out tokens_out + log "校验路由与后台页面..." + for slug in $(slugs); do + env_prefix="$(awk -F= '$1=="API_PREFIX"{print $2}' "$GEN_DIR/env/${slug}.env" 2>/dev/null | tail -1)" + container_prefix="$(docker exec "c2a-${slug}" sh -lc 'printf %s "${API_PREFIX:-}"' 2>/dev/null || true)" + if [ -z "$env_prefix" ] || [ "$env_prefix" != "$container_prefix" ]; then + err "${slug}: API_PREFIX 不一致(env=${env_prefix:-} container=${container_prefix:-})" + failed=1 + continue + fi + + nginx_block="$(docker exec c2a-nginx nginx -T 2>/dev/null | grep -A8 "location /${slug}/" || true)" + if ! printf '%s\n' "$nginx_block" | grep -q "$env_prefix"; then + err "${slug}: nginx 生效配置未指向 ${env_prefix}" + failed=1 + continue + fi + + if ! admin_out="$(check_contains "http://127.0.0.1:${port}/${slug}/admin/login" '管理后台登录\|Admin Login')"; then + err "${slug}: admin/login 校验失败" + printf '%s\n' "$admin_out" | head -3 + failed=1 + continue + fi + + if ! tokens_out="$(check_contains "http://127.0.0.1:${port}/${slug}/tokens" 'Tokens 管理')"; then + err "${slug}: /tokens 校验失败" + printf '%s\n' "$tokens_out" | head -3 + failed=1 + continue + fi + + ok "${slug}: admin/tokens 正常" + done + + if [ "$failed" -ne 0 ]; then + err "校验失败:请先处理上面的实例" + return 1 + fi +} + +cmd_install_cli() { + local config_tmp + config_tmp="$(mktemp)" + cat > "$config_tmp" < 移除单个账号 + apply(保留 data/) ./manage.sh list 所有容器状态(docker compose ps) ./manage.sh status 状态 + 抽样验证出口 IP + ./manage.sh verify 校验每个实例的后台/路由是否串线 ./manage.sh logs [N] 跟随该实例日志(默认 200 行) ./manage.sh shell 进入该实例容器 shell ./manage.sh secrets 打印所有 AUTH / ADMIN_PWD(敏感) ./manage.sh orch-password [pwd] 重置编排面板密码(不传则随机生成) + ./manage.sh install-cli 安装全局 chat2api 命令 ./manage.sh down 停止全部(数据保留) ./manage.sh help 显示本帮助 @@ -237,10 +329,12 @@ case "$cmd" in remove) cmd_remove "$@" ;; list) cmd_list "$@" ;; status) cmd_status "$@" ;; + verify) cmd_verify "$@" ;; logs) cmd_logs "$@" ;; shell) cmd_shell "$@" ;; secrets) cmd_secrets "$@" ;; orch-password) cmd_orch_password "$@" ;; + install-cli) cmd_install_cli "$@" ;; down) cmd_down "$@" ;; help|-h|--help) cmd_help ;; *) err "未知命令: $cmd"; cmd_help; exit 1 ;; From 86b10c70936f2148fa8ceceb38e5d93512101689 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Thu, 7 May 2026 17:09:50 +0800 Subject: [PATCH 49/96] =?UTF-8?q?=E4=BC=98=E5=8C=96bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/chat2api.py | 23 +- chatgpt/ChatService.py | 10 + chatgpt/chatFormat.py | 11 + chatgpt/session_sticky.py | 212 +++++++++++++++ deploy/chat2api.sh | 411 +++++++++++++++++++++++++++-- deploy/docker-compose.template.yml | 7 + deploy/multi/generate.py | 25 +- utils/configs.py | 20 ++ 8 files changed, 691 insertions(+), 28 deletions(-) create mode 100644 chatgpt/session_sticky.py diff --git a/api/chat2api.py b/api/chat2api.py index f54689e0..dc57a51d 100644 --- a/api/chat2api.py +++ b/api/chat2api.py @@ -11,9 +11,10 @@ from app import app, templates, security_scheme from chatgpt.ChatService import ChatService from chatgpt.authorization import refresh_all_tokens +from chatgpt import session_sticky from utils.bootstrap import initialize_from_env from utils.Logger import logger -from utils.configs import api_prefix, scheduled_refresh, history_disabled +from utils.configs import api_prefix, scheduled_refresh, history_disabled, enable_session_sticky from utils.retry import async_retry from utils import antiban from utils.antiban import circuit as antiban_circuit @@ -26,6 +27,16 @@ async def app_start(): initialize_from_env() await antiban.init() + # Session sticky: 启动时初始化 SQLite + 启动 TTL 清理定时任务 + if enable_session_sticky: + session_sticky.init_db() + scheduler.add_job( + id='session_sticky_cleanup', + func=session_sticky.cleanup_expired, + trigger='interval', + hours=24, + ) + # Antiban 自愈定时任务 from utils.configs import enable_antiban, circuit_bucket_heal_minutes if enable_antiban: @@ -44,6 +55,9 @@ async def app_start(): elif enable_antiban: # 只有 antiban 启用、没启用 refresh 时,也需要把 scheduler 跑起来 scheduler.start() + elif enable_session_sticky: + # 仅 session_sticky 启用时,scheduler 也要启动以执行 cleanup + scheduler.start() async def to_send_conversation(request_data, req_token): @@ -104,7 +118,14 @@ async def send_conversation(request: Request, credentials: HTTPAuthorizationCred request_data = await request.json() except Exception: raise HTTPException(status_code=400, detail={"error": "Invalid JSON body"}) + # Session sticky: LibreChat conv_id → ChatGPT conv_id 翻译注入 + # 副作用: 命中映射时改写 request_data['conversation_id'/'parent_message_id'/'messages'] + # 返回 lc_conv_id 用于流式响应嗅探回写;未启用或无 lc 字段时返回 None + lc_conv_id = session_sticky.inject_session(request_data) if enable_session_sticky else None chat_service, res = await async_retry(process, request_data, req_token) + # 把 lc_conv_id 挂到 chat_service 上,供 stream_response 嗅探时回写 DB + if lc_conv_id: + chat_service.librechat_conv_id = lc_conv_id try: if isinstance(res, types.AsyncGeneratorType): background = BackgroundTask(chat_service.close_client) diff --git a/chatgpt/ChatService.py b/chatgpt/ChatService.py index 3b106cff..5a1c2b4a 100644 --- a/chatgpt/ChatService.py +++ b/chatgpt/ChatService.py @@ -54,6 +54,8 @@ def __init__(self, origin_token=None): self.ws = None self.dynamic_model = False self.antiban_ctx = None + # Session sticky: 由 api 层 inject 后挂载,stream_response 嗅探时用于回写映射 + self.librechat_conv_id = None def model_not_found(self): return HTTPException( @@ -422,6 +424,14 @@ async def send_conversation(self): ) if r.status_code != 200: rtext = await r.atext() + # Session sticky: 注入的 conv_id 触发 4xx → 清理映射,让重试新建对话 + if 400 <= r.status_code < 500 and self.data.get("librechat_conversation_id") \ + and self.conversation_id: + try: + from chatgpt import session_sticky as _ss + _ss.drop_mapping(self.data.get("librechat_conversation_id")) + except Exception: + pass if "application/json" == r.headers.get("Content-Type", ""): detail = json.loads(rtext).get("detail", json.loads(rtext)) if r.status_code == 429: diff --git a/chatgpt/chatFormat.py b/chatgpt/chatFormat.py index 7459615a..817f172f 100644 --- a/chatgpt/chatFormat.py +++ b/chatgpt/chatFormat.py @@ -13,6 +13,7 @@ from api.files import get_file_content from api.models import model_system_fingerprint from api.tokens import split_tokens_from_content, calculate_image_tokens, num_tokens_from_messages +from chatgpt import session_sticky from utils.Logger import logger moderation_message = "I'm sorry, I cannot provide or engage in any content related to pornography, violence, or any unethical material. If you have any other questions or need assistance, please feel free to let me know. I'll do my best to provide support and assistance." @@ -321,6 +322,16 @@ async def stream_response(service, response, model, max_tokens): last_message_id = message_id last_role = role last_status = status + # Session sticky: 嗅探到 ChatGPT 端 conv_id + message_id 即回写映射 + # (多次 chunk 触发时最后一次会覆盖,最终 parent_msg_id = 最新一条 assistant message) + if getattr(service, "librechat_conv_id", None) and conversation_id and message_id \ + and role == 'assistant': + try: + session_sticky.sniff_and_save( + service.librechat_conv_id, conversation_id, message_id, + ) + except Exception as _e: + logger.error(f"[session_sticky] sniff error: {_e}") if not end and not delta.get("content"): delta = {"role": "assistant", "content": ""} chunk_new_data["choices"][0]["delta"] = delta diff --git a/chatgpt/session_sticky.py b/chatgpt/session_sticky.py new file mode 100644 index 00000000..c98829dc --- /dev/null +++ b/chatgpt/session_sticky.py @@ -0,0 +1,212 @@ +"""LibreChat 会话粘性:LibreChat conv_id ↔ ChatGPT conv_id 翻译层。 + +设计 (KISS / YAGNI / DRY): + - SQLite 单表持久化映射;WAL 模式 + 短连接,async 友好 + - 入口 inject_session:命中映射 → 在 request_data 注入 conversation_id / parent_message_id; + 未命中 → 不动 body,让 ChatService 走"新建对话"流程 + - 出口 sniff_and_save:流式响应每个 chunk 含 conv_id / message_id 时回写 DB + - 不影响现有逻辑:开关 enable_session_sticky 关闭时所有 API 直接 return + +外部 API: + init_db() + inject_session(request_data) -> Optional[str] # 返回 lc_conv_id 用于后续嗅探 + sniff_and_save(lc_conv_id, chatgpt_conv_id, parent_msg_id) + drop_mapping(lc_conv_id) # 命中后 ChatGPT 返回 404 时清理 + cleanup_expired() # 超 TTL 自动清理 +""" +from __future__ import annotations + +import os +import sqlite3 +import threading +import time +from typing import Optional, Tuple + +from utils.Logger import logger +from utils import configs + +_DB_INITIALIZED = False +_INIT_LOCK = threading.Lock() +_WRITE_LOCK = threading.Lock() # 避免并发写竞争 (uvicorn 单 worker async 通常不需要,但 worker>1 时保险) + + +def _enabled() -> bool: + return bool(getattr(configs, "enable_session_sticky", False)) + + +def _db_path() -> str: + return getattr(configs, "session_db_path", "data/sessions.db") + + +def _lc_field() -> str: + return getattr(configs, "session_lc_field", "librechat_conversation_id") + + +def _connect() -> sqlite3.Connection: + path = _db_path() + parent = os.path.dirname(path) + if parent and not os.path.exists(parent): + os.makedirs(parent, exist_ok=True) + conn = sqlite3.connect(path, timeout=5.0, isolation_level=None) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + conn.execute("PRAGMA busy_timeout=3000") + return conn + + +def init_db() -> None: + """容器启动时调用一次。多次调用安全。""" + global _DB_INITIALIZED + if not _enabled(): + return + with _INIT_LOCK: + if _DB_INITIALIZED: + return + try: + with _connect() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS lc_session_map ( + librechat_conv_id TEXT PRIMARY KEY, + chatgpt_conv_id TEXT NOT NULL, + parent_msg_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_lc_updated ON lc_session_map(updated_at)" + ) + _DB_INITIALIZED = True + logger.info(f"[session_sticky] db ready at {_db_path()}") + except Exception as e: + logger.error(f"[session_sticky] init failed: {e}") + + +def _get_mapping(lc_conv_id: str) -> Optional[Tuple[str, Optional[str]]]: + try: + with _connect() as conn: + row = conn.execute( + "SELECT chatgpt_conv_id, parent_msg_id FROM lc_session_map WHERE librechat_conv_id=?", + (lc_conv_id,), + ).fetchone() + return (row[0], row[1]) if row else None + except Exception as e: + logger.error(f"[session_sticky] get_mapping error: {e}") + return None + + +def _upsert_mapping(lc_conv_id: str, chatgpt_conv_id: str, parent_msg_id: Optional[str]) -> None: + now = int(time.time()) + try: + with _WRITE_LOCK, _connect() as conn: + conn.execute( + """ + INSERT INTO lc_session_map (librechat_conv_id, chatgpt_conv_id, parent_msg_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(librechat_conv_id) DO UPDATE SET + chatgpt_conv_id = excluded.chatgpt_conv_id, + parent_msg_id = excluded.parent_msg_id, + updated_at = excluded.updated_at + """, + (lc_conv_id, chatgpt_conv_id, parent_msg_id, now, now), + ) + except Exception as e: + logger.error(f"[session_sticky] upsert_mapping error: {e}") + + +def drop_mapping(lc_conv_id: str) -> None: + """ChatGPT 返回 404 / conv_id 失效时清理。下次同 lc_conv_id 会新建对话。""" + if not _enabled() or not lc_conv_id: + return + try: + with _WRITE_LOCK, _connect() as conn: + conn.execute("DELETE FROM lc_session_map WHERE librechat_conv_id=?", (lc_conv_id,)) + logger.info(f"[session_sticky] dropped mapping for {lc_conv_id[:12]}...") + except Exception as e: + logger.error(f"[session_sticky] drop_mapping error: {e}") + + +def cleanup_expired() -> int: + """删除超过 TTL 未更新的映射;返回删除条数。""" + if not _enabled(): + return 0 + ttl_days = int(getattr(configs, "session_ttl_days", 30)) + cutoff = int(time.time()) - ttl_days * 86400 + try: + with _WRITE_LOCK, _connect() as conn: + cur = conn.execute("DELETE FROM lc_session_map WHERE updated_at < ?", (cutoff,)) + deleted = cur.rowcount or 0 + if deleted: + logger.info(f"[session_sticky] cleanup_expired removed {deleted} rows") + return deleted + except Exception as e: + logger.error(f"[session_sticky] cleanup_expired error: {e}") + return 0 + + +def inject_session(request_data: dict) -> Optional[str]: + """请求入口注入。 + + 返回: + lc_conv_id(用于后续 sniff_and_save 回写);功能未启用或请求体无 lc_conv_id 时返回 None。 + + 副作用: + 命中映射 → 写入 request_data['conversation_id'] / ['parent_message_id'] + → 默认把 messages 截到只保留最后一条 user message(节省 token, + 依赖 ChatGPT 服务端续接历史) + """ + if not _enabled() or not isinstance(request_data, dict): + return None + lc_conv_id = request_data.get(_lc_field()) + if not lc_conv_id or not isinstance(lc_conv_id, str): + return None + + init_db() # 懒初始化 + mapping = _get_mapping(lc_conv_id) + if not mapping: + # 首次见到该 lc_conv_id;让 ChatGPT 创建新 conv,嗅探阶段再回写 + logger.info(f"[session_sticky] miss lc={lc_conv_id[:12]}... → new conv") + return lc_conv_id + + chatgpt_conv_id, parent_msg_id = mapping + # 仅当用户没有显式传入 conversation_id 时才注入(用户显式传入优先级最高) + if not request_data.get("conversation_id"): + request_data["conversation_id"] = chatgpt_conv_id + if parent_msg_id and not request_data.get("parent_message_id"): + request_data["parent_message_id"] = parent_msg_id + # 续接 ChatGPT 端历史 → 强制 history_disabled=False + request_data["history_disabled"] = False + + # 截短 messages:仅保留最后一条 user message + 可选 system + if getattr(configs, "session_trim_to_last_user", True): + msgs = request_data.get("messages") or [] + if isinstance(msgs, list) and len(msgs) > 1: + trimmed = [] + # 保留首条 system(若有) + if msgs and isinstance(msgs[0], dict) and msgs[0].get("role") == "system": + trimmed.append(msgs[0]) + # 取最后一条 user + last_user = next( + (m for m in reversed(msgs) if isinstance(m, dict) and m.get("role") == "user"), + None, + ) + if last_user: + trimmed.append(last_user) + request_data["messages"] = trimmed + + logger.info( + f"[session_sticky] hit lc={lc_conv_id[:12]}... → cv={chatgpt_conv_id[:8]}... " + f"parent={(parent_msg_id or '')[:8]}..." + ) + return lc_conv_id + + +def sniff_and_save(lc_conv_id: Optional[str], chatgpt_conv_id: Optional[str], + parent_msg_id: Optional[str]) -> None: + """流式响应嗅探回写。同一对话多次 chunk 触发时,最后一次 message_id 会覆盖。""" + if not _enabled() or not lc_conv_id or not chatgpt_conv_id: + return + init_db() + _upsert_mapping(lc_conv_id, chatgpt_conv_id, parent_msg_id) diff --git a/deploy/chat2api.sh b/deploy/chat2api.sh index a2393641..d5a8ea24 100644 --- a/deploy/chat2api.sh +++ b/deploy/chat2api.sh @@ -2,12 +2,15 @@ set -euo pipefail CONFIG_FILE="/etc/chat2api.env" +GITHUB_RAW_DEFAULT="https://raw.githubusercontent.com/nanashiwang/chat2api/main" if [[ -f "$CONFIG_FILE" ]]; then # shellcheck disable=SC1091 source "$CONFIG_FILE" fi +GITHUB_RAW="${GITHUB_RAW:-$GITHUB_RAW_DEFAULT}" + detect_install_dir() { if [[ -n "${INSTALL_DIR:-}" && -d "${INSTALL_DIR:-}" ]]; then printf "%s\n" "$INSTALL_DIR" @@ -66,24 +69,367 @@ run_compose() { fi } +# ============================================================ +# B: 模板同步(仅单实例模式) +# ============================================================ +# 提取 environment 块下的 ENV 名(" KEY: 'value'" 格式) +extract_env_keys() { + local file="$1" + awk ' + /^ environment:/ { in_env=1; next } + /^ [a-z]/ { in_env=0 } + in_env && /^ [A-Z][A-Z0-9_]*:/ { + sub(/:.*/, "") + sub(/^[ ]+/, "") + print + } + ' "$file" +} + +cmd_sync_template_check() { + # 仅打印是否有差异,返回 0=无差异 1=有差异 2=错误 + if is_multi_install; then + return 0 + fi + local local_compose="$INSTALL_DIR/docker-compose.yml" + [[ -f "$local_compose" ]] || return 2 + + local tmp_template + tmp_template="$(mktemp)" || return 2 + if ! curl -fsSL --max-time 8 "$GITHUB_RAW/deploy/docker-compose.template.yml" -o "$tmp_template" 2>/dev/null; then + rm -f "$tmp_template" + return 2 + fi + + local new_keys + new_keys="$(comm -23 \ + <(extract_env_keys "$tmp_template" | sort -u) \ + <(extract_env_keys "$local_compose" | sort -u))" + rm -f "$tmp_template" + + if [[ -z "$new_keys" ]]; then + return 0 + fi + echo "[i] Upstream template has new ENV keys:" + echo "$new_keys" | sed 's/^/ + /' + echo "[i] Run: chat2api sync-template # to merge" + return 1 +} + +cmd_sync_template() { + if is_multi_install; then + echo "Multi-instance mode auto-syncs via 'chat2api update' (regenerates from generate.py)." + echo "No template sync needed." + return 0 + fi + + local local_compose="$INSTALL_DIR/docker-compose.yml" + if [[ ! -f "$local_compose" ]]; then + echo "[!] $local_compose not found" + return 1 + fi + + local tmp_template + tmp_template="$(mktemp)" + echo "[*] Fetching latest template from $GITHUB_RAW ..." + if ! curl -fsSL --max-time 15 "$GITHUB_RAW/deploy/docker-compose.template.yml" -o "$tmp_template"; then + echo "[!] Download failed" + rm -f "$tmp_template" + return 1 + fi + + local local_keys remote_keys new_keys removed_keys + local_keys="$(extract_env_keys "$local_compose" | sort -u)" + remote_keys="$(extract_env_keys "$tmp_template" | sort -u)" + new_keys="$(comm -23 <(echo "$remote_keys") <(echo "$local_keys"))" + removed_keys="$(comm -13 <(echo "$remote_keys") <(echo "$local_keys"))" + + if [[ -z "$new_keys" && -z "$removed_keys" ]]; then + echo "[✓] Template already in sync." + rm -f "$tmp_template" + return 0 + fi + + if [[ -n "$new_keys" ]]; then + echo "[+] New ENV keys to merge:" + echo "$new_keys" | sed 's/^/ + /' + fi + if [[ -n "$removed_keys" ]]; then + echo "[-] Local-only ENV keys (will be kept untouched):" + echo "$removed_keys" | sed 's/^/ - /' + fi + + if [[ -z "$new_keys" ]]; then + rm -f "$tmp_template" + return 0 + fi + + echo + read -r -p "Merge new ENV keys into local docker-compose.yml? [y/N]: " ans "${local_compose}.new" + else + awk -v block="$insert_block" ' + /^ environment:/ { in_env=1 } + in_env && /^ [a-z]/ && !/^ environment:/ && !inserted { + printf "%s", block + inserted = 1 + } + { print } + ' "$local_compose" > "${local_compose}.new" + fi + + mv "${local_compose}.new" "$local_compose" + rm -f "$tmp_template" + echo "[✓] Merged. Run 'chat2api restart' to apply." +} + +# ============================================================ +# D: 单实例 → 多实例迁移 +# ============================================================ +cmd_migrate() { + local mode="${1:-prep}" + + if is_multi_install; then + echo "[i] Already in multi-instance mode. Nothing to do." + return 0 + fi + + case "$mode" in + prep) cmd_migrate_prep ;; + apply) cmd_migrate_apply ;; + rollback) cmd_migrate_rollback "${2:-}" ;; + *) + echo "Usage: chat2api migrate [prep|apply|rollback ]" + echo " prep Backup current install + generate accounts.csv (safe)" + echo " apply Stop single-instance + start multi (destructive)" + echo " rollback Restore from a previous backup directory" + return 1 + ;; + esac +} + +cmd_migrate_prep() { + local multi_dir="$INSTALL_DIR/deploy/multi" + local backup_dir="${INSTALL_DIR}.backup-$(date +%Y%m%d-%H%M%S)" + + if [[ ! -f "$INSTALL_DIR/.env" ]]; then + echo "[!] $INSTALL_DIR/.env not found; cannot migrate" + return 1 + fi + if [[ ! -d "$multi_dir" ]]; then + echo "[!] $multi_dir not found." + echo " Update repo first (re-run install.sh or git pull) so deploy/multi/ exists." + return 1 + fi + + echo "============================================================" + echo " chat2api migrate prep (Single → Multi-instance)" + echo "============================================================" + echo " Source: $INSTALL_DIR (single-instance)" + echo " Backup: $backup_dir" + echo " Multi: $multi_dir" + echo + echo "[!] WARNING: This step is non-destructive (only backups + generates csv)." + echo " 'chat2api migrate apply' is the destructive step." + echo + read -r -p "Continue? [y/N]: " ans /dev/null || true + echo "[✓] Backup ready." + + local csv="$multi_dir/accounts.csv" + local example_csv="$multi_dir/accounts.example.csv" + local tokens_file="$INSTALL_DIR/data/token.txt" + + if [[ -f "$csv" ]]; then + echo "[i] $csv already exists; skipping CSV generation" + else + echo "slug,proxy_url,note" > "$csv" + if [[ -f "$tokens_file" ]]; then + local count=0 i=0 + while IFS= read -r line; do + [[ -z "$line" || "$line" =~ ^# ]] && continue + i=$((i+1)) + printf 'acc%d,,migrated token #%d\n' "$i" "$i" >> "$csv" + count=$i + done < "$tokens_file" + if [[ "$count" -eq 0 ]]; then + echo "acc1,,migrated (please replace with real account)" >> "$csv" + echo "[!] $tokens_file empty; created template csv with 1 placeholder" + else + echo "[✓] Generated $csv with $count account slot(s)" + fi + else + echo "acc1,,migrated (please replace with real account)" >> "$csv" + echo "[!] $tokens_file not found; created template csv with 1 placeholder" + fi + fi + + cat <&1 | tail -5) || true + + echo "[*] Bringing up multi-instance..." + if (cd "$multi_dir" && ./manage.sh init); then + echo + echo "[✓] Migration applied." + echo " chat2api status # verify" + echo " chat2api secrets # see new credentials" + else + echo "[!] manage.sh init failed; you can rollback:" + echo " chat2api migrate rollback " + return 1 + fi +} + +cmd_migrate_rollback() { + local backup_dir="${1:-}" + if [[ -z "$backup_dir" ]]; then + echo "Usage: chat2api migrate rollback " + echo + echo "Available backups:" + ls -d "${INSTALL_DIR}.backup-"* 2>/dev/null || echo " (none found)" + return 1 + fi + if [[ ! -d "$backup_dir" ]]; then + echo "[!] $backup_dir not found" + return 1 + fi + + echo "[!] Rollback will:" + echo " 1. Stop multi-instance (if running)" + echo " 2. Restore $INSTALL_DIR from $backup_dir" + echo " 3. Restart single-instance" + echo + read -r -p 'Type "yes" to proceed: ' ans &1 | tail -5) || true + fi + + local trash="${INSTALL_DIR}.discarded-$(date +%Y%m%d-%H%M%S)" + mv "$INSTALL_DIR" "$trash" + cp -a "$backup_dir" "$INSTALL_DIR" + echo "[*] Restored from backup; old install moved to $trash (delete after verifying)" + + (cd "$INSTALL_DIR" && run_compose up -d) + echo "[✓] Rollback done. Run 'chat2api status' to verify." +} + +# ============================================================ +# Help +# ============================================================ show_help() { if is_multi_install; then cat <<'EOF' Usage: chat2api Multi-instance commands: - update Re-generate config and recreate services - start Same as update - restart Same as update - stop Stop all multi-instance services - status Show multi-instance status and sampled egress IPs - verify Verify admin/tokens routing for all instances - logs Tail one instance logs - shell Enter one instance shell - secrets Print instance auth/admin secrets - admin Print orchestrator/admin entry hints - path Print install directory - help Show this help + update Re-generate config and recreate services + start Same as update + restart Same as update + stop Stop all multi-instance services + status Show multi-instance status and sampled egress IPs + verify Verify admin/tokens routing for all instances + logs Tail one instance logs + shell Enter one instance shell + secrets Print instance auth/admin secrets + admin Print orchestrator/admin entry hints + path Print install directory + help Show this help Any other command is passed through to: deploy/multi/manage.sh EOF @@ -92,20 +438,31 @@ EOF cat <<'EOF' Usage: chat2api -Commands: - update Pull latest image and recreate containers - restart Restart services - stop Stop services - start Start services - status Show compose status - logs Tail chat2api logs - admin Print admin login URL - api Print API base URL - path Print install directory - help Show this help +Single-instance commands: + update Pull latest image and recreate containers + (also reports if upstream template has new ENV keys) + sync-template Merge new ENV keys from upstream template into + local docker-compose.yml (interactive, makes backup) + migrate prep Prepare migration to multi-instance: + backup install dir + generate deploy/multi/accounts.csv + migrate apply Stop single, start multi (destructive) + migrate rollback + Restore single-instance from a previous backup + restart Restart services + stop Stop services + start Start services + status Show compose status + logs Tail chat2api logs + admin Print admin login URL + api Print API base URL + path Print install directory + help Show this help EOF } +# ============================================================ +# Dispatcher +# ============================================================ command_name="${1:-help}" case "$command_name" in @@ -115,8 +472,16 @@ case "$command_name" in else run_compose pull run_compose up -d + # 提示是否有新 ENV 待合并(不强制) + cmd_sync_template_check || true fi ;; + sync-template) + cmd_sync_template + ;; + migrate) + cmd_migrate "${@:2}" + ;; restart) if is_multi_install; then run_multi_manage apply diff --git a/deploy/docker-compose.template.yml b/deploy/docker-compose.template.yml index 6ae79864..5746d333 100644 --- a/deploy/docker-compose.template.yml +++ b/deploy/docker-compose.template.yml @@ -46,6 +46,13 @@ services: INIT_APPLY_ON_EMPTY: 'true' INIT_FORCE: 'false' + # ============ Session Sticky(LibreChat → New-API → chat2api 链路下的窗口级会话续接) ============ + # 默认开启;仅在 request body 携带 librechat_conversation_id 时生效,对裸 OpenAI 客户端无影响 + ENABLE_SESSION_STICKY: 'true' + SESSION_TTL_DAYS: '30' + SESSION_LC_FIELD: 'librechat_conversation_id' + SESSION_TRIM_TO_LAST_USER: 'true' + healthcheck: test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:5005/${API_PREFIX}/admin/login > /dev/null || exit 1"] interval: 30s diff --git a/deploy/multi/generate.py b/deploy/multi/generate.py index 5d81e929..35063196 100755 --- a/deploy/multi/generate.py +++ b/deploy/multi/generate.py @@ -179,6 +179,13 @@ def render_env(acc: Account) -> str: networks: [c2a-net] labels: com.centurylinklabs.watchtower.enable: 'true' + # 资源/权限边界:单实例故障不污染母机与同行容器 + cap_drop: [ALL] + security_opt: + - no-new-privileges:true + pids_limit: 200 + mem_limit: 512m + cpus: '0.5' environment: TZ: 'Asia/Shanghai' CHATGPT_BASE_URL: 'https://chatgpt.com' @@ -190,11 +197,14 @@ def render_env(acc: Account) -> str: AUTO_SEED: 'true' RANDOM_TOKEN: 'false' RETRY_TIMES: '3' - ENABLE_ANTIBAN: 'false' - STRICT_IP_BINDING: 'false' + # 一容器一账号 + 独立住宅 IP:默认开启风控规避层并强制 IP 绑定 + ENABLE_ANTIBAN: 'true' + STRICT_IP_BINDING: 'true' BUCKET_MAX_ACCOUNTS_PER_IP: '1' - ACCOUNT_MIN_INTERVAL_SECONDS: '60' - FREE_ACCOUNT_MIN_INTERVAL_SECONDS: '180' + # 账号级冷却已关闭(一容器一账号,外部客户端节奏决定 QPS); + # 429/403 熔断退避走 CIRCUIT_* 独立路径,不受影响。 + ACCOUNT_MIN_INTERVAL_SECONDS: '0' + FREE_ACCOUNT_MIN_INTERVAL_SECONDS: '0' ACCOUNT_COOLDOWN_JITTER: '0.3' ACCOUNT_MAX_WAIT_SECONDS: '30' IP_GEO_PROVIDER: 'ip-api' @@ -203,6 +213,13 @@ def render_env(acc: Account) -> str: CIRCUIT_BUCKET_HEAL_MINUTES: '30' INIT_APPLY_ON_EMPTY: 'true' LOG_BUFFER_SIZE: '3000' + # Session Sticky (LibreChat → New-API → chat2api 链路下的窗口级会话续接) + # 默认开启:依赖 LibreChat 在 request body 注入 librechat_conversation_id(librechat.yaml addParams) + # 与 New-API 的 Channel Affinity(同 lc_conv_id 永远命中同一渠道)协作;不会影响不带该字段的请求 + ENABLE_SESSION_STICKY: 'true' + SESSION_TTL_DAYS: '30' + SESSION_LC_FIELD: 'librechat_conversation_id' + SESSION_TRIM_TO_LAST_USER: 'true' services: """ diff --git a/utils/configs.py b/utils/configs.py index ce866fc6..1f97fb48 100644 --- a/utils/configs.py +++ b/utils/configs.py @@ -138,6 +138,19 @@ def is_true(x): circuit_dead_account_recheck_hours = int(os.getenv('CIRCUIT_DEAD_ACCOUNT_RECHECK_HOURS', 24)) circuit_bucket_heal_minutes = int(os.getenv('CIRCUIT_BUCKET_HEAL_MINUTES', 30)) +# ========================= Session Sticky (LibreChat 会话粘性) ========================= +# 用于 LibreChat → New-API → chat2api 链路;将 LibreChat 端 conversationId +# 翻译为 ChatGPT 服务端 conversation_id,实现窗口级会话连续。默认关闭。 +enable_session_sticky = is_true(os.getenv('ENABLE_SESSION_STICKY', False)) +# SQLite 文件路径(默认在 data 卷内,跟随实例数据;与 utils/globals.DATA_FOLDER 保持一致) +session_db_path = os.getenv('SESSION_DB_PATH', os.path.join('data', 'sessions.db')) +# 多少天未更新的映射会被清理(cleanup_expired 调用时生效) +session_ttl_days = int(os.getenv('SESSION_TTL_DAYS', 30)) +# request body 中携带 LibreChat conversationId 的字段名(默认与 librechat.yaml addParams 对齐) +session_lc_field = os.getenv('SESSION_LC_FIELD', 'librechat_conversation_id') +# 命中映射时是否把 messages[] 截短到最后一条 user message(依赖 ChatGPT 服务端续接历史,省 token) +session_trim_to_last_user = is_true(os.getenv('SESSION_TRIM_TO_LAST_USER', True)) + with open('version.txt') as f: version = f.read().strip() @@ -192,4 +205,11 @@ def is_true(x): logger.info("IP_GEO_PROVIDER: " + str(ip_geo_provider)) logger.info("CIRCUIT_429_COOLDOWN: " + str(circuit_429_cooldown)) logger.info("CIRCUIT_403_COOLDOWN: " + str(circuit_403_cooldown)) +logger.info("--------------------- Session Sticky -----------------------") +logger.info("ENABLE_SESSION_STICKY: " + str(enable_session_sticky)) +if enable_session_sticky: + logger.info("SESSION_DB_PATH: " + str(session_db_path)) + logger.info("SESSION_TTL_DAYS: " + str(session_ttl_days)) + logger.info("SESSION_LC_FIELD: " + str(session_lc_field)) + logger.info("SESSION_TRIM_TO_LAST_USER: " + str(session_trim_to_last_user)) logger.info("-" * 60) From 230eefe6bebcc809d39b9210f1513466944050f8 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Thu, 7 May 2026 17:28:13 +0800 Subject: [PATCH 50/96] =?UTF-8?q?=E4=BC=98=E5=8C=96readme=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 156 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 153 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 55de8512..0e6a479a 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ curl -fsSL https://raw.githubusercontent.com/nanashiwang/chat2api/main/deploy/in | 🔐 **安全加固** | IP 白名单 / HttpOnly / CSRF / 密码隔离 / CF 指引 | [SECURITY](docs/SECURITY.md) | | 🔄 **UI 代理热加载** | 添加/删除代理即时生效,不需重启 | [FEATURES#3](docs/FEATURES.md#3-管理后台增强) | | 🎯 **新版 Token 识别** | 支持 `rt_*` 新格式 + `sess-*` SessionToken + chat_refresh 现代化 | [FEATURES#6](docs/FEATURES.md#6-新版-token-支持) | +| 🧩 **一容器一账号编排** | `deploy/multi/` 提供生成器 + nginx 路径分发 + orchestrator 面板,N 个账号 = N 个隔离容器 | [部署:多实例](#多实例一容器一账号) | +| 🔗 **LibreChat 会话续接** | request body 携带 `librechat_conversation_id` 即可让同窗口在 ChatGPT 端续会话(节省 token + 用上账号原生记忆) | [LibreChat 集成](#librechat--new-api-集成) | ### 核心运维流程 @@ -68,6 +70,127 @@ document.cookie.split(';').filter(x=>x.includes('session-token')).join('; ') --- +## LibreChat / New-API 集成 + +> 适用场景:`LibreChat → New-API → chat2api → ChatGPT` 链路下,希望**同一对话窗口在 ChatGPT 服务端续会话**(账号原生 Memory 自动累积,每次只发最新一条 user message 节省 token)。 + +### 工作原理 + +``` +[LibreChat 窗口 X] [chat2api] + messages = [system, u1, a1, u2, a2, u3] ① 看到 librechat_conversation_id + body 含 librechat_conversation_id ② 查 sqlite 映射 → ChatGPT conv_id + ↓ ③ 命中:注入 conversation_id + +[New-API Channel Affinity] parent_message_id,messages 截短 + 按 lc_conv_id 路由到固定渠道 ④ 转发 ChatGPT + ↓ ⑤ 嗅探响应里的 conv_id 回写映射 +[chat2api 实例 K] + ↓ +[ChatGPT 服务端] 续接 conv,自动用上账号原生记忆 +``` + +### 三段配置(chat2api 已默认开启) + +#### 1. LibreChat(`librechat.yaml`,零代码) + +```yaml +endpoints: + custom: + - name: "chat2api" + apiKey: "${NEWAPI_KEY}" + baseURL: "https://your-newapi/v1" + addParams: + librechat_conversation_id: "{{LIBRECHAT_BODY_CONVERSATIONID}}" + librechat_user_id: "{{LIBRECHAT_USER_ID}}" + models: + default: ["gpt-4o", "gpt-4o-mini", "o1-preview", "o3-mini"] +``` + +#### 2. New-API Channel Affinity(后台 UI 配置) + +```json +{ + "enabled": true, + "rules": [{ + "name": "librechat_conv_sticky", + "model_regex": ["gpt.*", "o1.*", "o3.*"], + "key_sources": [{"type": "gjson", "path": "librechat_conversation_id"}], + "ttl_seconds": 86400, + "switch_on_success": true, + "skip_retry_on_failure": false + }] +} +``` + +#### 3. chat2api(默认开启,对裸 OpenAI 客户端无影响) + +| 环境变量 | 默认 | 说明 | +|---|---|---| +| `ENABLE_SESSION_STICKY` | `true` | 总开关 | +| `SESSION_TTL_DAYS` | `30` | 多少天未活跃自动清理映射 | +| `SESSION_LC_FIELD` | `librechat_conversation_id` | request body 中携带 LibreChat conversationId 的字段名 | +| `SESSION_TRIM_TO_LAST_USER` | `true` | 命中映射时是否把 messages[] 截到只含最后一条 user(依赖 ChatGPT 服务端续历史) | + +数据存储:`/app/data/sessions.db`(SQLite,跟随实例数据卷)。 +映射失效(ChatGPT 端 conv 被删)→ 自动清理 + `async_retry` 重新建对话。 + +### 验证 + +```bash +# 在某 chat2api 实例容器内 +docker exec -it c2a- sqlite3 /app/data/sessions.db \ + "SELECT * FROM lc_session_map LIMIT 5;" + +# 查看命中日志 +docker logs c2a- | grep session_sticky +# 应有: [session_sticky] hit lc=lc-uuid... → cv=cv-XXX... +``` + +--- + +## 多实例(一容器一账号) + +> 适用场景:N 个 ChatGPT 账号 + 多用户并发;通过 `deploy/multi/` 把每个账号编排到独立容器(独立代理 / 独立指纹 / 独立 cookie 卷),单账号被风控时其他账号不连坐。 + +### 一句话部署 + +```bash +# 1. 先用一键脚本把 chat2api 装好(任意模式) +curl -fsSL https://raw.githubusercontent.com/nanashiwang/chat2api/main/deploy/install.sh | bash + +# 2. 切到 multi 目录,初始化 N 账号编排 +cd ~/chat2api/deploy/multi +cp accounts.example.csv accounts.csv +vi accounts.csv # 每行一个账号: slug,proxy_url,note +./manage.sh init # 生成 compose / nginx / 启动全部容器 +./manage.sh install-cli # 让全局 chat2api 命令切到多实例模式 +``` + +### 已默认应用的工程加固(`deploy/multi/generate.py`) + +| 类别 | 项 | 默认 | +|---|---|---| +| 风控 | `ENABLE_ANTIBAN` | `true` | +| 风控 | `STRICT_IP_BINDING` | `true` | +| 风控 | `BUCKET_MAX_ACCOUNTS_PER_IP` | `1` | +| 风控 | 账号级冷却 (`ACCOUNT_MIN_INTERVAL_SECONDS`) | `0`(一容器一账号无需自限速) | +| 风控 | 429/403 熔断退避 | 1800/3600s | +| 安全 | `cap_drop: [ALL]` + `no-new-privileges` | ✅ | +| 资源 | `mem_limit / cpus / pids_limit` | 512m / 0.5 / 200 | +| 续会话 | `ENABLE_SESSION_STICKY` | `true` | + +### 从单实例迁移到多实例 + +```bash +chat2api migrate prep # 备份 + 生成 accounts.csv 模板(安全) +vi ~/chat2api/deploy/multi/accounts.csv +chat2api migrate apply # 停单 + 启多(需输 yes 确认) +# 不满意可回滚: +chat2api migrate rollback ~/chat2api.backup-YYYYMMDD-HHMMSS +``` + +--- + ## 交流群 [https://t.me/chat2api](https://t.me/chat2api) @@ -197,6 +320,16 @@ curl --location 'http://127.0.0.1:5005/v1/chat/completions' \ | | RANDOM_TOKEN | `true` | `true` | 是否随机选取后台 `Token` ,开启后随机后台账号,关闭后为顺序轮询 | | 网关功能 | ENABLE_GATEWAY | `false` | `false` | 是否启用网关模式,开启后可以使用镜像站,但也将会不设防 | | | AUTO_SEED | `false` | `true` | 是否启用随机账号模式,默认启用,输入`seed`后随机匹配后台`Token`。关闭之后需要手动对接接口,来进行`Token`管控。 | +| Antiban | ENABLE_ANTIBAN | `true` | `false`(多实例 generate.py 默认 `true`) | 风控规避层总开关,开启后启用 IP 粘性桶 / 地域一致性 / 熔断自愈 | +| | STRICT_IP_BINDING | `true` | `true` | 严格 IP 绑定,开启后无匹配代理时拒绝(不退化到母机直连) | +| | BUCKET_MAX_ACCOUNTS_PER_IP | `1` | `5`(多实例默认 `1`) | 每个 IP 桶容纳的账号数;一容器一账号 + 独立住宅 IP 时设 1 | +| | ACCOUNT_MIN_INTERVAL_SECONDS | `60` | `60` | Plus/Team 账号最小请求间隔;多实例下默认 `0`(不限速) | +| | CIRCUIT_429_COOLDOWN | `1800` | `1800` | 429 触发后该账号冷却秒数(独立于账号级冷却,始终生效) | +| | CIRCUIT_403_COOLDOWN | `3600` | `3600` | 403/cf_chl_opt 触发后 IP 桶冷冻秒数 | +| Session Sticky | ENABLE_SESSION_STICKY | `true` | `false`(一键部署模板默认 `true`) | LibreChat → New-API → chat2api 链路下的窗口级会话续接总开关 | +| | SESSION_LC_FIELD | `librechat_conversation_id` | `librechat_conversation_id` | request body 中携带 LibreChat conversationId 的字段名(与 librechat.yaml addParams 对齐) | +| | SESSION_TTL_DAYS | `30` | `30` | 多少天未活跃的映射会被自动清理 | +| | SESSION_TRIM_TO_LAST_USER | `true` | `true` | 命中映射时是否把 messages[] 截到只含最后一条 user(节省 token,依赖 ChatGPT 服务端续历史) | ## 部署 @@ -248,9 +381,26 @@ docker-compose up -d 本分支的一键部署脚本会自动安装宿主管理命令,部署完成后可直接使用: ```bash -chat2api status -chat2api update -chat2api logs +# 通用(单/多实例自动适配) +chat2api status # 容器状态(多实例下含出口 IP 抽样) +chat2api update # 拉镜像并重建(多实例下走 manage.sh apply) +chat2api logs [slug] # 实时日志(多实例需指定 slug) +chat2api restart # 重启 +chat2api stop # 停止 +chat2api path # 打印安装目录 + +# 单实例专属 +chat2api sync-template # 检测并合并上游 docker-compose 模板的新 ENV + # (update 完会自动提示,但不强制改写) +chat2api migrate prep # 准备从单实例迁到多实例(仅备份+生成 csv,安全) +chat2api migrate apply # 切换到多实例(destructive,需输 yes 确认) +chat2api migrate rollback # 从备份回滚到单实例 + +# 多实例专属 +chat2api verify # 校验每实例的 admin / tokens 路由 +chat2api secrets # 打印每实例 AUTH/ADMIN 凭据 + orchestrator 入口 +chat2api shell # 进入指定实例容器 shell +chat2api admin # 打印管理后台访问 URL ``` 旧机器如果曾经手动部署,重新跑一键部署脚本即可沿用现有配置并补装命令: From 74e9cf3267ed3f5abee2e83491e03b1d07280fe8 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Thu, 7 May 2026 17:50:42 +0800 Subject: [PATCH 51/96] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 328 ++++++++++++++++++++++++++---------------------------- 1 file changed, 160 insertions(+), 168 deletions(-) diff --git a/README.md b/README.md index 0e6a479a..b0ed16ff 100644 --- a/README.md +++ b/README.md @@ -191,208 +191,176 @@ chat2api migrate rollback ~/chat2api.backup-YYYYMMDD-HHMMSS --- -## 交流群 +## 功能总览 -[https://t.me/chat2api](https://t.me/chat2api) +### OpenAI 兼容接口 -要提问请先阅读完仓库文档,尤其是常见问题部分。 +- 流式 / 非流式响应 +- 模型支持:免登录 `GPT-3.5`、`GPT-4 / 4o / 4o-mini`、`o1 / o1-mini / o1-preview / o1-pro`、`o3-mini / o3-mini-high` +- GPTs(`gpt-4-gizmo-g-*`)/ Team / Plus 账号 / 文件 / 图片 / 联网 / 画图 +- AccessToken / RefreshToken / SessionToken(`rt_*` 新格式)多 Tokens 轮询 + 失败自动重试 +- O3 / O1 系列推理过程输出 +- conversation_id / parent_message_id 续接(用于 [LibreChat 集成](#librechat--new-api-集成)) -提问时请提供: +### 官网镜像(Gateway 模式) -1. 启动日志截图(敏感信息打码,包括环境变量和版本号) -2. 报错的日志信息(敏感信息打码) -3. 接口返回的状态码和响应体 - -## 功能 - -### 最新版本号存于 `version.txt` - -### 逆向API 功能 -> - [x] 流式、非流式传输 -> - [x] 免登录 GPT-3.5 对话 -> - [x] GPT-3.5 模型对话(传入模型名不包含 gpt-4,则默认使用 gpt-3.5,也就是 text-davinci-002-render-sha) -> - [x] GPT-4 系列模型对话(传入模型名包含: gpt-4,gpt-4o,gpt-4o-mini,gpt-4-moblie 即可使用对应模型,需传入 AccessToken) -> - [x] O1 系列模型对话(传入模型名包含 o1-preview,o1-mini 即可使用对应模型,需传入 AccessToken) -> - [x] GPT-4 模型画图、代码、联网 -> - [x] 支持 GPTs(传入模型名:gpt-4-gizmo-g-*) -> - [x] 支持 Team Plus 账号(需传入 team account id) -> - [x] 上传图片、文件(格式为 API 对应格式,支持 URL 和 base64) -> - [x] 可作为网关使用,可多机分布部署 -> - [x] 多账号轮询,同时支持 `AccessToken` 和 `RefreshToken` -> - [x] 请求失败重试,自动轮询下一个 Token -> - [x] Tokens 管理,支持上传、清除 -> - [x] 定时使用 `RefreshToken` 刷新 `AccessToken` / 每次启动将会全部非强制刷新一次,每4天晚上3点全部强制刷新一次。 -> - [x] 支持文件下载,需要开启历史记录 -> - [x] 支持 `O3-mini/high`、`O1/mini/Pro` 等模型推理过程输出 - -### 官网镜像 功能 -> - [x] 支持官网原生镜像 -> - [x] 后台账号池随机抽取,`Seed` 设置随机账号 -> - [x] 输入 `RefreshToken` 或 `AccessToken` 直接登录使用 -> - [x] 支持 `O3-mini/high`、`O1/mini/Pro`、`GPT-4/4o/mini` -> - [x] 敏感信息接口禁用、部分设置接口禁用 -> - [x] /login 登录页面,注销后自动跳转到登录页面 -> - [x] /?token=xxx 直接登录, xxx 为 `RefreshToken` 或 `AccessToken` 或 `SeedToken` (随机种子) -> - [x] 支持不同 SeedToken 会话隔离 -> - [x] 支持 `GPTs` 商店 -> - [x] 支持 `DeepReaserch`、`Canvas` 等官网独有功能 -> - [x] 支持切换各国语言 - - -> TODO -> - [ ] 暂无,欢迎提 `issue` - -## 逆向API - -完全 `OpenAI` 格式的 API ,支持传入 `AccessToken` 或 `RefreshToken`,可用 GPT-4, GPT-4o, GPT-4o-Mini, GPTs, O1-Pro, O1, O1-Mini, O3-Mini, O3-Mini-High: +`ENABLE_GATEWAY=true` 后启用: -```bash -curl --location 'http://127.0.0.1:5005/v1/chat/completions' \ ---header 'Content-Type: application/json' \ ---header 'Authorization: Bearer {{Token}}' \ ---data '{ - "model": "gpt-3.5-turbo", - "messages": [{"role": "user", "content": "Say this is a test!"}], - "stream": true - }' -``` +- `/login` 登录页 + 后台账号池随机抽取(`Seed` 设置随机账号) +- `/?token=xxx` 直接登录(值为 RefreshToken / AccessToken / SeedToken) +- 不同 SeedToken 会话隔离 +- 支持 GPTs 商店、DeepResearch、Canvas +- 多语言切换、敏感接口禁用 -将你账号的 `AccessToken` 或 `RefreshToken` 作为 `{{ Token }}` 传入。 -也可填写你设置的环境变量 `Authorization` 的值, 将会随机选择后台账号 +### 工程化能力(nanashiwang 分支) -如果有team账号,可以传入 `ChatGPT-Account-ID`,使用 Team 工作区: +完整能力清单见上文 [✨ nanashiwang 分支新特性](#-nanashiwang-分支新特性) 表格。 -- 传入方式一: -`headers` 中传入 `ChatGPT-Account-ID`值 +--- -- 传入方式二: -`Authorization: Bearer ,` +## API 使用 -如果设置了 `AUTHORIZATION` 环境变量,可以将设置的值作为 `{{ Token }}` 传入进行多 Tokens 轮询。 +### 调用示例 -> - `AccessToken` 获取: chatgpt官网登录后,再打开 [https://chatgpt.com/api/auth/session](https://chatgpt.com/api/auth/session) 获取 `accessToken` 这个值。 -> - `RefreshToken` 获取: 此处不提供获取方法。 -> - 免登录 gpt-3.5 无需传入 Token。 +```bash +curl --location 'http://127.0.0.1:5005/${API_PREFIX}/v1/chat/completions' \ + --header 'Content-Type: application/json' \ + --header 'Authorization: Bearer {{Token}}' \ + --data '{ + "model": "gpt-4o", + "messages": [{"role": "user", "content": "Say this is a test!"}], + "stream": true + }' +``` -## Tokens 管理 +`{{Token}}` 可以是: -1. 配置环境变量 `AUTHORIZATION` 作为 `授权码` ,然后运行程序。 +- 你账号的 `AccessToken` / `RefreshToken` —— 单账号直连 +- 你设置的 `AUTHORIZATION` 环境变量值 —— 后台 Tokens 轮询(推荐) +- LibreChat → New-API 流转下来的 New-API key —— 见 [LibreChat 集成](#librechat--new-api-集成) -2. 访问 `/tokens` 或者 `/{api_prefix}/tokens` 可以查看现有 Tokens 数量,也可以上传新的 Tokens ,或者清空 Tokens。 +### Team 账号 -3. 请求时传入 `AUTHORIZATION` 中配置的 `授权码` 即可使用轮询的Tokens进行对话 +传 `ChatGPT-Account-ID` header,或将其拼接在 Authorization: -![tokens.png](docs/tokens.png) +``` +Authorization: Bearer , +``` -## 官网原生镜像 +### Tokens 来源 -1. 配置环境变量 `ENABLE_GATEWAY` 为 `true`,然后运行程序, 注意开启后别人也可以直接通过域名访问你的网关。 +- **AccessToken**:登录 chatgpt.com 后访问 [`https://chatgpt.com/api/auth/session`](https://chatgpt.com/api/auth/session) 取 `accessToken` +- **RefreshToken / SessionToken**:浏览器登录后 F12 抓 cookie,**强烈建议走 Harvester UI**([`docs/COOKIE_HARVEST.md`](docs/COOKIE_HARVEST.md)) +- **免登录 GPT-3.5**:无需 Token -2. 在 Tokens 管理页面上传 `RefreshToken` 或 `AccessToken` +### Tokens 管理 -3. 访问 `/login` 到登录页面 +1. 设置 `AUTHORIZATION` 环境变量作为授权码 +2. 访问管理后台(`/{api_prefix}/admin/login`)→ Tokens 管理 / Harvester +3. 请求时把 `AUTHORIZATION` 当成 `APIKEY` 传入即可使用轮询 -![login.png](docs/login.png) +--- -4. 进入官网原生镜像页面使用 +## 环境变量参考 + +> 表格仅列**最常用**变量;未列出的请勿设置或使用默认值。完整参考可在容器启动日志的 `Environment variables:` 区域查看。 + +| 分类 | 变量 | 默认值 | 说明 | +|---|---|---|---| +| 安全 | `API_PREFIX` | `None` | API 路径前缀(推荐设置,避免被扫描) | +| 安全 | `AUTHORIZATION` | `[]` | 授权码(多个用英文逗号分隔),用于 Tokens 轮询 | +| 安全 | `ADMIN_PASSWORD` | `None` | 管理后台登录密码 | +| 安全 | `ADMIN_IP_WHITELIST` | (空) | 管理后台 IP 白名单(CIDR 支持),强烈建议配置 | +| 请求 | `CHATGPT_BASE_URL` | `https://chatgpt.com` | 上游网关,多个用逗号分隔 | +| 请求 | `PROXY_URL` | `[]` | 全局代理 URL,多个用逗号分隔(也可 UI 配置) | +| 功能 | `HISTORY_DISABLED` | `true` | 不保存聊天记录到 ChatGPT 服务端 | +| 功能 | `ENABLE_LIMIT` | `true` | 不突破官方次数限制(防封号) | +| 功能 | `SCHEDULED_REFRESH` | `false` | 定时刷新 AccessToken | +| 功能 | `RANDOM_TOKEN` | `true` | 随机选取后台 Token(关闭则顺序轮询) | +| 网关 | `ENABLE_GATEWAY` | `false` | 启用官网镜像;开启后默认无认证,需配 `AUTH_KEY` 或 IP 白名单 | +| 网关 | `AUTO_SEED` | `true` | 启用随机账号模式(`seed` 参数自动匹配账号) | +| Antiban | `ENABLE_ANTIBAN` | `false`(multi 默认 `true`) | 风控规避层:IP 粘性桶 / 地域一致性 / 熔断自愈 | +| Antiban | `STRICT_IP_BINDING` | `true` | 无匹配代理时拒绝(不退化到母机直连) | +| Antiban | `BUCKET_MAX_ACCOUNTS_PER_IP` | `5`(multi 默认 `1`) | 每 IP 桶容纳的账号数 | +| Antiban | `CIRCUIT_429_COOLDOWN` | `1800` | 429 触发后账号冷却秒数 | +| Antiban | `CIRCUIT_403_COOLDOWN` | `3600` | 403/cf_chl_opt 触发后 IP 桶冷冻秒数 | +| Session | `ENABLE_SESSION_STICKY` | `false`(一键部署默认 `true`) | LibreChat 窗口级会话续接总开关 | +| Session | `SESSION_LC_FIELD` | `librechat_conversation_id` | LibreChat conversationId 在 request body 的字段名 | +| Session | `SESSION_TTL_DAYS` | `30` | 多少天未活跃自动清理映射 | +| Session | `SESSION_TRIM_TO_LAST_USER` | `true` | 命中映射时把 messages 截到最后一条 user | + +详细文档: + +- Antiban 工作原理:[`docs/FEATURES.md#1-antiban-风控规避层`](docs/FEATURES.md#1-antiban-风控规避层) +- 安全加固:[`docs/SECURITY.md`](docs/SECURITY.md) +- Cookie 采集:[`docs/COOKIE_HARVEST.md`](docs/COOKIE_HARVEST.md) +- 会话续接(本仓库新增):[LibreChat 集成](#librechat--new-api-集成) -![chatgpt.png](docs/chatgpt.png) +--- -## 环境变量 +## 部署方式 -每个环境变量都有默认值,如果不懂环境变量的含义,请不要设置,更不要传空值,字符串无需引号。 +### 一键部署(推荐) -| 分类 | 变量名 | 示例值 | 默认值 | 描述 | -|------|-------------------|-------------------------------------------------------------|-----------------------|--------------------------------------------------------------| -| 安全相关 | API_PREFIX | `your_prefix` | `None` | API 前缀密码,不设置容易被人访问,设置后需请求 `/your_prefix/v1/chat/completions` | -| | AUTHORIZATION | `your_first_authorization`,
    `your_second_authorization` | `[]` | 你自己为使用多账号轮询 Tokens 设置的授权码,英文逗号分隔 | -| | AUTH_KEY | `your_auth_key` | `None` | 私人网关需要加`auth_key`请求头才设置该项 | -| 请求相关 | CHATGPT_BASE_URL | `https://chatgpt.com` | `https://chatgpt.com` | ChatGPT 网关地址,设置后会改变请求的网站,多个网关用逗号分隔 | -| | PROXY_URL | `http://ip:port`,
    `http://username:password@ip:port` | `[]` | 全局代理 URL,出 403 时启用,多个代理用逗号分隔 | -| | EXPORT_PROXY_URL | `http://ip:port`或
    `http://username:password@ip:port` | `None` | 出口代理 URL,防止请求图片和文件时泄漏源站 ip | -| 功能相关 | HISTORY_DISABLED | `true` | `true` | 是否不保存聊天记录并返回 conversation_id | -| | POW_DIFFICULTY | `00003a` | `00003a` | 要解决的工作量证明难度,不懂别设置 | -| | RETRY_TIMES | `3` | `3` | 出错重试次数,使用 `AUTHORIZATION` 会自动随机/轮询下一个账号 | -| | CONVERSATION_ONLY | `false` | `false` | 是否直接使用对话接口,如果你用的网关支持自动解决 `POW` 才启用 | -| | ENABLE_LIMIT | `true` | `true` | 开启后不尝试突破官方次数限制,尽可能防止封号 | -| | UPLOAD_BY_URL | `false` | `false` | 开启后按照 `URL+空格+正文` 进行对话,自动解析 URL 内容并上传,多个 URL 用空格分隔 | -| | SCHEDULED_REFRESH | `false` | `false` | 是否定时刷新 `AccessToken` ,开启后每次启动程序将会全部非强制刷新一次,每4天晚上3点全部强制刷新一次。 | -| | RANDOM_TOKEN | `true` | `true` | 是否随机选取后台 `Token` ,开启后随机后台账号,关闭后为顺序轮询 | -| 网关功能 | ENABLE_GATEWAY | `false` | `false` | 是否启用网关模式,开启后可以使用镜像站,但也将会不设防 | -| | AUTO_SEED | `false` | `true` | 是否启用随机账号模式,默认启用,输入`seed`后随机匹配后台`Token`。关闭之后需要手动对接接口,来进行`Token`管控。 | -| Antiban | ENABLE_ANTIBAN | `true` | `false`(多实例 generate.py 默认 `true`) | 风控规避层总开关,开启后启用 IP 粘性桶 / 地域一致性 / 熔断自愈 | -| | STRICT_IP_BINDING | `true` | `true` | 严格 IP 绑定,开启后无匹配代理时拒绝(不退化到母机直连) | -| | BUCKET_MAX_ACCOUNTS_PER_IP | `1` | `5`(多实例默认 `1`) | 每个 IP 桶容纳的账号数;一容器一账号 + 独立住宅 IP 时设 1 | -| | ACCOUNT_MIN_INTERVAL_SECONDS | `60` | `60` | Plus/Team 账号最小请求间隔;多实例下默认 `0`(不限速) | -| | CIRCUIT_429_COOLDOWN | `1800` | `1800` | 429 触发后该账号冷却秒数(独立于账号级冷却,始终生效) | -| | CIRCUIT_403_COOLDOWN | `3600` | `3600` | 403/cf_chl_opt 触发后 IP 桶冷冻秒数 | -| Session Sticky | ENABLE_SESSION_STICKY | `true` | `false`(一键部署模板默认 `true`) | LibreChat → New-API → chat2api 链路下的窗口级会话续接总开关 | -| | SESSION_LC_FIELD | `librechat_conversation_id` | `librechat_conversation_id` | request body 中携带 LibreChat conversationId 的字段名(与 librechat.yaml addParams 对齐) | -| | SESSION_TTL_DAYS | `30` | `30` | 多少天未活跃的映射会被自动清理 | -| | SESSION_TRIM_TO_LAST_USER | `true` | `true` | 命中映射时是否把 messages[] 截到只含最后一条 user(节省 token,依赖 ChatGPT 服务端续历史) | +零交互,自动装 Docker、生成随机凭据、启动服务、安装 `chat2api` 全局命令: -## 部署 +```bash +curl -fsSL https://raw.githubusercontent.com/nanashiwang/chat2api/main/deploy/install.sh | bash +``` -### Zeabur 部署 +可选环境变量: -[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/6HEGIZ?referralCode=LanQian528) +| 变量 | 用途 | +|---|---| +| `INSTALL_DIR` | 自定义安装目录(默认 `~/chat2api`) | +| `CHAT2API_PORT` | 监听端口(默认 `60403`) | +| `INTERACTIVE=1` | 交互式询问密码 / API 前缀 | -### 直接部署 +### 直接源码部署 ```bash -git clone https://github.com/LanQian528/chat2api +git clone https://github.com/nanashiwang/chat2api cd chat2api pip install -r requirements.txt python app.py ``` -### Docker 部署 - -您需要安装 Docker 和 Docker Compose。 +### Docker ```bash -docker run -d \ - --name chat2api \ +docker run -d --name chat2api \ -p 5005:5005 \ - lanqian528/chat2api:latest + -v $(pwd)/data:/app/data \ + -e AUTHORIZATION=sk-your-key \ + ghcr.io/nanashiwang/chat2api:latest ``` -### (推荐,可用 PLUS 账号) Docker Compose 部署 - -创建一个新的目录,例如 chat2api,并进入该目录: +### Docker Compose(自定义部署) ```bash -mkdir chat2api -cd chat2api +mkdir chat2api && cd chat2api +curl -fsSL https://raw.githubusercontent.com/nanashiwang/chat2api/main/deploy/docker-compose.template.yml -o docker-compose.yml +# 创建 .env 写入 ADMIN_PASSWORD / AUTHORIZATION / API_PREFIX +docker compose up -d ``` -在此目录中下载库中的 docker-compose.yml 文件: +> ⚠️ **不要使用** `docker-compose-warp.yml`:所有账号共用一个 WARP 出口 IP,多账号同 IP 是高危风控信号。请用一容器一账号 + 独立住宅代理([多实例编排](#多实例一容器一账号))。 -```bash -wget https://raw.githubusercontent.com/LanQian528/chat2api/main/docker-compose-warp.yml -``` +### chat2api CLI 命令 -修改 docker-compose-warp.yml 文件中的环境变量,保存后: +部署完成后全局命令自动可用,单/多实例自动适配: ```bash -docker-compose up -d -``` - -本分支的一键部署脚本会自动安装宿主管理命令,部署完成后可直接使用: - -```bash -# 通用(单/多实例自动适配) +# 通用 chat2api status # 容器状态(多实例下含出口 IP 抽样) -chat2api update # 拉镜像并重建(多实例下走 manage.sh apply) -chat2api logs [slug] # 实时日志(多实例需指定 slug) -chat2api restart # 重启 -chat2api stop # 停止 +chat2api update # 拉镜像并重建 +chat2api logs [slug] # 实时日志 +chat2api restart / stop / start chat2api path # 打印安装目录 # 单实例专属 chat2api sync-template # 检测并合并上游 docker-compose 模板的新 ENV - # (update 完会自动提示,但不强制改写) -chat2api migrate prep # 准备从单实例迁到多实例(仅备份+生成 csv,安全) +chat2api migrate prep # 准备从单实例迁到多实例(仅备份+生成 csv) chat2api migrate apply # 切换到多实例(destructive,需输 yes 确认) chat2api migrate rollback # 从备份回滚到单实例 @@ -403,38 +371,62 @@ chat2api shell # 进入指定实例容器 shell chat2api admin # 打印管理后台访问 URL ``` -旧机器如果曾经手动部署,重新跑一键部署脚本即可沿用现有配置并补装命令: +老机器升级最简方式: ```bash -curl -fsSL https://raw.githubusercontent.com/nanashiwang/chat2api/main/deploy/install.sh | bash +# 拉新版 chat2api.sh 脚本 +sudo curl -fsSL https://raw.githubusercontent.com/nanashiwang/chat2api/main/deploy/chat2api.sh \ + -o /usr/local/bin/chat2api && sudo chmod +x /usr/local/bin/chat2api +chat2api update # 拉镜像 +chat2api sync-template # 单实例:拉新 ENV ``` -如果安装目录不是默认的 `~/chat2api`: +--- -```bash -curl -fsSL https://raw.githubusercontent.com/nanashiwang/chat2api/main/deploy/install.sh | INSTALL_DIR=/你的/chat2api/目录 bash -``` +## 常见问题 +### 错误码 -## 常见问题 +| 状态码 | 原因 | 处理 | +|---|---|---| +| `401` | 当前 IP 不支持免登录 / 鉴权失败 | 换 IP / 配代理 / 检查 `AUTHORIZATION` | +| `403` | 风控触发(cf_chl_opt / 区域限制) | 看日志,配住宅代理;antiban 会自动冷却 IP 桶 | +| `429` | 1 小时内请求超限 | 等待或换 IP;antiban 会自动账号冷却 | +| `500` | 服务器内部错误 | 看 chat2api 日志 | +| `502` | 上游网关错误 | 换网络环境 / 检查代理 | + +### 已知情况 -> - 错误代码: -> - `401`:当前 IP 不支持免登录,请尝试更换 IP 地址,或者在环境变量 `PROXY_URL` 中设置代理,或者你的身份验证失败。 -> - `403`:请在日志中查看具体报错信息。 -> - `429`:当前 IP 请求1小时内请求超过限制,请稍后再试,或更换 IP。 -> - `500`:服务器内部错误,请求失败。 -> - `502`:服务器网关错误,或网络不可用,请尝试更换网络环境。 +- **日本 IP** 很多不支持免登录 GPT-3.5,建议美国 IP +- **GPT-4o 免费** 99% 账号支持,但按 IP 区域开启(日本/新加坡 IP 概率较高) +- **机房 IP**(DigitalOcean / WARP / Vultr 等)几乎已被风控;优先用住宅或移动蜂窝代理 -> - 已知情况: -> - 日本 IP 很多不支持免登,免登 GPT-3.5 建议使用美国 IP。 -> - 99%的账号都支持免费 `GPT-4o` ,但根据 IP 地区开启,目前日本和新加坡 IP 已知开启概率较大。 +### 提问前请准备 -> - 环境变量 `AUTHORIZATION` 是什么? -> - 是一个自己给 chat2api 设置的一个身份验证,设置后才可使用已保存的 Tokens 轮询,请求时当作 `APIKEY` 传入。 -> - AccessToken 如何获取? -> - chatgpt官网登录后,再打开 [https://chatgpt.com/api/auth/session](https://chatgpt.com/api/auth/session) 获取 `accessToken` 这个值。 +1. 启动日志截图(敏感信息打码,环境变量 + 版本号必备) +2. 报错日志(含 chat2api / nginx / 上游) +3. 接口返回的状态码和响应体 + +--- + +## 学习交流声明 + +> ⚠️ **本项目仅供学习与技术交流,请勿用于任何商业或违反 [OpenAI 服务条款](https://openai.com/policies/terms-of-use) 的用途。** +使用本项目即代表你已阅读并同意以下条款: + +- 仅出于个人学习、技术研究、交流目的使用 +- 不得用于任何形式的商业牟利 +- 不得用于任何违反 OpenAI 服务条款或所在地法律法规的活动 +- 一切因使用本项目产生的风险(账号被封、数据丢失、服务中断、法律责任等)由使用者**自行承担** +- 作者及贡献者不对使用本项目造成的任何直接或间接后果负责 + +如果你不接受上述任何一条,请立即停止使用并删除本项目。 + +本分支基于 [LanQian528/chat2api](https://github.com/LanQian528/chat2api) 二次开发,所有上游代码版权归原作者所有。 + +--- ## License -MIT License +MIT License \ No newline at end of file From d69308934f705742b1088a245458aa1118501963 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Mon, 11 May 2026 16:23:05 +0800 Subject: [PATCH 52/96] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E8=8A=B1=E5=BF=83token=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BF=9D?= =?UTF-8?q?=E8=AF=81=E8=B4=A6=E5=8F=B7=E4=B8=8D=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chatgpt/refreshToken.py | 225 +++++++++++++++++++++++++++++++++++----- utils/globals.py | 13 +++ 2 files changed, 211 insertions(+), 27 deletions(-) diff --git a/chatgpt/refreshToken.py b/chatgpt/refreshToken.py index 4ce36224..40f981be 100644 --- a/chatgpt/refreshToken.py +++ b/chatgpt/refreshToken.py @@ -1,3 +1,5 @@ +import asyncio +import base64 import hashlib import json import random @@ -15,9 +17,12 @@ openai_auth_token_url, proxy_url_list, ) -from utils.routing import get_bound_proxy +from utils.routing import get_bound_proxy, save_routing_config import utils.globals as globals +# 跨结构 key 迁移锁,防止多个并发刷新同时改 token_list/refresh_map/routing_config +_session_key_lock = asyncio.Lock() + def persist_refresh_map(): with open(globals.REFRESH_MAP_FILE, "w", encoding="utf-8") as f: @@ -30,6 +35,137 @@ def persist_error_tokens(): f.write(token + "\n") +def _decode_jwt_exp(jwt_token): + """解析 JWT payload 的 'exp' 字段(秒级时间戳);任何失败返回 0。""" + if not jwt_token or "." not in jwt_token: + return 0 + try: + payload_b64 = jwt_token.split(".")[1] + payload_b64 += "=" * (-len(payload_b64) % 4) + payload = json.loads(base64.urlsafe_b64decode(payload_b64.encode("ascii"))) + return int(payload.get("exp") or 0) + except Exception: + return 0 + + +def _extract_rotated_cookie(response, original_cookie): + """从响应里取 NextAuth 滚动续期回写的新 session-token。 + + 支持: + - 单片:`__Secure-next-auth.session-token=` + - 多片:`__Secure-next-auth.session-token.0=...; .1=...; .2=...` + 多片按下标排序后用 SESS_CHUNK_SEPARATOR('|||')拼接,与存储格式一致 + + 返回: + - 新 cookie 值(已剥除 cookie 名);与 original_cookie 相同则返回 None + - 解析失败或服务端未续期返回 None + """ + base_name = NEXTAUTH_COOKIE_NAME # __Secure-next-auth.session-token + chunks = {} # idx -> value;-1 代表单片 + try: + # curl_cffi 响应的 cookies 实际是 RequestsCookieJar(dict-like + .items()) + jar = getattr(response, "cookies", None) + if jar is not None: + try: + items = list(jar.items()) + except Exception: + items = [(c.name, c.value) for c in jar] + for name, value in items: + if not name or not value: + continue + if name == base_name: + chunks[-1] = value + elif name.startswith(base_name + "."): + suffix = name[len(base_name) + 1:] + if suffix.isdigit(): + chunks[int(suffix)] = value + except Exception as e: + logger.warning(f"[rotated_cookie] parse cookies failed: {e!r}") + return None + + if not chunks: + return None + + if -1 in chunks and len(chunks) == 1: + new_value = chunks[-1] + else: + ordered = [chunks[i] for i in sorted(k for k in chunks if k >= 0)] + if not ordered: + return None + new_value = SESS_CHUNK_SEPARATOR.join(ordered) + + if not new_value or new_value == original_cookie: + return None + return new_value + + +async def _migrate_session_key(old_key, new_key, new_access_token, jwt_exp, proxy_url=""): + """cookie 滚动后,把所有以 old_key 为索引的数据迁移到 new_key 并持久化。 + + 覆盖:refresh_map / token_list(+ token.txt) / fp_map(+ fp_map.json) + / routing_config.bindings & account_meta(+ routing_config.json) + / error_token_list(+ error_token.txt) + """ + if old_key == new_key: + return + async with _session_key_lock: + now = int(time.time()) + + # 1) refresh_map:复制旧条目并合并新字段,再删旧 key + meta = dict(globals.refresh_map.get(old_key, {})) + meta.update({ + "token": new_access_token, + "timestamp": now, + "last_success_at": now, + "last_error": "", + "last_error_at": 0, + "fail_count": 0, + "jwt_exp": jwt_exp, + "last_proxy": proxy_url or meta.get("last_proxy", ""), + "rotated_from": old_key[:24] + "...", + "rotated_at": now, + }) + globals.refresh_map[new_key] = meta + globals.refresh_map.pop(old_key, None) + persist_refresh_map() + + # 2) token_list / token.txt + if old_key in globals.token_list: + idx = globals.token_list.index(old_key) + globals.token_list[idx] = new_key + globals.persist_token_list() + + # 3) fp_map / fp_map.json + if old_key in globals.fp_map: + globals.fp_map[new_key] = globals.fp_map.pop(old_key) + globals.persist_fp_map() + + # 4) routing_config bindings & account_meta + routing_changed = False + bindings = globals.routing_config.get("bindings", {}) if isinstance(globals.routing_config, dict) else {} + if old_key in bindings: + bindings[new_key] = bindings.pop(old_key) + routing_changed = True + account_meta = globals.routing_config.get("account_meta", {}) if isinstance(globals.routing_config, dict) else {} + if old_key in account_meta: + account_meta[new_key] = account_meta.pop(old_key) + routing_changed = True + if routing_changed: + save_routing_config(globals.routing_config) + + # 5) error_token_list(理论上 rotation 发生时 old_key 不在 error 里,但兜底) + if old_key in globals.error_token_list: + globals.error_token_list[:] = [ + (new_key if t == old_key else t) for t in globals.error_token_list + ] + persist_error_tokens() + + logger.info( + f"[rotation] session-token rotated: {old_key[:16]}... -> {new_key[:16]}... " + f"(jwt_exp={jwt_exp}, +{jwt_exp - now}s)" + ) + + async def rt2ac(refresh_token, force_refresh=False): if not force_refresh and (refresh_token in globals.refresh_map and int(time.time()) - globals.refresh_map.get(refresh_token, {}).get("timestamp", 0) < 5 * 24 * 60 * 60): access_token = globals.refresh_map[refresh_token]["token"] @@ -60,10 +196,13 @@ async def rt2ac(refresh_token, force_refresh=False): async def sess2ac(session_token, force_refresh=False): - """Session cookie → access_token。 + """Session cookie → access_token(带 NextAuth 滚动续期)。 `session_token` 是带 'sess-' 前缀的存储形态(外部传入时已剥除 or 保留都支持)。 - 缓存 8 分钟(session accessToken 寿命约 10-15 分钟)。 + 缓存条件: + 1) timestamp 距今 < 8 分钟(绝对节流,避免高频请求打爆 NextAuth) + 2) 解码后的 JWT exp 距今 > 5 分钟(确保 token 真的还能用,避免拿死 token) + 任一条件不满足都强制刷新。 """ # 统一 key:带前缀的是存储形态,剥除后的是 cookie 真实值 if session_token.startswith("sess-"): @@ -73,32 +212,44 @@ async def sess2ac(session_token, force_refresh=False): storage_key = "sess-" + session_token cookie_value = session_token - # 缓存命中 + # 缓存命中:timestamp 节流 + JWT exp 真实有效性双重校验 + now = int(time.time()) + cached_meta = globals.refresh_map.get(storage_key, {}) + cached_token = cached_meta.get("token", "") + cached_ts = int(cached_meta.get("timestamp", 0)) + cached_exp = int(cached_meta.get("jwt_exp", 0)) or _decode_jwt_exp(cached_token) if (not force_refresh - and storage_key in globals.refresh_map - and int(time.time()) - globals.refresh_map.get(storage_key, {}).get("timestamp", 0) < 8 * 60): - cached = globals.refresh_map[storage_key].get("token") - if cached: - return cached + and cached_token + and now - cached_ts < 8 * 60 + and (cached_exp == 0 or cached_exp - now > 300)): + return cached_token try: - access_token = await fetch_session_access_token(cookie_value) - refresh_meta = globals.refresh_map.get(storage_key, {}) - now = int(time.time()) - refresh_meta.update({ - "token": access_token, - "timestamp": now, - "last_success_at": now, - "last_error": "", - "last_error_at": 0, - "fail_count": 0, - }) - globals.refresh_map[storage_key] = refresh_meta - if storage_key in globals.error_token_list: - globals.error_token_list[:] = [item for item in globals.error_token_list if item != storage_key] - persist_error_tokens() - persist_refresh_map() - logger.info(f"session_cookie -> access_token OK (key={storage_key[:12]}...)") + access_token, effective_key, jwt_exp = await fetch_session_access_token(cookie_value) + # 若 fetch 内部完成了 cookie rotation,effective_key != storage_key, + # 此时 refresh_map[effective_key] 已被 _migrate_session_key 完整填充,无需重复写 + if effective_key == storage_key: + now = int(time.time()) + refresh_meta = globals.refresh_map.get(storage_key, {}) + refresh_meta.update({ + "token": access_token, + "timestamp": now, + "last_success_at": now, + "last_error": "", + "last_error_at": 0, + "fail_count": 0, + "jwt_exp": jwt_exp, + }) + globals.refresh_map[storage_key] = refresh_meta + if storage_key in globals.error_token_list: + globals.error_token_list[:] = [item for item in globals.error_token_list if item != storage_key] + persist_error_tokens() + persist_refresh_map() + logger.info( + f"session_cookie -> access_token OK (key={effective_key[:12]}..., " + f"jwt_exp_in={(jwt_exp - int(time.time())) if jwt_exp else 'n/a'}s, " + f"rotated={'yes' if effective_key != storage_key else 'no'})" + ) return access_token except HTTPException as e: raise HTTPException(status_code=e.status_code, detail=e.detail) @@ -174,7 +325,27 @@ async def fetch_session_access_token(session_cookie): f"session cookie 无效或过期(response keys={list(payload.keys())})。" f"提示:NextAuth session token 可能分片,请确保同时提供 .0 和 .1(若存在)" ) - return access_token + + # NextAuth 滚动续期:尝试从响应 Set-Cookie 中提取新 session-token; + # 若拿到,立刻把所有数据结构里的旧 key 替换为新 key 并持久化,达成"永不过期" + jwt_exp = _decode_jwt_exp(access_token) + effective_storage_key = storage_key + try: + rotated = _extract_rotated_cookie(r, session_cookie) + except Exception as e: + rotated = None + logger.warning(f"[sess2ac] extract rotated cookie failed (non-fatal): {e!r}") + if rotated: + new_storage_key = "sess-" + rotated + await _migrate_session_key( + old_key=storage_key, + new_key=new_storage_key, + new_access_token=access_token, + jwt_exp=jwt_exp, + proxy_url=proxy_url or "", + ) + effective_storage_key = new_storage_key + return access_token, effective_storage_key, jwt_exp except Exception as e: now = int(time.time()) refresh_meta = globals.refresh_map.get(storage_key, {}) diff --git a/utils/globals.py b/utils/globals.py index 234eb632..dd9ea04d 100644 --- a/utils/globals.py +++ b/utils/globals.py @@ -141,3 +141,16 @@ if token_list: logger.info(f"Token list count: {len(token_list)}, Error token list count: {len(error_token_list)}") logger.info("-" * 60) + + +def persist_token_list(): + """全量重写 data/token.txt(用于 cookie 滚动续期后同步磁盘)。""" + with open(TOKENS_FILE, "w", encoding="utf-8") as f: + for t in token_list: + f.write(t + "\n") + + +def persist_fp_map(): + """全量重写 data/fp_map.json。""" + with open(FP_FILE, "w", encoding="utf-8") as f: + json.dump(fp_map, f, indent=2, ensure_ascii=False) From ec04bf0df4c1cd3d31a18f64ffb8600b119c2472 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Mon, 11 May 2026 17:04:38 +0800 Subject: [PATCH 53/96] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=E7=99=BB=E5=BD=95=E6=8C=89=E9=92=AE=E7=BC=BA=E5=A4=B1?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- templates/account_proxy_bindings.html | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/account_proxy_bindings.html b/templates/account_proxy_bindings.html index 24144ce2..d42743ac 100644 --- a/templates/account_proxy_bindings.html +++ b/templates/account_proxy_bindings.html @@ -1028,6 +1028,7 @@ ${harvStatusChip(a.status)} + From 0dd5560fb34b11a5bad00ed9c07a92318f702acd Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Mon, 11 May 2026 17:36:41 +0800 Subject: [PATCH 54/96] =?UTF-8?q?=E5=88=A0=E9=99=A4=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E4=B8=8E=E4=BB=A4=E7=89=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- templates/account_proxy_bindings.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/templates/account_proxy_bindings.html b/templates/account_proxy_bindings.html index d42743ac..8e6c04bd 100644 --- a/templates/account_proxy_bindings.html +++ b/templates/account_proxy_bindings.html @@ -1329,7 +1329,8 @@
    + +
    @@ -42,11 +44,12 @@

    实例列表

    运行时长 cookie 鲜度 备注 + 调用 操作 - 加载中... + 加载中...
    @@ -115,6 +118,153 @@

    操作审计日志

    + + + + + + + + + From 9b058be0da33176b889c894c675224da98e9c0b1 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Mon, 11 May 2026 18:54:51 +0800 Subject: [PATCH 56/96] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=BF=AB=E6=8D=B7=E9=94=AEbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/multi/manage.sh | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/deploy/multi/manage.sh b/deploy/multi/manage.sh index 97a36cb5..ca389c6d 100755 --- a/deploy/multi/manage.sh +++ b/deploy/multi/manage.sh @@ -69,7 +69,39 @@ cleanup_renamed_containers() { fi } +auto_pull() { + # cmd_apply 前自动 git pull --ff-only。 + # 跳过条件:NO_PULL=1 / 非 git 仓库 / detached HEAD / 工作区有未提交改动 / pull 失败 + # 设计原则:永不破坏现有部署——拉不到就用当前版本继续,绝不 reset / merge。 + if [ "${NO_PULL:-0}" = "1" ]; then + log "NO_PULL=1,跳过 git pull" + return 0 + fi + local repo_root + if ! repo_root="$(git -C "$DIR" rev-parse --show-toplevel 2>/dev/null)"; then + log "非 git 仓库,跳过 git pull" + return 0 + fi + local branch + branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)" + if [ "$branch" = "HEAD" ]; then + log "detached HEAD,跳过 git pull(手动 checkout 分支后再 update)" + return 0 + fi + if [ -n "$(git -C "$repo_root" status --porcelain 2>/dev/null)" ]; then + log "工作区有未提交改动,跳过 git pull(避免冲突;设 NO_PULL=1 可静默)" + return 0 + fi + log "git pull --ff-only ($branch)..." + if git -C "$repo_root" pull --ff-only --quiet 2>/dev/null; then + ok "代码已同步到 $(git -C "$repo_root" log -1 --pretty='%h %s')" + else + log "git pull 跳过(非 fast-forward 或远端不可达),继续用当前版本" + fi +} + cmd_apply() { + auto_pull ensure_csv log "生成配置..." python3 "$DIR/generate.py" From 652d05cc32c621a5c15403be8aeee13e089d975b Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Mon, 11 May 2026 20:27:11 +0800 Subject: [PATCH 57/96] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=89=8D=E7=AB=AFbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/multi/manage.sh | 29 +++++++++++++++++++ deploy/multi/orchestrator/app.py | 20 +++++++++++-- .../orchestrator/templates/dashboard.html | 4 +-- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/deploy/multi/manage.sh b/deploy/multi/manage.sh index ca389c6d..432f839e 100755 --- a/deploy/multi/manage.sh +++ b/deploy/multi/manage.sh @@ -106,11 +106,17 @@ cmd_apply() { log "生成配置..." python3 "$DIR/generate.py" cleanup_renamed_containers + local has_orchestrator=0 if dc config --services 2>/dev/null | grep -qx orchestrator; then + has_orchestrator=1 log "构建 orchestrator 镜像..." dc build orchestrator fi log "应用 docker compose..." + if [ "$has_orchestrator" -eq 1 ]; then + # orchestrator 是本地 build 镜像,静态文件变更后必须替换容器才能加载新面板。 + dc up -d --remove-orphans --force-recreate orchestrator + fi dc up -d --remove-orphans # nginx.conf 变化时 compose 不会重启 nginx,主动 reload if docker ps --format '{{.Names}}' | grep -qx c2a-nginx; then @@ -119,6 +125,9 @@ cmd_apply() { || log "nginx reload 失败(首次启动可忽略)" fi cmd_verify + if [ "$has_orchestrator" -eq 1 ]; then + cmd_verify_orchestrator + fi ok "完成。运行 ./manage.sh secrets 查看凭证 / 编排面板访问入口" } @@ -307,6 +316,24 @@ cmd_verify() { fi } +cmd_verify_orchestrator() { + require_compose + local port="${CHAT2API_GATEWAY_PORT:-60403}" + local js_out models_out + log "校验 orchestrator 静态资源..." + if ! js_out="$(check_contains "http://127.0.0.1:${port}/orchestrator/static/app.js" 'probe-models' 6)"; then + err "orchestrator: app.js 仍是旧版本(缺少模型看板代码)" + printf '%s\n' "$js_out" | head -3 + return 1 + fi + if ! models_out="$(check_contains "http://127.0.0.1:${port}/orchestrator/static/models_by_plan.json" '"plans"' 6)"; then + err "orchestrator: models_by_plan.json 不可访问" + printf '%s\n' "$models_out" | head -3 + return 1 + fi + ok "orchestrator: 模型看板静态资源正常" +} + cmd_install_cli() { local config_tmp config_tmp="$(mktemp)" @@ -333,6 +360,7 @@ chat2api 多实例运维(一容器一账号) ./manage.sh list 所有容器状态(docker compose ps) ./manage.sh status 状态 + 抽样验证出口 IP ./manage.sh verify 校验每个实例的后台/路由是否串线 + ./manage.sh verify-orchestrator 校验编排面板模型看板静态资源 ./manage.sh logs [N] 跟随该实例日志(默认 200 行) ./manage.sh shell 进入该实例容器 shell ./manage.sh secrets 打印所有 AUTH / ADMIN_PWD(敏感) @@ -362,6 +390,7 @@ case "$cmd" in list) cmd_list "$@" ;; status) cmd_status "$@" ;; verify) cmd_verify "$@" ;; + verify-orchestrator) cmd_verify_orchestrator "$@" ;; logs) cmd_logs "$@" ;; shell) cmd_shell "$@" ;; secrets) cmd_secrets "$@" ;; diff --git a/deploy/multi/orchestrator/app.py b/deploy/multi/orchestrator/app.py index f1c8470a..7531276e 100644 --- a/deploy/multi/orchestrator/app.py +++ b/deploy/multi/orchestrator/app.py @@ -15,6 +15,7 @@ import base64 import csv import functools +import hashlib import io import json import logging @@ -86,6 +87,19 @@ app = FastAPI(title="chat2api Orchestrator", docs_url=None, redoc_url=None) app.mount("/static", StaticFiles(directory="static"), name="static") templates = Jinja2Templates(directory="templates") +APP_DIR = Path(__file__).parent + + +@functools.lru_cache(maxsize=1) +def static_version() -> str: + """根据前端资源内容生成版本号,避免部署后浏览器继续使用旧 JS/CSS。""" + digest = hashlib.sha256() + for name in ("static/app.js", "static/styles.css", "static/models_by_plan.json"): + try: + digest.update((APP_DIR / name).read_bytes()) + except FileNotFoundError: + digest.update(name.encode("utf-8")) + return digest.hexdigest()[:12] # ---------- 工具:subprocess + docker ---------- @@ -418,7 +432,10 @@ async def dashboard( ) -> Response: if not verify_session_token(orch_session): return RedirectResponse(url="./login", status_code=303) - return templates.TemplateResponse("dashboard.html", {"request": request}) + return templates.TemplateResponse( + "dashboard.html", + {"request": request, "static_version": static_version()}, + ) # ---------- API: accounts CRUD ---------- @@ -1158,4 +1175,3 @@ async def api_playground_invoke(request: Request) -> JSONResponse: reason="exception", error=str(e)[:200], latency_ms=latency_ms) return JSONResponse({"ok": False, "latency_ms": latency_ms, "error": str(e)[:300]}, status_code=200) - diff --git a/deploy/multi/orchestrator/templates/dashboard.html b/deploy/multi/orchestrator/templates/dashboard.html index 3a562228..ce5ecad5 100644 --- a/deploy/multi/orchestrator/templates/dashboard.html +++ b/deploy/multi/orchestrator/templates/dashboard.html @@ -5,7 +5,7 @@ chat2api Orchestrator - + @@ -268,6 +268,6 @@

    🎮 Playground 试调用

    - + From 440845edb6df764eb3f40fd93e65f65597d8d6d9 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Mon, 11 May 2026 20:43:21 +0800 Subject: [PATCH 58/96] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=A4=9A=E5=AE=9E?= =?UTF-8?q?=E4=BE=8B=E8=87=AA=E5=8A=A8=E6=9B=B4=E6=96=B0=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/chat2api.sh | 56 ++++++++++++++++++++++++++++++++++++++++++ deploy/multi/manage.sh | 21 +++++++++++++--- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/deploy/chat2api.sh b/deploy/chat2api.sh index d5a8ea24..bb26698f 100644 --- a/deploy/chat2api.sh +++ b/deploy/chat2api.sh @@ -60,6 +60,61 @@ run_multi_manage() { (cd "$INSTALL_DIR/deploy/multi" && ./manage.sh "$@") } +install_latest_cli_from_repo() { + local src="$INSTALL_DIR/deploy/chat2api.sh" + [[ -f "$src" ]] || return 0 + if [[ "$(id -u)" -eq 0 ]]; then + install -m 0755 "$src" /usr/local/bin/chat2api 2>/dev/null || true + elif command -v sudo >/dev/null 2>&1; then + sudo install -m 0755 "$src" /usr/local/bin/chat2api 2>/dev/null || true + fi +} + +update_repo_for_multi() { + # 让 `chat2api update` 自己先更新部署脚本,再调用 deploy/multi/manage.sh。 + # 本地跟踪文件改动会自动放入 git stash;不 reset、不删除用户改动。 + if [[ "${NO_PULL:-0}" == "1" ]]; then + echo "[*] NO_PULL=1,跳过 git pull" + return 0 + fi + + local repo_root branch stash_msg="" + if ! repo_root="$(git -C "$INSTALL_DIR" rev-parse --show-toplevel 2>/dev/null)"; then + echo "[*] 非 git 仓库,跳过 git pull" + return 0 + fi + branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)" + if [[ "$branch" == "HEAD" ]]; then + echo "[*] detached HEAD,跳过 git pull" + return 0 + fi + + if [[ -n "$(git -C "$repo_root" status --porcelain 2>/dev/null)" ]]; then + stash_msg="chat2api auto-stash before update $(date -u +%Y%m%dT%H%M%SZ)" + echo "[*] 检测到本地改动,自动暂存到 git stash..." + if git -C "$repo_root" stash push -m "$stash_msg" -- . >/dev/null 2>&1; then + echo "[✓] 本地改动已暂存:$stash_msg" + else + echo "[!] 自动暂存失败,继续使用当前代码" + return 0 + fi + fi + + echo "[*] git pull --ff-only ($branch)..." + if git -C "$repo_root" pull --ff-only --quiet; then + echo "[✓] 代码已同步到 $(git -C "$repo_root" log -1 --pretty='%h %s')" + install_latest_cli_from_repo + if [[ -n "$stash_msg" ]]; then + echo "[i] 被暂存的本地改动可用以下命令查看:git -C \"$repo_root\" stash list" + fi + else + if [[ -n "$stash_msg" ]]; then + git -C "$repo_root" stash pop --quiet >/dev/null 2>&1 || true + fi + echo "[!] git pull 失败,已继续使用当前代码" + fi +} + run_compose() { if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then sudo docker compose "$@" @@ -468,6 +523,7 @@ command_name="${1:-help}" case "$command_name" in update) if is_multi_install; then + update_repo_for_multi run_multi_manage apply else run_compose pull diff --git a/deploy/multi/manage.sh b/deploy/multi/manage.sh index 432f839e..9bd24918 100755 --- a/deploy/multi/manage.sh +++ b/deploy/multi/manage.sh @@ -71,8 +71,8 @@ cleanup_renamed_containers() { auto_pull() { # cmd_apply 前自动 git pull --ff-only。 - # 跳过条件:NO_PULL=1 / 非 git 仓库 / detached HEAD / 工作区有未提交改动 / pull 失败 - # 设计原则:永不破坏现有部署——拉不到就用当前版本继续,绝不 reset / merge。 + # 有本地跟踪文件改动时自动 stash,避免旧部署脚本挡住更新。 + # 设计原则:永不 reset;本地改动只暂存到 git stash,方便需要时找回。 if [ "${NO_PULL:-0}" = "1" ]; then log "NO_PULL=1,跳过 git pull" return 0 @@ -88,14 +88,27 @@ auto_pull() { log "detached HEAD,跳过 git pull(手动 checkout 分支后再 update)" return 0 fi + local stash_msg="" if [ -n "$(git -C "$repo_root" status --porcelain 2>/dev/null)" ]; then - log "工作区有未提交改动,跳过 git pull(避免冲突;设 NO_PULL=1 可静默)" - return 0 + stash_msg="chat2api auto-stash before update $(date -u +%Y%m%dT%H%M%SZ)" + log "检测到本地改动,自动暂存到 git stash..." + if git -C "$repo_root" stash push -m "$stash_msg" -- . >/dev/null 2>&1; then + ok "本地改动已暂存:$stash_msg" + else + log "自动暂存失败,跳过 git pull(可手动处理后重试)" + return 0 + fi fi log "git pull --ff-only ($branch)..." if git -C "$repo_root" pull --ff-only --quiet 2>/dev/null; then ok "代码已同步到 $(git -C "$repo_root" log -1 --pretty='%h %s')" + if [ -n "$stash_msg" ]; then + log "如需查看被暂存的本地改动:git -C \"$repo_root\" stash list" + fi else + if [ -n "$stash_msg" ]; then + git -C "$repo_root" stash pop --quiet >/dev/null 2>&1 || true + fi log "git pull 跳过(非 fast-forward 或远端不可达),继续用当前版本" fi } From 4ccf814240a00c8ac27db2ca9a70301646e5466b Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Mon, 11 May 2026 20:57:43 +0800 Subject: [PATCH 59/96] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=85=B7=E4=BD=93?= =?UTF-8?q?=E5=8F=AF=E7=94=A8=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/multi/orchestrator/app.py | 28 +++++++++++++++---- deploy/multi/orchestrator/static/app.js | 27 ++++++++++++++++-- .../orchestrator/templates/dashboard.html | 2 +- 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/deploy/multi/orchestrator/app.py b/deploy/multi/orchestrator/app.py index 7531276e..f430aaae 100644 --- a/deploy/multi/orchestrator/app.py +++ b/deploy/multi/orchestrator/app.py @@ -590,6 +590,17 @@ async def api_instance_op(slug: str, op: str, request: Request) -> JSONResponse: # ---------- API: 状态 ---------- +def _cached_instance_info(slug: str) -> dict: + """带 5min 内存缓存的 info 取数。api_status 每 5s 轮询用,避免每次重算 JWT+IO。""" + now = time.time() + cached = _models_cache.get(slug) + if cached and now - cached[1] < INFO_CACHE_TTL: + return cached[0] + info = _get_instance_info(slug) + _models_cache[slug] = (info, now) + return info + + @app.get("/api/status", dependencies=[Depends(require_session)]) async def api_status() -> JSONResponse: rows = read_accounts() @@ -612,6 +623,12 @@ async def api_status() -> JSONResponse: ) except (ValueError, TypeError): uptime_seconds = None + # 调用层信息(带 5min cache,不影响轮询性能) + try: + call_info = _cached_instance_info(slug) + except Exception as e: + logger.warning("status: get info for %s failed: %s", slug, e) + call_info = {} instances.append({ "slug": slug, "container": f"c2a-{slug}", @@ -624,6 +641,11 @@ async def api_status() -> JSONResponse: "exit_ip": _exit_ip_cache.get(slug, (None, 0))[0], "cookie_last_success_at": get_cookie_last_success(slug), "note": r["note"], + # 新增:调用层(不含 AUTHORIZATION 原文) + "plan_type": call_info.get("plan_type", "unknown"), + "plan_label": call_info.get("plan_label", "未知"), + "plan_color": call_info.get("plan_color", "rose"), + "models": [m.get("id") for m in (call_info.get("models") or []) if isinstance(m, dict) and m.get("id")], }) return JSONResponse({ "instances": instances, @@ -784,11 +806,7 @@ async def api_instance_info(slug: str) -> JSONResponse: now = time.time() cached = _models_cache.get(slug) - if cached and now - cached[1] < INFO_CACHE_TTL: - info = cached[0] - else: - info = _get_instance_info(slug) - _models_cache[slug] = (info, now) + info = _cached_instance_info(slug) # 出口前再次剥离 AUTHORIZATION 原文,浏览器只见 masked safe = {k: v for k, v in info.items() if k != "authorization"} diff --git a/deploy/multi/orchestrator/static/app.js b/deploy/multi/orchestrator/static/app.js index c5938955..4a51d1ae 100644 --- a/deploy/multi/orchestrator/static/app.js +++ b/deploy/multi/orchestrator/static/app.js @@ -91,6 +91,24 @@ function escapeHtml(s) { }[c])); } +/** + * 紧凑型行内模型 chip 渲染:最多展示 maxVisible 个,超出显示 "+N"。 + * 入参:models 可以是 ["gpt-5", ...] 或 [{id, source}, ...] + */ +function renderInlineModels(models, maxVisible = 4) { + if (!models || !models.length) { + return '
    无可用模型
    '; + } + const ids = models.map(m => (typeof m === 'string') ? m : (m && m.id)); + const visible = ids.slice(0, maxVisible); + const more = ids.length - visible.length; + const chips = visible.map(id => `${escapeHtml(id)}`).join(''); + const moreBadge = more > 0 + ? `+${more}` + : ''; + return `
    ${chips}${moreBadge}
    `; +} + function renderRows(instances) { if (!instances.length) { $('#tbody').innerHTML = '暂无账号,点击右上角「新增账号」开始'; @@ -107,6 +125,7 @@ function renderRows(instances) { ${escapeHtml(it.note || '-')} + ${renderInlineModels(it.models, 4)} @@ -518,13 +537,17 @@ async function openSummaryModal() { ? '● healthy' : '● ' + escapeHtml(r.container_health || '?') + '') : '○ ' + escapeHtml(r.container_state || 'absent') + ''; + const modelIds = (r.models || []).map(m => (typeof m === 'string') ? m : (m && m.id)).filter(Boolean); + const modelsHtml = modelIds.length + ? `
    ${modelIds.map(id => `${escapeHtml(id)}`).join('')}
    ` + : ''; return ` - + ${escapeHtml(r.slug)} ${escapeHtml(r.plan_label || r.plan_type)} ${escapeHtml(endpoint)} ${escapeHtml(r.auth_masked || '-')} - ${(r.models || []).length} + ${modelsHtml} ${health} `; diff --git a/deploy/multi/orchestrator/templates/dashboard.html b/deploy/multi/orchestrator/templates/dashboard.html index ce5ecad5..f93f9ae3 100644 --- a/deploy/multi/orchestrator/templates/dashboard.html +++ b/deploy/multi/orchestrator/templates/dashboard.html @@ -193,7 +193,7 @@

    📊 调用汇总

    套餐 endpoint api_key - 模型数 + 模型 健康 From 6399fc7f850aebfe6aa66883f7253d98ac78e441 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Mon, 11 May 2026 21:14:03 +0800 Subject: [PATCH 60/96] =?UTF-8?q?=E5=8F=AF=E4=BB=A5=E5=AF=B9=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=B0=B1=E8=A1=8C=E6=8E=A2=E6=B5=8B=EF=BC=8C=E8=8E=B7?= =?UTF-8?q?=E5=BE=97=E5=8F=AF=E7=94=A8=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/multi/orchestrator/app.py | 2 +- deploy/multi/orchestrator/static/app.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy/multi/orchestrator/app.py b/deploy/multi/orchestrator/app.py index f430aaae..83fc9e60 100644 --- a/deploy/multi/orchestrator/app.py +++ b/deploy/multi/orchestrator/app.py @@ -833,7 +833,7 @@ async def _probe_models(slug: str, api_prefix: str, auth: str) -> list[str]: @app.post( - "/api/instances/{slug}/probe-models", + "/api/probe-models/{slug}", dependencies=[Depends(require_session), Depends(require_csrf)], ) async def api_probe_models(slug: str, request: Request) -> JSONResponse: diff --git a/deploy/multi/orchestrator/static/app.js b/deploy/multi/orchestrator/static/app.js index 4a51d1ae..fd3a2100 100644 --- a/deploy/multi/orchestrator/static/app.js +++ b/deploy/multi/orchestrator/static/app.js @@ -466,7 +466,7 @@ $('#btn-probe-models').addEventListener('click', async () => { btn.disabled = true; btn.textContent = '探测中...'; try { - const d = await api('POST', '/api/instances/' + encodeURIComponent(slug) + '/probe-models', {}); + const d = await api('POST', '/api/probe-models/' + encodeURIComponent(slug), {}); const models = (d.models || []).map(id => ({ id, source: 'probe' })); if (invokeCurrent) { invokeCurrent.models = models; From b28381120d1588d7a15d71122b3e2d3c279ba317 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Mon, 11 May 2026 21:18:38 +0800 Subject: [PATCH 61/96] =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E5=A4=9A=E5=AE=9E?= =?UTF-8?q?=E4=BE=8B=E6=9B=B4=E6=96=B0=E5=AE=B9=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/multi/manage.sh | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/deploy/multi/manage.sh b/deploy/multi/manage.sh index 9bd24918..ddfa581c 100755 --- a/deploy/multi/manage.sh +++ b/deploy/multi/manage.sh @@ -43,6 +43,14 @@ dc() { docker compose -f "$COMPOSE" --project-directory "$DIR" "$@" } +compose_up() { + if dc up -d --remove-orphans --pull missing "$@"; then + return 0 + fi + log "镜像拉取失败,尝试使用本地已有镜像继续..." + dc up -d --remove-orphans --pull never "$@" +} + slugs() { awk -F, 'NR>1 && $1!="" {print $1}' "$CSV" } @@ -128,9 +136,9 @@ cmd_apply() { log "应用 docker compose..." if [ "$has_orchestrator" -eq 1 ]; then # orchestrator 是本地 build 镜像,静态文件变更后必须替换容器才能加载新面板。 - dc up -d --remove-orphans --force-recreate orchestrator + compose_up --force-recreate orchestrator fi - dc up -d --remove-orphans + compose_up # nginx.conf 变化时 compose 不会重启 nginx,主动 reload if docker ps --format '{{.Names}}' | grep -qx c2a-nginx; then docker exec c2a-nginx nginx -s reload 2>/dev/null \ From 4ac7a15ce8d17d74cb572a1ebeaac04d44c8d6d2 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Mon, 11 May 2026 21:25:03 +0800 Subject: [PATCH 62/96] =?UTF-8?q?=E5=85=BC=E5=AE=B9=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E6=8E=A2=E6=B5=8B=E6=97=A7=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/multi/orchestrator/app.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/deploy/multi/orchestrator/app.py b/deploy/multi/orchestrator/app.py index 83fc9e60..6b385334 100644 --- a/deploy/multi/orchestrator/app.py +++ b/deploy/multi/orchestrator/app.py @@ -573,6 +573,9 @@ async def api_delete_account(slug: str, request: Request) -> JSONResponse: dependencies=[Depends(require_session), Depends(require_csrf)], ) async def api_instance_op(slug: str, op: str, request: Request) -> JSONResponse: + if op == "probe-models": + # 兼容旧版前端路径:/api/instances/{slug}/probe-models + return await api_probe_models(slug, request) if op not in ALLOWED_OPS: raise HTTPException(status_code=400, detail=f"非法操作 {op}") if not SLUG_RE.match(slug): From 50aeecccae008f944283f51f0ea35edfae4e7711 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Mon, 11 May 2026 21:39:43 +0800 Subject: [PATCH 63/96] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=BC=96=E6=8E=92?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E8=AF=8A=E6=96=AD=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/multi/orchestrator/app.py | 78 +++++++++++++++++++ deploy/multi/orchestrator/static/app.js | 53 ++++++++++++- .../orchestrator/templates/dashboard.html | 28 +++++++ 3 files changed, 158 insertions(+), 1 deletion(-) diff --git a/deploy/multi/orchestrator/app.py b/deploy/multi/orchestrator/app.py index 6b385334..136aefd1 100644 --- a/deploy/multi/orchestrator/app.py +++ b/deploy/multi/orchestrator/app.py @@ -309,6 +309,33 @@ def read_audit(limit: int = 200) -> list[dict]: return out +def redact_log_text(text: str) -> str: + """日志对前端展示前做基础脱敏,避免误复制密钥。""" + patterns = [ + (r"(Bearer\s+)[A-Za-z0-9._=-]{20,}", r"\1***"), + (r"(AUTHORIZATION=)[^\s]+", r"\1***"), + (r"(sk-[A-Za-z0-9_-]{8})[A-Za-z0-9_-]+", r"\1***"), + (r"(sess-)[^\s\"']{24,}", r"\1***"), + (r"(rt_[^\s\"']{8})[^\s\"']+", r"\1***"), + (r"(eyJ[A-Za-z0-9_-]{16})[A-Za-z0-9._-]+", r"\1***"), + ] + for pattern, repl in patterns: + text = re.sub(pattern, repl, text) + return text + + +def log_targets() -> list[dict[str, str]]: + targets = [ + {"id": "orchestrator", "label": "orchestrator 面板", "container": "c2a-orchestrator"}, + {"id": "nginx", "label": "nginx 网关", "container": "c2a-nginx"}, + {"id": "watchtower", "label": "watchtower 更新器", "container": "c2a-watchtower"}, + ] + for row in read_accounts(): + slug = row["slug"] + targets.append({"id": slug, "label": f"实例 {slug}", "container": f"c2a-{slug}"}) + return targets + + # ---------- 鉴权 ---------- def issue_session_token() -> str: @@ -697,6 +724,57 @@ async def api_audit(limit: int = Query(200, ge=1, le=2000)) -> JSONResponse: return JSONResponse({"records": read_audit(limit)}) +@app.get( + "/api/log-targets", + dependencies=[Depends(require_session), Depends(require_csrf)], +) +async def api_log_targets() -> JSONResponse: + return JSONResponse({"targets": log_targets()}) + + +@app.get( + "/api/logs", + dependencies=[Depends(require_session), Depends(require_csrf)], +) +async def api_logs( + request: Request, + target: str = Query("orchestrator"), + tail: int = Query(200, ge=20, le=2000), +) -> JSONResponse: + target_map = {item["id"]: item for item in log_targets()} + item = target_map.get(target) + if not item: + audit("view_logs", request, False, target=target, reason="invalid_target") + raise HTTPException(status_code=400, detail="日志目标不存在") + + container = item["container"] + rc, out, err = run( + ["docker", "logs", "--tail", str(tail), "--timestamps", container], + timeout=30, + ) + raw = "\n".join(part for part in (out, err) if part.strip()) + text = redact_log_text(raw or "(无日志输出)") + audit("view_logs", request, rc == 0, target=target, tail=tail) + if rc != 0: + return JSONResponse( + { + "ok": False, + "target": target, + "container": container, + "tail": tail, + "logs": text, + }, + status_code=200, + ) + return JSONResponse({ + "ok": True, + "target": target, + "container": container, + "tail": tail, + "logs": text, + }) + + # ==================================================================== # 调用层信息聚合 (info / probe / playground / export) # ==================================================================== diff --git a/deploy/multi/orchestrator/static/app.js b/deploy/multi/orchestrator/static/app.js index fd3a2100..b445e4e8 100644 --- a/deploy/multi/orchestrator/static/app.js +++ b/deploy/multi/orchestrator/static/app.js @@ -111,7 +111,7 @@ function renderInlineModels(models, maxVisible = 4) { function renderRows(instances) { if (!instances.length) { - $('#tbody').innerHTML = '暂无账号,点击右上角「新增账号」开始'; + $('#tbody').innerHTML = '暂无账号,点击右上角「新增账号」开始'; return; } $('#tbody').innerHTML = instances.map(it => ` @@ -316,6 +316,57 @@ $('#btn-close-audit').addEventListener('click', () => { $('#modal-audit').classList.remove('flex'); }); +// ---------- 诊断日志 ---------- + +let logTargetsLoaded = false; + +async function loadLogTargets() { + const d = await api('GET', '/api/log-targets'); + const targets = d.targets || []; + $('#log-target').innerHTML = targets.map(t => + `` + ).join(''); + logTargetsLoaded = true; +} + +async function refreshLogs() { + const target = $('#log-target').value || 'orchestrator'; + const tail = $('#log-tail').value || '200'; + $('#logs-body').textContent = '加载中...'; + $('#logs-meta').textContent = ''; + try { + const d = await api('GET', `/api/logs?target=${encodeURIComponent(target)}&tail=${encodeURIComponent(tail)}`); + $('#logs-body').textContent = d.logs || '(无日志输出)'; + $('#logs-meta').textContent = `${d.ok ? 'OK' : '异常'} · ${d.container || target} · 最近 ${d.tail || tail} 行`; + if (!d.ok) toast('日志目标返回异常,内容已显示', true); + } catch (e) { + $('#logs-body').textContent = '加载日志失败:' + e.message; + $('#logs-meta').textContent = '加载失败'; + toast('加载日志失败:' + e.message, true); + } +} + +async function openLogsModal() { + $('#modal-logs').classList.remove('hidden'); + $('#modal-logs').classList.add('flex'); + try { + if (!logTargetsLoaded) await loadLogTargets(); + await refreshLogs(); + } catch (e) { + $('#logs-body').textContent = '加载日志失败:' + e.message; + toast('加载日志失败:' + e.message, true); + } +} + +$('#btn-diag-logs').addEventListener('click', openLogsModal); +$('#btn-close-logs').addEventListener('click', () => { + $('#modal-logs').classList.add('hidden'); + $('#modal-logs').classList.remove('flex'); +}); +$('#btn-refresh-logs').addEventListener('click', refreshLogs); +$('#log-target').addEventListener('change', refreshLogs); +$('#log-tail').addEventListener('change', refreshLogs); + // ---------- 调用信息 (单实例) ---------- let invokeCurrent = null; // 当前 modal 展示的 info dict diff --git a/deploy/multi/orchestrator/templates/dashboard.html b/deploy/multi/orchestrator/templates/dashboard.html index f93f9ae3..6b063964 100644 --- a/deploy/multi/orchestrator/templates/dashboard.html +++ b/deploy/multi/orchestrator/templates/dashboard.html @@ -20,6 +20,7 @@

    chat2api Orchestrator

    + @@ -118,6 +119,33 @@

    操作审计日志

    + + + From 5fdf0ce0c3c84429fcd527bad22cb245617cc06c Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Mon, 11 May 2026 23:06:09 +0800 Subject: [PATCH 66/96] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8C=87=E7=BA=B9?= =?UTF-8?q?=E5=A4=B4=E7=B1=BB=E5=9E=8B=E5=AF=BC=E8=87=B4=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E6=8E=A2=E6=B5=8B=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- chatgpt/ChatService.py | 29 +++++++++++++++++++++++++++-- chatgpt/fp.py | 16 +++++++++++++--- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/chatgpt/ChatService.py b/chatgpt/ChatService.py index b646e0c3..3ecfef17 100644 --- a/chatgpt/ChatService.py +++ b/chatgpt/ChatService.py @@ -41,6 +41,29 @@ ) +def _stringify_header_value(value): + if value is None: + return None + if isinstance(value, str): + return value + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return str(value) + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str) + + +def _sanitize_headers(headers): + clean = {} + for key, value in (headers or {}).items(): + if not key: + continue + value = _stringify_header_value(value) + if value is not None: + clean[str(key)] = value + return clean + + class ChatService: available_model_cache = {} available_model_cache_ttl = 300 @@ -189,7 +212,7 @@ async def initialize_request_context(self): 'sec-fetch-mode': 'cors', 'sec-fetch-site': 'same-origin' } - self.base_headers.update(self.fp) + self.base_headers.update(_sanitize_headers(self.fp)) if self.access_token: self.base_url = self.host_url + "/backend-api" @@ -207,7 +230,9 @@ async def initialize_request_context(self): for k, v in self.antiban_ctx.header_overrides.items(): if k.startswith("_") or not v: continue - self.base_headers[k] = v + normalized = _stringify_header_value(v) + if normalized is not None: + self.base_headers[k] = normalized logger.info( f"[antiban] headers overridden by geo: " f"accept-language={self.base_headers.get('accept-language')} " diff --git a/chatgpt/fp.py b/chatgpt/fp.py index e00abac6..e3bb215d 100644 --- a/chatgpt/fp.py +++ b/chatgpt/fp.py @@ -11,6 +11,16 @@ from utils.routing import get_bound_proxy +def _stringify_ch_value(value): + if value is None or isinstance(value, str): + return value + if isinstance(value, bool): + return "?1" if value else "?0" + if isinstance(value, (int, float)): + return str(value) + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str) + + def select_impersonate(user_agent): ua = (user_agent or "").lower() if "edg/" in ua: @@ -74,9 +84,9 @@ def get_fp(req_token): "oai-device-id": str(uuid.uuid4()) } if ua.device == "desktop" and ua.browser in ("chrome", "edge"): - fp["sec-ch-ua-platform"] = ua.ch.platform - fp["sec-ch-ua"] = ua.ch.brands - fp["sec-ch-ua-mobile"] = ua.ch.mobile + fp["sec-ch-ua-platform"] = _stringify_ch_value(ua.ch.platform) + fp["sec-ch-ua"] = _stringify_ch_value(ua.ch.brands) + fp["sec-ch-ua-mobile"] = _stringify_ch_value(ua.ch.mobile) if not req_token: return fp From 7b596f365266683b56ab9f38c5078be29cf9a398 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Mon, 11 May 2026 23:21:29 +0800 Subject: [PATCH 67/96] =?UTF-8?q?=E5=B1=95=E7=A4=BA=E6=B7=B1=E5=BA=A6?= =?UTF-8?q?=E7=A0=94=E7=A9=B6=E6=A8=A1=E5=9E=8B=E5=88=AB=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/models.py | 29 +++++++++++ chatgpt/ChatService.py | 6 +-- deploy/multi/orchestrator/app.py | 48 ++++++++++++++++--- deploy/multi/orchestrator/static/app.js | 18 +++++-- .../orchestrator/static/models_by_plan.json | 12 ++--- 5 files changed, 93 insertions(+), 20 deletions(-) diff --git a/api/models.py b/api/models.py index fb2da67e..f6baf59d 100644 --- a/api/models.py +++ b/api/models.py @@ -58,6 +58,35 @@ ("auto", "auto"), ) +DEEP_RESEARCH_MODEL_ALIASES = ( + "o3-deep-research", + "o4-mini-deep-research", + "gpt-4o-deep-research", + "deep-research", +) + + +def should_expose_deep_research_aliases(model_slugs): + paid_markers = ( + "gpt-4", + "gpt-4o", + "gpt-5", + "o1", + "o3", + "o4", + ) + return any( + isinstance(slug, str) and slug.startswith(paid_markers) + for slug in (model_slugs or []) + ) + + +def augment_model_slugs(model_slugs): + slugs = set(model_slugs or []) + if should_expose_deep_research_aliases(slugs): + slugs.update(DEEP_RESEARCH_MODEL_ALIASES) + return slugs + def get_response_model(origin_model): return model_proxy.get(origin_model, origin_model) diff --git a/chatgpt/ChatService.py b/chatgpt/ChatService.py index 3ecfef17..cd48e389 100644 --- a/chatgpt/ChatService.py +++ b/chatgpt/ChatService.py @@ -9,7 +9,7 @@ from starlette.concurrency import run_in_threadpool from api.files import get_image_size, get_file_extension, determine_file_use_case -from api.models import extract_model_slugs, get_response_model, resolve_request_model +from api.models import augment_model_slugs, extract_model_slugs, get_response_model, resolve_request_model from chatgpt.authorization import get_req_token, verify_token from chatgpt.chatFormat import api_messages_to_chat, stream_response, format_not_stream_response, head_process_response from chatgpt.chatLimit import check_is_limit, handle_request_limit @@ -116,12 +116,12 @@ async def fetch_available_models(self): raise HTTPException(status_code=r.status_code, detail=detail) models_payload = r.json() - model_slugs = extract_model_slugs(models_payload) + model_slugs = augment_model_slugs(extract_model_slugs(models_payload)) self.available_model_cache[cache_key] = { "time": now, "slugs": model_slugs, } - logger.info(f"Available upstream models: {len(model_slugs)}") + logger.info(f"Available models exposed: {len(model_slugs)}") return model_slugs async def validate_model_access(self): diff --git a/deploy/multi/orchestrator/app.py b/deploy/multi/orchestrator/app.py index 5a2a758b..a64c9e59 100644 --- a/deploy/multi/orchestrator/app.py +++ b/deploy/multi/orchestrator/app.py @@ -786,6 +786,20 @@ async def api_logs( # ==================================================================== MODELS_BY_PLAN_FILE = Path(__file__).parent / "static" / "models_by_plan.json" +DEEP_RESEARCH_MODEL_ALIASES = ( + "o3-deep-research", + "o4-mini-deep-research", + "gpt-4o-deep-research", + "deep-research", +) +DEEP_RESEARCH_CAPABLE_PREFIXES = ( + "gpt-4", + "gpt-4o", + "gpt-5", + "o1", + "o3", + "o4", +) _models_cache: dict[str, tuple[dict, float]] = {} # slug -> (info_dict, ts) INFO_CACHE_TTL = 300.0 # 5 分钟 PROBE_MIN_INTERVAL = 30.0 # 单 slug 探测最小间隔 @@ -802,6 +816,25 @@ def _load_static_models() -> dict: return {"plans": {"unknown": {"label": "未知", "color": "rose", "models": []}}} +def _is_deep_research_capable(model_id: str) -> bool: + if not model_id or model_id in DEEP_RESEARCH_MODEL_ALIASES: + return False + return model_id.startswith(DEEP_RESEARCH_CAPABLE_PREFIXES) + + +def _probe_model_entries(model_ids: list[str]) -> list[dict[str, str]]: + ids = {str(model_id) for model_id in (model_ids or []) if model_id} + if any(_is_deep_research_capable(model_id) for model_id in ids): + ids.update(DEEP_RESEARCH_MODEL_ALIASES) + return [ + { + "id": model_id, + "source": "alias" if model_id in DEEP_RESEARCH_MODEL_ALIASES else "probe", + } + for model_id in sorted(ids) + ] + + def _parse_jwt_plan(access_token: str) -> str: """从 OpenAI access_token JWT 的 payload 取 chatgpt_plan_type。 @@ -901,10 +934,11 @@ async def api_instance_info(slug: str) -> JSONResponse: return JSONResponse(safe) -async def _probe_models(slug: str, api_prefix: str, auth: str) -> list[str]: - """容器内网 GET c2a-{slug}:5005/{api_prefix}/v1/models,返回 model id 列表。 +async def _probe_models(slug: str, api_prefix: str, auth: str) -> list[dict[str, str]]: + """容器内网 GET c2a-{slug}:5005/{api_prefix}/v1/models,返回模型展示条目。 chat2api 的 /v1/models 是 OpenAI 兼容协议,返回 {"object":"list","data":[{"id":"...",...}]}。 + 深度研究是 chat2api 支持的调用别名,上游模型列表不一定直接返回,所以这里补充展示。 认证用 AUTHORIZATION 作 Bearer token。 """ url = f"http://c2a-{slug}:5005/{api_prefix}/v1/models" if api_prefix else f"http://c2a-{slug}:5005/v1/models" @@ -916,7 +950,8 @@ async def _probe_models(slug: str, api_prefix: str, auth: str) -> list[str]: items = data.get("data") if isinstance(data, dict) else None if not isinstance(items, list): return [] - return [str(it.get("id")) for it in items if isinstance(it, dict) and it.get("id")] + model_ids = [str(it.get("id")) for it in items if isinstance(it, dict) and it.get("id")] + return _probe_model_entries(model_ids) @app.post( @@ -944,7 +979,7 @@ async def api_probe_models(slug: str, request: Request) -> JSONResponse: raise HTTPException(status_code=404, detail=f"slug={slug} env 不存在") try: - model_ids = await _probe_models(slug, env.get("API_PREFIX", ""), env.get("AUTHORIZATION", "")) + model_entries = await _probe_models(slug, env.get("API_PREFIX", ""), env.get("AUTHORIZATION", "")) except httpx.HTTPStatusError as e: _, logs = read_container_logs(f"c2a-{slug}", tail=80) audit("probe_models", request, False, slug=slug, http_status=e.response.status_code) @@ -966,14 +1001,15 @@ async def api_probe_models(slug: str, request: Request) -> JSONResponse: # 把 probe 结果合入 cache(增量),保留 plan_type 等信息 cached = _models_cache.get(slug) base = cached[0] if cached else _get_instance_info(slug) - base = {**base, "models": [{"id": m, "source": "probe"} for m in model_ids], - "probed_at": int(now)} + model_ids = [m["id"] for m in model_entries] + base = {**base, "models": model_entries, "probed_at": int(now)} _models_cache[slug] = (base, now) audit("probe_models", request, True, slug=slug, model_count=len(model_ids)) return JSONResponse({ "slug": slug, "models": model_ids, + "model_entries": model_entries, "probed_at": int(now), }) diff --git a/deploy/multi/orchestrator/static/app.js b/deploy/multi/orchestrator/static/app.js index 3d997475..d972c7a7 100644 --- a/deploy/multi/orchestrator/static/app.js +++ b/deploy/multi/orchestrator/static/app.js @@ -443,10 +443,14 @@ function renderModelChips(models, sourceHint) { wrap.innerHTML = '无可用模型'; } else { wrap.innerHTML = models.map(m => { - const sourceTag = m.source === 'probe' + const modelId = (typeof m === 'string') ? m : (m && m.id); + const source = (typeof m === 'string') ? '' : (m && m.source); + const sourceTag = source === 'probe' ? '实测' - : ''; - return `${escapeHtml(m.id)}${sourceTag}`; + : (source === 'alias' + ? '别名' + : ''); + return `${escapeHtml(modelId)}${sourceTag}`; }).join(''); } $('#invoke-models-source-hint').textContent = sourceHint || ''; @@ -522,10 +526,14 @@ $('#btn-probe-models').addEventListener('click', async () => { btn.textContent = '探测中...'; try { const d = await api('POST', '/api/probe-models/' + encodeURIComponent(slug), {}); - const models = (d.models || []).map(id => ({ id, source: 'probe' })); + const models = d.model_entries || (d.models || []).map(id => ({ id, source: 'probe' })); if (invokeCurrent) { invokeCurrent.models = models; - renderModelChips(models, '(实测 @ ' + new Date(d.probed_at * 1000).toLocaleTimeString() + ')'); + const hasAlias = models.some(m => m.source === 'alias'); + renderModelChips( + models, + (hasAlias ? '(实测 + 深度研究别名 @ ' : '(实测 @ ') + new Date(d.probed_at * 1000).toLocaleTimeString() + ')' + ); renderInvokeSnippet(); // 用新的第一个 model 刷新代码示例 } $('#invoke-error').classList.add('hidden'); diff --git a/deploy/multi/orchestrator/static/models_by_plan.json b/deploy/multi/orchestrator/static/models_by_plan.json index ebc29b4b..a427e241 100644 --- a/deploy/multi/orchestrator/static/models_by_plan.json +++ b/deploy/multi/orchestrator/static/models_by_plan.json @@ -1,7 +1,7 @@ { - "version": 1, + "version": 2, "updated_at": "2026-05-11", - "comment": "每个套餐能用的模型清单。OpenAI 改版后只需更新此文件,无需改 Python。plan_type 从 access_token JWT 的 chatgpt_plan_type 字段解码得到。", + "comment": "每个套餐能用的模型清单。OpenAI 改版后只需更新此文件,无需改 Python。plan_type 从 access_token JWT 的 chatgpt_plan_type 字段解码得到;deep-research 项是 chat2api 支持的调用别名。", "plans": { "free": { "label": "Free", @@ -11,22 +11,22 @@ "plus": { "label": "Plus", "color": "blue", - "models": ["gpt-4o", "gpt-5", "gpt-5-thinking", "o4-mini", "o3", "gpt-4o-mini"] + "models": ["gpt-4o", "gpt-5", "gpt-5-thinking", "o4-mini", "o3", "o3-deep-research", "o4-mini-deep-research", "gpt-4o-deep-research", "deep-research", "gpt-4o-mini"] }, "team": { "label": "Team", "color": "emerald", - "models": ["gpt-5", "gpt-5-thinking", "gpt-5-pro", "o3", "o4-mini", "gpt-4o"] + "models": ["gpt-5", "gpt-5-thinking", "gpt-5-pro", "o3", "o4-mini", "o3-deep-research", "o4-mini-deep-research", "gpt-4o-deep-research", "deep-research", "gpt-4o"] }, "pro": { "label": "Pro", "color": "amber", - "models": ["gpt-5-pro", "gpt-5-thinking", "o3-pro", "gpt-5", "o3", "gpt-4o"] + "models": ["gpt-5-pro", "gpt-5-thinking", "o3-pro", "gpt-5", "o3", "o3-deep-research", "o4-mini-deep-research", "gpt-4o-deep-research", "deep-research", "gpt-4o"] }, "enterprise": { "label": "Enterprise", "color": "violet", - "models": ["gpt-5", "gpt-5-thinking", "gpt-5-pro", "o3", "o4-mini", "gpt-4o"] + "models": ["gpt-5", "gpt-5-thinking", "gpt-5-pro", "o3", "o4-mini", "o3-deep-research", "o4-mini-deep-research", "gpt-4o-deep-research", "deep-research", "gpt-4o"] }, "unknown": { "label": "未知", From dcb79aee0ec694fe1e9bed445a14951cd1469d06 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Tue, 12 May 2026 01:11:04 +0800 Subject: [PATCH 68/96] =?UTF-8?q?=E7=AE=80=E5=8C=96=E5=A4=9A=E5=AE=9E?= =?UTF-8?q?=E4=BE=8B=E9=83=A8=E7=BD=B2=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 23 +++++++++++++------ deploy/multi/manage.sh | 51 ++++++++++++++++++++++++++++++++---------- 2 files changed, 55 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 01c4bed2..3c860c2b 100644 --- a/README.md +++ b/README.md @@ -155,17 +155,25 @@ docker logs c2a- | grep session_sticky ### 一句话部署 ```bash -# 1. 先用一键脚本把 chat2api 装好(任意模式) +# 1. 先用一键脚本把 chat2api 装好 curl -fsSL https://raw.githubusercontent.com/nanashiwang/chat2api/main/deploy/install.sh | bash -# 2. 切到 multi 目录,初始化 N 账号编排 +# 2. 初始化多实例编排面板 cd ~/chat2api/deploy/multi -cp accounts.example.csv accounts.csv -vi accounts.csv # 每行一个账号: slug,proxy_url,note -./manage.sh init # 生成 compose / nginx / 启动全部容器 +./manage.sh init # 启动 orchestrator 面板;账号后续在 UI 新增 ./manage.sh install-cli # 让全局 chat2api 命令切到多实例模式 ``` +初始化完成后运行 `./manage.sh secrets` 查看编排面板入口和密码,然后打开: + +```text +http://:60403/orchestrator/ +``` + +进入面板后点「新增账号」,填写 `slug`、代理和备注即可;不需要手动编辑 `accounts.csv`。 + +`accounts.csv` 仍然保留给批量导入/脚本化部署使用。 + ### 已默认应用的工程加固(`deploy/multi/generate.py`) | 类别 | 项 | 默认 | @@ -183,12 +191,13 @@ vi accounts.csv # 每行一个账号: slug,proxy_url,note ```bash chat2api migrate prep # 备份 + 生成 accounts.csv 模板(安全) -vi ~/chat2api/deploy/multi/accounts.csv chat2api migrate apply # 停单 + 启多(需输 yes 确认) # 不满意可回滚: chat2api migrate rollback ~/chat2api.backup-YYYYMMDD-HHMMSS ``` +迁移完成后打开 orchestrator 面板管理账号;如需批量预置账号,再编辑 `~/chat2api/deploy/multi/accounts.csv`。 + --- ## 功能总览 @@ -471,4 +480,4 @@ chat2api sync-template # 单实例:拉新 ENV ## License -MIT License \ No newline at end of file +MIT License diff --git a/deploy/multi/manage.sh b/deploy/multi/manage.sh index ddfa581c..4ef99769 100755 --- a/deploy/multi/manage.sh +++ b/deploy/multi/manage.sh @@ -22,13 +22,8 @@ export MULTI_HOST_PATH="$DIR" ensure_csv() { if [ ! -f "$CSV" ]; then - if [ -f "$EXAMPLE_CSV" ]; then - cp "$EXAMPLE_CSV" "$CSV" - log "已复制 accounts.example.csv → accounts.csv,请编辑后再次运行" - exit 0 - fi - err "accounts.csv 不存在且无 example 可复制" - exit 1 + printf 'slug,proxy_url,note\n' > "$CSV" + log "已创建空 accounts.csv;可在编排面板里新增账号" fi } @@ -66,6 +61,37 @@ as_root() { fi } +public_host() { + curl -fsSL --max-time 5 https://api.ipify.org 2>/dev/null \ + || curl -fsSL --max-time 5 https://ifconfig.me 2>/dev/null \ + || printf '' +} + +orch_password() { + awk -F= '$1=="ORCH_PASSWORD"{print $2}' "$GEN_DIR/orch.env" 2>/dev/null | tail -1 +} + +cmd_access_summary() { + [ -f "$GEN_DIR/orch.env" ] || return 0 + local port host orch_pwd + port="${CHAT2API_GATEWAY_PORT:-60403}" + host="$(public_host)" + orch_pwd="$(orch_password)" + cat <:${port}/orchestrator/" + echo " URL: http://$(public_host):${port}/orchestrator/" echo "===============================================" fi } @@ -374,8 +401,8 @@ cmd_help() { chat2api 多实例运维(一容器一账号) 用法: - ./manage.sh init 首次部署(自动从 example 复制 csv) - ./manage.sh apply 编辑 accounts.csv 后重新应用 + ./manage.sh init 首次部署(启动编排面板;账号可在 UI 新增) + ./manage.sh apply 重新生成配置并应用 ./manage.sh add [proxy] [note] 追加单个账号 + apply ./manage.sh remove 移除单个账号 + apply(保留 data/) ./manage.sh list 所有容器状态(docker compose ps) From 64e8099d2d64b3932cb5124a0d3e18e259940987 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Tue, 12 May 2026 01:12:43 +0800 Subject: [PATCH 69/96] =?UTF-8?q?=E4=B8=80=E9=94=AE=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=A4=9A=E5=AE=9E=E4=BE=8B=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/install.sh | 71 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 3 deletions(-) diff --git a/deploy/install.sh b/deploy/install.sh index f4def1b0..86e01a04 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -17,6 +17,7 @@ # INSTALL_DIR 安装目录(默认 $HOME/chat2api) # CHAT2API_PORT 监听端口(默认 60403) # GITHUB_RAW 仓库 raw URL(默认官方) +# GITHUB_REPO 仓库 URL(默认官方,用于下载 deploy/multi) # INTERACTIVE 设为 1 进入交互模式(询问密码/前缀) # ============================================================ set -euo pipefail @@ -32,9 +33,14 @@ err() { echo -e "${C_ERR}[✗]${C_RESET} $*" >&2; } # ----- 配置默认值 ----- INSTALL_DIR="${INSTALL_DIR:-$HOME/chat2api}" GITHUB_RAW="${GITHUB_RAW:-https://raw.githubusercontent.com/nanashiwang/chat2api/main}" +GITHUB_REPO="${GITHUB_REPO:-https://github.com/nanashiwang/chat2api}" CHAT2API_PORT="${CHAT2API_PORT:-60403}" INTERACTIVE="${INTERACTIVE:-0}" -SCRIPT_SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd || true)" +SCRIPT_SOURCE="${BASH_SOURCE[0]-}" +SCRIPT_SOURCE_DIR="" +if [ -n "$SCRIPT_SOURCE" ] && [ -f "$SCRIPT_SOURCE" ]; then + SCRIPT_SOURCE_DIR="$(cd "$(dirname "$SCRIPT_SOURCE")" 2>/dev/null && pwd || true)" +fi # ----- sudo / root 判定 ----- if [ "$(id -u)" -eq 0 ]; then @@ -115,10 +121,61 @@ shell_escape() { printf "%s" "$1" | sed "s/'/'\\\\''/g" } +sync_deploy_assets() { + if [ -x "$INSTALL_DIR/deploy/multi/manage.sh" ] && [ -f "$INSTALL_DIR/deploy/chat2api.sh" ]; then + return 0 + fi + + log "准备部署辅助脚本(含多实例编排)..." + mkdir -p "$INSTALL_DIR/deploy" + + if [ -n "$SCRIPT_SOURCE_DIR" ] && [ -d "$SCRIPT_SOURCE_DIR/multi" ]; then + local src_deploy dest_deploy + src_deploy="$(cd "$SCRIPT_SOURCE_DIR" 2>/dev/null && pwd -P || true)" + dest_deploy="$(cd "$INSTALL_DIR/deploy" 2>/dev/null && pwd -P || true)" + if [ "$src_deploy" != "$dest_deploy" ]; then + cp -R "$SCRIPT_SOURCE_DIR"/. "$INSTALL_DIR/deploy/" + fi + chmod +x "$INSTALL_DIR/deploy/chat2api.sh" "$INSTALL_DIR/deploy/multi/manage.sh" 2>/dev/null || true + ok "部署辅助脚本已准备" + return 0 + fi + + if ! command -v tar >/dev/null 2>&1; then + warn "tar 不可用,跳过 deploy/multi 下载;单实例部署不受影响" + return 0 + fi + + local tmp_dir archive_dir + tmp_dir="$(mktemp -d)" || { + warn "临时目录创建失败,跳过 deploy/multi 下载" + return 0 + } + + if curl -fsSL "${GITHUB_REPO}/archive/refs/heads/main.tar.gz" | tar -xz -C "$tmp_dir"; then + archive_dir="$(find "$tmp_dir" -maxdepth 1 -type d -name 'chat2api-*' | head -1)" + if [ -n "$archive_dir" ] && [ -d "$archive_dir/deploy" ]; then + cp -R "$archive_dir/deploy"/. "$INSTALL_DIR/deploy/" + chmod +x "$INSTALL_DIR/deploy/chat2api.sh" "$INSTALL_DIR/deploy/multi/manage.sh" 2>/dev/null || true + ok "部署辅助脚本已准备(含 deploy/multi)" + else + warn "仓库包缺少 deploy/,跳过 deploy/multi 下载" + fi + else + warn "deploy/multi 下载失败;单实例部署不受影响" + fi + rm -rf "$tmp_dir" +} + install_manage_command() { local script_src tmp_script - script_src="${SCRIPT_SOURCE_DIR}/chat2api.sh" + script_src="" + if [ -n "$SCRIPT_SOURCE_DIR" ] && [ -f "$SCRIPT_SOURCE_DIR/chat2api.sh" ]; then + script_src="${SCRIPT_SOURCE_DIR}/chat2api.sh" + elif [ -f "$INSTALL_DIR/deploy/chat2api.sh" ]; then + script_src="$INSTALL_DIR/deploy/chat2api.sh" + fi $SUDO mkdir -p /etc || return 1 if ! $SUDO tee /etc/chat2api.env >/dev/null < Date: Tue, 12 May 2026 01:16:09 +0800 Subject: [PATCH 70/96] =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E5=A4=9A=E5=AE=9E=E4=BE=8B=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 17 +++++++------- deploy/install-command.sh | 15 ++++++++++-- deploy/install.sh | 48 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 3c860c2b..aa31a486 100644 --- a/README.md +++ b/README.md @@ -155,21 +155,22 @@ docker logs c2a- | grep session_sticky ### 一句话部署 ```bash -# 1. 先用一键脚本把 chat2api 装好 -curl -fsSL https://raw.githubusercontent.com/nanashiwang/chat2api/main/deploy/install.sh | bash - -# 2. 初始化多实例编排面板 -cd ~/chat2api/deploy/multi -./manage.sh init # 启动 orchestrator 面板;账号后续在 UI 新增 -./manage.sh install-cli # 让全局 chat2api 命令切到多实例模式 +CHAT2API_MODE=multi bash <(curl -fsSL https://raw.githubusercontent.com/nanashiwang/chat2api/main/deploy/install.sh) ``` -初始化完成后运行 `./manage.sh secrets` 查看编排面板入口和密码,然后打开: +初始化完成后,脚本会直接输出编排面板入口和密码,然后打开: ```text http://:60403/orchestrator/ ``` +如果你已经用一键脚本装了单实例,请走迁移流程,避免 60403 端口冲突: + +```bash +chat2api migrate prep +chat2api migrate apply +``` + 进入面板后点「新增账号」,填写 `slug`、代理和备注即可;不需要手动编辑 `accounts.csv`。 `accounts.csv` 仍然保留给批量导入/脚本化部署使用。 diff --git a/deploy/install-command.sh b/deploy/install-command.sh index 9e738654..ed164249 100644 --- a/deploy/install-command.sh +++ b/deploy/install-command.sh @@ -40,7 +40,11 @@ if [[ ! -f "$INSTALL_DIR/docker-compose.yml" && ! -f "$INSTALL_DIR/compose.yml" exit 1 fi -SCRIPT_SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT_SOURCE="${BASH_SOURCE[0]-}" +SCRIPT_SOURCE_DIR="" +if [ -n "$SCRIPT_SOURCE" ] && [ -f "$SCRIPT_SOURCE" ]; then + SCRIPT_SOURCE_DIR="$(cd "$(dirname "$SCRIPT_SOURCE")" && pwd)" +fi $SUDO mkdir -p /etc $SUDO tee /etc/chat2api.env >/dev/null < Date: Tue, 12 May 2026 01:25:55 +0800 Subject: [PATCH 71/96] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=8D=B8=E8=BD=BD?= =?UTF-8?q?=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + deploy/chat2api.sh | 115 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index aa31a486..a2d02f67 100644 --- a/README.md +++ b/README.md @@ -408,6 +408,8 @@ chat2api status # 容器状态(多实例下含出口 IP 抽 chat2api update # 拉镜像并重建 chat2api logs [slug] # 实时日志 chat2api restart / stop / start +chat2api uninstall # 卸载:停止服务并删除安装目录 +chat2api uninstall --keep-data # 只停止服务,保留安装目录和数据 chat2api path # 打印安装目录 # 单实例专属 diff --git a/deploy/chat2api.sh b/deploy/chat2api.sh index bb26698f..4586450e 100644 --- a/deploy/chat2api.sh +++ b/deploy/chat2api.sh @@ -115,15 +115,121 @@ update_repo_for_multi() { fi } +as_root() { + if [[ "$(id -u)" -eq 0 ]]; then + "$@" + elif command -v sudo >/dev/null 2>&1; then + sudo "$@" + else + echo "需要 root 权限或安装 sudo" + return 1 + fi +} + run_compose() { - if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then + if ! command -v docker >/dev/null 2>&1 || ! docker compose version >/dev/null 2>&1; then + echo "docker compose is not available" + exit 1 + fi + + if [[ "$(id -u)" -eq 0 ]] || docker info >/dev/null 2>&1; then + docker compose "$@" + elif command -v sudo >/dev/null 2>&1; then sudo docker compose "$@" else - echo "docker compose is not available" + echo "docker requires root permission or sudo" exit 1 fi } +confirm_uninstall() { + local keep_data="$1" + if [[ "${CHAT2API_UNINSTALL_CONFIRM:-}" == "yes" ]]; then + return 0 + fi + if [[ "${2:-}" == "--yes" || "${2:-}" == "-y" ]]; then + return 0 + fi + + echo "============================================================" + echo " chat2api uninstall" + echo "============================================================" + echo "安装目录: $INSTALL_DIR" + if [[ "$keep_data" == "1" ]]; then + echo "操作: 停止服务,保留安装目录和数据" + else + echo "操作: 停止服务,并删除安装目录、/usr/local/bin/chat2api、/etc/chat2api.env" + fi + echo + read -r -p 'Type "yes" to proceed: ' ans }" >&2 + return 1 + fi + resolved="$(cd "$INSTALL_DIR" && pwd -P)" || return 1 + case "$resolved" in + /|/root|/home|/Users|/opt|/srv|/tmp) + echo "[!] Refusing to remove unsafe install directory: $resolved" >&2 + return 1 + ;; + esac + if [[ ! -f "$resolved/docker-compose.yml" && ! -f "$resolved/compose.yml" && ! -f "$resolved/compose.yaml" && ! -x "$resolved/deploy/multi/manage.sh" ]]; then + echo "[!] Refusing to remove non-chat2api directory: $resolved" >&2 + return 1 + fi + printf "%s\n" "$resolved" +} + +cmd_uninstall() { + local keep_data=0 assume_yes=0 arg install_dir_resolved + for arg in "$@"; do + case "$arg" in + --keep-data) keep_data=1 ;; + --yes|-y) assume_yes=1 ;; + *) + echo "Usage: chat2api uninstall [--keep-data] [--yes]" + return 1 + ;; + esac + done + + install_dir_resolved="$(resolve_uninstall_dir)" || return 1 + + if ! confirm_uninstall "$keep_data" "$([[ "$assume_yes" == "1" ]] && printf -- --yes)"; then + echo "Aborted." + return 0 + fi + + echo "[*] Stopping chat2api services..." + if is_multi_install; then + if ! run_multi_manage down; then + echo "[!] Failed to stop multi-instance services; uninstall aborted." + return 1 + fi + else + if ! run_compose down --remove-orphans; then + echo "[!] Failed to stop services; uninstall aborted." + return 1 + fi + fi + + if [[ "$keep_data" == "1" ]]; then + echo "[✓] Services stopped. Data kept at: $INSTALL_DIR" + return 0 + fi + + echo "[*] Removing install directory and command..." + cd / + rm -rf -- "$install_dir_resolved" || as_root rm -rf -- "$install_dir_resolved" + as_root rm -f /usr/local/bin/chat2api "$CONFIG_FILE" + echo "[✓] chat2api uninstalled." +} + # ============================================================ # B: 模板同步(仅单实例模式) # ============================================================ @@ -477,6 +583,7 @@ Multi-instance commands: start Same as update restart Same as update stop Stop all multi-instance services + uninstall Stop and remove chat2api; use --keep-data to keep files status Show multi-instance status and sampled egress IPs verify Verify admin/tokens routing for all instances logs Tail one instance logs @@ -505,6 +612,7 @@ Single-instance commands: Restore single-instance from a previous backup restart Restart services stop Stop services + uninstall Stop and remove chat2api; use --keep-data to keep files start Start services status Show compose status logs Tail chat2api logs @@ -552,6 +660,9 @@ case "$command_name" in run_compose stop fi ;; + uninstall) + cmd_uninstall "${@:2}" + ;; start) if is_multi_install; then run_multi_manage apply From 39566c296e73d019e073d0351c6c0497b82f5b24 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Tue, 12 May 2026 01:33:06 +0800 Subject: [PATCH 72/96] =?UTF-8?q?=E5=8D=95=E5=AE=9E=E4=BE=8B=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E5=90=8C=E6=AD=A5=E7=AE=A1=E7=90=86=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/chat2api.sh | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/deploy/chat2api.sh b/deploy/chat2api.sh index 4586450e..b4f3c677 100644 --- a/deploy/chat2api.sh +++ b/deploy/chat2api.sh @@ -64,12 +64,27 @@ install_latest_cli_from_repo() { local src="$INSTALL_DIR/deploy/chat2api.sh" [[ -f "$src" ]] || return 0 if [[ "$(id -u)" -eq 0 ]]; then - install -m 0755 "$src" /usr/local/bin/chat2api 2>/dev/null || true + install -m 0755 "$src" /usr/local/bin/chat2api 2>/dev/null && echo "[✓] 管理命令已更新: /usr/local/bin/chat2api" || true elif command -v sudo >/dev/null 2>&1; then - sudo install -m 0755 "$src" /usr/local/bin/chat2api 2>/dev/null || true + sudo install -m 0755 "$src" /usr/local/bin/chat2api 2>/dev/null && echo "[✓] 管理命令已更新: /usr/local/bin/chat2api" || true fi } +install_latest_cli_from_remote() { + local tmp_script + tmp_script="$(mktemp)" || return 0 + if curl -fsSL --max-time 15 "$GITHUB_RAW/deploy/chat2api.sh" -o "$tmp_script"; then + if [[ "$(id -u)" -eq 0 ]]; then + install -m 0755 "$tmp_script" /usr/local/bin/chat2api 2>/dev/null && echo "[✓] 管理命令已更新: /usr/local/bin/chat2api" || true + elif command -v sudo >/dev/null 2>&1; then + sudo install -m 0755 "$tmp_script" /usr/local/bin/chat2api 2>/dev/null && echo "[✓] 管理命令已更新: /usr/local/bin/chat2api" || true + fi + else + echo "[!] 管理命令更新失败,继续执行当前操作" + fi + rm -f "$tmp_script" +} + update_repo_for_multi() { # 让 `chat2api update` 自己先更新部署脚本,再调用 deploy/multi/manage.sh。 # 本地跟踪文件改动会自动放入 git stash;不 reset、不删除用户改动。 @@ -634,6 +649,7 @@ case "$command_name" in update_repo_for_multi run_multi_manage apply else + install_latest_cli_from_remote run_compose pull run_compose up -d # 提示是否有新 ENV 待合并(不强制) @@ -660,7 +676,7 @@ case "$command_name" in run_compose stop fi ;; - uninstall) + uninstall|unstaill) cmd_uninstall "${@:2}" ;; start) From 80ec2ab1bdd85c97b08c846751a7bdaeb8fb901f Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Tue, 12 May 2026 01:39:44 +0800 Subject: [PATCH 73/96] =?UTF-8?q?=E9=BB=98=E8=AE=A4=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E5=A4=9A=E5=AE=9E=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 13 ++++++++++--- deploy/install.sh | 24 +++++++++--------------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index a2d02f67..64e2518b 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ curl -fsSL https://raw.githubusercontent.com/nanashiwang/chat2api/main/deploy/install.sh | bash ``` -脚本自动:装 Docker → 下载 compose → 生成随机 ADMIN/API 密钥 → 启动 → 安装 `chat2api` 管理命令 → 打印访问地址。 +脚本默认部署多实例编排面板:装 Docker → 下载编排脚本 → 启动 orchestrator → 安装 `chat2api` 管理命令 → 打印访问地址。 ### 新增能力一览 @@ -155,7 +155,7 @@ docker logs c2a- | grep session_sticky ### 一句话部署 ```bash -CHAT2API_MODE=multi bash <(curl -fsSL https://raw.githubusercontent.com/nanashiwang/chat2api/main/deploy/install.sh) +curl -fsSL https://raw.githubusercontent.com/nanashiwang/chat2api/main/deploy/install.sh | bash ``` 初始化完成后,脚本会直接输出编排面板入口和密码,然后打开: @@ -354,18 +354,25 @@ curl -N 'http://127.0.0.1:5005/${API_PREFIX}/v1/chat/completions' \ ### 一键部署(推荐) -零交互,自动装 Docker、生成随机凭据、启动服务、安装 `chat2api` 全局命令: +零交互,默认安装多实例编排面板: ```bash curl -fsSL https://raw.githubusercontent.com/nanashiwang/chat2api/main/deploy/install.sh | bash ``` +如需旧的单实例模式: + +```bash +CHAT2API_MODE=single bash <(curl -fsSL https://raw.githubusercontent.com/nanashiwang/chat2api/main/deploy/install.sh) +``` + 可选环境变量: | 变量 | 用途 | |---|---| | `INSTALL_DIR` | 自定义安装目录(默认 `~/chat2api`) | | `CHAT2API_PORT` | 监听端口(默认 `60403`) | +| `CHAT2API_MODE` | `multi`(默认)或 `single` | | `INTERACTIVE=1` | 交互式询问密码 / API 前缀 | ### 直接源码部署 diff --git a/deploy/install.sh b/deploy/install.sh index ec7918f6..aa252dce 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -7,18 +7,18 @@ # 或下载后: # bash install.sh # -# 本脚本会: +# 本脚本默认部署多实例编排面板,会: # 1. 自动安装 Docker(如缺) -# 2. 生成随机 ADMIN_PASSWORD / AUTHORIZATION / API_PREFIX -# 3. 下载 docker-compose 模板(不预设代理,代理请在 UI 里配) -# 4. 启动服务并打印访问信息 +# 2. 下载 deploy/multi 编排脚本 +# 3. 启动 orchestrator 面板 +# 4. 安装 chat2api 管理命令并打印访问入口 # # 自定义环境变量(可选,脚本启动前 export 即可): # INSTALL_DIR 安装目录(默认 $HOME/chat2api) # CHAT2API_PORT 监听端口(默认 60403) # GITHUB_RAW 仓库 raw URL(默认官方) # GITHUB_REPO 仓库 URL(默认官方,用于下载 deploy/multi) -# CHAT2API_MODE 部署模式:single(默认)/ multi +# CHAT2API_MODE 部署模式:multi(默认)/ single # INTERACTIVE 设为 1 进入交互模式(询问密码/前缀) # ============================================================ set -euo pipefail @@ -36,7 +36,7 @@ INSTALL_DIR="${INSTALL_DIR:-$HOME/chat2api}" GITHUB_RAW="${GITHUB_RAW:-https://raw.githubusercontent.com/nanashiwang/chat2api/main}" GITHUB_REPO="${GITHUB_REPO:-https://github.com/nanashiwang/chat2api}" CHAT2API_PORT="${CHAT2API_PORT:-60403}" -CHAT2API_MODE="${CHAT2API_MODE:-single}" +CHAT2API_MODE="${CHAT2API_MODE:-multi}" INTERACTIVE="${INTERACTIVE:-0}" SCRIPT_SOURCE="${BASH_SOURCE[0]-}" SCRIPT_SOURCE_DIR="" @@ -224,10 +224,10 @@ if [ "$CHAT2API_MODE" = "multi" ]; then curl -fsSL --max-time 5 https://ifconfig.me 2>/dev/null || \ echo 'your-server-ip')" - cat < Date: Tue, 12 May 2026 01:47:26 +0800 Subject: [PATCH 74/96] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8D=95=E5=AE=9E?= =?UTF-8?q?=E4=BE=8B=E5=88=B0=E5=A4=9A=E5=AE=9E=E4=BE=8B=E7=AB=AF=E5=8F=A3?= =?UTF-8?q?=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/chat2api.sh | 28 +++++++++++++++++++++++++--- deploy/install.sh | 14 ++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/deploy/chat2api.sh b/deploy/chat2api.sh index b4f3c677..c233bc2b 100644 --- a/deploy/chat2api.sh +++ b/deploy/chat2api.sh @@ -56,6 +56,10 @@ is_multi_install() { [[ -x "$INSTALL_DIR/deploy/multi/manage.sh" ]] } +is_multi_generated() { + [[ -f "$INSTALL_DIR/deploy/multi/generated/docker-compose.yml" ]] +} + run_multi_manage() { (cd "$INSTALL_DIR/deploy/multi" && ./manage.sh "$@") } @@ -157,6 +161,21 @@ run_compose() { fi } +stop_single_compose_if_present() { + if [[ -f "$INSTALL_DIR/docker-compose.yml" || -f "$INSTALL_DIR/compose.yml" || -f "$INSTALL_DIR/compose.yaml" ]]; then + run_compose down --remove-orphans + return + fi + return 1 +} + +remove_known_chat2api_containers() { + if ! command -v docker >/dev/null 2>&1; then + return 0 + fi + docker rm -f chat2api watchtower c2a-nginx c2a-orchestrator c2a-watchtower >/dev/null 2>&1 || true +} + confirm_uninstall() { local keep_data="$1" if [[ "${CHAT2API_UNINSTALL_CONFIRM:-}" == "yes" ]]; then @@ -221,13 +240,16 @@ cmd_uninstall() { fi echo "[*] Stopping chat2api services..." - if is_multi_install; then + if is_multi_install && is_multi_generated; then if ! run_multi_manage down; then echo "[!] Failed to stop multi-instance services; uninstall aborted." return 1 fi - else - if ! run_compose down --remove-orphans; then + elif ! stop_single_compose_if_present; then + if is_multi_install; then + echo "[*] Multi-instance config is not generated; removing known chat2api containers..." + remove_known_chat2api_containers + else echo "[!] Failed to stop services; uninstall aborted." return 1 fi diff --git a/deploy/install.sh b/deploy/install.sh index aa252dce..2f43f5dd 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -123,6 +123,19 @@ shell_escape() { printf "%s" "$1" | sed "s/'/'\\\\''/g" } +cleanup_single_instance_for_multi() { + if [ "$CHAT2API_MODE" != "multi" ]; then + return 0 + fi + if ! command -v docker >/dev/null 2>&1; then + return 0 + fi + if docker ps -a --format '{{.Names}}' | grep -qx 'chat2api'; then + log "检测到旧单实例容器占用端口,先停止并移除..." + docker rm -f chat2api >/dev/null 2>&1 || true + fi +} + sync_deploy_assets() { if [ -x "$INSTALL_DIR/deploy/multi/manage.sh" ] && [ -f "$INSTALL_DIR/deploy/chat2api.sh" ]; then return 0 @@ -206,6 +219,7 @@ EOF } sync_deploy_assets +cleanup_single_instance_for_multi if [ "$CHAT2API_MODE" = "multi" ]; then if [ ! -x "$INSTALL_DIR/deploy/multi/manage.sh" ]; then From 110db67abb66f36b73b9739d258bd7e1385a3362 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Tue, 12 May 2026 02:06:48 +0800 Subject: [PATCH 75/96] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=BC=96=E6=8E=92?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E7=AE=A1=E7=90=86=E4=B8=AD=E5=BF=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/multi/generate.py | 4 + deploy/multi/manage.sh | 2 + deploy/multi/orchestrator/app.py | 109 ++++++++++++++++-- deploy/multi/orchestrator/static/app.js | 50 ++++++++ .../orchestrator/templates/dashboard.html | 31 +++++ .../multi/orchestrator/templates/login.html | 16 ++- 6 files changed, 202 insertions(+), 10 deletions(-) diff --git a/deploy/multi/generate.py b/deploy/multi/generate.py index 35063196..aba59790 100755 --- a/deploy/multi/generate.py +++ b/deploy/multi/generate.py @@ -466,14 +466,17 @@ def ensure_orch_env() -> tuple[str, bool]: """ if ORCH_ENV.exists(): env = parse_env_file(ORCH_ENV) + username = env.get("ORCH_USERNAME", "") pwd = env.get("ORCH_PASSWORD", "") secret = env.get("ORCH_SESSION_SECRET", "") if pwd and secret: return pwd, False + username = "admin" pwd = gen_admin_password() secret = pysecrets.token_hex(32) body = ( "# 自动生成 — orchestrator 凭证(generate.py 维护)\n" + f"ORCH_USERNAME={username}\n" f"ORCH_PASSWORD={pwd}\n" f"ORCH_SESSION_SECRET={secret}\n" ) @@ -534,6 +537,7 @@ def main() -> int: sys.stdout.write( "\n" "============================================================\n" + " Orchestrator 首次访问用户名:admin\n" f" Orchestrator 首次访问密码:{orch_first_pwd}\n" " 请立即记录!可通过 ./manage.sh orch-password 重置\n" " 访问入口:http://:{port}/orchestrator/\n" diff --git a/deploy/multi/manage.sh b/deploy/multi/manage.sh index 4ef99769..06ef765c 100755 --- a/deploy/multi/manage.sh +++ b/deploy/multi/manage.sh @@ -253,6 +253,8 @@ cmd_secrets() { if [ -f "$GEN_DIR/orch.env" ]; then echo echo "============ Orchestrator 编排面板(管理所有容器) ============" + grep '^ORCH_USERNAME=' "$GEN_DIR/orch.env" 2>/dev/null \ + | sed 's/^/ /' grep '^ORCH_PASSWORD=' "$GEN_DIR/orch.env" 2>/dev/null \ | sed 's/^/ /' local port="${CHAT2API_GATEWAY_PORT:-60403}" diff --git a/deploy/multi/orchestrator/app.py b/deploy/multi/orchestrator/app.py index a64c9e59..21af55de 100644 --- a/deploy/multi/orchestrator/app.py +++ b/deploy/multi/orchestrator/app.py @@ -61,6 +61,7 @@ DATA_DIR = WORK / "data" AUDIT_FILE = WORK / "audit.jsonl" +USERNAME = (os.environ.get("ORCH_USERNAME") or "admin").strip() or "admin" PASSWORD = (os.environ.get("ORCH_PASSWORD") or "").strip() SESSION_SECRET = (os.environ.get("ORCH_SESSION_SECRET") or "").strip() SESSION_MAX_AGE = 8 * 3600 # 8h @@ -74,6 +75,7 @@ SLUG_RE = re.compile(r"^[a-z0-9-]{1,16}$") PROXY_RE = re.compile(r"^(socks5|socks5h|http|https)://[^\s]+$") +ORCH_USERNAME_RE = re.compile(r"^[A-Za-z0-9_.@-]{3,64}$") LOG_LEVEL = os.environ.get("ORCH_LOG_LEVEL", "INFO").upper() logging.basicConfig( @@ -212,6 +214,29 @@ def read_env_file(path: Path) -> dict[str, str]: return out +def write_env_file(path: Path, values: dict[str, str]) -> None: + tmp = path.with_suffix(path.suffix + ".tmp") + with tmp.open("w", encoding="utf-8") as f: + for key, value in values.items(): + f.write(f"{key}={value}\n") + tmp.chmod(0o600) + tmp.replace(path) + + +def orch_credentials() -> dict[str, str]: + env = read_env_file(ORCH_ENV) + username = (env.get("ORCH_USERNAME") or USERNAME or "admin").strip() or "admin" + password = (env.get("ORCH_PASSWORD") or PASSWORD).strip() + secret = (env.get("ORCH_SESSION_SECRET") or SESSION_SECRET).strip() + version = hashlib.sha256(f"{username}\0{password}".encode("utf-8")).hexdigest()[:16] + return { + "username": username, + "password": password, + "secret": secret, + "version": version, + } + + def mask_proxy(url: str) -> str: """socks5://user:pass@host:port → socks5://****@host:port""" if not url: @@ -349,8 +374,8 @@ def read_container_logs(container: str, tail: int = 80) -> tuple[bool, str]: # ---------- 鉴权 ---------- -def issue_session_token() -> str: - return serializer.dumps({"u": "admin"}) +def issue_session_token(username: str, version: str) -> str: + return serializer.dumps({"u": username, "v": version}) def verify_session_token(token: str | None) -> bool: @@ -358,7 +383,12 @@ def verify_session_token(token: str | None) -> bool: return False try: data = serializer.loads(token, max_age=SESSION_MAX_AGE) - return isinstance(data, dict) and data.get("u") == "admin" + creds = orch_credentials() + return ( + isinstance(data, dict) + and data.get("u") == creds["username"] + and data.get("v") == creds["version"] + ) except (BadSignature, SignatureExpired): return False @@ -416,6 +446,7 @@ async def login_page(request: Request) -> HTMLResponse: async def login( request: Request, response: Response, + username: str = Form("admin"), password: str = Form(...), ) -> Response: ip = request.client.host if request.client else "?" @@ -426,16 +457,20 @@ async def login( {"request": request, "error": "尝试过多,请稍候再试"}, status_code=429, ) - if not pysecrets.compare_digest(password, PASSWORD): + creds = orch_credentials() + if ( + not pysecrets.compare_digest(username.strip(), creds["username"]) + or not pysecrets.compare_digest(password, creds["password"]) + ): record_login_failure(ip) - audit("login", request, False, reason="bad_password") + audit("login", request, False, reason="bad_credentials", username=username[:80]) return templates.TemplateResponse( "login.html", - {"request": request, "error": "密码错误"}, + {"request": request, "error": "用户名或密码错误", "username": username}, status_code=401, ) - token = issue_session_token() + token = issue_session_token(creds["username"], creds["version"]) csrf = gen_csrf() is_https = request.url.scheme == "https" or \ request.headers.get("x-forwarded-proto") == "https" @@ -450,7 +485,7 @@ async def login( max_age=SESSION_MAX_AGE, httponly=False, samesite="strict", secure=is_https, path="/", ) - audit("login", request, True) + audit("login", request, True, username=creds["username"]) return resp @@ -515,6 +550,64 @@ def _proxy(cls, v: str | None) -> str | None: return v +class OrchestratorCredentialPatch(BaseModel): + username: str + current_password: str + new_password: str + + @field_validator("username") + @classmethod + def _username(cls, v: str) -> str: + v = v.strip() + if not ORCH_USERNAME_RE.match(v): + raise ValueError("用户名需 3-64 位,仅支持字母、数字、_.@-") + return v + + @field_validator("current_password") + @classmethod + def _current_password(cls, v: str) -> str: + if not v or "\n" in v or "\r" in v: + raise ValueError("当前密码必填") + return v + + @field_validator("new_password") + @classmethod + def _new_password(cls, v: str) -> str: + if len(v) < 8: + raise ValueError("新密码至少 8 位") + if "\n" in v or "\r" in v: + raise ValueError("新密码不能包含换行") + return v + + +@app.get("/api/orchestrator/account", dependencies=[Depends(require_session)]) +async def api_orchestrator_account() -> JSONResponse: + creds = orch_credentials() + return JSONResponse({"username": creds["username"]}) + + +@app.patch( + "/api/orchestrator/account", + dependencies=[Depends(require_session), Depends(require_csrf)], +) +async def api_update_orchestrator_account( + payload: OrchestratorCredentialPatch, + request: Request, +) -> JSONResponse: + creds = orch_credentials() + if not pysecrets.compare_digest(payload.current_password, creds["password"]): + audit("update_orchestrator_account", request, False, reason="bad_current_password") + raise HTTPException(status_code=403, detail="当前密码错误") + + env = read_env_file(ORCH_ENV) + env["ORCH_USERNAME"] = payload.username + env["ORCH_PASSWORD"] = payload.new_password + env["ORCH_SESSION_SECRET"] = env.get("ORCH_SESSION_SECRET") or creds["secret"] + write_env_file(ORCH_ENV, env) + audit("update_orchestrator_account", request, True, username=payload.username) + return JSONResponse({"ok": True, "username": payload.username, "relogin": True}) + + @app.get("/api/accounts", dependencies=[Depends(require_session)]) async def api_list_accounts() -> JSONResponse: rows = read_accounts() diff --git a/deploy/multi/orchestrator/static/app.js b/deploy/multi/orchestrator/static/app.js index d972c7a7..207f0b3f 100644 --- a/deploy/multi/orchestrator/static/app.js +++ b/deploy/multi/orchestrator/static/app.js @@ -289,6 +289,56 @@ $('#btn-close-secret').addEventListener('click', () => { $('#secret-body').innerHTML = ''; // 立即清屏 }); +// ---------- 管理中心 ---------- + +async function openAdminCenter() { + $('#admin-center-error').classList.add('hidden'); + $('#admin-center-error').textContent = ''; + $('#admin-current-password').value = ''; + $('#admin-new-password').value = ''; + $('#modal-admin-center').classList.remove('hidden'); + $('#modal-admin-center').classList.add('flex'); + try { + const d = await api('GET', '/api/orchestrator/account'); + $('#admin-username').value = d.username || 'admin'; + } catch (e) { + $('#admin-center-error').textContent = e.message; + $('#admin-center-error').classList.remove('hidden'); + } +} + +function closeAdminCenter() { + $('#modal-admin-center').classList.add('hidden'); + $('#modal-admin-center').classList.remove('flex'); +} + +$('#btn-admin-center').addEventListener('click', openAdminCenter); +$('#btn-close-admin-center').addEventListener('click', closeAdminCenter); +$('#btn-cancel-admin-center').addEventListener('click', closeAdminCenter); + +$('#admin-center-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const btn = $('#btn-save-admin-center'); + btn.disabled = true; + btn.textContent = '保存中...'; + $('#admin-center-error').classList.add('hidden'); + try { + await api('PATCH', '/api/orchestrator/account', { + username: $('#admin-username').value.trim(), + current_password: $('#admin-current-password').value, + new_password: $('#admin-new-password').value, + }); + toast('已更新,请重新登录'); + setTimeout(() => { location.href = './login'; }, 600); + } catch (e) { + $('#admin-center-error').textContent = e.message; + $('#admin-center-error').classList.remove('hidden'); + } finally { + btn.disabled = false; + btn.textContent = '保存'; + } +}); + // ---------- 审计 ---------- $('#btn-audit').addEventListener('click', async () => { diff --git a/deploy/multi/orchestrator/templates/dashboard.html b/deploy/multi/orchestrator/templates/dashboard.html index 93091468..2048882f 100644 --- a/deploy/multi/orchestrator/templates/dashboard.html +++ b/deploy/multi/orchestrator/templates/dashboard.html @@ -21,6 +21,7 @@

    chat2api Orchestrator

    +
    @@ -94,6 +95,36 @@

    实例凭证

    + + + @@ -65,7 +64,7 @@
    -

    小写字母 / 数字 / 连字符,最多 16 位

    +

    小写字母 / 数字 / 连字符,最多 16 位

    From b7168ee83918058d3d17199f50fa7c404ca733f9 Mon Sep 17 00:00:00 2001 From: nanashiwang Date: Tue, 12 May 2026 10:08:59 +0800 Subject: [PATCH 78/96] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E8=B0=83=E5=BA=A6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 15 ++ deploy/multi/generate.py | 25 +- deploy/multi/manage.sh | 18 +- deploy/multi/orchestrator/app.py | 247 +++++++++++++++++- deploy/multi/orchestrator/static/app.js | 29 ++ .../orchestrator/templates/dashboard.html | 33 +++ 6 files changed, 364 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 64e2518b..4e21d438 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,21 @@ chat2api migrate apply `accounts.csv` 仍然保留给批量导入/脚本化部署使用。 +### 统一调用入口 + +多实例会额外暴露一个统一 OpenAI 兼容入口,由 Orchestrator 自动均衡到各个账号容器: + +```text +Base URL: http://:60403/v1 +API Key: 编排面板右上角「统一 API」查看 +``` + +路由策略: + +- 无会话键:轮询分配到不同容器 +- 有 `librechat_conversation_id` / `conversation_id` / `X-Chat2API-Affinity`:固定到同一容器 +- Orchestrator 会把统一 Key 转成目标容器自己的 `AUTHORIZATION` + ### 已默认应用的工程加固(`deploy/multi/generate.py`) | 类别 | 项 | 默认 | diff --git a/deploy/multi/generate.py b/deploy/multi/generate.py index aba59790..22af7054 100755 --- a/deploy/multi/generate.py +++ b/deploy/multi/generate.py @@ -352,6 +352,13 @@ def render_compose(accounts: list[Account]) -> str: """ NGINX_ORCH_LOCATION = """\ + # ---- unified OpenAI-compatible API (load-balanced by orchestrator) ---- + location /v1/ { + proxy_pass http://c2a-orchestrator:8080/v1/; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection ''; + } + # ---- orchestrator (编排面板) ---- location /orchestrator/ { proxy_pass http://c2a-orchestrator:8080/; @@ -460,7 +467,7 @@ def cleanup_orphan_envs(accounts: list[Account]) -> None: def ensure_orch_env() -> tuple[str, bool]: - """orchestrator 凭证:首次自动生成 24 位密码 + 64 字符 session secret。 + """orchestrator 凭证:首次自动生成登录密码、会话密钥和统一 API key。 返回 (password, was_generated)。 """ @@ -469,16 +476,32 @@ def ensure_orch_env() -> tuple[str, bool]: username = env.get("ORCH_USERNAME", "") pwd = env.get("ORCH_PASSWORD", "") secret = env.get("ORCH_SESSION_SECRET", "") + api_key = env.get("ORCH_API_KEY", "") + changed = False + if not username: + env["ORCH_USERNAME"] = "admin" + changed = True + if not api_key: + env["ORCH_API_KEY"] = "sk-orch-" + pysecrets.token_hex(24) + changed = True if pwd and secret: + if changed: + body = "# 自动生成 — orchestrator 凭证(generate.py 维护)\n" + for key in ("ORCH_USERNAME", "ORCH_PASSWORD", "ORCH_SESSION_SECRET", "ORCH_API_KEY"): + if env.get(key): + body += f"{key}={env[key]}\n" + write_file(ORCH_ENV, body, mode=0o600) return pwd, False username = "admin" pwd = gen_admin_password() secret = pysecrets.token_hex(32) + api_key = "sk-orch-" + pysecrets.token_hex(24) body = ( "# 自动生成 — orchestrator 凭证(generate.py 维护)\n" f"ORCH_USERNAME={username}\n" f"ORCH_PASSWORD={pwd}\n" f"ORCH_SESSION_SECRET={secret}\n" + f"ORCH_API_KEY={api_key}\n" ) write_file(ORCH_ENV, body, mode=0o600) return pwd, True diff --git a/deploy/multi/manage.sh b/deploy/multi/manage.sh index 8105f7df..97243629 100755 --- a/deploy/multi/manage.sh +++ b/deploy/multi/manage.sh @@ -71,6 +71,10 @@ orch_password() { awk -F= '$1=="ORCH_PASSWORD"{print $2}' "$GEN_DIR/orch.env" 2>/dev/null | tail -1 } +orch_api_key() { + awk -F= '$1=="ORCH_API_KEY"{print $2}' "$GEN_DIR/orch.env" 2>/dev/null | tail -1 +} + cmd_access_summary() { [ -f "$GEN_DIR/orch.env" ] || return 0 local port host orch_pwd @@ -85,6 +89,10 @@ cmd_access_summary() { URL: http://${host}:${port}/orchestrator/ ORCH_PASSWORD: ${orch_pwd:-见 $GEN_DIR/orch.env} +统一 API: + BASE_URL: http://${host}:${port}/v1 + API_KEY: $(orch_api_key) + 单个实例后台 / API 凭证: ./manage.sh secrets ============================================================ @@ -258,7 +266,15 @@ cmd_secrets() { grep '^ORCH_PASSWORD=' "$GEN_DIR/orch.env" 2>/dev/null \ | sed 's/^/ /' local port="${CHAT2API_GATEWAY_PORT:-60403}" - echo " URL: http://$(public_host):${port}/orchestrator/" + local host + host="$(public_host)" + echo " URL: http://${host}:${port}/orchestrator/" + echo + echo "============ 统一 API(自动均衡所有实例) ============" + grep '^ORCH_API_KEY=' "$GEN_DIR/orch.env" 2>/dev/null \ + | sed 's/^/ /' + echo " BASE_URL: http://${host}:${port}/v1" + echo " CHAT: http://${host}:${port}/v1/chat/completions" echo "===============================================" fi } diff --git a/deploy/multi/orchestrator/app.py b/deploy/multi/orchestrator/app.py index 21af55de..58de1b15 100644 --- a/deploy/multi/orchestrator/app.py +++ b/deploy/multi/orchestrator/app.py @@ -40,11 +40,12 @@ Response, status, ) -from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer from pydantic import BaseModel, Field, field_validator +from starlette.background import BackgroundTask # ---------- 配置 ---------- @@ -67,6 +68,7 @@ SESSION_MAX_AGE = 8 * 3600 # 8h SESSION_COOKIE = "orch_session" CSRF_COOKIE = "orch_csrf" +UNIFIED_API_KEY_ENV = "ORCH_API_KEY" if not PASSWORD or not SESSION_SECRET: raise RuntimeError( @@ -256,6 +258,173 @@ def mask_secret(s: str, head: int = 6, tail: int = 4) -> str: return f"{s[:head]}...{s[-tail:]}" +def public_origin(request: Request) -> str: + proto = request.headers.get("x-forwarded-proto") or request.url.scheme + host = request.headers.get("x-forwarded-host") or request.headers.get("host") or "localhost" + return f"{proto}://{host}" + + +def unified_api_key() -> str: + env = read_env_file(ORCH_ENV) + return (env.get(UNIFIED_API_KEY_ENV) or os.environ.get(UNIFIED_API_KEY_ENV) or "").strip() + + +def require_unified_api_key(request: Request) -> None: + expected = unified_api_key() + if not expected: + raise HTTPException(status_code=503, detail="统一 API Key 未生成,请重新 ./manage.sh apply") + auth = (request.headers.get("authorization") or "").strip() + token = auth[7:].strip() if auth.lower().startswith("bearer ") else auth + if not pysecrets.compare_digest(token, expected): + raise HTTPException( + status_code=401, + detail="Invalid API key", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def _extract_json_body(body: bytes, content_type: str) -> dict[str, Any]: + if not body or "json" not in content_type.lower(): + return {} + try: + data = json.loads(body.decode("utf-8")) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _affinity_key(request: Request, body_json: dict[str, Any]) -> str: + for header in ("x-chat2api-affinity", "x-conversation-id", "x-request-affinity"): + val = (request.headers.get(header) or "").strip() + if val: + return val + for key in ( + "chat2api_affinity_key", + "librechat_conversation_id", + "conversation_id", + "parent_conversation_id", + ): + val = body_json.get(key) + if isinstance(val, str) and val.strip(): + return val.strip() + return "" + + +def _candidate_models(slug: str) -> set[str]: + try: + info = _get_instance_info(slug) + return { + (m.get("id") if isinstance(m, dict) else str(m)) + for m in info.get("models", []) + if m + } + except Exception: + return set() + + +def _backend_candidates(model: str = "") -> list[dict[str, str]]: + all_rows: list[dict[str, str]] = [] + matched: list[dict[str, str]] = [] + for row in read_accounts(): + slug = row["slug"] + env = read_env_file(WORK / "generated" / "env" / f"{slug}.env") + auth = env.get("AUTHORIZATION", "") + prefix = env.get("API_PREFIX", "") + if not auth or not prefix: + continue + item = {"slug": slug, "auth": auth, "api_prefix": prefix} + all_rows.append(item) + models = _candidate_models(slug) if model else set() + if model and models and model in models: + matched.append(item) + return matched or all_rows + + +_proxy_rr_cursor = 0 + + +def _ordered_backends(candidates: list[dict[str, str]], affinity: str) -> list[dict[str, str]]: + global _proxy_rr_cursor + if not candidates: + return [] + if affinity: + digest = hashlib.sha256(affinity.encode("utf-8")).hexdigest() + start = int(digest, 16) % len(candidates) + else: + start = _proxy_rr_cursor % len(candidates) + _proxy_rr_cursor += 1 + return candidates[start:] + candidates[:start] + + +HOP_BY_HOP_HEADERS = { + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "host", + "content-length", + "authorization", + "cookie", +} + + +async def _close_upstream(resp: httpx.Response, client: httpx.AsyncClient) -> None: + await resp.aclose() + await client.aclose() + + +async def _forward_unified_request( + request: Request, + backend: dict[str, str], + path: str, + body: bytes, +) -> StreamingResponse: + upstream_path = f"/{backend['api_prefix']}/v1/{path.lstrip('/')}" + url = f"http://c2a-{backend['slug']}:5005{upstream_path}" + if request.url.query: + url += f"?{request.url.query}" + + headers = { + k: v + for k, v in request.headers.items() + if k.lower() not in HOP_BY_HOP_HEADERS + } + headers["Authorization"] = f"Bearer {backend['auth']}" + headers["X-Chat2API-Orchestrator"] = "1" + headers["X-Chat2API-Upstream-Slug"] = backend["slug"] + + client = httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=15.0)) + req = client.build_request( + request.method, + url, + headers=headers, + content=body, + ) + try: + resp = await client.send(req, stream=True) + except Exception: + await client.aclose() + raise + + response_headers = { + k: v + for k, v in resp.headers.items() + if k.lower() not in HOP_BY_HOP_HEADERS + and k.lower() not in {"content-length", "content-encoding"} + } + response_headers["X-Chat2API-Upstream-Slug"] = backend["slug"] + return StreamingResponse( + resp.aiter_raw(), + status_code=resp.status_code, + headers=response_headers, + background=BackgroundTask(_close_upstream, resp, client), + ) + + # ---------- 出口 IP 缓存 ---------- _exit_ip_cache: dict[str, tuple[str, float]] = {} @@ -435,6 +604,68 @@ async def healthz() -> JSONResponse: return JSONResponse({"ok": True}) +@app.get("/v1/models") +async def unified_models(request: Request) -> JSONResponse: + require_unified_api_key(request) + model_ids: set[str] = set() + for row in read_accounts(): + model_ids.update(_candidate_models(row["slug"])) + data = [ + { + "id": mid, + "object": "model", + "created": 0, + "owned_by": "chat2api-orchestrator", + } + for mid in sorted(model_ids) + ] + audit("unified_models", request, True, model_count=len(data)) + return JSONResponse({"object": "list", "data": data}) + + +@app.api_route( + "/v1/{path:path}", + methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], +) +async def unified_proxy(path: str, request: Request) -> Response: + require_unified_api_key(request) + body = await request.body() + body_json = _extract_json_body(body, request.headers.get("content-type", "")) + model = str(body_json.get("model") or "").strip() + affinity = _affinity_key(request, body_json) + candidates = _backend_candidates(model) + if not candidates: + raise HTTPException(status_code=503, detail="暂无可用实例,请先在编排面板新增账号") + + last_error = "" + for backend in _ordered_backends(candidates, affinity): + try: + resp = await _forward_unified_request(request, backend, path, body) + audit( + "unified_proxy", + request, + True, + slug=backend["slug"], + path=f"/v1/{path}", + model=model or "", + affinity=bool(affinity), + ) + return resp + except httpx.RequestError as e: + last_error = str(e)[:200] + audit( + "unified_proxy", + request, + False, + slug=backend["slug"], + path=f"/v1/{path}", + model=model or "", + error=last_error, + ) + continue + raise HTTPException(status_code=502, detail=f"所有实例转发失败:{last_error}") + + @app.get("/login", response_class=HTMLResponse) async def login_page(request: Request) -> HTMLResponse: return templates.TemplateResponse( @@ -586,6 +817,20 @@ async def api_orchestrator_account() -> JSONResponse: return JSONResponse({"username": creds["username"]}) +@app.get("/api/unified", dependencies=[Depends(require_session), Depends(require_csrf)]) +async def api_unified_credentials(request: Request) -> JSONResponse: + key = unified_api_key() + if not key: + raise HTTPException(status_code=503, detail="统一 API Key 未生成,请重新 ./manage.sh apply") + audit("reveal_unified_api", request, True) + return JSONResponse({ + "base_url": f"{public_origin(request)}/v1", + "chat_completions_url": f"{public_origin(request)}/v1/chat/completions", + "api_key": key, + "strategy": "无会话键时轮询;有 librechat_conversation_id / conversation_id / X-Chat2API-Affinity 时固定到同一容器", + }) + + @app.patch( "/api/orchestrator/account", dependencies=[Depends(require_session), Depends(require_csrf)], diff --git a/deploy/multi/orchestrator/static/app.js b/deploy/multi/orchestrator/static/app.js index 890d697d..3225419f 100644 --- a/deploy/multi/orchestrator/static/app.js +++ b/deploy/multi/orchestrator/static/app.js @@ -294,6 +294,35 @@ $('#btn-close-secret').addEventListener('click', () => { $('#secret-body').innerHTML = ''; // 立即清屏 }); +// ---------- 统一 API ---------- + +async function showUnifiedApi() { + if (!confirm('查看统一 API Key?\n该 Key 可调用所有实例,请勿泄露。')) return; + try { + const d = await api('GET', '/api/unified'); + $('#unified-base-url').textContent = d.base_url; + $('#unified-api-key').textContent = d.api_key; + $('#unified-curl').textContent = +`curl ${d.chat_completions_url} \\ + -H "Authorization: Bearer ${d.api_key}" \\ + -H "Content-Type: application/json" \\ + -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"你好"}]}'`; + $('#unified-strategy').textContent = d.strategy || ''; + $('#modal-unified-api').classList.remove('hidden'); + $('#modal-unified-api').classList.add('flex'); + } catch (e) { + toast('获取统一 API 失败:' + e.message, true); + } +} + +$('#btn-unified-api').addEventListener('click', showUnifiedApi); +$('#btn-close-unified-api').addEventListener('click', () => { + $('#modal-unified-api').classList.add('hidden'); + $('#modal-unified-api').classList.remove('flex'); + $('#unified-api-key').textContent = ''; + $('#unified-curl').textContent = ''; +}); + // ---------- 管理中心 ---------- async function openAdminCenter() { diff --git a/deploy/multi/orchestrator/templates/dashboard.html b/deploy/multi/orchestrator/templates/dashboard.html index f69e0724..58847144 100644 --- a/deploy/multi/orchestrator/templates/dashboard.html +++ b/deploy/multi/orchestrator/templates/dashboard.html @@ -16,6 +16,7 @@

    chat2api Orchestrator

    加载中...

    + @@ -124,6 +125,38 @@

    管理中心

    + + + +
    +
    已支持接口
    +
    +
    + Chat Completions + + +
    +
    + Responses + + +
    +
    + Responses Compact + + +
    +
    +
    curl 示例
    
    
    From 0680bc2bebaae3074e666eb99f02d8d7182d3acb Mon Sep 17 00:00:00 2001
    From: nanashiwang 
    Date: Tue, 12 May 2026 15:00:44 +0800
    Subject: [PATCH 82/96] =?UTF-8?q?=E5=89=8D=E7=AB=AFui=E5=B8=83=E5=B1=80?=
     =?UTF-8?q?=E4=BC=98=E5=8C=96?=
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    ---
     templates/account_proxy_bindings.html | 983 ++++++++++++++++++--------
     1 file changed, 678 insertions(+), 305 deletions(-)
    
    diff --git a/templates/account_proxy_bindings.html b/templates/account_proxy_bindings.html
    index 8e6c04bd..d4f190fe 100644
    --- a/templates/account_proxy_bindings.html
    +++ b/templates/account_proxy_bindings.html
    @@ -5,6 +5,99 @@
         
         Chat2API Admin
         
    +    
         
     
    -
    -    
    -