diff --git a/README.md b/README.md index c73ad0e..b7b6d4c 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,8 @@ docker compose up -d | `PROXY_PORT` | 服务监听端口 | `3029` | | `API_TIMEOUT` | 请求超时(秒) | `300` | | `ACCESS_API_KEY` | 访问鉴权密钥,留空不启用 | | -| `DEBUG` | 调试模式,输出详细请求/响应日志 | `false` | +| `DEBUG` | 兼容旧版调试开关,开启后等价于 `DEBUG_MODE=simple` | `false` | +| `DEBUG_MODE` | 调试模式:`off` / `simple` / `verbose` | `off` | ### 模型映射 @@ -80,8 +81,9 @@ docker compose up -d - **Cursor 模型名** — 在 Cursor 自定义模型中填入的名称 - **上游模型名** — 发送到中转站的实际模型名 -- **后端类型** — `openai` (CC 格式) / `anthropic` (Messages 格式) / `responses` (Responses 格式) / `auto` (自动检测) +- **后端类型** — `openai` (CC 格式) / `anthropic` (Messages 格式) / `responses` (Responses 格式) / `gemini` (Gemini Contents 格式) / `auto` (自动检测) - **自定义地址/密钥** — 可选,覆盖全局设置,实现分流到不同中转站 +- **日志模式** — 可在管理面板全局设置中切换 `off` / `simple` / `verbose` **示例**:在 Cursor 中添加 `claude-sonnet-4-5-20250929`,映射到上游 `gpt-5.3-codex`,后端选 `openai`。Cursor 会用 CC 格式发送请求,代理直接转发到中转站的 `/v1/chat/completions`。 @@ -89,6 +91,26 @@ docker compose up -d > **提示**:使用 Claude 风格的模型名(如 `claude-sonnet-4-5-20250929`)可以让 Cursor 显示思考过程(thinking)。 +### 调试日志模式 + +项目支持三档调试模式,可通过环境变量 `DEBUG_MODE` 或管理面板全局设置切换: + +- `off` — 关闭调试日志 +- `simple` — 仅输出控制台调试日志,不写文件 +- `verbose` — 输出控制台调试日志,并写入详细的对话级文件日志 + +详细日志会写入: + +```text +data/conversations/YYYY-MM-DD/{conversation_id}.json +``` + +特性: +- 同一段多轮对话聚合到同一个文件 +- 自动记录 client request、upstream request/response、client response、错误信息 +- 流式事件只保留前 12 条和后 12 条,中间部分折叠计数,避免文件膨胀 +- 流式 `client_response` 只记录 summary,不重复保存完整事件数组 + ### 在 Cursor 中配置 1. 打开 Cursor 设置 → Models diff --git a/config.py b/config.py index fdc272b..99ffaca 100644 --- a/config.py +++ b/config.py @@ -20,5 +20,17 @@ class Config: API_TIMEOUT = int(os.getenv('API_TIMEOUT', '300')) # 访问鉴权密钥,留空则不启用鉴权 ACCESS_API_KEY = os.getenv('ACCESS_API_KEY', '') - # 调试模式:开启后输出详细的请求/响应日志 - DEBUG = os.getenv('DEBUG', '').lower() in ('1', 'true', 'yes', 'on') + + # 调试模式分级: + # - off: 关闭调试 + # - simple: 仅控制台调试日志 + # - verbose: 控制台调试 + 详细文件日志 + _debug_mode_raw = os.getenv('DEBUG_MODE', '').strip().lower() + _legacy_debug = os.getenv('DEBUG', '').lower() in ('1', 'true', 'yes', 'on') + if _debug_mode_raw in ('off', 'simple', 'verbose'): + DEBUG_MODE = _debug_mode_raw + else: + DEBUG_MODE = 'simple' if _legacy_debug else 'off' + + DEBUG = DEBUG_MODE in ('simple', 'verbose') + VERBOSE_FILE_LOG = DEBUG_MODE == 'verbose' diff --git a/routes/admin.py b/routes/admin.py index fbb62a5..e8a9e77 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -86,6 +86,7 @@ def get_settings(): return jsonify({ 'proxy_target_url': s.get('proxy_target_url', ''), 'proxy_api_key': s.get('proxy_api_key', ''), + 'debug_mode': s.get('debug_mode', '') or Config.DEBUG_MODE, 'env_target_url': Config.PROXY_TARGET_URL, 'env_api_key': '***' if Config.PROXY_API_KEY else '', }) @@ -99,7 +100,7 @@ def update_settings(): return err data = request.get_json(force=True) s = settings.get() - for key in ('proxy_target_url', 'proxy_api_key'): + for key in ('proxy_target_url', 'proxy_api_key', 'debug_mode'): if key in data: s[key] = data[key] return _save_and_respond(s, '全局设置已更新') diff --git a/routes/chat.py b/routes/chat.py index 63eb18c..60eedf0 100644 --- a/routes/chat.py +++ b/routes/chat.py @@ -11,6 +11,7 @@ import json import logging from typing import Any +import settings from flask import Blueprint, jsonify, request from adapters.cc_anthropic_adapter import ( @@ -79,7 +80,7 @@ bp = Blueprint('chat', __name__) def _dbg(message: str) -> None: """仅在调试模式下输出详细日志。""" - if Config.DEBUG: + if settings.get_debug_mode() in ('simple', 'verbose'): logger.info('[聊天补全调试] %s', message) @@ -233,7 +234,7 @@ def _handle_openai_stream( attach_client_response(turn, { 'type': 'chat.completion.stream.summary', 'model': ctx.client_model, - 'chunks': client_chunks, + 'chunk_count': len(client_chunks), 'usage': last_usage, }) finalize_turn(turn, usage=last_usage) @@ -274,7 +275,7 @@ def _handle_openai_stream( attach_client_response(turn, { 'type': 'chat.completion.stream.summary', 'model': ctx.client_model, - 'chunks': client_chunks, + 'chunk_count': len(client_chunks), 'usage': last_usage, }) finalize_turn(turn, usage=last_usage) @@ -384,7 +385,7 @@ def _handle_responses_stream( attach_client_response(turn, { 'type': 'chat.completion.stream.summary', 'model': ctx.client_model, - 'chunks': client_chunks, + 'chunk_count': len(client_chunks), }) finalize_turn(turn) @@ -486,7 +487,7 @@ def _handle_gemini_stream( attach_client_response(turn, { 'type': 'chat.completion.stream.summary', 'model': ctx.client_model, - 'chunks': client_chunks, + 'chunk_count': len(client_chunks), }) finalize_turn(turn) @@ -599,7 +600,7 @@ def _handle_anthropic_stream( attach_client_response(turn, { 'type': 'chat.completion.stream.summary', 'model': ctx.client_model, - 'chunks': client_chunks, + 'chunk_count': len(client_chunks), }) finalize_turn(turn) diff --git a/routes/messages.py b/routes/messages.py index f25da8b..0d9faa5 100644 --- a/routes/messages.py +++ b/routes/messages.py @@ -106,7 +106,7 @@ def messages_passthrough(): set_stream_summary(turn, summary) attach_client_response(turn, { 'type': 'messages.stream.summary', - 'events': client_events, + 'event_count': len(client_events), }) finalize_turn(turn) except req_lib.RequestException as e: diff --git a/routes/responses.py b/routes/responses.py index 6df5166..aa61f06 100644 --- a/routes/responses.py +++ b/routes/responses.py @@ -10,6 +10,7 @@ import json import logging from typing import Any +import settings from flask import Blueprint, jsonify, request from adapters.cc_anthropic_adapter import cc_to_messages_request, messages_to_cc_response @@ -64,7 +65,7 @@ bp = Blueprint('responses', __name__) def _dbg(message: str) -> None: """仅在调试模式下输出详细日志。""" - if Config.DEBUG: + if settings.get_debug_mode() in ('simple', 'verbose'): logger.info('[响应生成调试] %s', message) @@ -203,7 +204,7 @@ def _handle_openai_stream( attach_client_response(turn, { 'type': 'responses.stream.summary', 'model': ctx.client_model, - 'events': client_events, + 'event_count': len(client_events), }) finalize_turn(turn) return @@ -319,7 +320,7 @@ def _handle_responses_stream( attach_client_response(turn, { 'type': 'responses.stream.summary', 'model': ctx.client_model, - 'events': client_events, + 'event_count': len(client_events), }) finalize_turn(turn) @@ -422,7 +423,7 @@ def _handle_gemini_stream( attach_client_response(turn, { 'type': 'responses.stream.summary', 'model': ctx.client_model, - 'events': client_events, + 'event_count': len(client_events), }) finalize_turn(turn) @@ -530,7 +531,7 @@ def _handle_anthropic_stream( attach_client_response(turn, { 'type': 'responses.stream.summary', 'model': ctx.client_model, - 'events': client_events, + 'event_count': len(client_events), }) finalize_turn(turn) diff --git a/settings.py b/settings.py index e8f788f..383f408 100644 --- a/settings.py +++ b/settings.py @@ -22,6 +22,7 @@ _cache = None _DEFAULTS = { 'proxy_target_url': '', 'proxy_api_key': '', + 'debug_mode': '', 'model_mappings': {}, } @@ -77,6 +78,12 @@ def get_key(): return get().get('proxy_api_key') or Config.PROXY_API_KEY +def get_debug_mode(): + """获取当前生效的调试模式,优先使用持久化配置。""" + mode = (get().get('debug_mode') or '').strip().lower() + return mode if mode in ('off', 'simple', 'verbose') else Config.DEBUG_MODE + + def resolve_model(model_name): """解析模型映射并返回完整的上游路由信息。""" settings = get() diff --git a/static/admin.html b/static/admin.html index 76e7653..5d382ad 100644 --- a/static/admin.html +++ b/static/admin.html @@ -55,6 +55,17 @@
+
+ +
+ +
+
关闭:不输出调试日志;简易日志:仅控制台输出;详细日志:额外写入对话级文件日志,并对流式事件做采样截断。
+
diff --git a/static/admin.js b/static/admin.js index e4d8899..a5d5e6c 100644 --- a/static/admin.js +++ b/static/admin.js @@ -66,6 +66,7 @@ async function loadDashboard() { const s = await api('/api/admin/settings'); document.getElementById('targetUrl').value = s.proxy_target_url || ''; document.getElementById('proxyKey').value = s.proxy_api_key || ''; + document.getElementById('debugMode').value = s.debug_mode || 'off'; document.getElementById('envUrl').textContent = s.env_target_url ? '环境变量: ' + s.env_target_url : ''; document.getElementById('envKey').textContent = s.env_api_key ? '环境变量: (已配置)' : '环境变量: (未设置)'; await loadMappings(); @@ -130,6 +131,7 @@ async function saveSettings() { body: JSON.stringify({ proxy_target_url: document.getElementById('targetUrl').value.trim(), proxy_api_key: document.getElementById('proxyKey').value.trim(), + debug_mode: document.getElementById('debugMode').value, }), }); toast('设置已保存'); diff --git a/utils/request_logger.py b/utils/request_logger.py index 67229c1..7658453 100644 --- a/utils/request_logger.py +++ b/utils/request_logger.py @@ -1,7 +1,7 @@ """对话级文件日志 将同一段多轮对话聚合到一个 JSON 文件中,而不是按单次请求散落成多个文件。 -仅在 DEBUG 开启时记录。 +仅在详细日志模式开启时记录。 日志目录: data/conversations/YYYY-MM-DD/{conversation_id}.json """ @@ -18,6 +18,7 @@ from typing import Any from config import Config from settings import DATA_DIR +import settings from utils.http import gen_id logger = logging.getLogger(__name__) @@ -25,6 +26,8 @@ logger = logging.getLogger(__name__) _LOG_DIR = os.path.join(DATA_DIR, 'conversations') _LOCKS: dict[str, threading.Lock] = {} _LOCKS_GUARD = threading.Lock() +_STREAM_KEEP_HEAD = 12 +_STREAM_KEEP_TAIL = 12 def start_turn( @@ -40,7 +43,7 @@ def start_turn( metadata: dict[str, Any] | None = None, ) -> dict[str, Any] | None: """创建一条新的对话 turn 上下文。""" - if not Config.DEBUG: + if settings.get_debug_mode() != 'verbose': return None now = datetime.utcnow().isoformat() + 'Z' @@ -66,6 +69,10 @@ def start_turn( 'stream_trace': { 'upstream_events': [], 'client_events': [], + 'upstream_total': 0, + 'client_total': 0, + 'upstream_dropped': 0, + 'client_dropped': 0, 'summary': {}, }, 'error': None, @@ -111,18 +118,18 @@ def attach_client_response(turn: dict[str, Any] | None, response_data: Any) -> N def append_upstream_event(turn: dict[str, Any] | None, event: Any) -> None: - """记录一条上游流式事件。""" + """记录一条上游流式事件,超限时截断保留头尾。""" if turn is None: return - turn['stream_trace']['upstream_events'].append(deep_copy_jsonable(event)) + _append_stream_event(turn['stream_trace'], 'upstream', deep_copy_jsonable(event)) _touch(turn) def append_client_event(turn: dict[str, Any] | None, event: Any) -> None: - """记录一条返回给客户端的流式事件。""" + """记录一条返回给客户端的流式事件,超限时截断保留头尾。""" if turn is None: return - turn['stream_trace']['client_events'].append(deep_copy_jsonable(event)) + _append_stream_event(turn['stream_trace'], 'client', deep_copy_jsonable(event)) _touch(turn) @@ -149,7 +156,7 @@ def finalize_turn( duration_ms: int = 0, ) -> None: """将 turn 追加/更新到对应的会话日志文件。""" - if turn is None or not Config.DEBUG: + if turn is None or settings.get_debug_mode() != 'verbose': return turn['updated_at'] = datetime.utcnow().isoformat() + 'Z' @@ -157,6 +164,15 @@ def finalize_turn( if usage is not None: turn['usage'] = deep_copy_jsonable(usage) + stream_trace = turn.get('stream_trace', {}) + summary = stream_trace.setdefault('summary', {}) + summary['upstream_total'] = stream_trace.get('upstream_total', 0) + summary['client_total'] = stream_trace.get('client_total', 0) + summary['upstream_dropped'] = stream_trace.get('upstream_dropped', 0) + summary['client_dropped'] = stream_trace.get('client_dropped', 0) + if stream_trace.get('upstream_dropped', 0) or stream_trace.get('client_dropped', 0): + summary['truncated'] = True + threading.Thread(target=_write_turn, args=(deep_copy_jsonable(turn),), daemon=True).start() @@ -235,6 +251,29 @@ def _get_lock(conversation_id: str) -> threading.Lock: return _LOCKS[conversation_id] +def _append_stream_event(stream_trace: dict[str, Any], kind: str, event: Any) -> None: + events_key = f'{kind}_events' + total_key = f'{kind}_total' + dropped_key = f'{kind}_dropped' + + events = stream_trace.setdefault(events_key, []) + stream_trace[total_key] = stream_trace.get(total_key, 0) + 1 + + # 前 KEEP_HEAD 条完整保留;之后只保留最后 KEEP_TAIL 条, + # 中间部分通过 dropped 计数折叠,避免文件膨胀。 + if len(events) < (_STREAM_KEEP_HEAD + _STREAM_KEEP_TAIL): + events.append(event) + return + + head = events[:_STREAM_KEEP_HEAD] + tail = events[_STREAM_KEEP_HEAD:] + if len(tail) >= _STREAM_KEEP_TAIL: + tail.pop(0) + stream_trace[dropped_key] = stream_trace.get(dropped_key, 0) + 1 + tail.append(event) + stream_trace[events_key] = head + tail + + def _touch(turn: dict[str, Any] | None) -> None: if turn is None: return @@ -259,25 +298,126 @@ def _pick_explicit_conversation_id(payload: dict[str, Any]) -> str: def _conversation_seed(route: str, payload: dict[str, Any]) -> str: + """生成稳定的对话种子。 + + 关键原则:不能直接把整段历史消息都放进 seed, + 否则每一轮历史增长都会导致 conversation_id 改变,最终每次请求都新建文件。 + + 这里改为基于“对话根消息”生成种子: + - chat/messages: 第一条 user + 第一条 assistant(没有 assistant 时退化为第一条 user) + - responses: input 中的第一条 user + 第一条 assistant(没有 assistant 时退化为第一条 user) + """ if route == 'chat': - messages = payload.get('messages', []) - return 'chat|' + _normalize_messages_seed(messages) + return 'chat|' + _root_seed_from_messages(payload.get('messages', [])) if route == 'responses': - instructions = payload.get('instructions') or '' - input_data = payload.get('input', []) - if isinstance(input_data, str): - seed_input = input_data - else: - seed_input = json.dumps(input_data, ensure_ascii=False, default=str) - return 'responses|' + instructions + '|' + seed_input + return 'responses|' + _root_seed_from_responses_input(payload) if route == 'messages': - messages = payload.get('messages', []) system = payload.get('system', '') - return 'messages|' + str(system) + '|' + json.dumps(messages, ensure_ascii=False, default=str) + root = _root_seed_from_messages(payload.get('messages', [])) + return 'messages|' + str(system) + '|' + root - return route + '|' + json.dumps(payload, ensure_ascii=False, default=str) + return route + '|' + _pick_explicit_conversation_id(payload) + + +def _root_seed_from_messages(messages: Any) -> str: + if not isinstance(messages, list): + return '' + + first_user = None + first_assistant = None + for msg in messages: + if not isinstance(msg, dict): + continue + role = msg.get('role', '') + if role in ('system', 'developer'): + continue + normalized = { + 'role': role, + 'content': _normalize_content(msg.get('content')), + 'tool_call_id': msg.get('tool_call_id', ''), + 'tool_calls': [ + { + 'id': tc.get('id', ''), + 'name': (tc.get('function') or {}).get('name', ''), + } + for tc in msg.get('tool_calls', []) + if isinstance(tc, dict) + ], + } + if role == 'user' and first_user is None: + first_user = normalized + elif role == 'assistant' and first_assistant is None: + first_assistant = normalized + if first_user is not None and first_assistant is not None: + break + + seed_parts = [] + if first_user is not None: + seed_parts.append(first_user) + if first_assistant is not None: + seed_parts.append(first_assistant) + return json.dumps(seed_parts, ensure_ascii=False, separators=(',', ':')) + + +def _root_seed_from_responses_input(payload: dict[str, Any]) -> str: + instructions = payload.get('instructions') or '' + input_data = payload.get('input', []) + + if isinstance(input_data, str): + seed_input = input_data + elif isinstance(input_data, list): + seed_input = _root_seed_from_responses_items(input_data) + else: + seed_input = json.dumps(input_data, ensure_ascii=False, default=str) + + return instructions + '|' + seed_input + + +def _root_seed_from_responses_items(items: list[Any]) -> str: + first_user = None + first_assistant = None + + for item in items: + if not isinstance(item, dict): + continue + item_type = item.get('type', '') + role = item.get('role', '') + + if item_type in ('message', 'input_text', 'output_text'): + normalized = { + 'type': item_type, + 'role': role, + 'content': _normalize_content( + item.get('content') + or item.get('text') + or item.get('input_text') + or item.get('output_text') + or '' + ), + } + if role == 'user' and first_user is None: + first_user = normalized + elif role == 'assistant' and first_assistant is None: + first_assistant = normalized + + elif item_type == 'function_call' and first_assistant is None: + first_assistant = { + 'type': 'function_call', + 'name': item.get('name', ''), + 'call_id': item.get('call_id', ''), + } + + if first_user is not None and first_assistant is not None: + break + + seed_parts = [] + if first_user is not None: + seed_parts.append(first_user) + if first_assistant is not None: + seed_parts.append(first_assistant) + return json.dumps(seed_parts, ensure_ascii=False, separators=(',', ':')) def _normalize_messages_seed(messages: Any) -> str: