From fb8bd4c8aaaedcdf4b914b208afd3caa6a6ce56a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?= <277378677+maiphucgiang@users.noreply.github.com> Date: Wed, 16 Sep 2026 07:04:36 +0800 Subject: [PATCH 1/6] Fix Buddy travel protocol and standardize English comments Accept empty successful write receipts, select current upstream locations, and verify travel state after writes without replaying uncertain POSTs. Expose safe diagnostics in the management UI and document partial outcomes. Keep handwritten comments and docstrings concise and English without changing unrelated executable code or configuration semantics. Validated: 55 targeted backend tests and 86 subtests; 17 frontend tests; frontend formatting, lint, type checks and build; 80-file comment-only AST comparison. No version changes or live account actions. --- .env.example | 37 +- .gitignore | 10 +- Dockerfile | 2 +- app/adapters/anthropic_adapter.py | 129 ++---- app/adapters/responses_adapter.py | 141 +++--- app/adapters/responses_projection.py | 31 +- app/admin_api.py | 12 +- app/audit_store.py | 18 +- app/auth_oauth.py | 62 +-- app/client_hangup.py | 16 +- app/client_profiles.py | 8 +- app/content_filter.py | 4 +- app/control_store.py | 2 +- app/credential_io.py | 14 +- app/credits.py | 146 +++--- app/desensitize.py | 70 ++- app/harness_context.py | 6 +- app/inbound_limits.py | 20 +- app/model_blocks.py | 41 +- app/observability.py | 37 +- app/request_limits.py | 11 +- app/runtime_management.py | 2 +- app/safe_logging.py | 41 +- app/site_routing.py | 10 +- app/travel.py | 131 ++++-- app/trial_rewards.py | 22 +- app/upstream_io.py | 34 +- converter.py | 636 ++++++++++---------------- docker-compose.yml | 9 +- docs/advanced.md | 2 + docs/advanced.zh-CN.md | 2 + docs/webui.md | 3 +- docs/webui.zh-CN.md | 3 +- examples/codex-codebuddy.example.toml | 67 +-- scripts/check_version.py | 2 +- tests/test_admin_api.py | 4 +- tests/test_anthropic_adapter.py | 53 +-- tests/test_audit_store.py | 8 +- tests/test_auth_oauth.py | 84 ++-- tests/test_catalog.py | 9 +- tests/test_catalog_scope.py | 12 +- tests/test_credential_runtime.py | 6 +- tests/test_credit_identity_dedupe.py | 27 +- tests/test_credits.py | 195 ++++---- tests/test_deployment.py | 14 +- tests/test_developer_role.py | 22 +- tests/test_harness_context.py | 2 +- tests/test_identity_sync.py | 13 +- tests/test_login.py | 6 +- tests/test_model_site_blocks.py | 52 +-- tests/test_nonstream_disconnect.py | 51 +-- tests/test_reasoning.py | 21 +- tests/test_refusal.py | 13 +- tests/test_region_routing.py | 36 +- tests/test_request_limits.py | 8 +- tests/test_responses_adapter.py | 67 ++- tests/test_runtime_endpoints.py | 51 +-- tests/test_safe_logging.py | 4 +- tests/test_security.py | 4 +- tests/test_site_routing.py | 2 +- tests/test_stream_failover.py | 126 ++--- tests/test_stream_status_contract.py | 68 +-- tests/test_travel.py | 232 ++++++++-- tests/test_trial_rewards.py | 10 +- tests/test_upstream_io.py | 18 +- tests/test_version.py | 4 +- tests/test_webui_integration.py | 2 +- tests/test_workbuddy_filter.py | 10 +- tests/test_workbuddy_templates.py | 15 +- web/src/Travel.tsx | 50 ++ web/src/automation.test.tsx | 92 ++++ web/src/pages/Credentials.tsx | 11 + web/src/values.tsx | 37 +- 73 files changed, 1525 insertions(+), 1695 deletions(-) create mode 100644 web/src/Travel.tsx diff --git a/.env.example b/.env.example index 040ab7e..5a2e9ca 100644 --- a/.env.example +++ b/.env.example @@ -1,46 +1,39 @@ -# 首次使用:复制为 .env 后按需修改;已有 .env 请保留并补齐配置。 -# Compose 自动读取 .env;本地启动使用 uv run --env-file .env。 +# Copy to .env for first use; preserve existing settings when updating. +# Compose reads .env automatically; local launches use uv run --env-file .env. -# Compose 部署:默认构建当前源码;使用发布镜像时改为完整镜像名和版本。 +# Compose builds local source by default; set a versioned image name to use a release. CODEBUDDY2API_IMAGE=codebuddy2api:local CODEBUDDY2API_BIND=127.0.0.1 CODEBUDDY2API_PORT=8787 CODEBUDDY2API_AUTH_PATH=./auth -# 留空仅兼容无鉴权客户端,管理界面与接口将锁定;对外监听前务必设置随机密钥。 +# An empty key permits legacy inference but locks management; set a random key before public binding. CODEBUDDY2API_KEY= -# 管理 Origin/CSRF 校验;仅受信任本地环境可设 false,鉴权仍保留,修改后重启。 +# Disable Origin/CSRF checks only in trusted local environments; authentication remains required. CODEBUDDY2API_ADMIN_CSRF=true -# 工具描述保留开关默认 false;不设置此变量时可在 WebUI 配置,显式 true/false 会锁定设置。 +# Unset uses the WebUI value; an explicit Boolean locks tool-metadata retention. # CODEBUDDY2API_KEEP_TOOL_METADATA=true -# 单次请求的全部历史图片;truncate 保留最新图片,error 超限返回 413。 -# 0 表示不允许图片,不是无限制。 +# Image overflow keeps the newest blocks or returns 413; zero forbids all images. CODEBUDDY2API_MAX_IMAGES=16 CODEBUDDY2API_IMAGE_POLICY=truncate -# 图片处理与适配后的上游 JSON 字节上限(32 MiB),必须大于 0。 +# Positive upstream JSON byte limit after image policy and protocol adaptation. CODEBUDDY2API_MAX_REQUEST_BYTES=33554432 -# SQLite 审计默认写入凭证目录的 logs.sqlite3,容量/保留期在 WebUI 配置。 -# 以下仅控制额外的兼容文本日志;留空不输出文本文件。 +# SQLite audit storage defaults to auth/logs.sqlite3; configure retention in the WebUI. +# This optional setting controls the separate legacy text log. CODEBUDDY2API_LOG= -# 正文日志最多预览 64 KiB;0 仅记录摘要,常见认证字段与图片 base64 会脱敏。 +# Bound text previews and redact common credentials and images; zero logs metadata only. CODEBUDDY2API_LOG_BODY_LIMIT=65536 -# 一次性体验积分仅在 WebUI 凭证页手动领取,无自动领取开关。 +# One-time trial credits are claimed manually from the WebUI. -# 换凭证重放次数:失败发生在向下游落第一个字节之前时,最多再换几个凭证就地重放。 -# 默认 0(关闭):如实把上游 429/502 回给下游。只重放上游确定没收下请求体(建连失败/建连超时) -# 或用 401/403/429/502/503/504 拒绝的失败;内容审核拒绝与上游已回 200 之后合成的 502 不重放。 -# 计费口径:401/403/429/503 与建连类失败都发生在受理阶段,不产生扣费;502/504 有可能已被后端 -# 处理并计费,但那次结果对下游根本拿不到,不重放也退不回额度——日志给这类重打单独标 -# 「上游可能已处理该请求」,便于按官方用量明细核对。详见 docs/advanced.zh-CN.md「换凭证重放」。 +# Maximum credential failovers before response bytes are sent; zero disables replay. +# Some gateway failures may already be billed; see docs/advanced.md for replay boundaries. CODEBUDDY2API_FAILOVER_MAX=0 -# 是否把「写请求体超时」也算作上游没收下请求体(同时作用于连接重试与上面的换凭证重放)。 -# 默认 false:写超时只能证明正文没写完,上游有没有按已收到的半截正文计费,从这一侧看不到。 -# 跨境长会话最容易撞的恰是 60s 写超时,确认自己的上游不会按半截正文计费再打开。 +# Opt in to replaying incomplete writes only if partial requests cannot be billed upstream. CODEBUDDY2API_RETRY_WRITE_TIMEOUT=false diff --git a/.gitignore b/.gitignore index 39cd166..d082247 100644 --- a/.gitignore +++ b/.gitignore @@ -12,15 +12,15 @@ ENV/ .pytest_cache/ .mypy_cache/ -# 本地运行产物 / 日志 +# Local runtime artifacts and logs *.log .DS_Store Thumbs.db -# 本地执行计划(不提交) +# Untracked local implementation plans /docs/plans/ -# 个人 / 敏感信息(绝不提交) +# Private credentials and secrets *.token *.key secrets.* @@ -29,13 +29,13 @@ secrets.* !.env.example -# 自管凭证目录与凭据文件(敏感,绝不提交) +# Managed credentials and private databases auth/ *.info *.sqlite3 *.sqlite3-wal *.sqlite3-shm -# 运行日志(含请求体,敏感) +# Runtime logs may contain sensitive request data converter.log* # IDE diff --git a/Dockerfile b/Dockerfile index 12af3e1..67319f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,6 @@ COPY converter.py ./ COPY app/ ./app/ COPY --from=frontend /web/dist/ ./web/dist/ EXPOSE 8787 -# 容器内监听 0.0.0.0 是硬需求;对外暴露边界在端口映射层(compose 默认只绑回环) +# Listen on container interfaces; host port mapping controls external access. ENV CODEBUDDY2API_ALLOW_OPEN_NOAUTH=true CMD ["python3", "converter.py", "--host", "0.0.0.0", "--port", "8787", "--skip-check"] diff --git a/app/adapters/anthropic_adapter.py b/app/adapters/anthropic_adapter.py index 8b3fd65..3dcf71d 100644 --- a/app/adapters/anthropic_adapter.py +++ b/app/adapters/anthropic_adapter.py @@ -1,13 +1,4 @@ -""" -anthropic_adapter.py — Anthropic Messages API ↔ OpenAI Chat Completions API 适配层。 - -Claude Code / CC Switch 使用 Anthropic Messages API(POST /v1/messages), -而 CodeBuddy 后端只支持 OpenAI Chat Completions 协议。本模块做双向转换: - 请求:Anthropic Messages 格式 → OpenAI Chat 格式 - 响应:OpenAI Chat SSE → Anthropic Messages SSE 事件流 - -Anthropic Messages API 参考:https://docs.anthropic.com/en/docs/messages -""" +"""Translate requests and SSE responses between Anthropic Messages and OpenAI Chat.""" from __future__ import annotations @@ -17,7 +8,7 @@ from typing import Any # --------------------------------------------------------------------------- -# ID 生成 +# ID generation # --------------------------------------------------------------------------- def _rand_id(prefix: str = "") -> str: @@ -25,7 +16,7 @@ def _rand_id(prefix: str = "") -> str: def map_usage_to_anthropic(u: dict) -> dict: - """OpenAI 风格 usage → Anthropic 风格;缓存读从 input_tokens 中扣除。""" + """Map Chat usage to Anthropic counters, subtracting cache reads from input tokens.""" cached = (u.get("cache_read_input_tokens") or (u.get("prompt_tokens_details") or {}).get("cached_tokens") or 0) @@ -38,28 +29,21 @@ def map_usage_to_anthropic(u: dict) -> dict: # --------------------------------------------------------------------------- -# 请求转换:Anthropic → Chat +# Anthropic to Chat requests # --------------------------------------------------------------------------- def anthropic_request_to_chat(body: dict) -> dict: - """将 Anthropic Messages API 请求体转换为 OpenAI Chat Completions 请求体。 - - 关键映射: - system → messages[0] role=system - messages[].content (blocks) → content (string) / tool_calls / tool role - tools[].input_schema → tools[].function.parameters - metadata / thinking → 丢弃 - """ + """Convert Anthropic instructions, message blocks and tools to a Chat request.""" messages: list[dict] = [] - # system → 首条 system 消息 + # Place system instructions first. system = body.get("system") if system: sys_content = _extract_system_text(system) if sys_content: messages.append({"role": "system", "content": sys_content}) - # messages → 消息转换 + # Convert message content. for m in body.get("messages", []): if not isinstance(m, dict): continue @@ -67,7 +51,7 @@ def anthropic_request_to_chat(body: dict) -> dict: chat: dict[str, Any] = {"messages": messages, "stream": True} - # model(透传,不做映射) + # Preserve the requested model. if "model" in body: chat["model"] = body["model"] @@ -90,16 +74,16 @@ def anthropic_request_to_chat(body: dict) -> dict: chat["tool_choice"] = "required" if kind == "any" else kind else: raise ValueError("unsupported tool_choice type") - # 禁止并行工具调用的约束必须传到上游,不接受后静默丢失 + # Preserve the caller's restriction on parallel tool calls. if isinstance(tc.get("disable_parallel_tool_use"), bool): chat["parallel_tool_calls"] = not tc["disable_parallel_tool_use"] elif isinstance(tc, str): chat["tool_choice"] = tc if tc in ("none", "auto", "required") else {"type": "function", "function": {"name": tc}} - # 透传常见参数 + # Forward supported parameters. for key in ("temperature", "top_p", "stop", "top_k"): if key in body: chat[key] = body[key] - # Anthropic 正式停止序列字段;显式 stop 优先 + # Explicit stop takes precedence over Anthropic stop_sequences. stop_sequences = body.get("stop_sequences") if stop_sequences is not None and "stop" not in chat: if not isinstance(stop_sequences, list) or not all(isinstance(s, str) for s in stop_sequences): @@ -110,7 +94,7 @@ def anthropic_request_to_chat(body: dict) -> dict: def _extract_system_text(system) -> str: - """提取 system 字段为纯文本字符串。支持 string 和 [{type:text, text:...}] 数组。""" + """Extract plain system text from a string or text-block array.""" if isinstance(system, str): return system if isinstance(system, list): @@ -123,22 +107,22 @@ def _extract_system_text(system) -> str: def _convert_anthropic_message(msg: dict) -> list[dict]: - """将单个 Anthropic 消息转换为 OpenAI 格式的消息(可能为多条)。""" + """Convert one Anthropic message into one or more Chat messages.""" role = msg.get("role", "") content = msg.get("content") - # 简单字符串 content + # Plain text content if isinstance(content, str): return [{"role": role, "content": content}] - # 空 content + # Empty content if not isinstance(content, list) or not content: return [] - # content blocks → 需要解析 + # Structured content blocks blocks = content - # 检查是否包含 tool_result(role=user 时) + # User messages may contain tool results. if role == "user": result: list[dict] = [] user_blocks: list[dict] = [] @@ -149,24 +133,24 @@ def _convert_anthropic_message(msg: dict) -> list[dict]: if bt in ("text", "image"): user_blocks.append(block) elif bt == "tool_result": - # tool_result → 独立的 tool 消息 + # Emit each tool result as a separate tool message. tc_id = block.get("tool_use_id", "") output = block.get("content", "") if isinstance(output, list): output = _convert_content_blocks(output) if block.get("is_error") is True: - # Chat 协议无等价字段:失败标记编码进正文;含图片的列表内容前置文本块而非拼接 + # Encode tool failure as text while preserving image blocks. if isinstance(output, list): output = [{"type": "text", "text": "[tool execution failed]"}] + output else: output = "[tool execution failed]\n" + (output or "") result.append({"role": "tool", "tool_call_id": tc_id, "content": output}) if user_blocks: - # 工具结果必须紧随 assistant 的 tool_calls;普通文本排在它们之后 + # Tool results must immediately follow their assistant tool calls. result.append({"role": "user", "content": _convert_content_blocks(user_blocks)}) return result - # assistant 角色 + # Assistant content if role == "assistant": content_out = _convert_content_blocks(blocks) tool_calls: list[dict] = [] @@ -196,7 +180,7 @@ def _convert_anthropic_message(msg: dict) -> list[dict]: def _convert_content_blocks(blocks: list) -> str | list[dict]: - """保留图片与文本顺序;纯文本仍使用原来的字符串表示。""" + """Preserve image/text order and use strings for text-only content.""" parts = [] has_image = False for block in blocks: @@ -227,16 +211,12 @@ def _convert_content_blocks(blocks: list) -> str | list[dict]: def _convert_anthropic_tools(tools: list) -> list: - """将 Anthropic 格式的 tools 转为 OpenAI Chat 格式。 - - Anthropic: {"name": "...", "description": "...", "input_schema": {...}} - Chat: {"type": "function", "function": {"name": "...", "description": "...", "parameters": {...}}} - """ + """Convert Anthropic tool definitions to Chat function objects.""" result = [] for t in tools: if not isinstance(t, dict): continue - # 已经是 Chat 格式 + # Already in Chat format. if "function" in t: result.append(t) continue @@ -250,52 +230,43 @@ def _convert_anthropic_tools(tools: list) -> list: # --------------------------------------------------------------------------- -# 响应转换:Chat SSE → Anthropic Messages SSE +# Chat SSE to Anthropic Messages events # --------------------------------------------------------------------------- class AnthropicStreamConverter: - """将 OpenAI Chat SSE 流实时转换为 Anthropic Messages SSE 事件流。 - - 用法: - converter = AnthropicStreamConverter(model="deepseek-v4-pro") - for line in backend_sse: - events = converter.feed_line(line) - if events: - yield events.encode() - yield converter.finish().encode() - """ + """Convert Chat SSE increments to Anthropic Messages events.""" def __init__(self, model: str = "unknown"): self.msg_id = _rand_id("msg_") self.model = model self.created_at = int(time.time()) - # 状态 + # Stream state self._emitted_start = False - # text 内容块 + # Text block state self._text_content = "" self._text_block_open = False self._text_block_idx = 0 - # thinking 内容块(上游 reasoning_content → Anthropic thinking block) + # Map reasoning_content to thinking blocks. self._thinking_content = "" self._thinking_block_open = False self._thinking_block_idx = 0 - # tool_use 内容块(index → {id, name, args, block_idx, open}) + # Tool blocks indexed by upstream call position. self._tool_uses: dict[int, dict] = {} self._next_block_idx = 0 - # 结束信息 + # Completion metadata self._finish_reason: str | None = None self._usage: dict | None = None self._content_filter: bool = False - # ---- 公开接口 ---- + # Public methods def feed_line(self, line: str) -> str: - """处理一行 SSE(如 'data: {...}'),返回 Anthropic SSE 事件字符串。""" + """Convert one SSE line into Anthropic event text.""" line = line.strip() if not line or not line.startswith("data:"): return "" @@ -309,24 +280,24 @@ def feed_line(self, line: str) -> str: return self._process_chunk(chunk) def finish(self) -> str: - """流结束,发出收尾事件。""" + """Emit final events and close the message.""" events: list[str] = [] - # 关闭 thinking 块 + # Close thinking blocks. if self._thinking_block_open: events.append(self._evt( "content_block_stop", {"index": self._thinking_block_idx} )) self._thinking_block_open = False - # 关闭 text 块 + # Close text blocks. if self._text_block_open: events.append(self._evt( "content_block_stop", {"index": self._text_block_idx} )) self._text_block_open = False - # 关闭 tool_use 块 + # Close tool blocks. for tc in self._tool_uses.values(): if tc.get("open"): events.append(self._evt( @@ -334,7 +305,7 @@ def finish(self) -> str: )) tc["open"] = False - # stop_reason 映射 + # Map the finish reason. sr = self._finish_reason or "stop" stop_map = { "stop": "end_turn", @@ -356,7 +327,7 @@ def finish(self) -> str: return "".join(events) def get_nonstream_response(self) -> dict: - """获取完整的非流式 Message 响应对象。""" + """Return the complete non-streaming Message response.""" content = self._build_content_blocks() sr = self._finish_reason or "stop" stop_map = { @@ -379,7 +350,7 @@ def get_nonstream_response(self) -> dict: resp["usage"] = map_usage_to_anthropic(self._usage) return resp - # ---- 内部 ---- + # Internal helpers def _process_chunk(self, chunk: dict) -> str: events: list[str] = [] @@ -387,7 +358,7 @@ def _process_chunk(self, chunk: dict) -> str: if chunk.get("model"): self.model = chunk["model"] - # 首次 → message_start + # Emit message_start once. if not self._emitted_start: events.append(self._evt("message_start", { "message": { @@ -408,7 +379,7 @@ def _process_chunk(self, chunk: dict) -> str: delta = choice.get("delta", {}) finish = choice.get("finish_reason") - # thinking delta(reasoning_content → thinking block,位于正文之前) + # Emit reasoning before text. thinking = delta.get("reasoning_content") if thinking: self._thinking_content += thinking @@ -425,10 +396,10 @@ def _process_chunk(self, chunk: dict) -> str: "delta": {"type": "thinking_delta", "thinking": thinking}, })) - # Anthropic 没有 refusal 文本块;使用 text 保留原始拒绝说明。 + # Anthropic has no refusal block; preserve refusal text as ordinary content. content = (delta.get("content") or "") + (delta.get("refusal") or "") if content: - # 正文开始时关闭 thinking 块(thinking 必须位于正文之前) + # Close thinking before text begins. if self._thinking_block_open: events.append(self._evt("content_block_stop", { "index": self._thinking_block_idx @@ -469,7 +440,7 @@ def _process_chunk(self, chunk: dict) -> str: slot["name"] = fn["name"] if not slot["open"]: - # thinking 块先于 tool_use 关闭(reasoning → tool_call 无正文时) + # Close thinking before a tool block begins. if self._thinking_block_open: events.append(self._evt("content_block_stop", { "index": self._thinking_block_idx @@ -491,7 +462,7 @@ def _process_chunk(self, chunk: dict) -> str: if finish: self._finish_reason = finish - # finish_reason 出现时关闭当前打开的块 + # Close open blocks when the upstream finishes. if self._thinking_block_open: events.append(self._evt("content_block_stop", { "index": self._thinking_block_idx @@ -514,15 +485,15 @@ def _process_chunk(self, chunk: dict) -> str: return "".join(events) def _evt(self, event_type: str, data: dict) -> str: - """格式化一个 Anthropic SSE 事件(含 event: 行)。""" + """Format an Anthropic SSE event with its event name.""" payload = {"type": event_type, **data} return f"event: {event_type}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n" def _build_content_blocks(self) -> list[dict]: - """构造完整的 content blocks 数组(用于非流式响应)。""" + """Build content blocks for a non-streaming response.""" blocks: list[dict] = [] - # thinking block(须位于 text 之前) + # Thinking must precede text. if self._thinking_content: blocks.append({"type": "thinking", "thinking": self._thinking_content}) @@ -538,7 +509,7 @@ def _build_content_blocks(self) -> list[dict]: "name": tc["name"], "input": {}, } - # 尝试将 args 解析为 JSON object + # Parse tool arguments as a JSON object. try: block["input"] = json.loads(tc["args"]) except (json.JSONDecodeError, ValueError): diff --git a/app/adapters/responses_adapter.py b/app/adapters/responses_adapter.py index d3a6b33..bf46ee1 100644 --- a/app/adapters/responses_adapter.py +++ b/app/adapters/responses_adapter.py @@ -1,13 +1,4 @@ -""" -responses_adapter.py — OpenAI Responses API ↔ Chat Completions API 适配层。 - -Codex CLI 使用 Responses API(POST /v1/responses),而 CodeBuddy 后端只支持 -Chat Completions 协议。本模块做双向转换: - 请求:Responses input/instructions/tools → Chat messages/tools - 响应:Chat SSE delta → Responses 语义事件流(response.created / output_text.delta / …) - -事件类型参考:https://developers.openai.com/api/docs/guides/streaming-responses -""" +"""Translate requests and SSE responses between OpenAI Responses and Chat Completions.""" from __future__ import annotations @@ -17,18 +8,18 @@ from typing import Any # --------------------------------------------------------------------------- -# ID 生成 +# ID generation # --------------------------------------------------------------------------- def _rand_id(prefix: str = "resp_") -> str: return prefix + os.urandom(12).hex() # --------------------------------------------------------------------------- -# 请求转换:Responses → Chat +# Responses to Chat requests # --------------------------------------------------------------------------- def _text_format_to_response_format(fmt) -> dict | None: - """Responses text.format → Chat response_format;只转换语义等价的形态,其余显式报错。""" + """Map Responses text.format to equivalent Chat formats, rejecting unsupported variants.""" if fmt is None: return None if not isinstance(fmt, dict): @@ -52,14 +43,7 @@ def _text_format_to_response_format(fmt) -> dict | None: def responses_request_to_chat(body: dict) -> dict: - """将 Responses API 请求体转换为 Chat Completions 请求体。 - - 关键映射: - input → messages - instructions → system message(置顶) - max_output_tokens → max_tokens - tools 格式微调(Responses 用 name,Chat 用 function.name) - """ + """Convert Responses input, instructions and tools to a Chat request.""" messages: list[dict] = [] # instructions → system message @@ -74,28 +58,28 @@ def responses_request_to_chat(body: dict) -> dict: elif isinstance(inp, list): messages.extend(_convert_input_items(inp)) - # 构造 Chat body + # Build the Chat request body. chat: dict[str, Any] = {"messages": messages, "stream": True} # model if "model" in body: chat["model"] = body["model"] - # tools — Responses 和 Chat 的 function tool 格式略有不同 + # Normalize function tool definitions. tools = body.get("tools") if tools: chat["tools"] = _convert_tools_for_chat(tools) if "tool_choice" in body: chat["tool_choice"] = body["tool_choice"] - # 透传常见参数 + # Forward supported parameters. for key in ("temperature", "top_p", "stop", "seed", "presence_penalty", "frequency_penalty", "response_format", "reasoning_effort", "parallel_tool_calls"): if key in body: chat[key] = body[key] - # 正式嵌套字段 → Chat 顶层等价物;显式顶层字段优先 + # Explicit top-level values override equivalent nested fields. reasoning = body.get("reasoning") if isinstance(reasoning, dict) and "reasoning_effort" not in chat: effort = reasoning.get("effort") @@ -116,16 +100,9 @@ def responses_request_to_chat(body: dict) -> dict: def _convert_input_items(items: list) -> list[dict]: - """将 Responses API 的 input 数组转换为 Chat messages。 - - input 里可能包含: - - {"role": "user/developer", "content": ...} → 直接映射 - - {"type": "message", ...} → 助手消息 - - {"type": "function_call", ...} → 需合并到前面的助手消息 - - {"type": "function_call_output", ...} → tool 角色 - """ + """Convert input items and merge adjacent assistant messages with tool calls.""" messages: list[dict] = [] - # 临时缓存:合并相邻的 assistant message 和 function_call + # Buffer adjacent assistant text and function calls. pending_assistant_content: str | list[dict] | None = None pending_tool_calls: list[dict] = [] @@ -147,7 +124,7 @@ def _flush_assistant(): item_type = item.get("type") role = item.get("role", "") - # 简单消息 {"role": "user", "content": "..."} + # Untyped role messages if item_type is None and role in ("user", "system", "developer"): _flush_assistant() mapped_role = "system" if role == "developer" else role @@ -155,7 +132,7 @@ def _flush_assistant(): messages.append({"role": mapped_role, "content": content}) continue - # typed message(Responses 里常见) + # Typed message items if item_type == "message" and role in ("user", "system", "developer"): _flush_assistant() mapped_role = "system" if role == "developer" else role @@ -163,7 +140,7 @@ def _flush_assistant(): messages.append({"role": mapped_role, "content": content}) continue - # assistant 消息(来自前一轮输出) + # Assistant output from history if item_type == "message" and role == "assistant": _flush_assistant() content_parts = item.get("content", []) @@ -171,14 +148,14 @@ def _flush_assistant(): pending_assistant_content = text continue - # 简单 role=assistant(无 type 标记) + # Untyped assistant messages if item_type is None and role == "assistant": _flush_assistant() content = _extract_content(item.get("content", "")) pending_assistant_content = content continue - # function_call — 合并到前面的 assistant 消息 + # Merge calls into the preceding assistant message. if item_type == "function_call": if pending_assistant_content is None: pending_assistant_content = "" @@ -192,7 +169,7 @@ def _flush_assistant(): }) continue - # function_call_output → tool 消息 + # Map function results to tool messages. if item_type == "function_call_output": _flush_assistant() messages.append({ @@ -203,7 +180,7 @@ def _flush_assistant(): }) continue - # 其他未知类型 — 尝试当作普通消息 + # Retain compatible message content from unknown item types. if role: _flush_assistant() content = _extract_content(item.get("content", "")) @@ -214,7 +191,7 @@ def _flush_assistant(): def _extract_content(content) -> str | list[dict]: - """转换协议内容块;含图片时保留多模态数组,不字符串化图片。""" + """Convert protocol blocks without stringifying image content.""" if isinstance(content, str): return content if isinstance(content, list): @@ -248,7 +225,7 @@ def _extract_content(content) -> str | list[dict]: def _extract_output_text(content_parts: list) -> str | list[dict]: - """保留历史消息图片;没有图片时沿用 output_text 提取行为。""" + """Preserve historical images and extract output_text for text-only messages.""" if any(isinstance(part, dict) and part.get("type") in ("input_image", "image_url") for part in content_parts): return _extract_content(content_parts) @@ -260,22 +237,18 @@ def _extract_output_text(content_parts: list) -> str | list[dict]: def _convert_tools_for_chat(tools: list) -> list: - """将 Responses 格式的 tools 转为 Chat 格式。 - - Responses: {"type": "function", "name": "shell", "description": ..., "parameters": ...} - Chat: {"type": "function", "function": {"name": "shell", "description": ..., "parameters": ...}} - """ + """Convert Responses tool definitions to Chat function objects.""" result = [] for t in tools: if not isinstance(t, dict): continue if t.get("type") != "function": continue - # 已经是 Chat 格式(有 "function" key) + # Already in Chat format. if "function" in t: result.append(t) continue - # Responses 扁平格式 → Chat 嵌套格式 + # Nest the flat Responses function fields. fn: dict[str, Any] = {"name": t.get("name", "")} if "description" in t: fn["description"] = t["description"] @@ -288,23 +261,11 @@ def _convert_tools_for_chat(tools: list) -> list: # --------------------------------------------------------------------------- -# 响应转换:Chat → Responses +# Chat SSE to Responses events # --------------------------------------------------------------------------- class ResponsesStreamConverter: - """将 Chat SSE 流实时转换为 Responses API 语义事件流。 - - 用法: - converter = ResponsesStreamConverter(model="glm-5.2") - # 对后端返回的每个 SSE 行调 feed_line() - # feed_line 返回要发送给客户端的 Responses 事件字符串(可能多行) - for line in backend_sse: - events = converter.feed_line(line) - if events: - yield events.encode() - # 流结束后调 finish() 获取收尾事件 - yield converter.finish().encode() - """ + """Convert Chat SSE increments into Responses events.""" def __init__(self, model: str = "unknown", parallel_tool_calls: bool = True): self.resp_id = _rand_id("resp_") @@ -313,25 +274,25 @@ def __init__(self, model: str = "unknown", parallel_tool_calls: bool = True): self._parallel_tool_calls = bool(parallel_tool_calls) self.created_at = int(time.time()) - # 状态标记 + # Stream state self._emitted_created = False self._emitted_msg_item = False self._emitted_content_part = False - # 累积内容 + # Collected content self._content = "" - # reasoning item(上游 reasoning_content → Responses reasoning,位于 message 之前) + # Reasoning items precede message items. self._reasoning = "" self._reasoning_item_id = _rand_id("rs_") self._emitted_reasoning_item = False self._tool_calls: dict[int, dict] = {} # index → {id, name, args, fc_id, output_idx, emitted} self._finish_reason: str | None = None self._usage: dict | None = None - self._seq = 0 # 事件序号:每个发出的事件递增 - # ---- 公开接口 ---- + self._seq = 0 # Monotonic emitted-event sequence + # Public methods def feed_line(self, line: str) -> str: - """处理一行 SSE(如 'data: {...}'),返回转换后的 Responses 事件字符串。""" + """Convert one SSE line into Responses event text.""" line = line.strip() if not line or not line.startswith("data:"): return "" @@ -345,11 +306,11 @@ def feed_line(self, line: str) -> str: return self._process_chunk(chunk) def finish(self) -> str: - """流结束后,发出收尾事件(done + 终止状态)。""" + """Close output items and emit the terminal response status.""" status, reason = self._final_status() events: list[str] = [] - # 关闭 reasoning item + # Close reasoning items. if self._emitted_reasoning_item: events.append(self._evt("response.reasoning_summary_text.done", { "output_index": 0, "summary_index": 0, "text": self._reasoning, @@ -359,7 +320,7 @@ def finish(self) -> str: "output_index": 0, "item": self._reasoning_item(status) })) - # 关闭 text content + # Close text content. if self._emitted_content_part: events.append(self._evt("response.output_text.done", { "output_index": self._msg_idx(), "content_index": 0, "text": self._content, @@ -377,7 +338,7 @@ def finish(self) -> str: "item": self._msg_item(status) })) - # 关闭 function calls + # Close function calls. for idx in sorted(self._tool_calls): tc = self._tool_calls[idx] if tc.get("emitted"): @@ -389,19 +350,19 @@ def finish(self) -> str: "output_index": oi, "item": self._fc_item(tc, status) })) - # 终止事件:completed 或 incomplete(截断/过滤绝不伪装完成) + # Truncated or filtered responses must not report completion. events.append(self._evt(f"response.{status}", { "response": self._response_obj(status, incomplete_reason=reason) })) return "".join(events) def get_nonstream_response(self) -> dict: - """流结束后获取完整的非流式 Response 对象。""" + """Return the complete non-streaming Response object.""" status, reason = self._final_status() return self._response_obj(status, incomplete_reason=reason) def _final_status(self) -> tuple[str, str | None]: - """上游 finish_reason → (response status, incomplete reason);截断/过滤不作 completed。""" + """Map finish reasons to response status without hiding truncation or filtering.""" fr = self._finish_reason if fr in (None, "stop", "tool_calls"): return "completed", None @@ -410,16 +371,16 @@ def _final_status(self) -> tuple[str, str | None]: if fr in ("content_filter", "content-filter", "refusal"): return "incomplete", "content_filter" return "incomplete", None - # ---- 内部 ---- + # Internal helpers def _process_chunk(self, chunk: dict) -> str: events: list[str] = [] - # 模型名 + # Model identity if chunk.get("model"): self.model = chunk["model"] - # 首次 → 发 created + in_progress + # Emit created and in_progress once. if not self._emitted_created: resp = self._response_obj("in_progress") events.append(self._evt("response.created", {"response": resp})) @@ -434,7 +395,7 @@ def _process_chunk(self, chunk: dict) -> str: delta = choice.get("delta", {}) finish = choice.get("finish_reason") - # ---- reasoning delta(reasoning_content → reasoning item,位于 message 之前)---- + # Emit reasoning before message content. reasoning = delta.get("reasoning_content") if reasoning: if not self._emitted_reasoning_item: @@ -450,7 +411,7 @@ def _process_chunk(self, chunk: dict) -> str: "item_id": self._reasoning_item_id })) - # 当前适配器只发 output_text;拒绝说明也保留为合法文本,不丢弃原文。 + # Preserve refusal content as valid output_text. content = (delta.get("content") or "") + (delta.get("refusal") or "") if content: if not self._emitted_msg_item: @@ -478,7 +439,7 @@ def _process_chunk(self, chunk: dict) -> str: for tc in delta.get("tool_calls", []): idx = tc.get("index", 0) if idx not in self._tool_calls: - # 计算 output_index:reasoning/msg item 在前,function_call 依次往后排 + # Place function calls after reasoning and message items. base = (1 if self._emitted_reasoning_item else 0) + \ (1 if (self._emitted_msg_item or self._content) else 0) oi = base + len(self._tool_calls) @@ -498,9 +459,9 @@ def _process_chunk(self, chunk: dict) -> str: slot["name"] = fn["name"] if not slot["emitted"]: - # 确保 msg item 已发出(即使 content 为空) + # Ensure the message item exists even when empty. if not self._emitted_msg_item and (self._content or not self._tool_calls): - pass # 不需要额外处理 + pass events.append(self._evt("response.output_item.added", { "output_index": slot["output_idx"], "item": self._fc_item(slot, "in_progress") @@ -520,17 +481,17 @@ def _process_chunk(self, chunk: dict) -> str: return "".join(events) def _evt(self, event_type: str, data: dict) -> str: - """格式化一个 SSE 事件;sequence_number 单调递增,供客户端校验事件顺序。""" + """Format SSE events with monotonically increasing sequence numbers.""" self._seq += 1 payload = {"type": event_type, **data, "sequence_number": self._seq} return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" def _msg_idx(self) -> int: - """message item 的 output_index;reasoning item 存在时占据 0,message 顺延到 1。""" + """Place the message at index one when reasoning occupies index zero.""" return 1 if self._emitted_reasoning_item else 0 def _reasoning_item(self, status: str) -> dict: - """Responses reasoning item:思考全文放在 summary 第一段。""" + """Build a reasoning item with its text in the first summary block.""" return {"type": "reasoning", "id": self._reasoning_item_id, "status": status, "summary": [{"type": "summary_text", "text": self._reasoning}]} @@ -571,7 +532,7 @@ def _response_obj(self, status: str, incomplete_reason: str | None = None) -> di if self._usage: u = self._usage reasoning_tokens = (u.get("completion_tokens_details") or {}).get("reasoning_tokens", 0) - # 缓存命中透传上游字段;两个键都缺失时省略 details,区分“未知”与“真正的 0”。 + # Omit unknown cache details instead of reporting a fabricated zero. cached = (u.get("prompt_tokens_details") or {}).get("cached_tokens", u.get("cache_read_input_tokens")) usage = { @@ -594,6 +555,6 @@ def _response_obj(self, status: str, incomplete_reason: str | None = None) -> di "usage": usage, } if status == "incomplete": - # 客户端据 incomplete_details 决定续写/重试;未知原因保留 null reason。 + # Preserve a null incomplete reason when the upstream supplies none. obj["incomplete_details"] = {"reason": incomplete_reason} return obj diff --git a/app/adapters/responses_projection.py b/app/adapters/responses_projection.py index 9495214..35d5d0e 100644 --- a/app/adapters/responses_projection.py +++ b/app/adapters/responses_projection.py @@ -1,25 +1,4 @@ -""" -responses_projection — /v1/responses 的后端投影层。 - -目标 ----- -Codex CLI 会把大量运行时提示、完整工具 schema、长历史、以及工具输出一并塞进 -/v1/responses 请求里。腾讯后端对这类 agentic payload 很容易触发内容审核,或者 -因为上下文过长而表现不稳定。 - -本模块在保持外部 OpenAI Responses 兼容的前提下,只对发往后端的 Chat body 做 -"最小语义闭包"投影: - -- 添加短 system 基线,仅压缩有可信边界的 Codex/Claude Code harness -- 完整保留自定义 system 和最新真实用户正文,harness 上下文独立限额 -- 保留最近一段真实 assistant/tool 链路 -- 把更早历史压缩成规则摘要 -- 把 tool schema 收敛成结构字段 -- 把超长 tool output / tool arguments 压缩成可继续推理的摘要 - -含图片时保留消息历史与图片块,只压缩上下文、助手/工具文本及工具元数据。 -真实用户正文不做局部截断;超大请求由既有网关请求上限拒绝。 -""" +"""Compact trusted harness context, history and tools while preserving user text and images.""" from __future__ import annotations @@ -107,7 +86,7 @@ def project_responses_chat_body(body: dict, *, keep_tool_metadata: bool = False) -> tuple[dict, dict]: - """把 Responses 转出来的 Chat body 投影成更适合腾讯后端的最小上下文。""" + """Project a Responses-derived Chat body into bounded upstream context.""" projected = dict(body) messages = list(body.get("messages") or []) tools = list(body.get("tools") or []) @@ -293,11 +272,7 @@ def _project_content(content: Any, transform) -> Any: def _project_harness_content(content: Any, context_limit: int) -> tuple[Any, bool, bool]: - """Budget only recognized context; never truncate real user/system text. - - Parse each text block independently: a wrapper crossing block boundaries is - conservatively retained. Images and unknown blocks count as real content. - """ + """Budget recognized context within each block; preserve real text, images and unknown blocks.""" matched = False has_user_text = isinstance(content, list) and any( isinstance(block, dict) and block.get("type") != "text" for block in content diff --git a/app/admin_api.py b/app/admin_api.py index eb0825e..34ba696 100644 --- a/app/admin_api.py +++ b/app/admin_api.py @@ -62,11 +62,7 @@ def _public_credential(item): def install_admin(app, config, gateway): - """Install once before serving; config owns control_store, audit_store, api_key. - - Gateway inventories must expose real model ``id`` and credential ``account_key`` - (or fingerprint ``id``), plus a safe ``name``/``filename`` for file operations. - """ + """Install management routes using configured stores, stable inventory IDs and safe filenames.""" if getattr(app.state, "admin_installed", False): return app.state.admin_auth control, audit = config["control_store"], config["audit_store"] @@ -256,7 +252,7 @@ async def models_get(request): def build_models(): with mutation_lock: inventory = gateway.admin_model_inventory() - snapshot = control.snapshot() # 扫描可能同步账号身份,随后读取对应的规则版本。 + snapshot = control.snapshot() # Read policy after account identity synchronization. models = [] for item in inventory: item = {"id": item} if isinstance(item, str) else dict(item) @@ -392,14 +388,14 @@ def upload(body): directory = gateway.managed_auth_dir().resolve() for name, content in prepared: try: - data = auth_oauth.loads_strict(content) # 严格解析:拒绝 NaN/Infinity 常量 + data = auth_oauth.loads_strict(content) # Reject nonstandard JSON constants. uid, invalid = auth_oauth.validate_cred_data(data) if invalid or not isinstance(data.get("account") or {}, dict) or type(data["auth"].get("expiresAt", 0)) not in (int, float): raise CredentialFileError("凭据格式无效") if not body.get("replace", False) and (directory / name).exists(): results.append({"name": name, "ok": False, "error": "文件已存在,需明确允许替换"}) continue - # 落盘统一为规范形态:token 别名折叠为官方字段名,运行时只读 accessToken + # Persist token aliases using official runtime field names. content = json.dumps(auth_oauth.normalize_cred_data(data), ensure_ascii=False).encode("utf-8") gateway._store_credential(directory, name, content, uid, replace_existing=body.get("replace", False)) diff --git a/app/audit_store.py b/app/audit_store.py index 5ea9c77..1a7a19f 100644 --- a/app/audit_store.py +++ b/app/audit_store.py @@ -1,14 +1,5 @@ -"""Bounded, metadata-only SQLite audit storage (no import-time I/O). - -Methods are synchronous: ASGI callers must offload them. Lock and SQLite busy -waits are capped at 250ms; SQL has a cooperative 1s progress deadline (not a hard -wall-clock guarantee for filesystem I/O). Failures are observable, not retried. -Aggregates intentionally outlive all detail eviction. Ingest dedup rows expire with the -same retention cutoff as their details, so the dedup table cannot grow without bound. -Detail accounting is transactional; indexed cleanup commits bounded batches. -Large budget/retention reductions converge on subsequent writes or detail reads -(including storage()), reported as pending_cleanup until complete. This is a -logical detail budget, not a bound on aggregate, dedup, or physical file size. +"""Store metadata-only SQLite audits with bounded waits; detail eviction preserves aggregates. +ASGI callers must offload synchronous methods; capacity limits cover logical detail storage only. """ from __future__ import annotations @@ -148,8 +139,7 @@ def __init__(self, path, max_bytes=256 * 1024 * 1024, retention_days=30, raise def _migrate_ingest_time(self): - # v1 → v2:去重表获得时间维度。旧行用对应明细的时间回填,没有明细的按现在计, - # 随后与明细同截止期过期,不再无限期滞留。 + # Backfill dedup timestamps so retention can expire them alongside request details. columns = [row[1] for row in self._db.execute("PRAGMA table_info(ingest)").fetchall()] if "created_at" not in columns: self._db.execute("ALTER TABLE ingest ADD COLUMN created_at REAL") @@ -351,7 +341,7 @@ def _expire(self, retention_days=None, deadline=None): if time.monotonic() >= deadline: break self._db.execute(f"DELETE FROM {table} WHERE id=?", (record_id,)) - # 去重行与明细同截止期过期:保留期之外不再防重放,也不再无限增长 + # Expire dedup records with request details to bound retention growth. for row in self._db.execute("SELECT id FROM ingest WHERE created_at= deadline: diff --git a/app/auth_oauth.py b/app/auth_oauth.py index 2506c8c..1c7076e 100644 --- a/app/auth_oauth.py +++ b/app/auth_oauth.py @@ -1,14 +1,5 @@ #!/usr/bin/env python3 -"""auth_oauth.py — WorkBuddy/CodeBuddy 无感登录采集(OAuth state 轮询)与凭据入库校验。 - -无感登录采集流程(OAuth state 轮询 + 入库严格校验): - 1. POST {apiHost}/v2/plugin/auth/state?platform=... 申请 state + 授权链接 - 2. 用户在浏览器完成扫码授权(桌面端全程不退出、无需安装) - 3. 轮询 GET /v2/plugin/auth/token?state=... 拿 accessToken - 4. GET /v2/plugin/login/account?state=... 拉账号信息,拼成官方 .info 结构入库 - -仅依赖 httpx;文件落盘与凭证池热加载由调用方(converter.py)完成。 -""" +"""Acquire and validate WorkBuddy/CodeBuddy OAuth credentials; callers handle persistence and pool reload.""" from __future__ import annotations @@ -25,22 +16,22 @@ from .site_routing import profile_for_auth PLUGIN_PREFIX = "/v2/plugin" -OAUTH_TIMEOUT_S = 600 # 授权等待超时秒数 -RESULT_RETENTION_S = 300 # 完成状态保留,供调用方重复轮询取结果 +OAUTH_TIMEOUT_S = 600 # Authorization deadline in seconds +RESULT_RETENTION_S = 300 # Retain completed results for repeated polling REQUEST_TIMEOUT_S = 15.0 -# 各站点无感登录 apiHost(与 auth.domain 一致;签到/积分也打各自域名,不互用) +# Keep OAuth hosts isolated by product and region. SITE_HOSTS = { "cn": "https://www.codebuddy.cn", "intl": "https://www.workbuddy.ai", "intl-codebuddy": "https://www.codebuddy.ai", } -# 入库站点白名单:auth.domain 或 access token 的 JWT issuer 命中其一才收 +# Require an allowlisted credential domain or JWT issuer. ALLOWED_ORIGINS = { "https://www.workbuddy.cn", "https://www.codebuddy.cn", - "https://copilot.tencent.com", # 国内版新版 Keycloak issuer + "https://copilot.tencent.com", # Domestic Keycloak issuer "https://www.workbuddy.ai", "https://www.codebuddy.ai", } @@ -49,7 +40,7 @@ def _normalize_origin(value) -> str: - """域名/URL 归一化为小写 origin(无 scheme 补 https://);无效返回 ''。""" + """Normalize a domain or URL to a lowercase HTTPS origin, or return an empty string.""" raw = str(value or "").strip() if not raw: return "" @@ -60,7 +51,7 @@ def _normalize_origin(value) -> str: def _token_issuer_origin(access_token: str) -> str: - """解码 JWT payload 的 iss,返回 origin;失败返回 ''。""" + """Decode a JWT issuer origin, returning an empty string on failure.""" try: part = access_token.split(".")[1] part += "=" * ((4 - len(part) % 4) % 4) @@ -75,12 +66,12 @@ def _reject_constant(value): def loads_strict(text): - """严格 JSON 解析:拒绝 NaN/Infinity 等非标准常量(json.loads 默认接受)。""" + """Parse strict JSON without nonstandard NaN or Infinity constants.""" return json.loads(text, parse_constant=_reject_constant) def validate_cred_data(data) -> tuple[str | None, str | None]: - """入库校验(严格模式):返回 (uid, None) 或 (None, 原因)。""" + """Validate credentials and return either the account UID or a safe failure reason.""" if not isinstance(data, dict): return None, "凭据不是有效的 JSON 对象" acct = data.get("account") @@ -98,9 +89,9 @@ def validate_cred_data(data) -> tuple[str | None, str | None]: value = auth.get(field) if value is None: continue - # 原值范围比较同时拒绝非有限浮点数,避免超大整数转 float 溢出。 + # Compare before float conversion to reject nonfinite values and oversized integers. if isinstance(value, bool) or not isinstance(value, (int, float)) \ - or not 0 < value < 4102444800000: # 上限 2100-01-01 + or not 0 < value < 4102444800000: # Upper bound: 2100-01-01 return None, f"{field} 必须是合理范围内的有限毫秒时间戳" domain = _normalize_origin(auth.get("domain") or auth.get("issuer") or "") issuer = _token_issuer_origin(token) @@ -114,10 +105,7 @@ def validate_cred_data(data) -> tuple[str | None, str | None]: def normalize_cred_data(data: dict) -> dict: - """校验通过后生成唯一规范形态:token 别名折叠为官方字段名。 - - 运行时(client_profiles.credential_headers 等)只读 accessToken/refreshToken; - 导入侧若接受别名却不归一化,会得到「导入成功但认证头为空」的凭据。""" + """Normalize validated token aliases to the official runtime credential fields.""" out = copy.deepcopy(data) auth = out.get("auth") if not isinstance(auth, dict): @@ -136,7 +124,7 @@ def normalize_cred_data(data: dict) -> dict: def _norm_ts(v) -> int | None: - """时间戳归一化:秒/毫秒/数字字符串 → 毫秒;无效返回 None。""" + """Normalize numeric seconds or milliseconds to milliseconds; invalid values return None.""" if isinstance(v, bool): return None if isinstance(v, (int, float)): @@ -150,13 +138,13 @@ def _norm_ts(v) -> int | None: return None if ts <= 0: return None - if ts < 1e10: # 秒 → 毫秒 + if ts < 1e10: # Convert seconds to milliseconds. ts *= 1000 return round(ts) def build_auth_file(token_data, account_data) -> dict: - """把 OAuth token + 账号信息拼成官方 .info 结构(保留上游全部字段,不裁剪白名单)。""" + """Build the official .info structure while preserving upstream credential fields.""" now = round(time.time() * 1000) raw = token_data if isinstance(token_data, dict) else {} domain = str(raw.get("domain") or "") @@ -183,7 +171,7 @@ def build_auth_file(token_data, account_data) -> dict: "pluginEnabled": True, }) - auth = dict(raw) # 保留 idToken/sessionState 等官方后续可能依赖的字段 + auth = dict(raw) # Preserve official session fields beyond the access token. auth.update({ "accessToken": str(raw.get("accessToken") or raw.get("access_token") or ""), "refreshToken": str(raw.get("refreshToken") or raw.get("refresh_token") or ""), @@ -207,7 +195,7 @@ def build_auth_file(token_data, account_data) -> dict: def merge_existing_accounts(cred: dict, existing) -> dict: - """把 existing 文件的 accounts/allAccounts 并入 cred(按 uid 去重,cred 内账号优先)。""" + """Merge existing account lists by UID, preferring accounts in the new credential.""" if not isinstance(existing, dict): return cred arr = existing.get("allAccounts") or existing.get("accounts") @@ -223,7 +211,7 @@ def merge_existing_accounts(cred: dict, existing) -> dict: class OAuthManager: - """无感登录状态机:start 申请 state,poll 轮询直至授权完成。状态存内存,懒清理。""" + """Manage in-memory OAuth authorization sessions with lazy expiry cleanup.""" def __init__(self, user_agent: str = DEFAULT_UA, timeout_s: int = OAUTH_TIMEOUT_S, retention_s: int = RESULT_RETENTION_S, http_factory=None): @@ -232,7 +220,7 @@ def __init__(self, user_agent: str = DEFAULT_UA, timeout_s: int = OAUTH_TIMEOUT_ self._ua = user_agent self._timeout_s = timeout_s self._retention_s = retention_s - self._http_factory = http_factory # 测试注入;缺省 httpx.Client + self._http_factory = http_factory # Optional test transport factory def _client(self): return self._http_factory() if self._http_factory else httpx.Client(timeout=REQUEST_TIMEOUT_S) @@ -242,7 +230,7 @@ def _headers(self) -> dict: "Content-Type": "application/json"} def _purge(self): - """惰性清理:超时 + 结果保留期都过去的登录请求直接丢弃(调用时需已持锁)。""" + """Remove expired sessions and retained results while holding the session lock.""" now = time.time() drop = [k for k, s in self._states.items() if now > s["expires_at"] + self._retention_s] @@ -250,12 +238,12 @@ def _purge(self): self._states.pop(k, None) def start(self, site: str = "cn") -> dict: - """申请 state 与授权链接;intl 保留为国际 WorkBuddy,intl-codebuddy 为国际 CodeBuddy。""" + """Request authorization for the selected domestic, international WorkBuddy or CodeBuddy site.""" site = str(site or "").strip().lower() host = SITE_HOSTS.get(site) if not host: raise ValueError(f"未知站点(仅支持 {' / '.join(SITE_HOSTS)})") - # 官方 CodeBuddy CLI 的 platform 为大写 CLI;旧入口保留兼容参数。 + # CodeBuddy requires uppercase CLI; retain compatible parameters for other sites. platform = "CLI" if site == "intl-codebuddy" else "workbuddy" with self._client() as c: r = c.post(f"{host}{PLUGIN_PREFIX}/auth/state?platform={platform}", @@ -279,7 +267,7 @@ def start(self, site: str = "cn") -> dict: return {"login_id": login_id, "verification_uri": auth_url, "expires_in": self._timeout_s} def poll(self, login_id: str) -> dict: - """第二步:轮询授权结果。未完成 {"done": False};完成带 uid/nickname/cred 或 error。""" + """Poll authorization and return pending state, credentials or a safe error.""" with self._lock: self._purge() s = self._states.get(str(login_id or "")) @@ -298,7 +286,7 @@ def poll(self, login_id: str) -> dict: try: resp = c.get(url, headers=self._headers()).json() except Exception: - return {"done": False} # 上游抖动视为未完成,下轮再试 + return {"done": False} # Retry transient upstream failures on the next poll. data = resp.get("data") or {} if isinstance(resp, dict) else {} code = resp.get("code") if isinstance(resp, dict) else None if code not in (0, 200): diff --git a/app/client_hangup.py b/app/client_hangup.py index 3ba26e8..cde0f1b 100644 --- a/app/client_hangup.py +++ b/app/client_hangup.py @@ -1,15 +1,15 @@ -"""`stream=false` 的聚合窗口:监听下游断连,并把没听完的那一枪取消掉。""" +"""Cancel non-streaming upstream work when the downstream client disconnects.""" from __future__ import annotations import asyncio class ClientHungUp(Exception): - """客户端在响应成形之前挂断了。不是错误,是「没人听了」。""" + """Signal client cancellation before a response is ready.""" async def _listen_for_hangup(request): - """等 `http.disconnect`;多余的 `http.request` 残留消息不算断连。""" + """Wait for http.disconnect while ignoring remaining request-body messages.""" while True: message = await request.receive() if message.get("type") == "http.disconnect": @@ -17,11 +17,7 @@ async def _listen_for_hangup(request): async def await_or_hangup(awaitable, request): - """等待一次上游调用;期间下游挂断就取消它。返回原调用的结果,异常原样抛出。 - - `request is None` 时退化成普通的 `await`。取消必须等收尾跑完再交出去:httpx 的 - `async with` 在 `finally` 里才 `aclose()` 响应,否则上游连接会留在半关状态。 - """ + """Await upstream work, cancelling and draining it on disconnect; await directly without a request.""" if request is None: return await awaitable work = asyncio.ensure_future(awaitable) @@ -29,11 +25,11 @@ async def await_or_hangup(awaitable, request): try: done, _ = await asyncio.wait((work, watch), return_when=asyncio.FIRST_COMPLETED) if work in done: - return work.result() # 上游先回来:这一次结果是真的完整,照旧交给服务端投递 + return work.result() # Preserve a result that completed first. work.cancel() await asyncio.wait((work,)) if not work.cancelled(): - work.exception() # 取消途中自己报了错:也属于「没人听了」,但不留未取回的异常 + work.exception() # Retrieve errors raised during cancellation. raise ClientHungUp finally: work.cancel() diff --git a/app/client_profiles.py b/app/client_profiles.py index 23a9a92..a64a7d3 100644 --- a/app/client_profiles.py +++ b/app/client_profiles.py @@ -1,4 +1,4 @@ -"""CLI 与 WorkBuddy 产品身份头分开生成,共享认证字段与底层协议头。""" +"""Build separate CLI and WorkBuddy identity headers with shared authentication fields.""" import hashlib import json @@ -27,7 +27,7 @@ def identity_headers(profile: str) -> dict: - """版本来自已核对的官方包,不读取本机桌面用户配置或机器标识。""" + """Use verified package versions without reading desktop settings or device identities.""" if profile_product(profile) == "cli": return {"User-Agent": CLI_USER_AGENT, "X-IDE-Type": "CLI", "X-IDE-Name": "CLI", "X-IDE-Version": CLI_VERSION} name = "WorkBuddy AI" if profile_region(profile) == "intl" else "WorkBuddy" @@ -62,12 +62,12 @@ def catalog_headers(auth: dict, account: dict | None = None, *, user_agent="") - headers["x-client-platform"] = "cli" if user_agent: headers["User-Agent"] = user_agent - # WorkBuddy coordinator 不带 x-client-platform: cli,否则会取得另一产品的模型表。 + # A CLI platform header would select the wrong product catalog for WorkBuddy. return headers def account_key(profile: str, uid, enterprise_id="") -> str: - """稳定产品/账号/租户指纹,不包含 token 或本地路径。""" + """Hash product, account and tenant identity without tokens or local paths.""" identity = json.dumps([profile, str(uid or ""), str(enterprise_id or "")], ensure_ascii=False, separators=(",", ":")) return hashlib.sha256(identity.encode("utf-8")).hexdigest() diff --git a/app/content_filter.py b/app/content_filter.py index ef917bc..e994345 100644 --- a/app/content_filter.py +++ b/app/content_filter.py @@ -1,4 +1,4 @@ -"""识别明确的上游审核拒绝;不把正文中的审核关键词当作失败。""" +"""Recognize explicit upstream filter refusals without matching ordinary content keywords.""" from __future__ import annotations import json @@ -27,7 +27,7 @@ def is_filter_error(raw: bytes) -> bool: class ContentFilterDetector: - """有界跟踪已校验的 SSE delta,纯拒绝才允许请求级兜底。""" + """Inspect validated SSE with bounded storage and allow fallback only for pure refusals.""" def __init__(self): self.text = {"content": "", "refusal": ""} diff --git a/app/control_store.py b/app/control_store.py index 8055255..3938ff1 100644 --- a/app/control_store.py +++ b/app/control_store.py @@ -102,7 +102,7 @@ def _load(self): if not isinstance(data["models"], dict) or not isinstance(data["credentials"], dict): raise ValueError("管理数据库策略无效") for source, rule in data["models"].items(): - validate_model(source, rule, data["models"], legacy_scopes=True) # 旧联合范围保持原有交集,编辑时再显式转换。 + validate_model(source, rule, data["models"], legacy_scopes=True) # Preserve legacy scope intersections. for identity, metadata in data["credentials"].items(): _identifier(identity, "账号指纹") if (not isinstance(metadata, dict) or set(metadata) - {"enabled", "label", "auto_checkin", "auto_travel"} diff --git a/app/credential_io.py b/app/credential_io.py index fcc363a..59aefa9 100644 --- a/app/credential_io.py +++ b/app/credential_io.py @@ -1,4 +1,4 @@ -"""凭据导入目录读取与私有文件原子更新。""" +"""Read controlled credential imports and atomically update private files.""" from __future__ import annotations @@ -12,7 +12,7 @@ class CredentialFileError(ValueError): - """可安全返回给客户端的文件输入错误。""" + """Represent a file-input error safe to return to clients.""" def _valid_name(name: str) -> bool: @@ -22,11 +22,11 @@ def _valid_name(name: str) -> bool: def read_import_file(directory: Path, requested_path: str) -> tuple[str, bytes]: - """从服务端导入目录选择普通 .info 文件,限量读取一次。""" + """Select a regular .info file from the import directory and read it once within limits.""" if not isinstance(requested_path, str) or not requested_path or len(requested_path) > 4096: raise CredentialFileError("path 必须是导入目录中的 .info 文件名或绝对路径") root = directory.resolve(strict=True) - # 请求只用于选择服务端枚举的文件,不参与构造文件系统路径。 + # Requests select enumerated files rather than supplying filesystem paths. for entry in root.glob("*.info"): if requested_path not in (entry.name, str(entry)): continue @@ -54,7 +54,7 @@ def read_import_file(directory: Path, requested_path: str) -> tuple[str, bytes]: @contextmanager def credential_file_lock(directory: Path, name: str): - """序列化同一凭据的跨线程/进程写入;锁文件不含凭据内容。""" + """Serialize credential writes across threads and processes using secret-free lock files.""" if not isinstance(name, str) or not _valid_name(name): raise CredentialFileError("凭据文件名无效") root = directory.resolve() @@ -85,7 +85,7 @@ def credential_file_lock(directory: Path, name: str): def atomic_write_credential(directory: Path, name: str, content: bytes) -> Path: - """将已校验内容以 0600 临时文件原子写入,保留同名更新语义。""" + """Atomically persist validated content through a mode-0600 temporary file.""" if not isinstance(name, str) or not _valid_name(name): raise CredentialFileError("凭据文件名无效") if len(content) > MAX_CREDENTIAL_BYTES: @@ -101,7 +101,7 @@ def atomic_write_credential(directory: Path, name: str, content: bytes) -> Path: stream.write(content) stream.flush() os.fsync(stream.fileno()) - # 替换目录项而不是打开目标写入,避免跟随校验后被换入的链接。 + # Replace the directory entry instead of following a newly substituted symlink. os.replace(temporary, target) return target finally: diff --git a/app/credits.py b/app/credits.py index 140015d..0cc7278 100644 --- a/app/credits.py +++ b/app/credits.py @@ -1,11 +1,5 @@ #!/usr/bin/env python3 -"""credits.py — 每日签到 + 积分查询 + 快过期优先调度支持。 - -HTTP 流程(官方 Web 端接口,Bearer 鉴权): - 签到 POST {host}/billing/meter/daily-checkin(兜底 /v2/...) - 积分 POST {host}/v2/billing/meter/get-user-resource -财务域名按 site_routing 的凭据身份解析选择固定品牌 host,不跨产品或地域兜底。 -""" +"""Manage check-in, balances and credit-aware scheduling within each account's product and region.""" import base64 from copy import deepcopy @@ -26,7 +20,7 @@ "(KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36") REQUEST_TIMEOUT = 12.0 -# 财务使用官方 Web 品牌站;国内 CLI 的 chat/config 入口 copilot 不适用于此处。 +# Billing uses product websites rather than the CLI chat endpoint. BILLING_PROFILE_HOSTS = { "cn-cli": "https://www.codebuddy.cn", "cn-work": "https://www.workbuddy.cn", @@ -36,25 +30,25 @@ CHECKIN_PATHS = ("/v2/billing/meter/daily-checkin",) CHECKIN_STATUS_PATH = "/v2/billing/meter/checkin-activity-status" RESOURCE_PATH = "/v2/billing/meter/get-user-resource" -CONFIG_PATH = "/v3/config" # cbc CLI CloudProductProvider 同源:云端模型表 +CONFIG_PATH = "/v3/config" # Official cloud model catalog RESOURCE_PRODUCT_CODE = "p_tcaca" CREDITS_PAGE_SIZE = 100 -CREDITS_MAX_PAGES = 20 # 2000 个积分包封顶;到顶必须 partial 标记,不得装作完整 +CREDITS_MAX_PAGES = 20 # Mark capped results partial instead of reporting complete balances. _INACTIVE_RE = re.compile(r"未开启|未开始|未开放|已过期|无.*活动|活动.*(?:结束|关闭|暂停)", re.I) _ALREADY_RE = re.compile(r"已签到|已领取|已经.*(?:签到|领取)|重复签到|already", re.I) class AuthExpiredError(Exception): - """签到/积分接口 401:token 失效(重试无意义,由上层记录)。""" + """Signal an expired token from a billing HTTP 401 response.""" # --------------------------------------------------------------------------- -# 域名选择 +# Billing host selection # --------------------------------------------------------------------------- def token_issuer_origin(access_token: str) -> str | None: - """解码 JWT payload 的 iss 字段,返回 origin(如 https://www.codebuddy.cn)。""" + """Decode the JWT issuer and return its origin.""" try: part = access_token.split(".")[1] part += "=" * ((4 - len(part) % 4) % 4) @@ -67,13 +61,13 @@ def token_issuer_origin(access_token: str) -> str | None: def hosts_for_token(access_token: str, domain: str = "") -> list[str]: - """复用凭据身份解析,仅返回同 profile 财务 host;未知或冲突提示直接拒绝。""" + """Resolve the credential's billing host, rejecting unknown or conflicting identities.""" profile = profile_for_auth({"accessToken": access_token, "domain": domain}) return [BILLING_PROFILE_HOSTS[profile]] def _web_headers(api_host: str, access_token: str, uid: str = "", domain: str = "") -> dict: - """财务端点保留官方 Web 协议,不套用模型目录的 CLI/WorkBuddy 身份头。""" + """Build website billing headers independently of CLI model catalog headers.""" return { "accept": "application/json, text/plain, */*", "content-type": "application/json", @@ -88,7 +82,7 @@ def _web_headers(api_host: str, access_token: str, uid: str = "", domain: str = def _post_json(client: httpx.Client, url: str, headers: dict, body: dict) -> tuple[int, dict]: - """POST JSON 并解析响应;401 抛 AuthExpiredError;返回 (status, payload)。""" + """POST JSON and return status/payload, raising AuthExpiredError on HTTP 401.""" r = client.post(url, headers=headers, json=body, timeout=REQUEST_TIMEOUT) if r.status_code == 401: raise AuthExpiredError("登录身份过期") @@ -100,11 +94,11 @@ def _post_json(client: httpx.Client, url: str, headers: dict, body: dict) -> tup # --------------------------------------------------------------------------- -# 每日签到 +# Daily check-in # --------------------------------------------------------------------------- def classify_checkin_result(http_ok: bool, code, message: str) -> dict: - """兼容旧签到文案与桌面业务码,不把活动未开放误判为已领取。""" + """Normalize check-in codes without confusing inactive activities with completed claims.""" try: ncode = int(code) if type(code) in (int, str) else None except ValueError: @@ -135,7 +129,7 @@ def _checkin_request(access_token, uid, domain, path): def daily_checkin(access_token: str, uid: str = "", domain: str = "") -> dict: - """仅调用同 profile 的桌面 Bearer 接口;发送后失败不重放领取。""" + """Call the same-profile check-in endpoint without replaying uncertain claims.""" status, payload, url = _checkin_request(access_token, uid, domain, CHECKIN_PATHS[0]) message = payload.get("msg") or payload.get("message") or "" result = classify_checkin_result(200 <= status < 300, payload.get("code"), message) @@ -146,7 +140,7 @@ def daily_checkin(access_token: str, uid: str = "", domain: str = "") -> dict: def fetch_checkin_status(access_token: str, uid: str = "", domain: str = "") -> dict: - """查询活动状态;不完整响应不能授权领取。""" + """Query activity status without authorizing claims from incomplete responses.""" status, payload, _ = _checkin_request(access_token, uid, domain, CHECKIN_STATUS_PATH) result = classify_checkin_result(200 <= status < 300, payload.get("code"), payload.get("msg") or payload.get("message") or "") @@ -167,7 +161,7 @@ def fetch_checkin_status(access_token: str, uid: str = "", domain: str = "") -> # --------------------------------------------------------------------------- -# 积分查询(分段 + 过期时间) +# Credit segments and expiry # --------------------------------------------------------------------------- _REMAINING_FIELDS = ( @@ -207,7 +201,7 @@ def _first_number(item: dict, fields) -> float | None: def _parse_ts(value) -> float | None: - """秒/毫秒 epoch 或 'YYYY-MM-DD HH:MM:SS' → epoch 秒。""" + """Convert epoch seconds, milliseconds or formatted timestamps to epoch seconds.""" if value is None or value == "": return None if isinstance(value, (int, float)) or re.match(r"^\d+(?:\.\d+)?$", str(value).strip()): @@ -237,7 +231,7 @@ def _first_text(item: dict, fields) -> str: def extract_segments(accounts: list) -> list[dict]: - """从资源 Account 列表提取积分段(remaining>0),SlicePeriodUsageDetails 有明细则展开。""" + """Extract positive credit segments, expanding slice-level usage when available.""" out = [] for account in accounts or []: if not isinstance(account, dict): @@ -261,7 +255,7 @@ def extract_segments(accounts: list) -> list[dict]: def merge_segments(segments: list) -> list[dict]: - """按 (package_code|source, expires_at) 合并同包多记录,按过期时间升序(无过期时间排最后)。""" + """Merge matching package/expiry segments and sort unknown expiries last.""" merged: dict = {} for s in segments or []: if not s or float(s.get("remaining") or 0) <= 0: @@ -287,7 +281,7 @@ def merge_segments(segments: list) -> list[dict]: def soonest_expiry(segments: list, now: float | None = None) -> float | None: - """未过期且有余量积分段的最早过期时间;全部无过期时间/无段时返回 None。""" + """Return the earliest known expiry among unexpired positive balances.""" now = time.time() if now is None else now exps = [s["expires_at"] for s in segments or [] if s.get("expires_at") is not None and s["expires_at"] > now @@ -296,7 +290,7 @@ def soonest_expiry(segments: list, now: float | None = None) -> float | None: def _resource_body(page: int) -> dict: - """与官方 Web 端一致:有效状态 [0,3],结束时间范围 现在 ~ +101 年(只取未过期包)。""" + """Build the official active-package filter for future expiry dates.""" fmt = "%Y-%m-%d %H:%M:%S" return { "PageNumber": page, @@ -309,7 +303,7 @@ def _resource_body(page: int) -> dict: def _fetch_accounts_page(client, url: str, headers: dict, page: int, *, retry_empty: bool) -> list: - """拉一页积分包(限次重试);返回 Accounts 列表,失败抛 RuntimeError,401 抛 AuthExpiredError。""" + """Fetch one credit page with bounded retries, distinguishing expired authentication.""" last_err: Exception | None = None for attempt in range(3): try: @@ -329,7 +323,7 @@ def _fetch_accounts_page(client, url: str, headers: dict, page: int, *, retry_em raise RuntimeError(str(payload.get("msg") or f"积分接口 code={code}")) data = payload.get("data") or {} resp = (data.get("Response") or {}).get("Data") or (data.get("data") or {}).get("Response", {}).get("Data") or data - # 区分「合法的零余额」与「结构缺失的未知失败」:只有 Accounts/accounts 键存在才算有效响应 + # Missing account structure is not a confirmed zero balance. accounts = None if isinstance(resp, dict) and isinstance(resp.get("Accounts"), list): accounts = resp["Accounts"] @@ -339,7 +333,7 @@ def _fetch_accounts_page(client, url: str, headers: dict, page: int, *, retry_em last_err = RuntimeError("积分接口返回缺少 Accounts 结构") time.sleep(0.3 * (attempt + 1)) continue - if not accounts and retry_empty and attempt < 2: # 偶发空 Accounts,重试一次 + if not accounts and retry_empty and attempt < 2: # Retry a transient empty page once. time.sleep(0.3 * (attempt + 1)) continue return accounts @@ -347,9 +341,7 @@ def _fetch_accounts_page(client, url: str, headers: dict, page: int, *, retry_em def fetch_credits(access_token: str, uid: str = "", domain: str = "") -> dict: - """查询剩余积分:{credits, count, segments, soonest_expiry, partial}。401 抛 AuthExpiredError。 - - 分页遍历到不足一页为止;达到 CREDITS_MAX_PAGES 上限时 partial=True,调用方不得把结果当完整值。""" + """Fetch credit segments and mark page-limited results partial; HTTP 401 raises AuthExpiredError.""" host = hosts_for_token(access_token, domain)[0] url = host + RESOURCE_PATH headers = _web_headers(host, access_token, uid, domain) @@ -362,7 +354,7 @@ def fetch_credits(access_token: str, uid: str = "", domain: str = "") -> dict: if page > CREDITS_MAX_PAGES: partial = True break - rows = _fetch_accounts_page(client, url, headers, page, retry_empty=True) # 空页在任何页都可能是瞬时现象,一律重试 + rows = _fetch_accounts_page(client, url, headers, page, retry_empty=True) # Empty pages may be transient. accounts.extend(rows) if len(rows) < CREDITS_PAGE_SIZE: break @@ -375,7 +367,7 @@ def fetch_credits(access_token: str, uid: str = "", domain: str = "") -> dict: def select_product_models(data: dict, product: str = "cli", *, scope: str = "picker") -> list[dict]: - """按产品解析选择器或账号根表候选,保留禁用与 availableModels 筛选。""" + """Parse product selector/root catalogs while honoring disabled and available-model filters.""" if product not in ("cli", "workbuddy"): raise ValueError("未知模型目录产品") if scope not in ("picker", "account"): @@ -412,7 +404,7 @@ def text(value) -> bool: if available is not None and (not isinstance(available, list) or not all(text(value) for value in available)): invalid("data.availableModels") - # WorkBuddy 5.5.2 AvailableModelsFilterProvider:空 availableModels 表示不附加过滤。 + # WorkBuddy treats an empty availableModels list as no additional filter. def finish(items): return deepcopy([model for model in items if not model.get("disabled") and (not available or model["id"] in available)]) @@ -467,7 +459,7 @@ def select_cli_models(data: dict) -> list[dict]: def fetch_model_scopes(access_token: str, user_agent: str = "", *, domain: str = "", uid: str = "", enterprise_id: str = "") -> dict[str, list[dict]]: - """一次请求解析选择器与账号根表;候选模型是否可服务仍需上游确认。""" + """Fetch selector and account-root catalogs without assuming every candidate is servable.""" auth = {"accessToken": access_token, "domain": domain} profile = profile_for_auth(auth) headers = catalog_headers(auth, {"uid": uid, "enterpriseId": enterprise_id}, user_agent=user_agent) @@ -495,60 +487,53 @@ def fetch_model_scopes(access_token: str, user_agent: str = "", *, domain: str = def fetch_model_catalog(access_token: str, user_agent: str = "", *, domain: str = "", uid: str = "", enterprise_id: str = "") -> list[dict]: - """该凭据产品的选择器模型目录;账号可服务的全量见 fetch_model_scopes。""" + """Return selector models for this credential's product.""" return fetch_model_scopes(access_token, user_agent, domain=domain, uid=uid, enterprise_id=enterprise_id)["picker"] # --------------------------------------------------------------------------- -# 积分折算为金额(OpenAI 余额口径) +# OpenAI-compatible billing estimates # --------------------------------------------------------------------------- -# 官方无任何金额接口(实测 12 个候选端点全 404),也不公开 credit↔token 单价; -# 折算锚点取官方《计费概述》旗舰版连续包月 700 元 / 50,000 积分 = 0.014 元/Credit。 -CREDIT_PRICE_CNY = 0.014 # 国内:旗舰版连续包月 700 元 / 50,000 积分摊算 -CREDIT_PRICE_USD = 0.03 # 国际:Pro 加量包 $15 / 500 Credits(与国内不同体系,须分开折算) +# Estimate monetary value using independent domestic and international package rates. +CREDIT_PRICE_CNY = 0.014 # CNY 700 / 50,000 credits +CREDIT_PRICE_USD = 0.03 # USD 15 / 500 credits USD_RATE_CNY = 7.15 def is_international_host(host: str) -> bool: - """国际站判定。.ai 与国内站账号/积分完全隔离(实测国内 token 打 .ai 一律 401)。""" + """Identify international .ai sites with independent accounts and credits.""" return bool(host) and ".ai" in str(host).lower() USAGE_PATH = "/billing/meter/get-user-request-usage" -USAGE_MAX_DAYS = 30 # 官方硬限制:时间跨度 >31 天静默返回 total=0(不报错) +USAGE_MAX_DAYS = 30 # The upstream returns empty totals beyond its 31-day window. USAGE_PAGE_SIZE = 200 USAGE_MAX_PAGES = 30 def credits_to_usd(amount: float, price_cny: float = CREDIT_PRICE_CNY, rate: float = USD_RATE_CNY) -> float: - """Credits → 美元:先按订阅摊算折算人民币,再按汇率换美元。""" + """Estimate USD value from the CNY credit price and exchange rate.""" return float(amount or 0) * price_cny / rate def usd_per_credit(is_intl: bool, price_cny: float = CREDIT_PRICE_CNY, price_usd: float = CREDIT_PRICE_USD, rate: float = USD_RATE_CNY) -> float: - """每 Credit 的美元价值:国际站用 USD 单价,国内站按 CNY 单价除以汇率。""" + """Return the regional USD value per credit.""" return price_usd if is_intl else price_cny / rate def dedupe_by_identity(creds_snapshot: dict) -> dict: - """按账号身份折叠 ledger 快照:同一身份只保留一条余额记录。 - - 键是凭据绝对路径,只作索引不作身份(见 CreditLedger.bind_identity):换 - CODEBUDDY_AUTH_DIR、搬动项目目录或同一份凭据被重复登记时,同一账号会在多个键下 - 各留一份余额,而积分属于账号、不属于文件,逐键相加会把一份余额算好几次。 - 取舍优先级:有分段数据 > 无数据,其次 fetched_at 更新。未绑定身份的历史条目 - 无法安全判定归属,原样保留。""" + """Deduplicate account balances, preferring segmented and newer data while retaining unknown identities.""" winners: dict[str, tuple] = {} for cred_id, entry in (creds_snapshot or {}).items(): if not isinstance(entry, dict): continue identity = str(entry.get("identity") or "") if not identity: - continue # 未绑定身份:归属未知,不参与合并 + continue # Unknown owners cannot be merged safely. balance = entry.get("credits") or {} rank = (bool(balance.get("segments")), float(balance.get("fetched_at") or 0.0)) if identity not in winners or rank > winners[identity][0]: @@ -557,17 +542,14 @@ def dedupe_by_identity(creds_snapshot: dict) -> dict: out = {} for cred_id, entry in (creds_snapshot or {}).items(): identity = str(entry.get("identity") or "") if isinstance(entry, dict) else "" - if identity and cred_id not in keep: # 同身份的落选路径 + if identity and cred_id not in keep: # Drop duplicate paths for a known identity. continue out[cred_id] = entry return out def aggregate_credits(creds_snapshot: dict) -> dict: - """汇总 ledger 快照,并按国内/国际分组(两站积分独立、单价不同,必须分组折算)。 - - 顶层为合计值,groups 内为各组明细;含剩余、额度差已用、最早过期时间。 - 同一账号在多个凭据路径下重复记账时只算一次,见 dedupe_by_identity。""" + """Deduplicate accounts and aggregate regional balances, usage and earliest expiry.""" groups = {k: {"remaining": 0.0, "used_by_quota": 0.0, "soonest_expiry": None} for k in ("domestic", "international")} for e in dedupe_by_identity(creds_snapshot).values(): @@ -595,10 +577,7 @@ def aggregate_credits(creds_snapshot: dict) -> dict: def fetch_request_usage(access_token: str, days: int = USAGE_MAX_DAYS, uid: str = "", domain: str = "") -> dict: - """拉官方用量明细,按 日期×模型 聚合实际扣减的 credits。 - - 返回 {by_day: {'YYYY-MM-DD': {model: credits}}, total_credits, requests, partial}。 - 跨度超 31 天官方会静默返回空,故 days 强制夹到 USAGE_MAX_DAYS。""" + """Query the supported usage window and aggregate actual credit deductions by day and model.""" days = max(1, min(int(days or USAGE_MAX_DAYS), USAGE_MAX_DAYS)) host = hosts_for_token(access_token, domain)[0] url = host + USAGE_PATH @@ -621,7 +600,7 @@ def fetch_request_usage(access_token: str, days: int = USAGE_MAX_DAYS, if code not in (0, None): raise RuntimeError(f"用量明细接口 code={code}: {str(payload.get('msg'))[:120]}") data = payload.get("data") - # HTTP 200 但缺少业务结构不是「零用量」:total 缺失还会让分页提前中断 + # Missing business data must not be treated as zero usage or complete pagination. if not isinstance(data, dict) or not isinstance(data.get("data"), list) \ or not isinstance(data.get("total"), (int, float)): raise RuntimeError("用量明细接口返回缺少 data.data/total 结构") @@ -639,17 +618,17 @@ def fetch_request_usage(access_token: str, days: int = USAGE_MAX_DAYS, if requests >= int(data["total"]) or not rows: break else: - partial = True # 达到页数上限仍可能有剩余:标记不完整,不装作全量 + partial = True # The page cap may hide additional usage. return {"by_day": by_day, "total_credits": round(total_credits, 2), "requests": requests, "partial": partial} # --------------------------------------------------------------------------- -# CreditLedger:按凭证缓存签到/积分状态,JSON 原子持久化 +# Atomic JSON persistence for per-credential credit and check-in state # --------------------------------------------------------------------------- class CreditLedger: - """{cred_id: {checkin, credits, error}} 持久化缓存;soonest_expiry 供凭证池排序。""" + """Persist per-credential check-in and balance snapshots for expiry-aware scheduling.""" def __init__(self, path: Path): self.path = Path(path) @@ -679,12 +658,12 @@ def _entry(self, cred_id: str) -> dict: return self._data["creds"].setdefault(cred_id, {"checkin": {}, "credits": {}, "error": None}) def entry(self, cred_id: str) -> dict: - """单凭证快照;未知凭证返回空字典且不创建条目。""" + """Return a credential snapshot without creating unknown entries.""" with self._lock: return deepcopy(self._data["creds"].get(cred_id) or {}) def bind_identity(self, cred_id: str, identity: str) -> bool: - """路径只作索引;未知或不同账号的旧余额不得转移给新身份。""" + """Bind balance ownership independently of file paths, rejecting stale identity data.""" with self._lock: entry = self._data["creds"].get(cred_id) or {} if entry.get("identity") == identity: @@ -694,12 +673,12 @@ def bind_identity(self, cred_id: str, identity: str) -> bool: return True def remove(self, cred_id: str): - """凭证身份/站点替换时删除旧积分、签到与错误状态,幂等持久化。""" + """Clear persisted credit, check-in and error state after an identity change.""" with self._lock: self._data["creds"].pop(cred_id, None) self._save() - # ---- 签到 ---- + # Check-in state def checkin_done(self, cred_id: str, day: str) -> bool: with self._lock: @@ -721,7 +700,7 @@ def update_travel(self, cred_id: str, result: dict): self._entry(cred_id)["travel"] = deepcopy(result) self._save() - # ---- 积分 ---- + # Credit balances def update_credits(self, cred_id: str, result: dict): with self._lock: @@ -732,8 +711,8 @@ def update_credits(self, cred_id: str, result: dict): "segments": deepcopy(result.get("segments") or []), "soonest_expiry": result.get("soonest_expiry"), "fetched_at": time.time(), - "intl": bool(result.get("intl")), # 站点归属:国内/国际积分与单价均独立 - "partial": bool(result.get("partial")), # 分页到顶:余额被低估,下游必须可见 + "intl": bool(result.get("intl")), # Regional credits use independent pricing. + "partial": bool(result.get("partial")), # Expose incomplete pagination. } e["error"] = None self._save() @@ -744,7 +723,7 @@ def note_error(self, cred_id: str, message: str): self._save() def soonest_expiry_of(self, cred_id: str) -> float | None: - """pick 排序键:该凭证最早过期积分时间;无数据返回 None(排最后)。""" + """Return the earliest credit expiry, or None when unavailable.""" with self._lock: return soonest_expiry((self._data["creds"].get(cred_id) or {}).get("credits", {}).get("segments")) @@ -754,10 +733,7 @@ def snapshot(self) -> dict: class ModelCatalogCache: - """云端模型表按站点分组持久化缓存,TTL 内不重复拉取。 - - v1 根模型表保留供降级,但不视为 fresh;各组成功刷新后才升级 CLI 语义。 - 空目录同样缓存。每组版本独立,避免刷新一站后另一站旧数据误判 fresh。""" + """Cache scoped model catalogs by client version; legacy shared catalogs are not fresh account data.""" SCHEMA_VERSION = 2 @@ -788,13 +764,13 @@ def _load(self): if d["version"] == self.SCHEMA_VERSION and entry.get("version") == self.SCHEMA_VERSION else 1)} - # 根表是后加的作用域:形状不对就当没有,不能让一条坏数据毁掉整组目录。 + # Ignore malformed root entries without discarding the selector catalog. serves = entry.get("serves") if isinstance(serves, list) and all(isinstance(m, dict) for m in serves): groups[group]["serves"] = deepcopy(serves) self._data = {"version": self.SCHEMA_VERSION, "groups": groups} except (OSError, ValueError): - pass # 无法读取时不清除已载入的目录。 + pass # Keep the loaded catalog when disk reads fail. def _save(self): try: @@ -808,12 +784,12 @@ def _save(self): @staticmethod def group_for_token(access_token: str) -> str: - """凭证所属站点组:domestic / international。""" + """Return the credential's domestic or international cache group.""" return ("international" if is_international_host(hosts_for_token(access_token)[0]) else "domestic") def fresh(self, group: str) -> bool: - """该组缓存是否仍在 TTL 内(在则本轮无需拉云端)。""" + """Check whether the group's cache remains within its TTL.""" with self._lock: g = self._data["groups"].get(group) or {} age = time.time() - float(g.get("fetched_at") or 0) @@ -824,7 +800,7 @@ def models(self, group: str) -> list[dict]: return deepcopy((self._data["groups"].get(group) or {}).get("models") or []) def age(self, group: str) -> float | None: - """缓存年龄秒数(包括空目录);仅未知组返回 None。""" + """Return cache age in seconds, including empty catalogs; unknown groups return None.""" with self._lock: g = self._data["groups"].get(group) return time.time() - float(g.get("fetched_at") or 0) if g is not None else None @@ -839,6 +815,6 @@ def put(self, group: str, models: list[dict], serves: list[dict] | None = None): self._save() def serves(self, group: str) -> list[dict]: - """返回账号根表候选;旧缓存无此字段时返回空,由调用方回退选择器。""" + """Return root catalog candidates, or an empty list when legacy caches omit them.""" with self._lock: return deepcopy((self._data["groups"].get(group) or {}).get("serves") or []) diff --git a/app/desensitize.py b/app/desensitize.py index 94d5d63..82b6f26 100644 --- a/app/desensitize.py +++ b/app/desensitize.py @@ -1,8 +1,4 @@ -"""可选的客户端模板适配:固定句替换、运行时摘要与零宽词表。 - -默认只处理 system,可选 developer 和已识别的 harness user;真实对话不改写。 -只缓解固定模板误拦,不保证上游接受请求,也不改变对真实输入的审核。 -""" +"""Adapt selected roles and trusted harness templates while preserving real conversation content.""" from __future__ import annotations @@ -11,13 +7,12 @@ from app.harness_context import parse_harness_text -# 零宽空格:插入到关键词内部,打断后端的关键词匹配,但模型/人眼读起来无差别。 +# Insert a zero-width separator within matched template terms. _ZWSP = "\u200b" -# 触发审核的"合规声明高频词"(来自真实被拦截的客户端 system 模板)。 -# 全部是"拒绝作恶"语境里常见的英文术语。大小写不敏感匹配。 +# Case-insensitive terms found in known client compliance templates. SENSITIVE_TERMS: list[str] = [ - # 原有词表 + # Shared template terms "DoS", "DDoS", "exploit", @@ -49,7 +44,7 @@ "botnet", "zero-day", "0day", - # Codex CLI system prompt 里额外的高频触发词 + # Codex template terms "vulnerability", "vulnerabilities", "red teaming", @@ -94,7 +89,7 @@ "kill", "violence", "violent", - # Claude Code / Anthropic 品牌词(避免竞争品牌词触发审核) + # Claude Code template terms "Claude Code", "Claude Opus", "Claude Sonnet", @@ -105,9 +100,7 @@ "noreply@anthropic.com", ] -# 编译成一个大正则,按词长降序,避免短词先吃掉长词。 -# 词边界用显式环视而不是 \b:词表含连字符/@等标点词,\b 语义不可靠; -# 前后若是字母数字下划线则不匹配,skills 这类含关键词的标识符/路径不再被误改。 +# Match longer terms first and preserve identifier boundaries. _PATTERN = re.compile( r"(? re.Pattern: words = [(_ZWSP + "*").join(re.escape(char) for char in word) for word in text.split(" ")] return re.compile(r"(? bool: or "You are Claude Code" in text) -# Codex CLI 会把大量运行时上下文包装进一条 user 消息里;这些不是用户真正提问, -# 里面常含 permissions / sandbox / skills 等说明,也会触发后端审核。 +# Trusted harness wrappers distinguish runtime context from real user requests. _HARNESS_USER_MARKERS = ( "# AGENTS.md instructions", "", "", "", "", - "", # Claude Code 注入的运行时上下文 - "# claudeMd", # Claude Code CLAUDE.md 注入 + "", # Claude Code runtime context + "# claudeMd", # Injected CLAUDE.md context ) _CODEX_SYSTEM_MARKERS = ( @@ -215,15 +207,15 @@ def _is_claude_system(text: str) -> bool: def _zero_width_split(term: str) -> str: - """在词内部插入零宽空格。如 'DoS' -> 'Do\\u200bS'。""" + """Insert a zero-width separator inside a matched term.""" if len(term) <= 1: return term - # 在第 1 个字符后插入即可(足够打断子串匹配,且改动最小) + # One separator after the first character is sufficient. return term[0] + _ZWSP + term[1:] def desensitize_text(text: str) -> str: - """先替换已知客户端模板,再对词表插入零宽空格。""" + """Apply known template substitutions and word-level zero-width separators.""" if not text: return text context = _GIT_STATUS_CONTEXT.search(text) @@ -234,9 +226,9 @@ def desensitize_text(text: str) -> str: def _iter_text_blocks(content): - """遍历 OpenAI content(字符串或 [{type, text}, ...])里的文本块,返回 (容器, key)。""" + """Yield text containers and keys from string or block-based Chat content.""" if isinstance(content, str): - yield content, None # 字符串:调用方直接替换 + yield content, None # The caller replaces plain strings directly. elif isinstance(content, list): for blk in content: if isinstance(blk, dict) and blk.get("type") == "text": @@ -244,7 +236,7 @@ def _iter_text_blocks(content): def _content_to_text(content) -> str: - """把字符串或 content blocks 规整成纯文本,便于识别注入模板。""" + """Extract plain text from content blocks for trusted-template detection.""" text = content if isinstance(content, str) else "" if isinstance(content, list): parts = [] @@ -256,21 +248,21 @@ def _content_to_text(content) -> str: def _looks_like_harness_user_message(content) -> bool: - """判断 user 消息是否其实是 Codex/CLI 注入的上下文,而非用户自然输入。""" + """Identify trusted harness context rather than natural user input.""" text = _content_to_text(content) return any(marker in text for marker in _HARNESS_USER_MARKERS) def _prune_runtime_fragments(role: str, text: str) -> str: - """只处理有可信边界的上下文,不猜测未知段落或裁掉其后的指令。""" + """Compact only context with trusted boundaries, preserving unknown text and trailing instructions.""" return parse_harness_text(text).render() if text else text def _compact_harness_message(role: str, content) -> str | None: - """保留既有 system 压缩策略;user 由结构化提取路径单独处理。""" + """Compact system context independently of structured user-context extraction.""" if isinstance(content, list) and any( not isinstance(block, dict) or block.get("type") != "text" for block in content): - return None # 摘要不能吞掉图片或未知内容块。 + return None # Summaries must not discard images or unknown blocks. text = _content_to_text(content) if not text: return None @@ -298,7 +290,7 @@ def _compact_harness_message(role: str, content) -> str | None: def _desensitize_tool_value(value: Any, strip_metadata: bool = False): - """递归处理 tool 定义,必要时移除高风险描述字段。""" + """Recursively adapt tool metadata and remove optional descriptions when configured.""" if isinstance(value, dict): new_value = {} for key, item in value.items(): @@ -330,11 +322,7 @@ def desensitize_messages(messages: Iterable[dict], roles: tuple[str, ...] = ("system",), desensitize_harness_user: bool = False, compact_harness: bool = False) -> list[dict]: - """对指定角色的消息文本做脱敏,返回新的 messages 列表(不修改原对象)。 - - 默认只处理 system 角色(合规模板集中地)。可选处理 developer, - 以及 Codex 注入的 harness user 上下文;真实用户输入保持原样。 - """ + """Copy and adapt selected roles or trusted harness text without altering real user input.""" out: list[dict] = [] for m in messages: if not isinstance(m, dict): @@ -345,11 +333,11 @@ def desensitize_messages(messages: Iterable[dict], if role == "user" and desensitize_harness_user: should_desensitize = _looks_like_harness_user_message(m.get("content")) - nm = dict(m) # 浅拷贝,不污染调用方 + nm = dict(m) # Preserve caller-owned message objects. content = m.get("content") text = _content_to_text(content) if role == "user" and desensitize_harness_user else "" if _GIT_STATUS_CONTEXT.match(text) and re.search(r"(?m)^Current branch:", text): - # 官方 gitStatus 是上下文,不压缩分支/状态,也不改其中的普通词。 + # Preserve official gitStatus context without rewriting branches or status text. nm["content"] = _replace_git_status_content(content) out.append(nm) continue @@ -394,7 +382,7 @@ def desensitize_body(body: dict, roles: tuple[str, ...] = ("system",), desensitize_tools: bool = False, compact_harness: bool = False, strip_tool_metadata: bool = False) -> dict: - """对请求体里的 messages / tools 做脱敏,返回新的 body(浅拷贝)。""" + """Return a shallow request copy with adapted messages and tool metadata.""" changed = False nb = dict(body) if body.get("messages"): @@ -412,7 +400,7 @@ def desensitize_body(body: dict, roles: tuple[str, ...] = ("system",), # --------------------------------------------------------------------------- -# 自测:python3 desensitize.py +# Standalone template adaptation checks # --------------------------------------------------------------------------- if __name__ == "__main__": @@ -435,13 +423,13 @@ def desensitize_body(body: dict, roles: tuple[str, ...] = ("system",), print("=== messages 脱敏(只处理 system)===") msgs = [ {"role": "system", "content": "Refuse DoS attacks and exploit development."}, - {"role": "user", "content": "explain DoS attacks"}, # 不应被改 + {"role": "user", "content": "explain DoS attacks"}, # Preserve real user text. ] out = desensitize_messages(msgs) for m in out: print(f" [{m['role']}] {m['content']!r}") print() - # 验证:脱敏后 system 改了,user 没改 + # Only system templates may change. assert "\u200b" in out[0]["content"], "system 应被脱敏" assert "\u200b" not in out[1]["content"], "user 不应被脱敏" print("✓ 自测通过:system 被脱敏,user 保持原样") diff --git a/app/harness_context.py b/app/harness_context.py index 671adac..16c71ce 100644 --- a/app/harness_context.py +++ b/app/harness_context.py @@ -1,4 +1,4 @@ -"""按可信闭合边界提取 CLI 上下文;不确定的文本保留为用户内容。""" +"""Extract CLI context within trusted closed boundaries and preserve uncertain text as user content.""" from __future__ import annotations from dataclasses import dataclass, field @@ -83,7 +83,7 @@ def _render_block(text: str, block: _Block) -> str: def parse_harness_text(text: str) -> HarnessText: - """只识别行首/相邻结构标签,保留代码围栏、内联引用和未闭合块。""" + """Recognize structural tags while preserving code fences, inline references and unclosed blocks.""" if not text or not any(marker in text for marker in ("<", "#")): return _literal(text) roots: list[_Block] = [] @@ -151,7 +151,7 @@ def append(block: _Block) -> None: pending_heading = False offset += len(line) - # 未闭合父块没有进入 roots,其内部即使有闭合子块也不会被单独裁剪。 + # Unclosed parents prevent nested blocks from being trimmed independently. if not roots: return _literal(text) parts: list[HarnessPart] = [] diff --git a/app/inbound_limits.py b/app/inbound_limits.py index 6eec137..b0814f5 100644 --- a/app/inbound_limits.py +++ b/app/inbound_limits.py @@ -1,11 +1,5 @@ #!/usr/bin/env python3 -"""inbound_limits.py — 推理端点入站原始字节限量。 - -端点先 await request.json() 再检查处理后的上游请求体,原始入站大小无人管:大 JSON、 -被忽略的顶层字段、将被剥离的图片都在解析前已经占用了内存。本中间件在 ASGI receive 层 -累计原始字节(含 chunked 传输,不看 Content-Length),超限直接 413,不进入 JSON 解析。 -缓冲体随后原样回放给下游,端点行为不变。 -""" +"""Bound raw inference request bytes before JSON parsing and replay accepted bodies unchanged.""" from __future__ import annotations @@ -20,9 +14,7 @@ class ConcurrencyLimitMiddleware: - """推理端点并发上限:占满立即 503,不排队放大聚合内存。 - - 信号量从进入持有到响应体发完(含流式),覆盖整个上游连接生命周期。""" + """Reject excess inference concurrency with HTTP 503 and retain slots through response completion.""" def __init__(self, app, config): self.app = app @@ -48,7 +40,7 @@ async def __call__(self, scope, receive, send): if limit <= 0: return await self.app(scope, receive, send) gate = self._gate() - if gate.locked(): # 无空闲名额:立即失败并给出重试提示 + if gate.locked(): # Reject overload without queueing request bodies. error = {"message": "inference concurrency limit reached, retry later", "type": "rate_limit_error", "code": "concurrency_limit"} payload = {"error": error} @@ -69,7 +61,7 @@ async def __call__(self, scope, receive, send): class InboundBodyLimitMiddleware: - """仅缓冲生成及 token 估算 POST;其他路由不读取请求体。""" + """Buffer only inference and token-estimation POST bodies.""" def __init__(self, app, config): self.app = app @@ -104,7 +96,7 @@ async def __call__(self, scope, receive, send): async def replay(): nonlocal replayed if replayed: - return await receive() # 请求体结束不等于断连,继续监听真实连接。 + return await receive() # Body completion does not imply client disconnect. replayed = True return {"type": "http.request", "body": buffered, "more_body": False} @@ -112,7 +104,7 @@ async def replay(): @staticmethod async def _reject(send, path: str, limit: int): - # 与协议化错误处理一致的外形(middle ware 在路由之前,自行成形) + # Middleware builds protocol-shaped errors before routing. if path.startswith("/v1/messages"): payload = {"type": "error", "error": {"type": "invalid_request_error", "message": f"request body exceeds {limit} bytes", diff --git a/app/model_blocks.py b/app/model_blocks.py index cc1baec..6b4958b 100644 --- a/app/model_blocks.py +++ b/app/model_blocks.py @@ -1,18 +1,5 @@ #!/usr/bin/env python3 -"""model_blocks.py — (后端, 模型) 负缓存:官方已经答复「这里没有这个模型」,就别再往上打。 - -云端 /v3/config 的模型目录与实际能调通的模型并不一致:目录里没写的模型可能可用,目录里 -写着的模型(国际站 www.codebuddy.ai 的 deepseek-v3-2-volc)却固定回 11102 -"model [...] service info not found"。目录不可信,但 11102 是该后端的确定性答复,拿它当 -避让依据比任何目录都准。多站点共存时,缺模型的后端必须被跳过,否则黏性会话会一直落到 -它上面拿到空回复。 - -按后端(PROFILE_ENDPOINTS 里的入口)而不是按站点记账:同属国内站的 codebuddy 与 -workbuddy 是两套后端,模型可用性互不相关,拉黑一个不该牵连另一个。 - -不做永久拉黑:按 TTL 半开,到期后放行一次;再命中就指数退避(上限 max_ttl_s),这样后端 -悄悄上线某模型时能自愈,平时也不会一直白打。实测成功可 clear() 立即解除。 -""" +"""Cache confirmed unsupported backend/model pairs with bounded exponential retry backoff.""" from __future__ import annotations @@ -21,13 +8,13 @@ import threading import time -DEFAULT_TTL_S = 6 * 3600 # 首次避让时长 -MAX_TTL_S = 24 * 3600 # 反复命中后的退避上限:最多一天再试一次 -RETAIN_AFTER_S = 24 * 3600 # 过期记录再留一天,保住 hits 才能继续指数退避 +DEFAULT_TTL_S = 6 * 3600 # Initial model backoff +MAX_TTL_S = 24 * 3600 # Maximum repeated-failure backoff +RETAIN_AFTER_S = 24 * 3600 # Retain expired hits for continued backoff class ModelBlocks: - """线程安全的 {后端入口: {模型: 避让记录}};可选落盘,重启后不必重新踩坑。""" + """Maintain thread-safe per-backend model backoff with optional persistence.""" def __init__(self, path=None, ttl_s: float = DEFAULT_TTL_S, max_ttl_s: float = MAX_TTL_S): self.path = str(path) if path else None @@ -38,7 +25,7 @@ def __init__(self, path=None, ttl_s: float = DEFAULT_TTL_S, max_ttl_s: float = M if self.path: self._load() - # ---- 持久化 ---- + # Persistence def _load(self): try: @@ -74,7 +61,7 @@ def _save_locked(self): os.replace(tmp, self.path) os.chmod(self.path, 0o600) except OSError: - pass # 避让表写失败只影响重启后的精度,绝不影响请求路径 + pass # Persistence failure must not interrupt inference. def _prune_locked(self, now: float): cutoff = now - RETAIN_AFTER_S @@ -85,11 +72,11 @@ def _prune_locked(self, now: float): else: self._data.pop(endpoint, None) - # ---- 写入 ---- + # Updates def note(self, endpoint: str, model: str, code: str = "", msg: str = "", now: float | None = None) -> dict: - """记一次「该后端不提供该模型」;重复命中按 hits 指数退避,返回该条目。""" + """Record unsupported-model failures with hit-based exponential backoff.""" endpoint, model = str(endpoint or ""), str(model or "") if not endpoint or not model: return {} @@ -106,7 +93,7 @@ def note(self, endpoint: str, model: str, code: str = "", msg: str = "", return dict(entry) def clear(self, endpoint: str, model: str, now: float | None = None) -> bool: - """实测又通了就立刻解除,不必等 TTL 到期。""" + """Clear backoff immediately after confirmed model availability.""" now = time.time() if now is None else now with self._lock: rows = self._data.get(str(endpoint)) or {} @@ -117,10 +104,10 @@ def clear(self, endpoint: str, model: str, now: float | None = None) -> bool: self._save_locked() return True - # ---- 读取 ---- + # Queries def until(self, endpoint: str, model: str, now: float | None = None) -> float: - """仍在避让期返回解除时间戳,否则 0.0(到期即半开放行)。""" + """Return an active retry deadline, or zero when probing is allowed.""" now = time.time() if now is None else now with self._lock: row = (self._data.get(str(endpoint)) or {}).get(str(model)) @@ -131,7 +118,7 @@ def blocked(self, endpoint: str, model: str, now: float | None = None) -> bool: return self.until(endpoint, model, now) > 0.0 def view(self, now: float | None = None) -> dict: - """{后端入口: {模型: 解除时间}},只含仍在避让期的条目。""" + """Return active backend/model retry deadlines.""" now = time.time() if now is None else now with self._lock: return {endpoint: {m: float(r.get("until") or 0) for m, r in rows.items() @@ -139,7 +126,7 @@ def view(self, now: float | None = None) -> dict: for endpoint, rows in self._data.items()} def detail(self, now: float | None = None) -> list: - """看板用明细(按解除时间升序),含命中次数与官方错误码。""" + """Return backoff details ordered by retry time, including hits and upstream codes.""" now = time.time() if now is None else now with self._lock: rows = [{"endpoint": endpoint, "model": m, **r} diff --git a/app/observability.py b/app/observability.py index d91bd5a..d5e71b9 100644 --- a/app/observability.py +++ b/app/observability.py @@ -1,8 +1,5 @@ -"""Metadata-only observation for the three public inference POST endpoints. - -Request bodies and headers are never inspected. Response parsing retains two -16 KiB buffers at most, discards oversized SSE lines/JSON, and never changes the ASGI wire. Route -hooks should provide model/account identifiers (never a credential file/token). +"""Observe inference metadata with bounded response parsing; never inspect request bodies or headers. +Preserve the ASGI wire and exclude credential files or tokens from route metadata. """ from __future__ import annotations @@ -26,12 +23,7 @@ def _mapping(value): def normalize_usage(usage): - """Keep provider counters independent; cache/reasoning are not extra total. - -No total is fabricated: Anthropic input/cache semantics differ from OpenAI. -A known zero is retained. `usage_source` identifies observation provenance, not -an inferred billing rate; credit is never derived from token counts. -""" + """Preserve provider usage counters and known zeroes without inventing totals or billing credits.""" usage = _mapping(usage) result = {} aliases = {"input_tokens": ("input_tokens", "prompt_tokens"), @@ -161,7 +153,7 @@ def observe_attempt(stage, **safe_metadata): def observe_failure(code): - """记下失败并返回本次请求的失败序号,供 `observe_recovery(through=…)` 界定撤销范围。""" + """Record a failure and return its sequence number for scoped recovery.""" observation = _current.get() if observation is None: return None @@ -170,23 +162,13 @@ def observe_failure(code): def observe_failure_seq(): - """当前失败序号的快照;没有失败时为 0,调用方原样传给 `observe_recovery` 即可。""" + """Return the current failure sequence, or zero when no failure exists.""" observation = _current.get() return observation.failure_seq if observation is not None else None def observe_recovery(through=None): - """标记「`through` 那一次失败已经被就地重放救回」:请求对下游是完整正常响应。 - - 失败尝试仍留在 `attempts` 里(另加一条 `failover_recovered` 标记),只是不再决定 outcome - —— 否则一次成功的换凭证重放会留下 `outcome=error` + `status_code=200` 这种自相矛盾的 - 审计记录,看板和排障都会把它读成失败。 - - `through` 是重放前那次失败的序号,只有它仍然是最新一次失败时才撤销:序号对不上说明 - 重放之后的响应自己又记了新失败(换到的账号回了内容审核拒绝就是这种),那次失败必须留下, - 否则一个被审核拦截的请求会被持久化成 `outcome=success` 且没有 `error_code`。默认 `None` - 保持旧的「清掉当前失败」语义,给没有序号概念的调用方兜底。 - """ + """Mark a matching failure recovered while retaining attempts and any newer failure.""" observation = _current.get() if observation is None or not observation.failed: return @@ -329,12 +311,7 @@ async def lifespan_receive(): async def observed_receive(): message = await receive() - # Some ASGI servers (uvicorn) synthesise ``http.disconnect`` once the - # response is complete, because ``receive`` has nothing left to yield. - # Starlette's ``listen_for_disconnect`` helper, used for - # ``spec_version < 2.4`` servers, therefore always observes a trailing - # disconnect even for a fully delivered stream. Only treat the event - # as a client abort when the body was still in flight. + # Ignore disconnect events synthesized after the response body is complete. if message.get("type") == "http.disconnect" and not observation.body_finished: nonlocal cancelled cancelled = True diff --git a/app/request_limits.py b/app/request_limits.py index 3057ea0..b8eb2ec 100644 --- a/app/request_limits.py +++ b/app/request_limits.py @@ -80,15 +80,8 @@ def apply_image_policy( max_images: int = 16, policy: str = "truncate", ) -> tuple[dict, dict]: - """Keep the newest N protocol image blocks across the complete request. - - ``field='messages'`` handles Chat/Anthropic history; ``field='input'`` handles - Responses history, including function_call_output.output content arrays. - Repeated URLs count separately. Zero permits no images. ``error`` raises - ImageLimitError before any modification. Invalid limits/policies raise - ValueError. Truncation copies only changed containers; untouched subtrees - remain shared with the original payload. Strings and arbitrary JSON are not - searched, and image data/URLs are never decoded or fetched. + """Keep the newest N protocol images or reject overflow without mutating the input. + Count repeated blocks separately; never decode images or search arbitrary strings. """ if isinstance(max_images, bool) or not isinstance(max_images, int) or max_images < 0: raise ValueError("max_images must be a non-negative integer") diff --git a/app/runtime_management.py b/app/runtime_management.py index 76877d7..70d0ae2 100644 --- a/app/runtime_management.py +++ b/app/runtime_management.py @@ -94,7 +94,7 @@ def install(gateway): from .inbound_limits import ConcurrencyLimitMiddleware, InboundBodyLimitMiddleware app.add_middleware(InboundBodyLimitMiddleware, config=config) app.add_middleware(ConcurrencyLimitMiddleware, config=config) - # 最外层先校验请求头;未鉴权的慢请求不得占用推理名额或进入请求体缓冲。 + # Authenticate headers before consuming inference capacity or buffering request bodies. app.add_middleware(InferenceAuthMiddleware, config=config) install_pages(app, Path(gateway.__file__).resolve().parent / "web" / "dist") diff --git a/app/safe_logging.py b/app/safe_logging.py index c5caa5f..b58e20e 100644 --- a/app/safe_logging.py +++ b/app/safe_logging.py @@ -1,9 +1,5 @@ -"""Bounded, dependency-free log previews; never use these helpers for wire data. - -Only common credential fields/token shapes are recognized, not arbitrary secrets. -Text inspection is limited to the first ``max_bytes`` characters. Structured -previews additionally cap string length, depth, total nodes and container width; -truncated previews need not be valid JSON. No image data is decoded. +"""Create bounded log-only previews that redact common credentials without decoding images. +These helpers do not recognize every possible secret and must not modify wire data. """ import itertools @@ -129,13 +125,7 @@ def _redact_assignments(text): def _image_header(text, start, end): - """Find the earliest image MIME whose parameter chain ends at ``end``. - - Walk semicolon-separated segments backwards, at most twice per character. - A comma always stops the walk, so different base64 terminators cannot cause - overlapping header scans. Invalid/empty parameters stop earlier candidates, - but a valid image prefix inside the final segment is still recognized. - """ + """Find the earliest valid image header before the terminator without overlapping scans.""" candidate = None cursor = end while cursor > start: @@ -166,13 +156,7 @@ def _image_header(text, start, end): def _redact_image_urls(text): - """O(n) time/space in the already bounded preview, including failed URLs. - - Terminator searches advance monotonically. Header scans occupy disjoint - comma-delimited regions, body scans occupy disjoint matches, and output - slices never overlap. In particular, repeated data:image prefixes without - a base64 terminator need no header scan at all. - """ + """Redact image URLs in linear time and space within the bounded preview.""" parts = [] end = 0 for marker in _IMAGE_BASE64.finditer(text): @@ -229,13 +213,7 @@ def finish(self, truncated=False): def sanitize_log_text(text: str, max_bytes: int = 65536) -> str: - """Redact common text credentials and cap the entire UTF-8 result. - - Zero disables body output; negative/non-integer limits raise ValueError / - TypeError. Work is bounded by the supplied limit, not the original text - length. Any inspected-but-incomplete credential is redacted before clipping. - Normal error types, HTTP statuses and request IDs are not token patterns. - """ + """Redact common credentials within the UTF-8 byte budget; zero disables preview output.""" _check_limit(max_bytes) if max_bytes == 0: return "" @@ -246,14 +224,7 @@ def sanitize_log_text(text: str, max_bytes: int = 65536) -> str: def format_log_body(value, max_bytes: int = 65536) -> str: - """Return a non-mutating, bounded JSON-like preview of a JSON value. - - Credential fields are replaced without visiting their values. Large strings - retain only a sanitized prefix and character count; wide/deep containers - retain a few children and a count/limit summary. No whole-body serialization, - deep copy, image decoding, custom repr calls or external state is involved. - The output (including all truncation markers) fits ``max_bytes`` UTF-8 bytes. - """ + """Build a bounded redacted JSON-like preview without mutating or fully serializing the input.""" _check_limit(max_bytes) if max_bytes == 0: return "" diff --git a/app/site_routing.py b/app/site_routing.py index 02d1e1a..26d79e7 100644 --- a/app/site_routing.py +++ b/app/site_routing.py @@ -1,4 +1,4 @@ -"""根据凭据选择固定的地域/产品入口,不把输入域名直接作为请求目标。""" +"""Select fixed product/region endpoints from credential identity, never arbitrary input URLs.""" import base64 import binascii @@ -23,7 +23,7 @@ "www.codebuddy.ai": "intl-cli", "www.workbuddy.ai": "intl-work", } -# CLI 2.149.0 与 WorkBuddy 5.5.2 的 ExternalLinkAuthenticationProvider 使用相同相对路径。 +# Verified CLI and WorkBuddy packages share these relative authentication paths. _REFRESH_PATH = "/v2/plugin/auth/token/refresh" @@ -60,7 +60,7 @@ def _known_host(value: str, *, issuer: bool = False) -> str: def normalize_domain(value: str) -> str: - """仅规范化已知 HTTPS 主机,拒绝用户信息、端口与任意路径。""" + """Normalize known HTTPS hosts while rejecting userinfo, ports and arbitrary paths.""" return _known_host(value) @@ -82,7 +82,7 @@ def _hints(auth): def site_for_auth(auth: dict) -> str: - """JWT 仅作固定站点选择提示,签名与账户权限仍由上游验证。""" + """Use JWT claims only as routing hints; upstream validates signatures and account permissions.""" domain, issuer = _hints(auth) sites = {profile_site(DOMAIN_PROFILES[host]) for host in (domain, issuer) if host} if len(sites) > 1: @@ -93,7 +93,7 @@ def site_for_auth(auth: dict) -> str: def profile_for_auth(auth: dict) -> str: site_for_auth(auth) domain, issuer = _hints(auth) - # copilot.tencent.com 是共享国内入口;品牌信息优先取明确的账号域。 + # Explicit account domains disambiguate the shared domestic copilot endpoint. branded = [host for host in (domain, issuer) if host and host != "copilot.tencent.com"] profiles = {DOMAIN_PROFILES[host] for host in branded} if len(profiles) > 1: diff --git a/app/travel.py b/app/travel.py index e1665d1..7027132 100644 --- a/app/travel.py +++ b/app/travel.py @@ -1,4 +1,4 @@ -"""Domestic Buddy travel: query first, claim arrivals, then dispatch only confirmed idle accounts.""" +"""Query domestic Buddy travel, claim arrivals, and dispatch only confirmed idle accounts.""" import math import random import time @@ -8,7 +8,12 @@ HOST = "https://www.workbuddy.cn" PREFIX = "/activity/growth/buddy/travel/" TIMEOUT = 12.0 -LOCATIONS = {1: "咖啡馆", 2: "商场店铺", 3: "健身房", 4: "古镇客栈"} + + +class _Failure(ValueError): + def __init__(self, kind, http_status=200, code=0): + super().__init__("Travel response was not confirmed") + self.diagnostics = {"error_kind": kind, "http_status": http_status, "code": code} def supported(profile): @@ -23,40 +28,78 @@ def _number(value): return value if type(value) in (int, float) and 0 <= value <= 1e12 and math.isfinite(value) else None -def _request(client, token, operation): - headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"} - if operation == "status": +def _location_id(value): + return value if type(value) is int and 0 < value <= 2**31 - 1 else None + + +def _location_name(value): + if not isinstance(value, str) or not 0 < len(value.strip()) <= 80 or any(ord(c) < 32 for c in value): + return None + return value.strip() + + +def _request(client, token, operation, *, body=None): + headers = {"Authorization": f"Bearer {token}", "Accept": "application/json", "X-Product-Code": "workbuddy"} + if operation in {"status", "config"}: response = client.get(HOST + PREFIX + operation, headers=headers, timeout=TIMEOUT) else: - body = {"location_id": random.choice(tuple(LOCATIONS))} if operation == "depart" else {} - response = client.post(HOST + PREFIX + operation, headers=headers, json=body, timeout=TIMEOUT) - payload = response.json() - if (response.status_code != 200 or not isinstance(payload, dict) - or type(payload.get("code")) is not int or payload["code"] != 0 - or not isinstance(payload.get("data"), dict)): - raise ValueError("Travel response was not confirmed") - return payload["data"] + response = client.post(HOST + PREFIX + operation, headers=headers, json={} if body is None else body, timeout=TIMEOUT) + status = response.status_code + try: + payload = response.json() + except ValueError: + raise _Failure("protocol" if status == 200 else "http", status, None) from None + code = payload.get("code") if isinstance(payload, dict) else None + code = code if type(code) is int and -(2**31) <= code < 2**31 else None + if status != 200: + raise _Failure("http", status, code) + if code is None: + raise _Failure("protocol", status, None) + if code != 0: + raise _Failure("business", status, code) + data = payload.get("data") + # A successful claim may have no business data; reads still require a valid object. + if data is None and operation in {"claim", "depart"}: + return {} + if not isinstance(data, dict): + raise _Failure("protocol", status, code) + return data + + +def _locations(data): + rows = data.get("locations") + if not isinstance(rows, list) or not 0 < len(rows) <= 100: + raise _Failure("protocol") + locations = {} + for row in rows: + identity = _location_id(row.get("id")) if isinstance(row, dict) else None + name = _location_name(row.get("name")) if isinstance(row, dict) else None + if identity is None or name is None or identity in locations: + raise _Failure("protocol") + locations[identity] = name + return locations def _status(data): state = data.get("state") - if state not in {"idle", "traveling", "arrived"}: - raise ValueError("Travel state missing") + if not isinstance(state, str) or state not in {"idle", "traveling", "arrived"}: + raise _Failure("protocol") location = data.get("location") - location_id = location.get("id") if isinstance(location, dict) else None - if type(location_id) is not int or location_id not in LOCATIONS: - location_id = None + location_id = _location_id(location.get("id")) if isinstance(location, dict) else None + location_name = _location_name(location.get("name")) if isinstance(location, dict) else None + arrive_at, server_now = _number(data.get("arrive_at")), _number(data.get("server_now")) + remaining = max(0, arrive_at - server_now) if state == "traveling" and arrive_at is not None and server_now is not None else None return {"state": state, "daily_limit_reached": data.get("daily_limit_reached") if type(data.get("daily_limit_reached")) is bool else None, - "location_id": location_id, "location_name": LOCATIONS.get(location_id), - "reward_credit": _number(data.get("reward_credit")), - "arrive_at": _number(data.get("arrive_at")), "server_now": _number(data.get("server_now"))} + "location_id": location_id, "location_name": location_name if location_id is not None else None, + "reward_credit": _number(data.get("reward_credit")), "arrive_at": arrive_at, + "server_now": server_now, "remaining_seconds": remaining} def perform(token, profile, *, read_only=False, can_write=lambda: True): if not supported(profile): return unavailable() - result = {"ok": False, "state": "unknown", "claimed": False, "departed": False, "stale": False} + result = {"ok": False, "state": "unknown", "claimed": False, "departed": False, "stale": False, "phase": "status"} phase = "status" if not read_only and not can_write(): return {**result, "skipped": True, "message": "设置或凭证已变化,未执行旅行操作"} @@ -86,26 +129,46 @@ def perform(token, profile, *, read_only=False, can_write=lambda: True): result.update(ok=True, skipped=True, message=prefix + "今日派遣已达上限") return result if result["daily_limit_reached"] is not False: - result.update(message=prefix + "派遣上限状态未知,未派出") + result.update(stale=True, message=prefix + "派遣上限状态未知,未派出") + return result + if not can_write(): + result.update(skipped=True, message=prefix + "设置或凭证已变化,未发送派遣请求") return result + phase = "config" + locations = _locations(_request(client, token, "config")) + location_id = random.choice(tuple(locations)) if not can_write(): result.update(skipped=True, message=prefix + "设置或凭证已变化,未发送派遣请求") return result phase = "depart" - receipt = _request(client, token, "depart") - result.update(ok=True, departed=True, state="traveling", stale=False, - arrive_at=_number(receipt.get("arrive_at")), server_now=_number(receipt.get("server_now")), - message=prefix + "Buddy 已派出,余额可另行同步") - location = receipt.get("location") - location_id = location.get("id") if isinstance(location, dict) else None - result.update(location_id=location_id if type(location_id) is int and location_id in LOCATIONS else None) - result["location_name"] = LOCATIONS.get(result["location_id"]) + receipt = _request(client, token, "depart", body={"location_id": location_id}) + # The action is confirmed, but its current state requires a fresh read. + result.update(departed=True, state="unknown", stale=True, daily_limit_reached=None, + location_id=location_id, location_name=locations[location_id], reward_credit=None, + arrive_at=_number(receipt.get("arrive_at")), server_now=None, remaining_seconds=None) + phase = "after_depart" + result.update(_status(_request(client, token, "status"))) + if result["state"] == "idle": + result.update(message=prefix + "派遣已确认,但状态仍为空闲;请先查询核验,勿重复派出") + return result + if result["location_name"] is None: + result["location_name"] = locations.get(result["location_id"]) + result.update(ok=True, stale=False, message=prefix + ( + "Buddy 已到达,待领取" if result["state"] == "arrived" else "Buddy 已派出,余额可另行同步")) return result - except (httpx.HTTPError, ValueError, TypeError): + except (httpx.HTTPError, ValueError, TypeError) as error: messages = {"status": "旅行状态查询失败,未执行写操作", "claim": "领取结果未确认,未派出;下次先查询状态", - "after_claim": "领取已确认,后续状态查询失败,未派出", "depart": "派遣结果未确认;下次先查询状态"} - result.update(ok=False, stale=True, message=messages[phase]) + "after_claim": "领取已确认,后续状态查询失败,未派出", + "config": ("旅行积分已领取;" if result["claimed"] else "") + "地点配置查询失败,未派出", + "depart": ("旅行积分已领取;" if result["claimed"] else "") + "派遣结果未确认;下次先查询状态", + "after_depart": ("旅行积分已领取;" if result["claimed"] else "") + "派遣已确认,后续状态查询失败;勿重复派出"} + diagnostics = error.diagnostics if isinstance(error, _Failure) else { + "error_kind": "timeout" if isinstance(error, httpx.TimeoutException) else "network" + if isinstance(error, httpx.HTTPError) else "protocol", "http_status": None, "code": None} + result.update(ok=False, stale=True, message=messages[phase], **diagnostics) return result + finally: + result["phase"] = phase def remember(ledger, cid, result): diff --git a/app/trial_rewards.py b/app/trial_rewards.py index 0fa641e..ea5f76d 100644 --- a/app/trial_rewards.py +++ b/app/trial_rewards.py @@ -1,4 +1,4 @@ -"""国际 WorkBuddy 一次性体验领取;账号指纹记账,失败至少退避 24 小时。""" +"""Track one-time international WorkBuddy trial claims with account-scoped daily failure backoff.""" from __future__ import annotations @@ -23,7 +23,7 @@ MAX_RESPONSE_BYTES = 64 * 1024 RESPONSE_DEADLINE = 30.0 _MAX_BYTES = 1024 * 1024 -_MAX_ACCOUNTS = 2048 # 满时拒绝新增,不能逐出已经领取的永久记录。 +_MAX_ACCOUNTS = 2048 # Reject new entries rather than evict permanent claim records. _RESULT_FIELDS = {"ok", "already", "code", "status"} _RECORD_FIELDS = _RESULT_FIELDS | {"attempted_at", "finished_at"} @@ -67,7 +67,7 @@ def _trial_headers(headers): def claim_trial(headers: dict) -> dict: - """仅一次同域 POST;保留传入身份头,不跟随重定向、不重试、不输出响应原文。""" + """Send one same-origin POST without redirects, retries or raw response disclosure.""" headers = _trial_headers(headers) status = None started = time.monotonic() @@ -105,7 +105,7 @@ def claim_trial(headers: dict) -> dict: data = envelope.get("data") if data is not None and not isinstance(data, dict): return result - # 不把 code=0 与显式失败(或非布尔成功标志)的矛盾响应当成成功。 + # A zero code cannot override an explicit failure or malformed success flag. for layer in (envelope, data or {}): for flag in ("success", "ok"): if flag in layer and (type(layer[flag]) is not bool or (code == 0 and not layer[flag])): @@ -148,15 +148,15 @@ def __init__(self, result): class TrialLedger: - """锁保护的限量 JSON 账本;读改写均在同一跨进程锁内,不缓存磁盘状态。""" + """Persist a bounded JSON ledger under one cross-process read-modify-write lock.""" def __init__(self, path): path = Path(path) if not path.name or path.name in (".", ".."): raise ValueError("Trial ledger requires a file path") - # 只规范化父目录,不能 resolve 最终文件而跟随其符号链接。 + # Resolve the parent without following a symlink at the final filename. self.path = path.parent.resolve() / path.name - # credential_file_lock 的 name 契约是普通 .info 文件名;不限制账本后缀。 + # Use an .info lock name independently of the ledger filename suffix. self._lock_name = "trial-" + hashlib.sha256(self.path.name.encode()).hexdigest() + ".info" def _lock(self): @@ -219,7 +219,7 @@ def _save(self, accounts): stream.flush() os.fsync(stream.fileno()) os.replace(temporary, self.path) - # 文件 fsync 不保证 rename 在掉电后留存;准许 POST 前也同步目录项。 + # Sync the directory entry before allowing a claim POST. if os.name != "nt": directory_fd = os.open(self.path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) try: @@ -233,7 +233,7 @@ def _save(self, accounts): pass def begin(self, key, now=None) -> bool: - """仅返回 True 才可发送;保存/锁/读取错误向调用方传播。now 为 epoch 秒。""" + """Authorize sending only after durable reservation; propagate storage and lock failures.""" key = _key(key) with self._lock(): current = _timestamp(time.time() if now is None else now) @@ -250,7 +250,7 @@ def begin(self, key, now=None) -> bool: return True def finish(self, key, result, now=None) -> None: - """无 begin 则拒绝;永久状态不被迟到的失败覆盖;忽略额外/秘密字段。""" + """Require a reservation, preserve permanent outcomes and ignore unapproved response fields.""" key, result = _key(key), _safe_result(result) with self._lock(): current = _timestamp(time.time() if now is None else now) @@ -270,7 +270,7 @@ def snapshot(self) -> dict: def summary(self, key) -> dict: - """返回独立的安全快照,不包含指纹、路径、headers 或原始响应。""" + """Return a safe independent snapshot without identities, paths, headers or raw responses.""" key = _key(key) with self._lock(): return dict(self._load().get(key, _empty_record())) diff --git a/app/upstream_io.py b/app/upstream_io.py index 170f37c..0dcab36 100644 --- a/app/upstream_io.py +++ b/app/upstream_io.py @@ -1,4 +1,4 @@ -"""有界连接重试;请求已发送或响应已开始后不重放 POST。""" +"""Bound upstream retries and prohibit replay after a response has opened.""" import asyncio from contextlib import asynccontextmanager @@ -10,7 +10,7 @@ class UpstreamResponseError(Exception): - """保留上游 HTTP 状态与错误体,由端点映射为对应协议。""" + """Preserve upstream HTTP status and error bytes for protocol-specific mapping.""" def __init__(self, status, raw): self.status = status @@ -19,15 +19,11 @@ def __init__(self, status, raw): class UpstreamHTTPError(UpstreamResponseError): - """上游**用 HTTP 状态码**给出的答复(429 / 401 / 503 …)。 - - 与聚合器从 200 响应体里合成的 502(空流、坏 SSE、已开流后断连)区分开:只有前者的请求 - 确定没被上游收下处理,换一个账号重放不会重复计费;后者上游已经回了 200,可能已经计费。 - """ + """Distinguish actual upstream HTTP errors from failures synthesized while collecting a response.""" class ChatSSEAccumulator: - """聚合 Chat SSE,拒绝错误事件、空输出和无结束标记的残流。""" + """Collect Chat SSE and reject error events, empty output and incomplete streams.""" def __init__(self, *, collect=True, max_collect_bytes: int = 0): self.collect = collect @@ -133,7 +129,7 @@ def _consume_chunk(self, chunk): self.filter_detector.feed(delta, choice.get("finish_reason")) def _charge(self, size: int): - """聚合收集总字节预算:超限即失败,不把无界输出缓存在内存里。""" + """Fail when collected bytes exceed the configured memory budget.""" if not self.collect or not self.max_collect_bytes: return self.collected_bytes += size @@ -162,11 +158,11 @@ def result(self): "usage": self.usage, "model": self.model} -ERROR_BODY_LIMIT = 4 * 1024 * 1024 # 错误响应读取上限:错误页不应撑爆内存 +ERROR_BODY_LIMIT = 4 * 1024 * 1024 # Bound error-body memory usage. async def read_bounded_error(response, limit: int = ERROR_BODY_LIMIT) -> bytes: - """错误体有界读取:超限即截断,不再整段 aread。""" + """Read and truncate upstream error bytes within a fixed budget.""" if limit <= 0: return b"" buf = bytearray() @@ -177,25 +173,17 @@ async def read_bounded_error(response, limit: int = ERROR_BODY_LIMIT) -> bytes: return bytes(buf) -# 写下第一个请求体字节之前就失败:上游手里没有任何正文,重放零风险。 +# Connection failures occur before any request body is sent. BODY_NOT_ACCEPTED = (httpx.ConnectError, httpx.ConnectTimeout) -# 写请求体超时:只证明「声明的正文没写完」,证不了上游没收到或没处理已经收到的那部分。 -# 半截正文会怎样是上游的行为,从这一侧观察不到,因此默认不重放(见 `retry_write_timeout`)。 +# Write-timeout replay is opt-in because partial requests may already have been processed. WRITE_TIMEOUT = (httpx.WriteTimeout,) @asynccontextmanager async def open_backend_stream(url, headers, body, *, read_timeout=300, on_retry=None, retry_write_timeout=False): - """只重试一次「上游确定没收下请求体」的失败(建连失败 / 建连超时),其余交给调用方按协议返回。 - - 重试每次新建 `AsyncClient`,即重建 TCP+TLS,通常能换到另一个边缘节点。 - - `retry_write_timeout=True` 把 60s 写超时也算进重放集。跨境长会话最容易撞的正是写超时 - 而不是建连失败,实测某部署的传输失败 100% 是它;但写超时能证明的只有「正文没写完」, - 上游是否已按半截正文动过账,这一侧看不到,所以留给运维显式决定。 - - 响应已经开始之后(`opened` 置位)绝不重放 POST。 + """Retry connection failures once on a fresh client; write timeouts require explicit opt-in. + Never replay after the upstream response opens. """ retryable = BODY_NOT_ACCEPTED + (WRITE_TIMEOUT if retry_write_timeout else ()) timeout = httpx.Timeout(read_timeout, connect=15, write=60, pool=15) diff --git a/converter.py b/converter.py index 025ab87..607a703 100644 --- a/converter.py +++ b/converter.py @@ -1,24 +1,5 @@ #!/usr/bin/env python3 -""" -codebuddy2api — 把 CodeBuddy / WorkBuddy 的订阅暴露成标准 OpenAI 兼容 API。 - -原理(直连后端,原生 function calling): - - 读取本机已登录的 CodeBuddy 桌面端凭据(auth 文件里的 token / uid / enterpriseId)。 - - 直接转发到 CodeBuddy 后端 `https://copilot.tencent.com/v2/chat/completions`。 - 该后端本身就是标准 OpenAI chat/completions 协议(含原生 tools / tool_calls / SSE 流式)。 - - 转换器只做两件事:①注入鉴权 header(Authorization / X-User-Id 等) - ②在本地 /v1/* 与后端 /v2/* 之间做路径映射与透传(含 Anthropic / Chat / Responses 三种协议)。 - - token 过期时自动调 `/v2/plugin/auth/token/refresh` 刷新,并回写 auth 文件。 - - 支持无感登录采集新凭证(/admin/oauth/start + /admin/oauth/poll,OAuth state 轮询), - 种子/导入/无感登录入库统一做站点白名单校验;距上次刷新超 24h 每日保活刷新。 -跨平台:自动定位 auth 目录(macOS / Windows / Linux)。 -依赖:fastapi + uvicorn + httpx(pip install fastapi "uvicorn[standard]" httpx)。 - -用法: - python3 converter.py # 默认 127.0.0.1:8787 - python3 converter.py --port 9000 - python3 converter.py --api-key mysecret # 启用客户端鉴权 -""" +"""Expose CodeBuddy and WorkBuddy through compatible Chat, Responses and Messages APIs.""" from __future__ import annotations @@ -49,7 +30,7 @@ try: from app.desensitize import desensitize_body -except ImportError: # 模块缺失时降级为不脱敏 +except ImportError: # Disable desensitization when its module is unavailable. def desensitize_body(body, roles=("system",), desensitize_harness_user=False, desensitize_tools=False, compact_harness=False, strip_tool_metadata=False): @@ -87,11 +68,11 @@ def desensitize_body(body, roles=("system",), desensitize_harness_user=False, from app.client_profiles import CLI_VERSION, CLI_USER_AGENT, credential_headers, catalog_cache_key, account_key try: from app import credits as credits_mod -except ImportError: # 模块缺失时签到/积分/快过期优先调度不可用 +except ImportError: # Disable credit maintenance when its module is unavailable. credits_mod = None # --------------------------------------------------------------------------- -# 常量 +# Constants # --------------------------------------------------------------------------- APP_VERSION = Path(__file__).with_name("VERSION").read_text(encoding="utf-8").strip() @@ -101,17 +82,17 @@ def desensitize_body(body, roles=("system",), desensitize_harness_user=False, USER_AGENT = CLI_USER_AGENT # --------------------------------------------------------------------------- -# 平台相关:定位 auth 目录 +# Platform-specific credential directories # --------------------------------------------------------------------------- def managed_auth_dir() -> Path: - """自管凭证目录:CODEBUDDY_AUTH_DIR 已设置则用其(容器挂载场景),否则项目下 auth/。""" + """Use CODEBUDDY_AUTH_DIR when set, otherwise the project's auth directory.""" env_dir = os.environ.get("CODEBUDDY_AUTH_DIR") return Path(env_dir) if env_dir else Path(__file__).resolve().parent / "auth" def auth_dirs() -> list[Path]: - """桌面端登录态目录(仅作种子来源,不直接挂进池)。""" + """Locate desktop credentials used only as seed files.""" home = Path.home() plat = sys.platform if plat == "darwin": @@ -124,7 +105,7 @@ def auth_dirs() -> list[Path]: def seed_credentials(): - """把桌面端已登录凭据复制进自管目录(只补缺失文件,不覆盖)。CODEBUDDY_AUTH_DIR 模式跳过。""" + """Seed missing managed credentials without overwriting files; skip custom auth directories.""" if os.environ.get("CODEBUDDY_AUTH_DIR"): return dst_dir = managed_auth_dir() @@ -164,13 +145,13 @@ def seed_credentials(): def find_auth_files() -> list[Path]: - """扫描自管目录下的全部 *.info 凭据文件。""" + """Find managed .info credential files.""" d = managed_auth_dir() return sorted(d.glob("*.info")) if d.is_dir() else [] def _cred_uid(path) -> Optional[str]: - """读取凭据文件的 account.uid(兼容 accounts[0])作为账号去重键;读不出返回 None。""" + """Read the account UID for deduplication, or return None when unavailable.""" try: d = json.loads(Path(path).read_text(encoding="utf-8")) acct = d.get("account") @@ -207,11 +188,11 @@ def find_auth_file() -> Path | None: # --------------------------------------------------------------------------- -# Auth 凭据管理(读 + 自动刷新 + 回写) +# Credential loading, refresh and persistence # --------------------------------------------------------------------------- class CredentialManager: - """从 auth 文件读取凭据;token 临近过期时自动刷新并回写。""" + """Load credentials and refresh expiring tokens with persistence.""" def __init__(self, path: Path): self.path = path @@ -229,7 +210,7 @@ def _file_version(self): return st.st_dev, st.st_ino, st.st_mtime_ns, st.st_size def _load_if_stale(self): - """原子替换或外部更新后重读,并使旧请求持有的凭据代次失效。""" + """Reload changed credentials and invalidate leases held by older requests.""" mt = self._file_version() if self._cached is None or mt != self._mtime: self._cached = self._read_raw() @@ -245,7 +226,7 @@ def _session(self) -> dict: def _is_expired(self) -> bool: s = self._session() expires_at = (s.get("auth") or {}).get("expiresAt") or 0 - # 提前 60s 判定过期 + # Treat tokens as expired 60 seconds early. return time.time() * 1000 >= (expires_at - 60_000) def _refresh_needed(self, margin_s, keepalive_s): @@ -267,7 +248,7 @@ def _refresh(self, margin_s=60, keepalive_s=0): return True def _refresh_locked(self): - """与导入共享文件锁,避免刷新旧会话覆盖刚保存的新登录态。""" + """Share the import lock so token refresh cannot overwrite a newer login.""" s = self._session() auth = s.get("auth") or {} headers = self._build_headers_from(auth, _credential_account(s)) @@ -303,7 +284,7 @@ def _build_headers_from(self, auth: dict, account: dict) -> dict: return credential_headers(auth, account) def get_headers(self) -> dict: - """返回带最新 token 的后端请求 header;必要时先刷新。""" + """Return upstream headers with a current token, refreshing when necessary.""" with self._lock: if self._is_expired(): self._refresh() @@ -311,11 +292,11 @@ def get_headers(self) -> dict: return self._build_headers_from(s.get("auth") or {}, _credential_account(s)) def refresh_if_due(self, margin_s: int, keepalive_s: int) -> bool: - """后台和前台使用同一个刷新临界区与条件复查。""" + """Refresh under the shared foreground/background lock after rechecking expiry.""" return self._refresh(margin_s, keepalive_s) def invalidate(self): - """显式导入后重新读取磁盘,但保留管理器与刷新锁。""" + """Reload imported credentials while retaining the manager and refresh lock.""" with self._lock: self._cached = None self._mtime = None @@ -341,19 +322,19 @@ def summary(self) -> dict: } -STICKY_TTL = 30 * 60 # 会话黏绑闲置解绑秒数 -STICKY_MAX = 512 # 黏绑表容量上限 -CRED_COOLDOWN = 300 # 凭证熔断冷却秒数 -MODEL_COOLDOWN = 600 # 模型级冷却兜底秒数(429 错误体无重置时间时) -MODEL_COOLDOWN_MAX = 86400 # 模型级冷却上限秒数 -MODEL_SITE_BLOCK_S = 6 * 3600 # 官方判定「该后端无此模型」后的首次避让时长 -MODEL_SITE_BLOCK_MAX_S = 24 * 3600 # 反复命中的退避上限:最多一天放行重试一次 -# 后端确定性答复:这个站点根本没有这个模型(重试无意义,只能换后端)。 +STICKY_TTL = 30 * 60 # Idle session binding lifetime in seconds +STICKY_MAX = 512 # Session binding capacity +CRED_COOLDOWN = 300 # Credential cooldown in seconds +MODEL_COOLDOWN = 600 # Model cooldown when a 429 omits reset time +MODEL_COOLDOWN_MAX = 86400 # Maximum model cooldown in seconds +MODEL_SITE_BLOCK_S = 6 * 3600 # Initial unsupported-model backoff +MODEL_SITE_BLOCK_MAX_S = 24 * 3600 # Maximum unsupported-model backoff +# An unsupported model must be routed to a different backend. MODEL_NOT_SERVABLE_CODES = frozenset({"11102"}) _NOT_SERVABLE_MSG = re.compile(r"service info not found|model .{0,80}not (?:found|supported)", re.I) -CRED_REFRESH_MARGIN = 600 # 主动刷新提前量秒数 -CRED_KEEPALIVE_S = 24 * 3600 # 每日保活:距上次刷新超过该值即主动刷新,防 refresh token 闲置过期 -CRED_KEEPALIVE_RETRY_S = 3600 # 保活刷新失败后的重试间隔(与临期刷新失败解耦) +CRED_REFRESH_MARGIN = 600 # Proactive refresh margin in seconds +CRED_KEEPALIVE_S = 24 * 3600 # Maximum idle interval before refreshing +CRED_KEEPALIVE_RETRY_S = 3600 # Keepalive retry interval, independent of expiry retries def _msg_text(m: dict) -> str: @@ -366,7 +347,7 @@ def _msg_text(m: dict) -> str: def session_key(payload: dict) -> str | None: - """会话身份:system + 首条 user 消息哈希。同一会话各轮稳定,跨会话不同。""" + """Derive a stable session key from system instructions and the first user message.""" msgs = payload.get("messages") if not msgs: inp = payload.get("input") # Responses API @@ -390,7 +371,7 @@ def session_key(payload: dict) -> str | None: def _parse_reset_time(raw: bytes) -> float | None: - """从 429 错误体解析配额重置时间(如 '将在 2026-08-29 22:32:31 UTC+8 重置'),返回 epoch 秒。""" + """Parse a quota reset timestamp from a 429 response and return epoch seconds.""" try: text = raw.decode("utf-8", "replace") except Exception: @@ -409,11 +390,7 @@ def _parse_reset_time(raw: bytes) -> float | None: def _parse_not_servable(raw: bytes, status: int): - """识别 11102 之类的「该后端无此模型」答复,返回 (code, msg);不是则 None。 - - 只比对 code/msg 等独立字段:错误体里还带着 requestId,拿整段文本做子串匹配会把 - "11102" 撞在 ID 上,误避让一个本来能用的模型。 - """ + """Recognize unsupported-model errors from code/message fields, excluding incidental IDs.""" if status not in (400, 404) or not raw: return None try: @@ -444,12 +421,12 @@ def _parse_not_servable(raw: bytes, status: int): def _block_model(model: str | None) -> str | None: - """避让表按客户端可见的模型名记账:default-model 只是 intl 侧对 auto 的别名。""" + """Track blocked models by public ID, normalizing the international auto alias.""" return "auto" if model == "default-model" else model def _dynamic_request_headers(skey: str | None) -> dict: - """每次请求生成与官方客户端同构的追踪/请求 ID 头;会话 ID 随 session_key 稳定。""" + """Generate upstream request IDs while keeping session IDs stable.""" rid = secrets.token_hex(16) # X-Request-ID == X-Conversation-Message-ID crid = secrets.token_hex(16) # X-Conversation-Request-ID == X-Root-Request-ID == trace id span, parent = secrets.token_hex(8), secrets.token_hex(8) @@ -474,19 +451,19 @@ def _dynamic_request_headers(skey: str | None) -> dict: class CredentialPool: - """多凭证池:目录发现 + 热加载、黏性会话绑定、健康熔断、主动刷新。""" + """Manage credential discovery, reloads, sticky sessions, cooldowns and refresh.""" def __init__(self, paths: list[Path] | None = None, scan: bool = False, blocks_path: Path | None = None): self._lock = threading.RLock() self._entries: list[dict] = [] # {id, cm, fail_until} self._sticky: "OrderedDict[str, tuple[str, float]]" = OrderedDict() - self._model_fail: dict[tuple[str, str], float] = {} # (cred_id, model) -> 冷却截止 epoch(429 模型级冷却) - # (后端, 模型) -> 避让截止:官方回 11102 说明该后端根本没这个模型,路由自动绕开 + self._model_fail: dict[tuple[str, str], float] = {} # Per-credential/model 429 expiry + # Keep unsupported-model backoff isolated by backend and model. self._blocks = ModelBlocks(blocks_path, ttl_s=MODEL_SITE_BLOCK_S, max_ttl_s=MODEL_SITE_BLOCK_MAX_S) self._rr = {None: 0, "cn": 0, "intl": 0} - self._ledger = None # CreditLedger:pick 时按积分最早过期时间优先调度 - self._scan = scan # True 时 pick 前自动扫描目录增删凭证 + self._ledger = None # Prefer credits expiring sooner. + self._scan = scan # Rescan credentials before selection. self._ignored_duplicates: set[str] = set() self._sync_pending: set[str] = set() self._syncing: set[str] = set() @@ -495,10 +472,10 @@ def __init__(self, paths: list[Path] | None = None, scan: bool = False, self._sync_attempts: dict[str, int] = {} self.reload(paths or []) if self._scan: - self._rescan() # 启动即发现一轮,/health 不等首个请求 + self._rescan() # Discover credentials at startup. def reload(self, paths: list[Path], *, reset: bool = True): - """只在文件实际更新或显式导入时重置认证状态,并通知目录刷新。""" + """Reset authentication only for changed or imported files and schedule catalog refresh.""" with self._lock: by_id = {entry["id"]: entry for entry in self._entries} have_uids = {entry["account_key"]: entry["id"] @@ -514,7 +491,7 @@ def reload(self, paths: list[Path], *, reset: bool = True): try: summary = entry["cm"].summary() except Exception: - continue # 单个损坏文件不能阻止其他凭据被发现 + continue # A damaged file must not block other credentials. generation = entry["cm"]._generation identity = summary["account_key"] changed = reset or generation != entry.get("generation") @@ -572,7 +549,7 @@ def _queue_sync(self, cid): invalidate_model_table() def begin_sync(self, *, all_entries=False): - """消费待刷队列;事件和队列在同一把锁下清除,避免丢失唤醒。""" + """Drain refresh work and clear its wake event under the same lock.""" with self._lock: active = {entry["id"] for entry in self._entries if model_policy.credential_enabled(CONFIG, entry)} self._sync_pending.intersection_update(active) @@ -612,7 +589,7 @@ def sync_wait(self, periodic_delay): return max(0, min(periodic_delay, retry_delay)) def apply_if_current(self, cm, generation, update): - """过期请求的额度或目录结果不能覆盖新登录态的缓存。""" + """Prevent stale requests from replacing a newer credential's cached state.""" with self._lock, cm._lock: entry = next((entry for entry in self._entries if entry["cm"] is cm), None) if entry is None or not model_policy.credential_enabled(CONFIG, entry): @@ -625,7 +602,7 @@ def apply_if_current(self, cm, generation, update): return True def prune(self): - """移除已不存在文件的凭据,并清理其黏绑。""" + """Remove missing credential files and their session bindings.""" with self._lock: self._ignored_duplicates = {p for p in self._ignored_duplicates if os.path.exists(p)} before = len(self._entries) @@ -648,7 +625,7 @@ def prune(self): def find_by_uid(self, uid: str, identity: str | None = None) -> Optional[str]: - """按账号 uid 查池内凭据 id(用于导入冲突检测)。""" + """Find a credential ID by account UID for import conflict checks.""" with self._lock: for e in self._entries: if e.get("uid") == uid and (identity is None or e.get("account_key") == identity): @@ -656,7 +633,7 @@ def find_by_uid(self, uid: str, identity: str | None = None) -> Optional[str]: return None def set_ledger(self, ledger): - """挂接 CreditLedger 后,pick 按积分最早过期时间优先选凭证。""" + """Attach the credit ledger used for expiry-aware credential selection.""" with self._lock: self._ledger = ledger self.reload([Path(entry["id"]) for entry in self._entries], reset=False) @@ -671,12 +648,12 @@ def _bind_entry(self, entry): self._ledger.remove(entry["id"]) def entries(self) -> list[dict]: - """池内凭证条目快照(供签到/积分调度遍历)。""" + """Return credential snapshots for account maintenance.""" with self._lock: return [dict(e) for e in self._entries] def _expiry_rank(self, e: dict) -> tuple: - """快过期优先排序键:(无数据排后, 最早过期时间升序)。""" + """Order by earliest credit expiry, placing unknown balances last.""" exp = self._ledger.soonest_expiry_of(e["id"]) if self._ledger else None return (exp is None, exp or 0.0) def _rescan(self): @@ -700,7 +677,7 @@ def _entry_site(cls, entry): return profile_site(profile) if profile else None def _zero_balance(self, entry, profile) -> bool: - """该账号已确认余额为 0:只能使用目录声明的零倍率模型。""" + """Restrict a confirmed zero-balance account to its advertised zero-rate models.""" balance = (self._ledger.entry(entry["id"]).get("credits") or {}) if self._ledger else {} if not balance: return False @@ -745,19 +722,19 @@ def _eligible(self, entry, model, *, region=None, profile=None, rule=None): usable = _usable_models(models) supported = any(item["id"] == _upstream_model(model, profile) for item in usable) cli_auto = model == "auto" and profile == "cn-cli" and bool(usable) - # 关闭 guard 仅允许单产品的明确表外透传,不能把 A 的已知能力借给 B。 + # Disabling the guard must not borrow another account's model capabilities. declared = any(item["id"] == _upstream_model(model, profile) for item in _models_for_profile(profile, configured, scope="serves")) passthrough = (model != "auto" and not declared and not CONFIG.get("model_guard") and len(configured) == 1) if model and not (supported or cli_auto or passthrough): return False - # 零余额账号退出付费模型轮询,只保留自身目录声明为 x0.00 的模型。 + # Zero-balance accounts may only use their own advertised zero-rate models. return (not model or self._has_credit(entry, profile) or self._model_free(entry, model, profile=profile)) def _model_free(self, entry, model: str | None, *, profile=None) -> bool: - """该凭证的账号目录是否把此模型声明为零计费(x0.00)。""" + """Check whether this account advertises the model as zero-rate.""" if not model or model == "auto": return False profile = profile or self._entry_profile(entry) @@ -773,12 +750,12 @@ def _model_free(self, entry, model: str | None, *, profile=None) -> bool: @classmethod def _entry_endpoint(cls, e: dict) -> str | None: - """该凭证实际打的后端入口:模型可用性按入口判定,同站点不同产品互不牵连。""" + """Return the credential's backend endpoint for isolated model availability checks.""" profile = cls._entry_profile(e) return PROFILE_ENDPOINTS.get(profile) if profile else None def _model_servable(self, e: dict, model: str | None) -> bool: - """该后端未处于「无此模型」避让期;model 为空时不做后端级检查。""" + """Check backend/model backoff, skipping the check when no model is supplied.""" if not model: return True endpoint = self._entry_endpoint(e) @@ -787,7 +764,7 @@ def _model_servable(self, e: dict, model: str | None) -> bool: return time.time() >= self._blocks.until(endpoint, _block_model(model)) def _model_healthy(self, e: dict, model: str | None) -> bool: - """该凭证对指定模型未处于 429 冷却期;model 为空时不做模型级检查。""" + """Check this credential's model-specific 429 cooldown.""" if not model: return True routed_model = _upstream_model(model, self._entry_profile(e)) @@ -803,11 +780,7 @@ def _evict_sticky(self): break def _candidates(self, model: str | None, *, region=None, tried=()) -> list[dict]: - """可用凭证按(零计费优先, 快过期积分优先)排序;同级由调用方轮询。 - - `tried` 是本轮已经打过的凭证管理器:换凭证重放时把它们排除在候选外,避免又选回 - 同一个刚失败的站点。 - """ + """Exclude tried credentials and rank candidates by zero rate and credit expiry.""" tried = set(tried) healthy = [entry for entry in self._entries if entry["cm"] not in tried and self._healthy(entry) @@ -815,19 +788,14 @@ def _candidates(self, model: str | None, *, region=None, tried=()) -> list[dict] and self._model_servable(entry, model)] if not healthy: return [] - # 目录倍率 x0.00 的同名模型排最前,其次快过期积分优先;无数据排最后。 + # Prefer zero-rate models, then earlier credit expiry; unknown balances sort last. healthy.sort(key=lambda entry: (not self._model_free(entry, model), *self._expiry_rank(entry))) return healthy def pick(self, skey: str | None, model: str | None = None, *, region=None, tried=()) -> CredentialManager | None: - """按黏绑选凭证;未绑定/已失效则轮询取健康凭证并绑定。 - - model 非空时跳过该模型 429 冷却中的凭证(黏性会话自动换绑); - 全部凭证对该模型冷却时返回 None,由上层快速失败,不再打上游。 - 候选优先零计费账号;黏绑账号被更好的来源替代时自动重绑。 - """ - self._rescan() # 锁外扫描,reload/prune 各自取锁,避免死锁 + """Select a healthy sticky or round-robin credential, preferring eligible zero-rate accounts.""" + self._rescan() # Reload and prune acquire their own locks. with self._lock: self._evict_sticky() candidates = self._candidates(model, region=region, tried=tried) @@ -854,7 +822,7 @@ def pick(self, skey: str | None, model: str | None = None, *, region=None, def headers_for(self, skey: str | None, model: str | None = None, *, region=None, with_generation=False, tried=()): - """在发送前复核凭据代次和站点,避免重载竞态导致跨站调用。""" + """Recheck the credential generation and site before sending.""" for _ in range(max(1, len(self._entries))): cm = self.pick(skey, model, region=region, tried=tried) if cm is None: @@ -901,9 +869,7 @@ def cooldown(self, cm: CredentialManager, reason: str = "", *, generation=None): def note_status(self, cm: CredentialManager | None, status: int, model: str | None = None, raw: bytes = b"", *, generation=None): - """401/403 熔断整个凭证;429 只冷却 (凭证,模型) 至配额重置时间;11102 按 (后端,模型) 避让。 - - 三者都是局部降级:其他模型、其他凭证、其他后端不受影响。""" + """Apply credential-wide auth cooldowns, per-model 429 cooldowns and backend/model backoff.""" if cm is None: return if status in (401, 403): @@ -930,7 +896,7 @@ def note_status(self, cm: CredentialManager | None, status: int, f"{time.strftime('%m-%d %H:%M:%S', time.localtime(until))} (HTTP 429)") def model_cooldown_until(self, model: str | None, *, region=None) -> float | None: - """该模型在所有健康凭证上都在冷却时返回最早恢复时间;否则 None。""" + """Return the earliest reset only when all healthy credentials are cooling down.""" if not model: return None with self._lock: @@ -946,7 +912,7 @@ def model_cooldown_until(self, model: str | None, *, region=None) -> float | Non return min(untils) def note_not_servable(self, cm, model: str, code: str = "", msg: str = "") -> float: - """记下「这个后端没有这个模型」,返回解除时间;路由会自动绕开该后端。""" + """Block an unsupported backend/model pair and return its retry time.""" if not model: return 0.0 entry = next((e for e in self._entries if e["cm"] is cm), None) @@ -961,7 +927,7 @@ def note_not_servable(self, cm, model: str, code: str = "", msg: str = "") -> fl return until def note_model_ok(self, cm, model: str) -> bool: - """该后端实测认这个模型了:立刻解除避让,不必等 TTL 半开。""" + """Clear model backoff immediately after a successful backend response.""" if not model: return False entry = next((e for e in self._entries if e["cm"] is cm), None) @@ -969,7 +935,7 @@ def note_model_ok(self, cm, model: str) -> bool: return bool(endpoint) and self._blocks.clear(endpoint, _block_model(model)) def model_block_until(self, model: str | None, *, region=None) -> float | None: - """所有潜在后端均有实测避让时返回解除时间;未知目录不等于不支持。""" + """Return a retry time only when every potential backend has confirmed backoff.""" if not model: return None now = time.time() @@ -978,7 +944,7 @@ def model_block_until(self, model: str | None, *, region=None) -> float | None: if self._healthy(e) and (region is None or _in_region(self._entry_profile(e), region))] endpoints = {self._entry_endpoint(e) for e in candidates} - # 已知不支持的后端不抵消避让;未知目录仍是潜在来源,但不获得派发资格。 + # Unknown catalogs remain potential sources, but cannot authorize dispatch. capable = {self._entry_endpoint(e) for e in candidates if (profile := self._entry_profile(e)) and profile in _model_profiles(model, profile_region(profile))} @@ -1004,11 +970,11 @@ def catalog_unknown(entry): return max(untils) def model_blocks_detail(self) -> list: - """避让表明细(看板/排障用)。""" + """Return model backoff details for diagnostics.""" return self._blocks.detail() def refresh_due(self, margin_s: int = CRED_REFRESH_MARGIN, keepalive_s: int = CRED_KEEPALIVE_S): - """按到期与保活条件刷新,失败退避只作用于发起操作时的凭据代次。""" + """Refresh expiring or idle tokens with generation-scoped failure backoff.""" with self._lock: entries = list(self._entries) now = time.time() @@ -1041,7 +1007,7 @@ def refresh_due(self, margin_s: int = CRED_REFRESH_MARGIN, keepalive_s: int = CR _log(f"[cred] {'每日保活刷新' if keepalive_due else '已主动刷新'}并回写: {Path(entry['id']).name}") def remove_file(self, name: str) -> bool: - """删除与刷新共用锁,避免删除后被在途刷新重新创建。""" + """Share the refresh lock so an in-flight refresh cannot recreate a deleted file.""" with self._lock: entry = next((x for x in self._entries if os.path.basename(x["id"]) == name), None) if entry is None: @@ -1082,7 +1048,7 @@ def snapshot(self) -> list[dict]: def _refresher_loop(pool: CredentialPool): - """后台主动刷新:过期前刷新并回写,凭证不因闲置而失效。""" + """Refresh idle credentials before expiry and persist renewed tokens.""" while True: time.sleep(60) try: @@ -1090,8 +1056,8 @@ def _refresher_loop(pool: CredentialPool): except Exception as e: _log(f"[cred] 刷新线程异常: {e}") -CHECKIN_FIRST_DELAY = 30 # 启动后首次签到延迟秒数 -HOUSEKEEP_INTERVAL = 3600 # 签到兜底 + 积分刷新周期秒数 +CHECKIN_FIRST_DELAY = 30 # Initial check-in delay in seconds +HOUSEKEEP_INTERVAL = 3600 # Account maintenance interval in seconds def _bearer_token(headers: dict) -> str: @@ -1178,7 +1144,7 @@ def can_travel(): def _publish_model_cache(): - """只发布账号绑定的产品版本缓存;旧 root/profile 表没有可验证的所有者。""" + """Publish account-scoped versioned catalogs, excluding ownerless shared caches.""" cache = CONFIG.get("model_cache") if cache is not None: pool = CONFIG.get("cred_pool") @@ -1239,7 +1205,7 @@ def publish(): def _sync_usage(pool, entries=None, expected_identity=None): - """历史用量仅在定时/手动维护时同步;每账号独立快照,单账号失败只替换自身数据。""" + """Refresh usage during maintenance with independent per-account snapshots.""" accounts = CONFIG.get("usage_daily_accounts") if not isinstance(accounts, dict): accounts = CONFIG["usage_daily_accounts"] = {} @@ -1278,9 +1244,7 @@ def store(): def _publish_usage_daily(pool, stale=()): - """按当前启用账号的快照重建聚合视图;本轮失败的账号保留历史并列入 stale_accounts。 - - 窗口说明:凭据身份更换后,旧快照最多残留一个同步周期,随后被新账号的快照替换。""" + """Aggregate enabled accounts' usage, retaining failed snapshots with explicit staleness.""" accounts = CONFIG.get("usage_daily_accounts") if not isinstance(accounts, dict): accounts = {} @@ -1308,12 +1272,12 @@ def _publish_usage_daily(pool, stale=()): newest = max(newest, float(snap.get("fetched_at") or 0)) if snap.get("partial"): partial = True - # 本轮失败的启用账号即使没有任何历史快照也必须可见,否则不完整聚合被当成精确值 + # Failed enabled accounts must remain visible even without a prior snapshot. for cred_id in stale: if cred_id in enabled: partial = True stale_out.append(Path(cred_id).name) - # 无成功快照也发布完整性标记;fetched_at=0 使账务继续使用额度差回退。 + # A zero timestamp preserves quota-difference fallback when no usage snapshot exists. for group in groups.values(): group["total_credits"] = round(group["total_credits"], 2) out = {"by_day": by_day, "groups": groups, "total_credits": round(used, 2), @@ -1326,7 +1290,7 @@ def _publish_usage_daily(pool, stale=()): def _housekeep_once(pool: CredentialPool, ledger, *, pending_only=False): - """串行维护并提交同代次结果;新凭据只触发额度和目录查询。""" + """Serialize generation-scoped maintenance; new credentials only trigger balance/catalog reads.""" if credits_mod is None: return with _HOUSEKEEP_LOCK: @@ -1352,7 +1316,7 @@ def _housekeep_once(pool: CredentialPool, ledger, *, pending_only=False): def _housekeeper_loop(pool: CredentialPool, ledger) -> None: - """新凭据事件即时唤醒;失败退避重试,整轮维护仍按小时进行。""" + """Wake for new credentials, retry failed work with backoff, and run hourly maintenance.""" next_full = time.monotonic() + CHECKIN_FIRST_DELAY while True: pool._sync_event.wait(pool.sync_wait(next_full - time.monotonic())) @@ -1366,10 +1330,10 @@ def _housekeeper_loop(pool: CredentialPool, ledger) -> None: # --------------------------------------------------------------------------- -# 模型列表 +# Model inventory # --------------------------------------------------------------------------- -# 兜底模型表:云端 /v3/config 同步失败时使用,取值对齐官方国内账号模型集 +# Fallback models for legacy domestic deployments without a cloud catalog. DEFAULT_MODELS = [ "hy4-preview", "hy4-preview-x", "hy3", "hy3-x", @@ -1379,11 +1343,11 @@ def _housekeeper_loop(pool: CredentialPool, ledger) -> None: "minimax-m3", "minimax-m2.7", "minimax-m2.5", "kimi-k3-1", "kimi-k2.7", "kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "hunyuan-chat", "default", - "auto", # 网关侧调度别名:由后端自行选路 + "auto", # Backend-selected model alias ] -# 后端请求体里出现过的额外字段(透传时若客户端给了就保留) +# Supported optional upstream request fields. PASSTHROUGH_BODY_KEYS = { "model", "messages", "tools", "tool_choice", "temperature", "max_tokens", "max_completion_tokens", "top_p", "stream", @@ -1393,12 +1357,12 @@ def _housekeeper_loop(pool: CredentialPool, ledger) -> None: } # --------------------------------------------------------------------------- -# FastAPI 应用 +# FastAPI application # --------------------------------------------------------------------------- app = FastAPI(title="codebuddy2api", version=APP_VERSION) -# Anthropic 错误类型映射:按 https://platform.claude.com/docs/en/api/errors 成形 +# Anthropic error types: https://platform.claude.com/docs/en/api/errors _ANTHROPIC_ERROR_TYPES = { "auth_error": "authentication_error", "rate_limit_error": "rate_limit_error", @@ -1410,7 +1374,7 @@ def _housekeeper_loop(pool: CredentialPool, ledger) -> None: @app.exception_handler(HTTPException) async def _protocol_http_exception(request: Request, exc: HTTPException): - """推理端点(/v1/*)的错误体按客户端协议成形;/admin 与其他路由保持 FastAPI 默认 detail 包装。""" + """Shape /v1 errors for the client protocol; retain FastAPI defaults elsewhere.""" path = request.url.path if not path.startswith("/v1/"): return await _default_http_exception_handler(request, exc) @@ -1420,54 +1384,53 @@ async def _protocol_http_exception(request: Request, exc: HTTPException): err = {"message": str(detail), "type": "error"} message = str(err.get("message") or "") if path.startswith("/v1/messages"): - # Anthropic:{"type": "error", "error": {...}};上游业务 code 原样保留,客户端仍可识别 content_filter + # Preserve upstream business codes in Anthropic error envelopes. etype = _ANTHROPIC_ERROR_TYPES.get(str(err.get("type") or "")) if exc.status_code == 404: - etype = "not_found_error" # Anthropic 约定:404 恒为 not_found_error + etype = "not_found_error" # Anthropic's required type for HTTP 404. elif etype is None: etype = "api_error" if exc.status_code >= 500 else "invalid_request_error" - error_obj = {**err, "type": etype, "message": message} # code/param/image_count 等结构化字段原样保留 + error_obj = {**err, "type": etype, "message": message} # Retain structured error fields. return JSONResponse({"type": "error", "error": error_obj}, status_code=exc.status_code, headers=exc.headers) - # OpenAI:顶层 error 对象,保留 param/code 等既有字段 + # OpenAI uses a top-level error object. body = {"error": {**err, "message": message}} return JSONResponse(body, status_code=exc.status_code, headers=exc.headers) CONFIG: dict = {"api_key": "", "cred": None, "log_path": None, "ledger": None, - "admin_csrf": True, # 管理 Origin/CSRF 校验,仅允许启动配置关闭 - "models_remote": None, # 国内站云端模型表(缓存或同步结果) - "models_intl": None, # 国际站云端模型表(仅当有国际凭证且有额度时对外暴露) - "model_cache": None, # ModelCatalogCache:按站点分组持久化,TTL 内不打云端 - "model_catalogs": {}, # 仅供展示的产品合并目录(选择器子集) - "account_catalogs": None, # 生产按账号指纹绑定;None 仅兼容无持久缓存的嵌入模式 - # 每项含 models(选择器子集)与 serves(账号根表候选) + "admin_csrf": True, # Startup-only Origin/CSRF policy + "models_remote": None, # Domestic cloud model inventory + "models_intl": None, # Eligible international model inventory + "model_cache": None, # Versioned catalog cache + "model_catalogs": {}, # Display-only merged product catalogs + "account_catalogs": None, # Account-scoped models/serves; None enables legacy embedding "trial_ledger": None, - "model_guard": True, # 表外模型本地拦截,不转发上游 + "model_guard": True, # Reject models absent from authorized catalogs "max_images": 16, "image_policy": "truncate", "max_request_bytes": 32 * 1024 * 1024, "log_body_limit": 65536, "max_inbound_bytes": 64 * 1024 * 1024, "max_collect_bytes": 8 * 1024 * 1024, "max_concurrent": 64, - "failover_max": 0, # 流式失败在第一个字节之前发生时可换凭证重放的最大次数 - "retry_write_timeout": False, # 写请求体超时是否也算「上游没收下请求体」(默认否,见 --retry-write-timeout) - "usage_daily": None, # 官方用量聚合视图(日期×模型 credit),供 billing/usage 出 daily_costs - "usage_daily_accounts": None, # 按账号的用量快照;单账号失败不丢历史 + "failover_max": 0, # Credential failovers allowed before the first response byte + "retry_write_timeout": False, # Opt-in replay after incomplete writes + "usage_daily": None, # Usage aggregated by date and model + "usage_daily_accounts": None, # Independent per-account usage snapshots "credit_price_cny": None, "credit_price_usd": None, "usd_rate": None, - "desensitize": False, "no_compact": False, "keep_tool_metadata": False} # 单价 None=取 credits 模块默认 + "desensitize": False, "no_compact": False, "keep_tool_metadata": False} # None prices use module defaults. -# 无感登录状态机(内存态;重启后未完成的登录需重新发起) +# In-memory OAuth sessions do not survive restarts. _OAUTH = auth_oauth.OAuthManager(user_agent=USER_AGENT) # --------------------------------------------------------------------------- -# 日志(写文件) +# File logging # --------------------------------------------------------------------------- _LOG_LOCK = threading.Lock() -LOG_MAX_BYTES = 50 * 1024 * 1024 # 单日志文件上限(超限轮转;单条巨行可能略超) -LOG_BACKUPS = 2 # 轮转保留份数(log.1、log.2,最老丢弃) +LOG_MAX_BYTES = 50 * 1024 * 1024 # Rotation threshold; a single entry may exceed it. +LOG_BACKUPS = 2 # Retain the two most recent rotated logs. def _log(msg: str): - """写入脱敏有界日志,在同一把锁内检查大小与轮转。""" + """Write bounded redacted logs with rotation under a shared lock.""" audit = CONFIG.get("audit_store") component = re.match(r"\[(cred|credits|models|usage|trial|checkin|housekeeper)\]", msg) if audit is not None and component: @@ -1499,7 +1462,7 @@ def _log(msg: str): stream.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] ==== 日志轮转 ====\n") stream.write(line) except OSError: - pass # 日志失败不应影响主流程 + pass # Logging failures must not interrupt requests. def _log_json(label: str, value): @@ -1531,10 +1494,7 @@ def _check_admin_auth(authorization: Optional[str], x_api_key: Optional[str]): def _cred_for(payload: dict, model: str | None = None, *, region=None, tried=()): - """返回 ((凭据管理器, 代次), headers);无可用凭据返回 503,模型冷却返回 429。 - - `tried` 里的凭证不再入选,供换凭证重放使用(见 `_routed_stream`)。 - """ + """Select a fresh credential lease and headers, excluding tried accounts; report unavailable capacity.""" raw_key = session_key(payload) skey = f"{region}:{raw_key}" if raw_key and region is not None else raw_key skey = model_policy.sticky_scope(CONFIG, skey, model) @@ -1550,7 +1510,7 @@ def _cred_for(payload: dict, model: str | None = None, *, region=None, tried=()) "type": "rate_limit_error"}}) blocked = pool.model_block_until(model, region=region) if blocked: - # 后端已明确回过「无此模型」:给 404 让客户端换模型,别再拿空回复编故事 + # Report confirmed unsupported models as HTTP 404. t = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(blocked)) raise HTTPException(status_code=404, detail={"error": { "message": f"模型 {model} 在当前所有已登录后端均不可用(官方回 service info not found)," @@ -1576,7 +1536,7 @@ def _cred_for(payload: dict, model: str | None = None, *, region=None, tried=()) def _route_chat(payload, body, rid, *, tried=()): - """根据所选账号自动确定后端地域、产品及模型,不改变客户端地址。""" + """Resolve the account's backend, region and model without changing client URLs.""" cred, headers = _cred_for(payload, body.get("model"), tried=tried) profile = profile_for_headers(headers) routed_model = _upstream_model(body.get("model"), profile) @@ -1592,7 +1552,7 @@ def _route_chat(payload, body, rid, *, tried=()): def _note_cred_model_ok(cred, model: str | None) -> None: - """上游 200 即该后端认这个模型:解除 (后端, 模型) 避让。""" + """Clear backend/model backoff after an upstream HTTP 200 response.""" pool = CONFIG.get("cred_pool") if pool is not None and cred is not None and model: cm = cred[0] if isinstance(cred, tuple) else cred @@ -1600,9 +1560,7 @@ def _note_cred_model_ok(cred, model: str | None) -> None: def _note_cred_status(cred, status: int, model: str | None = None, raw: bytes = b""): - """后端 401/403 熔断该凭证;429 按 (凭证,模型) 冷却;11102 按 (后端,模型) 避让。 - - 黏性会话下次请求自动换绑/换后端。""" + """Record generation-scoped authentication, quota and unsupported-model failures.""" pool = CONFIG.get("cred_pool") if pool is not None and cred is not None: cm, generation = cred if isinstance(cred, tuple) else (cred, None) @@ -1610,14 +1568,14 @@ def _note_cred_status(cred, status: int, model: str | None = None, raw: bytes = @app.get("/health") def health(): - """公开存活检查,不访问或暴露凭证池。""" + """Return public liveness without accessing or exposing credentials.""" return {"status": "ok"} @app.get("/admin/credentials") def admin_list_credentials(authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): - """凭证池状态:账号、过期时间、健康度、黏绑会话数。""" + """Return account expiry, health and session-binding metadata.""" _check_admin_auth(authorization, x_api_key) pool = CONFIG.get("cred_pool") if CONFIG.get("management") is not None: @@ -1626,12 +1584,12 @@ def admin_list_credentials(authorization: Optional[str] = Header(default=None), class CredentialConflictError(CredentialFileError): - """同一账号已由其他凭据文件持有。""" + """Signal that another credential file already owns the account.""" def _store_credential(directory: Path, name: str, content: bytes, uid: str, *, replace_identity=True, replace_existing=True) -> Path: - """导入和登录共用的写入临界区,与后台刷新及独立 CLI 协调。""" + """Serialize imports and logins with background refresh and standalone CLI writes.""" pool = CONFIG.get("cred_pool") identity = _credential_identity(json.loads(content)) target = directory.resolve() / name @@ -1664,7 +1622,7 @@ def _store_credential(directory: Path, name: str, content: bytes, uid: str, *, r async def admin_add_credential(request: Request, authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): - """从允许目录导入已校验的凭据,原子更新并热加入池。""" + """Validate and atomically import credentials from the controlled directory.""" _check_admin_auth(authorization, x_api_key) try: body = await request.json() @@ -1683,7 +1641,7 @@ async def admin_add_credential(request: Request, if (not isinstance(cred_data.get("account") or {}, dict) or not isinstance(cred_data["auth"].get("expiresAt", 0), (int, float))): raise CredentialFileError("凭据账号或过期时间格式无效") - # 与上传路径一致:落盘前折叠 token 别名为官方字段名 + # Normalize token aliases before persistence. content = json.dumps(auth_oauth.normalize_cred_data(cred_data), ensure_ascii=False).encode("utf-8") except CredentialFileError: raise HTTPException(status_code=400, detail={"error": {"message": "凭据文件不符合导入要求", "type": "invalid_request_error"}}) from None @@ -1706,7 +1664,7 @@ async def admin_add_credential(request: Request, def admin_del_credential(name: str, authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): - """按文件名移除池内凭据(会删除该 *.info 文件)。""" + """Delete the named .info file and remove its credential from the pool.""" _check_admin_auth(authorization, x_api_key) pool = CONFIG.get("cred_pool") if CONFIG.get("management") is not None: @@ -1718,7 +1676,7 @@ def admin_del_credential(name: str, def _save_oauth_credential(cred: dict) -> Path: - """按产品/账号/租户更新;另一产品同 UID 的文件不可被 OAuth 覆盖。""" + """Update credentials by product, account and tenant without crossing identities.""" uid, error = auth_oauth.validate_cred_data(cred) if error: raise CredentialFileError("凭据格式或站点校验失败") @@ -1749,7 +1707,7 @@ def _save_oauth_credential(cred: dict) -> Path: def admin_oauth_start(site: str = "cn", authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): - """无感登录第一步:申请 OAuth state + 授权链接(浏览器扫码即可,无需桌面端)。site=cn|intl。""" + """Start OAuth and return the browser authorization URL.""" _check_admin_auth(authorization, x_api_key) try: return _OAUTH.start(site=site) @@ -1763,7 +1721,7 @@ def admin_oauth_start(site: str = "cn", def admin_oauth_poll(login_id: str = "", authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): - """无感登录第二步:轮询授权结果;完成后自动入库并热加入凭证池(同 uid 覆盖更新)。""" + """Poll OAuth and persist completed logins with pool reload.""" _check_admin_auth(authorization, x_api_key) try: r = _OAUTH.poll(login_id) @@ -1787,7 +1745,7 @@ def admin_oauth_poll(login_id: str = "", @app.get("/admin/credits") def admin_credits(authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): - """各凭证积分余额/分段过期时间/今日签到状态(CreditLedger 缓存快照)。""" + """Return cached balances, credit expiry segments and check-in status.""" _check_admin_auth(authorization, x_api_key) ledger = CONFIG.get("ledger") return {"credits": ledger.snapshot() if ledger else {}} @@ -1796,7 +1754,7 @@ def admin_credits(authorization: Optional[str] = Header(default=None), @app.get("/admin/model-blocks") def admin_model_blocks(authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): - """(后端, 模型) 避让表:官方回过 service info not found 的组合,到期自动放行重试。""" + """Return unsupported backend/model pairs and their retry deadlines.""" _check_admin_auth(authorization, x_api_key) pool = CONFIG.get("cred_pool") return {"model_blocks": pool.model_blocks_detail() if pool is not None else []} @@ -1805,7 +1763,7 @@ def admin_model_blocks(authorization: Optional[str] = Header(default=None), @app.post("/admin/checkin") def admin_checkin(authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): - """手动签到(按日幂等),余额和用量由独立同步操作更新。""" + """Run daily-idempotent manual check-in without implicitly syncing balances or usage.""" _check_admin_auth(authorization, x_api_key) return _admin_credential_action("checkin") @@ -1831,13 +1789,11 @@ def admin_credential_action(identity: str, action: str, # --------------------------------------------------------------------------- -# OpenAI 兼容余额端点:Credits 按订阅摊算口径折算为美元 +# OpenAI-compatible billing estimates # --------------------------------------------------------------------------- def _billing_totals() -> dict: - """余额快照:国内/国际分组折算(两站积分独立且单价不同)。 - - 已用量优先取官方明细的实际扣减,明细缺失时回退「总额度 − 剩余」。""" + """Convert regional balances independently, using official usage or a quota-difference fallback.""" empty_grp = {"remaining": 0.0, "used_by_quota": 0.0, "soonest_expiry": None} ledger = CONFIG.get("ledger") snap = ledger.snapshot() if ledger else {} @@ -1863,7 +1819,7 @@ def _billing_totals() -> dict: r = float(g.get("remaining") or 0) gd = detail_groups.get(grp) u = (float(gd.get("total_credits") or 0) if (detail and gd) - else float(g.get("used_by_quota") or 0)) # 该组无明细则回退额度差 + else float(g.get("used_by_quota") or 0)) # Fall back to the group's quota difference. unit = per_usd.get(grp, 0.0) remaining += r used += u @@ -1881,7 +1837,7 @@ def _billing_totals() -> dict: "soonest_expiry": agg.get("soonest_expiry"), "price_cny": price_cny, "price_usd": price_usd, "rate": rate, "used_source": "official_usage_detail" if detail else "quota_delta", - # 任一端数据不完整(积分分页到顶 / 用量到顶 / 账号同步失败)时对外可见 + # Expose incomplete pagination or failed account synchronization. "partial": bool(agg.get("partial") or cache.get("partial")), "groups": groups_out, "by_day": cache.get("by_day") or {}} @@ -1889,26 +1845,26 @@ def _billing_totals() -> dict: @app.get("/v1/dashboard/billing/subscription") def billing_subscription(authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): - """OpenAI 订阅端点外观:hard_limit_usd 为总额度折算,故余额 = hard_limit_usd − usage/100。""" + """Expose subscription estimates with balance equal to the hard limit minus usage.""" _check_auth(authorization, x_api_key) t = _billing_totals() limit = t["quota_usd"] return { "object": "billing_subscription", "has_payment_method": True, "canceled": False, "canceled_at": None, "delinquent": None, - # access_until 取池内最早积分过期时间:过期即额度归零的保守表达 + # Conservatively use the earliest credit expiry as the access deadline. "access_until": int(t["soonest_expiry"] or (time.time() + 30 * 86400)), "soft_limit": int(limit * 100), "hard_limit": int(limit * 100), "soft_limit_usd": limit, "hard_limit_usd": limit, "system_hard_limit_usd": limit, "plan": {"title": f"CodeBuddy Credits (CN {t['price_cny']:g} CNY/credit · " f"INTL {t['price_usd']:g} USD/credit)"}, - # 扩展字段:直接给出总计金额与各站拆分,未知字段的客户端会忽略 + # Include total and regional estimates as optional response fields. "codebuddy_credits_remaining": t["remaining"], "codebuddy_credits_used": t["used"], "codebuddy_balance_usd": t["remaining_usd"], "codebuddy_balance_cny": t["remaining_cny"], "codebuddy_sites": t["groups"], - # 余额/用量不完整(分页到顶或账号同步失败)时调用方必须能看到 + # Expose incomplete balance or usage data to callers. "codebuddy_partial": t["partial"], **({"codebuddy_stale_accounts": stale} if (stale := (CONFIG.get("usage_daily") or {}).get("stale_accounts")) else {}), } @@ -1918,11 +1874,10 @@ def billing_subscription(authorization: Optional[str] = Header(default=None), def billing_usage(start_date: Optional[str] = None, end_date: Optional[str] = None, authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): - """OpenAI 用量端点:total_usage 单位美分;daily_costs 为官方明细按天×模型聚合(最近 30 天)。""" + """Return estimated usage in cents, with daily model costs for the last 30 days.""" _check_auth(authorization, x_api_key) t = _billing_totals() - # 逐站逐日按本站单价折算后再合并:两站单价不同,统一平均价会让每天/每模型的金额失真。 - # Σdaily 与 total_usage 都由同一组分站用量算出,恒等关系保持不变。 + # Convert each region at its own rate before combining daily and total usage. cents = {"domestic": t["price_cny"] / t["rate"] * 100, "international": t["price_usd"] * 100} detail = CONFIG.get("usage_daily") or {} priced: dict = {} @@ -1947,9 +1902,9 @@ def billing_usage(start_date: Optional[str] = None, end_date: Optional[str] = No except ValueError: ts = 0 daily.append({"timestamp": ts, "line_items": items}) - if start_date or end_date: # 指定区间时按区间明细求和 + if start_date or end_date: # Sum only the requested interval. total_cents = round(sum(sum(i["cost"] for i in d["line_items"]) for d in daily), 2) - else: # 全量口径与 subscription 构成余额恒等式 + else: # Preserve the subscription balance identity. total_cents = round(t["used_usd"] * 100, 2) out = {"object": "list", "total_usage": total_cents, "daily_costs": daily} if t.get("partial"): @@ -1959,19 +1914,19 @@ def billing_usage(start_date: Optional[str] = None, end_date: Optional[str] = No return out -# 对外模型表:云端 /v3/config 同步结果优先,DEFAULT_MODELS 兜底补充 -_MODEL_TABLE_TTL = 60.0 # 快照复用秒数,避免每请求重建 +# Prefer cloud catalogs over static fallback models. +_MODEL_TABLE_TTL = 60.0 # Model snapshot lifetime in seconds _model_table_cache: dict = {} def invalidate_model_table() -> None: - """模型表变更后作废快照缓存。""" + """Invalidate the public model snapshot after catalog changes.""" global _model_table_cache _model_table_cache = {} def _catalog_for(profile: str, scope: str = "models"): - """该 profile 的模型目录;scope 含义见 _account_scope。""" + """Return a profile catalog within the requested account scope.""" accounts = CONFIG.get("account_catalogs") if accounts is not None or CONFIG.get("model_cache") is not None: pool = CONFIG.get("cred_pool") @@ -2017,19 +1972,19 @@ def _usable_models(models): def _account_scope(account: dict, scope: str = "models") -> list[dict] | None: - """models 取选择器;serves 合并根表候选,同名保留选择器元数据。""" + """Merge root candidates into selector models while retaining selector metadata.""" picker = account.get("models") if scope == "models" or picker is None: return picker seen = {item.get("id") for item in picker} - # 子集优先:同名条目保留 agent 里的那份元数据,根表只负责补名字。 + # Root entries supply missing names without replacing selector metadata. return picker + [item for item in account.get("serves") or [] if item.get("id") not in seen] def _models_for_profile(profile: str, configured=None, *, scope: str = "models") -> list[dict]: models = _catalog_for(profile, scope) if models is None: - # 只有旧式国内 CLI 单产品部署保留静态兜底,不把未知表借给 WorkBuddy。 + # Static fallback is limited to legacy domestic CLI deployments. configured = _configured_profiles(profile_region(profile)) if configured is None else configured return ([{"id": name, "supportsToolCall": True} for name in DEFAULT_MODELS] if CONFIG.get("model_cache") is None and CONFIG.get("account_catalogs") is None @@ -2042,7 +1997,7 @@ def _upstream_model(model: str | None, profile: str) -> str | None: def _free_multiplier(credits) -> bool: - """目录 credits 倍率是否为 0(官方对当前账号声明的零计费标记)。""" + """Check whether the account's catalog explicitly declares a zero credit rate.""" if not isinstance(credits, str): return False match = re.fullmatch(r"x\s*0(?:\.0+)?\s*(?:credits?)?", credits.strip(), re.IGNORECASE) @@ -2050,7 +2005,7 @@ def _free_multiplier(credits) -> bool: def _multiplier_value(credits): - """解析官方倍率字符串为数值;无倍率或格式未知返回 None。""" + """Parse an official model rate, returning None for missing or unknown formats.""" if not isinstance(credits, str): return None match = re.fullmatch(r"x\s*([0-9]+(?:\.[0-9]+)?)\s*(?:credits?)?", credits.strip(), re.IGNORECASE) @@ -2063,7 +2018,7 @@ def _multiplier_value(credits): def _model_free(models, model: str | None, profile: str) -> bool: - """该账号目录是否把此模型声明为 x0.00;名单里没有该模型时不算免费。""" + """Require an explicit zero-rate entry in this account's model catalog.""" if not model: return False routed = _upstream_model(model, profile) @@ -2080,7 +2035,7 @@ def _model_profiles(model: str | None, region: str | None = None, configured=Non if any(item["id"] == _upstream_model(model, profile) for item in _models_for_profile(profile, configured, scope="serves"))} if model == "auto" and region == "cn": - # WorkBuddy 有真实 Auto 时固定用它;只有 CLI 的旧部署保留 auto,不混轮询两种默认策略。 + # WorkBuddy uses its advertised Auto; legacy CLI defaults remain separate. if "cn-work" in configured and "cn-work" in supported: return {"cn-work"} if "cn-cli" in configured and _models_for_profile("cn-cli", configured): @@ -2115,7 +2070,7 @@ def _profile_has_credits(profile: str) -> bool: def current_models(region: str | None = None) -> list[str]: - """合并账号可用的模型;客户端不用按地域改变请求地址。""" + """Merge models eligible for current accounts without region-specific client URLs.""" pool = CONFIG.get("cred_pool") if pool is not None: pool._rescan() @@ -2131,7 +2086,7 @@ def current_models(region: str | None = None) -> list[str]: profile = entry.get("profile") if not profile or not _in_region(profile, region): continue - # 零余额账号退出付费模型:不发布付费项,仅保留自身声明的零倍率模型。 + # Publish only advertised zero-rate models for empty accounts. zero = pool._zero_balance(entry, profile) if not zero and not pool._has_credit(entry, profile): continue @@ -2148,7 +2103,7 @@ def current_models(region: str | None = None) -> list[str]: for profile in sorted(configured): entries = ([entry for entry in pool.entries() if pool._entry_profile(entry) == profile] if pool is not None else []) - # 该产品全部账号余额归零时,只发布自身目录声明的零倍率模型。 + # Empty products may publish only their advertised zero-rate models. zero_only = bool(entries) and all(pool._zero_balance(entry, profile) for entry in entries) if _profile_has_credits(profile) or zero_only: models = _models_for_profile(profile, configured) @@ -2162,7 +2117,7 @@ def current_models(region: str | None = None) -> list[str]: def current_model_details(region: str | None = None) -> list[dict]: - """模型表(含倍率):{id, credits, credits_by_profile};credits 取各来源最小值。""" + """Return public model rates with per-profile values and the minimum eligible rate.""" pool = CONFIG.get("cred_pool") if pool is not None: pool._rescan() @@ -2177,7 +2132,7 @@ def record(profile: str, item: dict, *, zero: bool) -> None: if name not in details: return if zero and not _free_multiplier(item.get("credits")): - return # 零余额账号不参与付费模型的倍率展示 + return # Empty accounts cannot supply paid model rates. value = _multiplier_value(item.get("credits")) if value is None: return @@ -2214,8 +2169,7 @@ def record(profile: str, item: dict, *, zero: bool) -> None: def _client_wants_stream(payload: dict) -> bool: - """stream 缺省为 False(OpenAI/Anthropic 协议默认非流式);非布尔类型显式 400。 - 目标客户端(Codex CLI / Claude Code)均显式发送 stream:true,不受影响。""" + """Default stream to false and reject non-Boolean values.""" value = payload.get("stream", False) if not isinstance(value, bool): raise HTTPException(status_code=400, detail={"error": { @@ -2224,7 +2178,7 @@ def _client_wants_stream(payload: dict) -> bool: def _prepare_payload(payload, field="messages") -> dict: - """先处理整次请求的图片,再进行适配、日志记录和凭证选取。""" + """Apply request-wide image limits before adaptation, logging and credential selection.""" if not isinstance(payload, dict): raise HTTPException(status_code=400, detail={"error": { "message": "请求体必须是 JSON 对象", "type": "invalid_request_error"}}) @@ -2242,7 +2196,7 @@ def _prepare_payload(payload, field="messages") -> dict: def _normalize_tool_choice(body): - """上游只接收字符串;点名调用等价于仅提供该工具并设 required。""" + """Map named tool choice to a single required tool for string-only upstream selection.""" choice = body.get("tool_choice") if not isinstance(choice, dict): return @@ -2258,7 +2212,7 @@ def _normalize_tool_choice(body): def _prepare_chat_body(body: dict, *, region=None) -> dict: - """统一模型、首条 system、后端流式参数、脱敏与体积预算。""" + """Normalize models, system messages, streaming, desensitization and payload budgets.""" body = dict(body) body["model"] = model_policy.resolve(CONFIG, body.get("model", "auto")) guard_model(body["model"], region=region, resolved=True) @@ -2266,11 +2220,7 @@ def _prepare_chat_body(body: dict, *, region=None) -> dict: if not isinstance(messages, list) or not messages or any(not isinstance(message, dict) for message in messages): raise HTTPException(status_code=400, detail={"error": { "message": "messages must be a non-empty array of objects", "type": "invalid_request_error"}}) - # Upstream compatibility: gateways such as copilot.tencent.com and - # workbuddy.ai reject the "developer" role with 11128 "Illegal API - # invocation from an unapproved channel"; official clients only send - # "system". Normalize the role, keep the content, and do not mutate the - # caller's message dicts. + # Upstreams reject developer roles; copy them as system messages without changing content. messages = [ dict(message, role="system") if message.get("role") == "developer" else message for message in messages @@ -2292,7 +2242,7 @@ def _prepare_chat_body(body: dict, *, region=None) -> dict: def _guard_request_size(body: dict) -> int: - """校验并返回上游 JSON 字节数,不截断文本或工具参数。""" + """Validate and measure upstream JSON bytes without truncating text or tool arguments.""" size = 0 limit = CONFIG["max_request_bytes"] try: @@ -2310,7 +2260,7 @@ def _guard_request_size(body: dict) -> int: def guard_model(name: str, *, region=None, resolved=False) -> None: - """表外模型本地拒绝;自动路由只考虑各账号明确支持的模型。""" + """Reject unauthorized models and route only through accounts with confirmed support.""" if not isinstance(name, str) or not name.strip(): raise HTTPException(status_code=400, detail={"error": { "message": "model must be a non-empty string", "type": "invalid_request_error", "param": "model"}}) @@ -2348,7 +2298,7 @@ async def chat_completions(request: Request, authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): _check_auth(authorization, x_api_key) - # 凭证在构造后端 headers 时按会话黏绑选取 + # Select sticky credentials while building upstream headers. try: payload = await request.json() @@ -2356,7 +2306,7 @@ async def chat_completions(request: Request, raise HTTPException(status_code=400, detail={"error": {"message": f"bad json: {e}", "type": "invalid_request_error"}}) payload = _prepare_payload(payload) - # 聚合路径无法保持多候选独立:n 缺省或恰为 1,否则显式拒绝而非拼接答案 + # Aggregation supports exactly one completion, so reject other n values. n_value = payload.get("n") if n_value is not None and not (isinstance(n_value, int) and not isinstance(n_value, bool) and n_value == 1): raise HTTPException(status_code=400, detail={"error": { @@ -2366,12 +2316,12 @@ async def chat_completions(request: Request, if not messages: raise HTTPException(status_code=400, detail={"error": {"message": "messages is required", "type": "invalid_request_error"}}) - # 构造后端 body:只透传已知的合法字段 + # Forward only supported request fields. client_wants_stream = _client_wants_stream(payload) body = {k: payload[k] for k in PASSTHROUGH_BODY_KEYS if k in payload} body = _prepare_chat_body(body) - # 日志:请求摘要 + # Record request metadata. model_name = payload.get("model", "auto") tool_names = [t.get("function", {}).get("name") for t in (payload.get("tools") or []) if isinstance(t, dict)] @@ -2380,8 +2330,8 @@ async def chat_completions(request: Request, _log(f"[{rid}] ▶ REQUEST {model_name} | stream={client_wants_stream} | msgs={len(messages)}" + (f" | tools={tool_names}" if tool_names else "") + (f" | last_user={_truncate(last_user, 60)!r}" if last_user else "")) - # 凭据选择/到期刷新持线程锁与文件锁并可能同步访问网络:放到受限线程池,不占事件循环 - prepared = body # 改写前的规范请求体,换凭证重放按它判定绑定 + # Credential selection and refresh perform blocking file and network I/O. + prepared = body # Keep canonical input for failover policy checks. body, cred, headers, url = await run_in_threadpool(_route_chat, payload, body, rid) _log_json(f"[{rid}] REQUEST BODY (发往后端,预览)", body) t0 = time.time() @@ -2392,11 +2342,11 @@ def attempt(routed, cred, headers, url): return _routed_stream(payload, prepared, model_name, rid, t0, attempt, body, cred, headers, url) - # 非流式:后端只支持流式,这里把后端 SSE 聚合成单个 chat.completion 响应 + # Aggregate upstream SSE for non-streaming clients. async def fetch(routed, cred, headers, url): return await _fetch_checked_chat(url, headers, routed, model_name, rid, cred, filter_retry=True) - # 断连监听包在重放外层:换凭证的那几枪同样属于「还没给下游一个字节」的窗口 + # Watch for disconnects across the entire failover sequence. try: collected = await await_or_hangup( _routed_fetch(payload, prepared, model_name, rid, t0, fetch, @@ -2410,7 +2360,7 @@ async def fetch(routed, cred, headers, url): def _last_user_text(messages: list) -> str: - """取最后一条 user 消息的文本,用于日志预览。""" + """Extract the latest user text for bounded log previews.""" for m in reversed(messages): if m.get("role") != "user": continue @@ -2425,7 +2375,7 @@ def _last_user_text(messages: list) -> str: def _log_finish(model_name: str, t0: float, result: dict, rid: str = ""): - """记录完成请求的耗时、结束原因、用量、工具调用和有界响应预览。""" + """Log request timing, finish reason, usage, tools and bounded response previews.""" elapsed = time.time() - t0 prefix = f"[{rid}] " if rid else "" choice = (result.get("choices") or [{}])[0] @@ -2434,7 +2384,7 @@ def _log_finish(model_name: str, t0: float, result: dict, rid: str = ""): detector = ContentFilterDetector() detector.feed(msg, finish) if detector.detected: - return # 审核只记录分类,不把可能回显输入的正文/思考写入预览。 + return # Filtered responses must not expose echoed content in previews. tcs = msg.get("tool_calls") or [] usage = result.get("usage") or {} tc_names = [t.get("function", {}).get("name") for t in tcs] @@ -2465,7 +2415,7 @@ def _completion_to_merged(result: dict) -> dict: async def _collect_stream(response: httpx.Response, *, accumulator=None) -> dict: - """使用公共聚合器保留正文、思考和工具调用,并验证流完整性。""" + """Collect content, reasoning and tools while validating stream completion.""" accumulator = accumulator if accumulator is not None else ChatSSEAccumulator() async for line in response.aiter_lines(): accumulator.feed_line(line) @@ -2489,7 +2439,7 @@ def _tool_choice_satisfied(tool_calls, body): def _tool_calls_healthy(tool_calls, body: dict | None = None) -> bool: - """校验聚合后的 tool_calls:name 属于已声明工具,arguments 是含 JSON 对象的字符串。""" + """Validate tool names and require arguments to encode a JSON object.""" if not tool_calls: return True names = {tool.get("function", {}).get("name") for tool in (body or {}).get("tools", []) @@ -2501,8 +2451,7 @@ def _tool_calls_healthy(tool_calls, body: dict | None = None) -> bool: name = fn.get("name") or "" if not name.strip() or not (fn.get("arguments") or "").strip(): return False - # 解析成功不等于正确:null/[]/42/"text" 都不是合法工具参数 - # 名称核对只在请求确实声明了工具时进行;未声明工具的请求收到的工具调用交由客户端裁决 + # Valid JSON must still be an object; check names only when tools were declared. if names and name not in names: return False try: @@ -2515,7 +2464,7 @@ def _tool_calls_healthy(tool_calls, body: dict | None = None) -> bool: def _merge_chat_sse_text(text: str) -> dict: - """文本路径与异步流路径使用同一聚合器。""" + """Use the shared SSE accumulator for collected text responses.""" accumulator = ChatSSEAccumulator(max_collect_bytes=CONFIG.get("max_collect_bytes", 0)) for line in text.splitlines(): accumulator.feed_line(line) @@ -2523,13 +2472,13 @@ def _merge_chat_sse_text(text: str) -> dict: def _chat_result_to_sse_lines(m: dict) -> list[str]: - """把聚合结果伪流式化为标准 OpenAI SSE 文本行(chat 直接转发,anthropic 喂转换器);reasoning 先于正文重放。""" + """Replay collected Chat output as SSE, emitting reasoning before content.""" content = m.get("content") or "" reasoning = m.get("reasoning_content") or "" tcs = m.get("tool_calls") or [] finish = m.get("finish_reason") or "stop" model = m.get("model") - # 一次响应的所有 chunk 共享稳定的 completion 标识,严格客户端可按契约关联事件 + # All chunks in a completion share one stable identifier. completion_id = "chatcmpl-" + os.urandom(12).hex() created = int(time.time()) @@ -2580,12 +2529,7 @@ def _public_sse_line(line, model_name): async def _backend_stream(url, headers, body, *, timeout=300, rid="", model_name="?"): started, opened = time.monotonic(), False def retry(error): - """同一连接上的底层重放:换凭证那条日志到不了这里,风险标记得自己带上。 - - 建连失败/建连超时上游手里没有正文,标出来反而是噪音;写超时按 opt-in 参与重放时, - 「正文没写完」证不了上游没动过账,所以必须和换凭证重放同一口径标注(评审 P2)。 - `stage` 分开记,审计里能一眼看出是哪一类重放。 - """ + """Record connection retries and flag possible billing after write timeouts.""" timeout_on_write = isinstance(error, WRITE_TIMEOUT_TRANSPORT) observe_attempt("write_timeout_retry" if timeout_on_write else "connect_retry", error_code=type(error).__name__, @@ -2621,7 +2565,7 @@ def _check_upstream_status(status, raw, cred, model): def _upstream_failure(error, model_name, t0, rid): - """统一失败日志与错误体,协议包装由各端点负责。""" + """Normalize failure logs and payloads before endpoint-specific error wrapping.""" if isinstance(error, UpstreamResponseError): status, raw = error.status, error.raw category = f"HTTP {status}" @@ -2640,18 +2584,14 @@ def _upstream_failure(error, model_name, t0, rid): def _hungup_response(rid, model_name, t0): - """下游已经不听了:安静地给一个不成体的响应,不编造结果。 - - 204 只是「ASGI 调用必须交付一个响应」的形式(Starlette 对 204 不写 content-length); - 审计由 `AuditMiddleware` 判定为 cancelled。 - """ + """Finish a disconnected ASGI request with an empty 204; auditing records cancellation.""" elapsed = time.time() - t0 if t0 else 0 _log(f"[{rid}] ✂ 下游已断连,取消这次聚合 | {model_name} | {elapsed:.1f}s") return Response(status_code=204) async def _fetch_checked_chat(url, headers, body, model_name, rid, cred=None, *, filter_retry=False): - """统一聚合与校验;非流式纯审核拒绝最多压缩兜底一次,网络错误不重放。""" + """Collect and validate replies with bounded tool repair and one eligible filter fallback.""" tool_attempt = 0 filter_retried = False while True: @@ -2695,24 +2635,24 @@ async def _fetch_checked_chat(url, headers, body, model_name, rid, cred=None, *, if _tool_calls_healthy(calls, body) and (detector.detected or _tool_choice_satisfied(calls, body)): observe_usage(result.get("usage") or {}) return result - # 审核拒绝不是工具损坏,不因 required 工具选择而重复生成。 + # Content filtering must not trigger tool-repair regeneration. budget = CONFIG.get("tool_call_max_retry", _TOOL_CALL_MAX_RETRY) if detector.detected or not body.get("tools") or tool_attempt >= budget: if not detector.detected and body.get("tools"): - # 耗尽预算的末次生成同样消耗额度:记入 attempts 再报错 + # Account for the final failed generation before returning an error. exhausted = result.get("usage") or {} observe_attempt("tool_args_exhausted", attempt=tool_attempt, max_attempts=budget, total_tokens=exhausted.get("total_tokens")) raise UpstreamResponseError(502, b"Invalid upstream tool_calls after retries") tool_attempt += 1 - # 被丢弃的这次生成也是真实消耗:连同序号记进 attempts,账务不再只看见最后一次 + # Discarded generations still consume credits and belong in the audit trail. discarded = result.get("usage") or {} observe_attempt("tool_args_retry", attempt=tool_attempt, max_attempts=budget, total_tokens=discarded.get("total_tokens")) _log(f"[{rid}] tool_calls 损坏,重试 {tool_attempt}/{budget} | {model_name}") async def _chat_sse_lines(url, headers, body, model_name, t0, rid, cred=None, *, aggregate=False): - """提供公共 Chat SSE 行流;流式请求不做审核重试,正文检测缓冲有界。""" + """Yield validated Chat SSE with bounded filter detection and no streaming filter retries.""" if aggregate: result = await _fetch_checked_chat(url, headers, body, model_name, rid, cred) for line in _chat_result_to_sse_lines(_completion_to_merged(result)): @@ -2731,7 +2671,7 @@ async def _chat_sse_lines(url, headers, body, model_name, t0, rid, cred=None, *, async for line in response.aiter_lines(): tracker.feed_line(line) if tracker.done or tracker.finish_reason: - tracker.result() # 先验证,再向客户端发出成功终止帧。 + tracker.result() # Validate completion before emitting a success marker. remaining = budget - len(preview) if remaining > 0: preview.extend((line[:remaining] + "\n").encode("utf-8")[:remaining]) @@ -2758,7 +2698,7 @@ async def _stream_upstream(url: str, headers: dict, body: dict, yield (_public_sse_line(line, model_name) + "\n").encode("utf-8") except (httpx.HTTPError, UpstreamResponseError) as error: if not sent: - raise # 一个字节都没发出去:交给端点还原成真实状态码,别把失败写成 200 + raise # Preserve the HTTP error while no response bytes have been sent. status, raw = _upstream_failure(error, model_name, t0, rid) yield _err_event(raw, status) @@ -2772,25 +2712,22 @@ def _err_event(msg: bytes, status: int) -> bytes: def _cred_manager(cred): - """凭证统一是 (管理器, 代次);兼容裸管理器(`_cred_for` 的单凭证回退分支)。""" + """Unwrap a credential lease, retaining support for a standalone manager.""" return cred[0] if isinstance(cred, tuple) else cred -# 可换凭证重放的上游 HTTP 状态:限流、认证、网关抖动。400/404/413 是确定性拒绝,换账号 -# 也一样,不在其中。 +# Retryable upstream auth, quota and gateway responses; deterministic request errors are excluded. FAILOVER_CODES = frozenset({401, 403, 429, 502, 503, 504}) -# 上游手里没有任何正文的传输失败(建连阶段就失败),重放零风险。 +# Connection failures occur before any request body is sent. REPLAYABLE_TRANSPORT = (httpx.ConnectError, httpx.ConnectTimeout) -# 写超时:正文没写完是确定的,上游有没有按已收到的半截正文动过账则观察不到, -# 因此只有 `--retry-write-timeout` 打开后才参与重放(两层重放都受这个开关约束)。 +# Write-timeout replay requires explicit opt-in because partial requests may already be billed. WRITE_TIMEOUT_TRANSPORT = (httpx.WriteTimeout,) -# 上游网关在拿到后端答复之前就把错误抛回来的状态:后端那侧可能已经处理完并计费。仍然重放 -# (理由见 _failover_safe),但要如实标出来,便于事后拿官方账本核对。 +# Gateway timeouts may follow billable upstream work and need an explicit cost warning. POSSIBLY_CHARGED_CODES = frozenset({502, 504}) def _replay_cost_note(error) -> str: - """重放日志里的代价标记:只给「可能已经付费」的那一类加,别把 429 也说成有风险。""" + """Label replays that may duplicate already billed work.""" if isinstance(error, UpstreamHTTPError) and error.status in POSSIBLY_CHARGED_CODES: return " | 上游可能已处理该请求" if isinstance(error, WRITE_TIMEOUT_TRANSPORT): @@ -2799,21 +2736,7 @@ def _replay_cost_note(error) -> str: def _failover_safe(error, raw=b"") -> bool: - """这次失败能不能换账号重放:只认「上游没收下请求体」和「上游用 HTTP 状态码拒绝」。 - - 三条硬边界:内容审核拒绝不切号重放(那是模型的真实答复,换账号只会再撞一次同一堵墙, - 还白烧一次额度);聚合器从 200 响应体里合成的 502(空流、坏 SSE、已开流后断连)不重放, - 因为上游已经回了 200、可能已经计费,而且那时状态码还收得回来;写超时默认也不重放, - 要显式 `--retry-write-timeout`。真正的重放窗口由 `open_backend_stream` 的 `opened` 标记 - 与 `_preflight_stream` 守住。 - - 为什么 502/504 这类「上游可能已经处理并计费」的失败仍然重放:这类失败对下游是**彻底 - 失败**——连响应头都没有,更没有可用的结果。不重放并不能把已经花掉的额度退回来,只是把 - 一次已经付出的请求换成一段静默断掉的会话。所以取舍不是「省钱 vs 花钱」,而是「花一次已 - 付的学费 vs 花两次并给出结果」。代价因此被严格夹住:默认 `--failover-max=0` 完全关闭, - 开启后每请求最多多打 N 次,且这类重放在日志里由 `_replay_cost_note()` 单独标注,可事后 - 按官方用量明细核对。 - """ + """Allow configured pre-response transport or HTTP failover, never filter or incomplete-stream replay.""" if is_filter_error(raw): return False if isinstance(error, UpstreamHTTPError): @@ -2826,7 +2749,7 @@ def _failover_safe(error, raw=b"") -> bool: class _StreamFailure(Exception): - """流式预取阶段的失败:状态码、错误体,以及原始异常(重放判定要看它是什么类型)。""" + """Carry the HTTP error and original exception from stream preflight.""" def __init__(self, status, raw, error=None): self.status = status @@ -2835,26 +2758,19 @@ def __init__(self, status, raw, error=None): super().__init__(f"stream failed before first byte (HTTP {status})") -# 断连收尾的等法:轮数而非墙上时间做上界。被反复取消时每次 await 都会立刻抛回来,用时间做 -# 上界就变成忙等;100 轮足够走完一次正常的关闭(实测个位数轮次),走完不成就交给后台。 +# Bound teardown waits by cycles so repeated cancellation cannot cause a busy loop. TEARDOWN_GRACE_CYCLES = 100 TEARDOWN_POLL_SECONDS = 0.01 def _drain_teardown(future) -> None: - """后台收尾任务的异常只取走、不重抛:它跑在没人再取消它的任务里,最终会做完。""" + """Retrieve teardown exceptions without rethrowing them.""" if not future.cancelled(): future.exception() async def _teardown_finished(task) -> None: - """尽量当场等收尾任务结束;等不到就挂个回调让它后台做完,绝不因此拖住取消本身。 - - 为什么不能老实 `await task`:下游断连时 anyio 的取消作用域**每个事件循环周期**重投一次 - 取消(`_deliver_cancellation` 用 `call_soon` 自循环),当前任务里的任何 await 都会被反复 - 打断。收尾因此放在独立任务里 —— 它不属于那个作用域,没人再取消它 —— 这里只是尽量把结果 - 等成同步的,等不到也不影响它最终跑完。 - """ + """Wait briefly for isolated cleanup, then drain it in the background without delaying cancellation.""" for _ in range(TEARDOWN_GRACE_CYCLES): if task.done(): _drain_teardown(task) @@ -2868,15 +2784,7 @@ async def _teardown_finished(task) -> None: async def _first_segment(agen): - """取生成器的第一段输出,但把「我们的等待」和「生成器自己的收尾」分开放。 - - 直接在当前任务里 `await agen.__anext__()` 有个实测问题:断连的取消打在生成器帧内部的 - await 上,帧自己的 `finally` 做到一半就被反复投进来的取消打断 —— `httpx` 正是在那里关 - 连接,于是清理根本跑不完,连接留到读超时。放进子任务之后,外层取消打断的是我们的 - `await`,子任务只被取消一次,它的 `finally` 能自己走完。 - - 取消语义下这一轮已经作废,所以子任务的结果不取;异常交给 `_teardown_finished` 收尾时取走。 - """ + """Read one stream segment in a shielded task so cancellation cannot interrupt its cleanup.""" task = asyncio.ensure_future(agen.__anext__()) try: return await asyncio.shield(task) @@ -2887,11 +2795,7 @@ async def _first_segment(agen): async def _stream_segments(agen): - """逐段读上游,语义等同 `async for chunk in agen`,但每一段都可被干净打断。 - - 复用 `_first_segment`:断连落在「两段之间」还是「正等下一段」都无所谓,生成器自己的 - `finally` 都能走完。 - """ + """Read cancellable segments while allowing the generator's cleanup to finish.""" while True: try: yield await _first_segment(agen) @@ -2900,15 +2804,7 @@ async def _stream_segments(agen): async def _preflight_stream(agen, model_name, t0, rid): - """取到第一段输出之后再决定怎么回 200。 - - `StreamingResponse` 一旦被迭代就把响应头发出去,而打上游发生在生成器里面 —— 于是上游的 - 429、建连/写超时乃至审核拒绝,在流式下全都只能塞进 SSE 正文:客户端看到的是一个没有 - `choices`、也等不到 `response.completed` 的 200 流,被读成「模型答了个空」,会话静默 - 结束,既不重试也不报错,审计里还记成一次成功。预取第一段之后,「一个字节都还没发出去」 - 的失败可以还原成真实状态码,流式与非流式同一口径;真的中途断流才继续用带内 error 事件 - (那时状态码已经收不回来了)。 - """ + """Read the first segment before committing HTTP 200, preserving pre-response error status.""" try: return await _first_segment(agen) except StopAsyncIteration: @@ -2922,11 +2818,7 @@ async def _preflight_stream(agen, model_name, t0, rid): async def _close_stream(agen) -> None: - """显式收尾上游生成器,收尾跑在不受当前取消作用域影响的任务里。 - - 覆盖「取消落在两段之间、帧还停在 yield 上」这种情况:直接 `await agen.aclose()` 会被 - 反复投递的取消打断在 `httpx` 关连接的半途。清理失败不改变已经定型的响应,所以只吞异常。 - """ + """Close the upstream generator in an isolated task that survives repeated cancellation.""" if agen is None: return @@ -2944,24 +2836,10 @@ def _chunk_bytes(chunk, charset: str = "utf-8"): class _DeferredStreamResponse(StreamingResponse): - """把「预取第一段 + 必要的换凭证重放」放进 ASGI 生命周期里做的流式响应。 - - 预取不能就在端点里 `await`:`StreamingResponse.__call__` 是把 `stream_response` 和 - `listen_for_disconnect` 放进同一个任务组跑的,端点返回之前根本没有谁在消费 - `http.disconnect`。上游首段一旦卡住而客户端已经走了,这个 await 会一直挂到读超时, - `ConcurrencyLimitMiddleware` 的名额也跟着占满 —— 表现为整个网关 503。搬进 - `stream_response` 之后,断连取消的就是我们此刻的 await,挂起的上游读被打断,生成器的 - finally 跑得完,名额立刻归还。 - - 响应头仍然等到确实有字节可发时才发出,所以「把失败还原成真实状态码」的能力不受影响: - 失败以 `HTTPException` 抛出,由 ExceptionMiddleware 成形(`/v1/*` 走协议化错误体), - 那一刻一个字节都还没出去。客户端中途断连则按普通流式断连处理 —— 取消穿出 `__call__`, - 和响应已经开始之后的行为一致;两种窗口里的读取都走 `_first_segment`, - 取消之后生成器的收尾仍然跑得完。 - """ + """Run stream preflight and failover inside ASGI disconnect monitoring before sending headers.""" def __init__(self, plan): - self._plan = plan # async callable -> (上游生成器, 已预取的第一段) + self._plan = plan # Async callable returning the upstream iterator and first segment. super().__init__(content=(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) @@ -2985,15 +2863,7 @@ def _failover_limit() -> int: async def _stream_plan(payload, canonical, model_name, rid, t0, make, routed, cred, headers, url): - """预取第一段,失败就按策略换凭证重打;返回 (生成器, 首段),全线失败才抛 `HTTPException`。 - - 重放只发生在「一个字节都没发给下游」的时候(`_preflight_stream` 保证了这点),所以下游 - 看到的仍然是一次正常请求。`make(routed, cred, headers, url)` 每轮只建一个生成器。 - - `canonical` 与 `routed` 必须分开:`_route_chat` 会把逻辑模型(`auto`)改写成该站点的 - 默认模型再发出去,所以 `routed` 是「本轮的真实报文」,而重路由只能拿改写前的 `canonical` - 去问绑定规则 —— 否则第二轮查的是默认模型,客户端原来说的 `auto` 的账号/站点限制就丢了。 - """ + """Prefetch with bounded credential failover, using canonical input to preserve routing restrictions.""" tried = [] recovered = None while True: @@ -3001,8 +2871,8 @@ async def _stream_plan(payload, canonical, model_name, rid, t0, make, routed, cr try: first = await _preflight_stream(stream, model_name, t0, rid) except _StreamFailure as failure: - await _close_stream(stream) # 本轮的上游已经终止,关掉只是兜底,不留半开的连接 - recovered = observe_failure_seq() # 这一枪记的失败,才是重放有权撤销的那一次 + await _close_stream(stream) # Release the failed upstream connection. + recovered = observe_failure_seq() # Recover only this failure sequence. tried.append(cred) limit = _failover_limit() surface = HTTPException(status_code=failure.status, @@ -3013,50 +2883,41 @@ async def _stream_plan(payload, canonical, model_name, rid, t0, make, routed, cr attempt = await run_in_threadpool(_route_chat, payload, canonical, rid, tried={_cred_manager(item) for item in tried}) except HTTPException: - raise surface from None # 换不出别的凭证,就如实回第一次的错 + raise surface from None # Preserve the failure when no alternative account exists. if _cred_manager(attempt[1]) in {_cred_manager(item) for item in tried}: raise surface from None routed, cred, headers, url = attempt _log(f"[{rid}] ↻ 换凭证重放 {len(tried)}/{limit} | {model_name} | 上游 HTTP " f"{failure.status} → {profile_for_headers(headers)}" f"{_replay_cost_note(failure.error)}") - continue # 换一个凭证,再预取一次 + continue # Prefetch from the replacement credential. except BaseException: - # 下游断连(取消)或没预料到的错误:先把本轮上游收掉,再把异常原样交出去 + # Release the current upstream before propagating cancellation or unexpected errors. await _close_stream(stream) raise if tried: - # 只撤销重放对应的那一次失败:序号对不上说明换到手的响应自己又记了新失败 - # (最典型是内容审核拒绝),那次失败要如实留在审计里。 - observe_recovery(recovered) # 重放救回来的请求对下游是正常响应,不该记成失败 + # Preserve newer failures such as content filtering on the replacement account. + observe_recovery(recovered) return stream, first def _routed_stream(payload, canonical, model_name, rid, t0, make, routed, cred, headers, url): - """流式端点入口:返回一个把预取与重放留待 ASGI 生命周期内执行的响应。 - - 这里刻意「什么都不做就返回」:预取必须发生在 `_DeferredStreamResponse.stream_response` - 里,那里才有下游断连监听(见该类的说明)。 - """ + """Defer stream preflight and failover until ASGI disconnect monitoring is active.""" return _DeferredStreamResponse( lambda: _stream_plan(payload, canonical, model_name, rid, t0, make, routed, cred, headers, url)) async def _routed_fetch(payload, canonical, model_name, rid, t0, fetch, routed, cred, headers, url): - """非流式请求:失败时按同一策略换凭证重打(此时一个字节都还没回给下游)。 - - `canonical` 同 `_routed_stream`:重路由用改写前的规范请求体,判定才落在客户端模型上。 - """ + """Apply bounded non-streaming failover while preserving canonical routing restrictions.""" tried = [] recovered = None while True: try: collected = await fetch(routed, cred, headers, url) if tried: - # 同 `_stream_plan`:聚合路径里 `_fetch_checked_chat` 会在返回前就记上审核拒绝, - # 无差别撤销会把被拦截的请求写成一次成功。 - observe_recovery(recovered) # 换凭证后成功的请求不该记成失败 + # Recover the earlier attempt without erasing a newer failure. + observe_recovery(recovered) return collected except (httpx.HTTPError, UpstreamResponseError) as error: status, raw = _upstream_failure(error, model_name, t0, rid) @@ -3102,19 +2963,14 @@ def _chat_body_desensitize(body: dict, *, force_compact: bool = False) -> dict: # --------------------------------------------------------------------------- -# Responses API 端点(Codex CLI 兼容) +# OpenAI Responses endpoint # --------------------------------------------------------------------------- @app.post("/v1/responses") async def create_response(request: Request, authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): - """OpenAI Responses API 兼容端点。 - - Codex CLI 使用 Responses API(wire_api = "responses")而非 Chat Completions。 - 本端点接收 Responses 格式请求,转换为 Chat 格式发往后端,再将后端的 Chat SSE - 转换为 Responses 语义事件流返回。 - """ + """Serve Responses requests through the shared Chat upstream and event adapter.""" _check_auth(authorization, x_api_key) try: @@ -3123,13 +2979,13 @@ async def create_response(request: Request, raise HTTPException(status_code=400, detail={"error": {"message": f"bad json: {e}", "type": "invalid_request_error"}}) payload = _prepare_payload(payload, field="input") - # 本网关不保留服务端响应状态:依赖服务端补全历史的字段必须显式拒绝而非静默开新对话 + # Reject server-side conversation references because this gateway is stateless. for stateful in ("previous_response_id", "conversation"): if payload.get(stateful): raise HTTPException(status_code=400, detail={"error": { "message": f"{stateful} is not supported: this gateway keeps no server-side response state; resubmit the full input instead", "type": "invalid_request_error", "param": stateful}}) - # 转换请求:Responses → Chat + # Convert Responses input to Chat format. try: chat_body = responses_request_to_chat(payload) except Exception as e: @@ -3154,8 +3010,8 @@ async def create_response(request: Request, f"| dropped_harness={projection_stats.get('dropped_harness_messages', 0)} " f"| anchor_user={projection_stats.get('anchor_user_preserved', False)}" ) - # 同上:凭据选择/刷新是阻塞操作,移出事件循环 - prepared = chat_body # 改写前的规范请求体,见 `_routed_stream` + # Keep blocking credential selection and refresh off the event loop. + prepared = chat_body # Preserve canonical input for routing policy checks. chat_body, cred, headers, url = await run_in_threadpool(_route_chat, payload, chat_body, rid) _log_json(f"[{rid}] RESPONSES → CHAT BODY (预览)", chat_body) t0 = time.time() @@ -3195,7 +3051,7 @@ async def fetch(routed, cred, headers, url): async def _stream_adapted(url, headers, body, model_name, t0, rid, cred=None, *, anthropic=False): - """协议适配只处理事件映射,连接、聚合与错误边界共用。""" + """Map protocol events while sharing connection, aggregation and failure handling.""" converter = (AnthropicStreamConverter(model=model_name) if anthropic else ResponsesStreamConverter(model=model_name, parallel_tool_calls=body.get("parallel_tool_calls", True))) sent = False try: @@ -3212,7 +3068,7 @@ async def _stream_adapted(url, headers, body, model_name, t0, rid, cred=None, *, yield events.encode("utf-8") except (httpx.HTTPError, UpstreamResponseError) as error: if not sent: - raise # 一个字节都没发出去:交给端点还原成真实状态码,别把失败写成 200 + raise # Preserve the HTTP error before any response bytes are sent. status, raw = _upstream_failure(error, model_name, t0, rid) event = {"type": "error", "error": { "message": sanitize_log_text(raw.decode("utf-8", "replace"), 512), @@ -3228,19 +3084,14 @@ async def _stream_responses(url: str, headers: dict, body: dict, # --------------------------------------------------------------------------- -# Anthropic Messages API 端点(Claude Code / CC Switch 兼容) +# Anthropic Messages endpoint # --------------------------------------------------------------------------- @app.post("/v1/messages") async def create_message(request: Request, authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): - """Anthropic Messages API 兼容端点。 - - Claude Code / CC Switch 使用 Anthropic Messages API(POST /v1/messages)。 - 本端点接收 Anthropic 格式请求,转换为 Chat 格式发往后端,再将后端的 Chat SSE - 转换为 Anthropic SSE 事件流返回。 - """ + """Serve Anthropic Messages through the shared Chat upstream and event adapter.""" _check_auth(authorization, x_api_key) try: @@ -3249,7 +3100,7 @@ async def create_message(request: Request, raise HTTPException(status_code=400, detail={"error": {"message": f"bad json: {e}", "type": "invalid_request_error"}}) payload = _prepare_payload(payload) - # 将 Anthropic 格式消息、工具规范在进入后端前统一转换为 OpenAI Chat 格式。 + # Convert Anthropic messages and tools to Chat format. messages = payload.get("messages") or [] if not messages: raise HTTPException(status_code=400, detail={"error": {"message": "messages is required", "type": "invalid_request_error"}}) @@ -3264,8 +3115,8 @@ async def create_message(request: Request, chat_messages = chat_body.get("messages", []) rid = os.urandom(4).hex() _log(f"[{rid}] ▶ ANTHROPIC {model_name} | msgs={len(chat_messages)} | anthropic_msgs={len(messages)}") - # 同上:凭据选择/刷新是阻塞操作,移出事件循环 - prepared = chat_body # 改写前的规范请求体,见 `_routed_stream` + # Keep blocking credential selection and refresh off the event loop. + prepared = chat_body # Preserve canonical input for routing policy checks. chat_body, cred, headers, url = await run_in_threadpool(_route_chat, payload, chat_body, rid) _log_json(f"[{rid}] ANTHROPIC → CHAT BODY (预览)", chat_body) t0 = time.time() @@ -3291,7 +3142,7 @@ async def _stream_anthropic(url: str, headers: dict, body: dict, async def count_tokens(request: Request, authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): - """Anthropic token 计数端点:字符启发式估算(Claude Code 发送前据此做预算)。""" + """Return heuristic token estimates for Anthropic request budgeting.""" _check_auth(authorization, x_api_key) try: payload = await request.json() @@ -3303,8 +3154,7 @@ async def count_tokens(request: Request, def _estimate_input_tokens(payload: dict) -> int: - """启发式估算:ASCII 约 4 字符 1 token,其余字符(如中文)按 1 token 计,每条消息加结构开销。 - 只是预算参考,不是精确计数;客户端不得据此断言与上游计费一致。""" + """Estimate tokens from character counts and message overhead, not upstream billing.""" def measure(value) -> int: if isinstance(value, str): @@ -3321,12 +3171,12 @@ def measure(value) -> int: if isinstance(messages, list): for message in messages: if isinstance(message, dict): - total += measure(message.get("content")) + 4 # 消息结构开销 + total += measure(message.get("content")) + 4 # Message structure overhead. return total # --------------------------------------------------------------------------- -# 启动 +# Startup # --------------------------------------------------------------------------- def preflight() -> bool: @@ -3357,7 +3207,7 @@ def preflight() -> bool: def login(site: str = "cn", open_browser: bool = True) -> int: - """独立完成扫码与入库;凭据只写入自管目录,不经过本地 HTTP 接口。""" + """Complete browser login and persist managed credentials without local HTTP calls.""" import webbrowser try: @@ -3395,7 +3245,7 @@ def login(site: str = "cn", open_browser: bool = True) -> int: print("登录失败:无法保存凭据,请检查凭据目录的写入权限。", file=sys.stderr) return 1 except (httpx.HTTPError, ValueError, RuntimeError): - # 上游异常可能包含授权 URL 或响应正文,不向终端转储。 + # Upstream errors may contain authorization URLs or sensitive response data. print("登录失败:登录接口请求失败或响应无效,请检查网络后重试。", file=sys.stderr) return 1 @@ -3520,11 +3370,11 @@ def main(): CONFIG["usd_rate"] = args.usd_rate or None CONFIG["credit_price_usd"] = args.credit_price_usd or None CONFIG["model_guard"] = not args.no_model_guard - # --log 直接指定文件路径即开启;不传则不记 + # File logging is enabled only when a path is configured. CONFIG["log_path"] = args.log if args.log else os.environ.get("CODEBUDDY2API_LOG") from app import runtime_management runtime_management.initialize(sys.modules[__name__], args) - # 持久化设置解析后再核对实际监听地址和生效 key,且必须早于凭据扫描、线程及监听。 + # Validate effective binding and authentication before credential scans or background work. if (args.host not in ("127.0.0.1", "::1", "localhost") and not CONFIG.get("api_key") and os.environ.get("CODEBUDDY2API_ALLOW_OPEN_NOAUTH", "").lower() not in ("1", "true", "yes")): runtime_management.close(CONFIG) @@ -3532,17 +3382,17 @@ def main(): "请设置 CODEBUDDY2API_KEY,或确知风险后以 CODEBUDDY2API_ALLOW_OPEN_NOAUTH=true 显式放行") files = [Path(p) for p in args.auth_file] if not files: - seed_credentials() # 自管模式:启动时把桌面端缺失凭据复制进 auth/ + seed_credentials() # Seed missing desktop credentials into managed storage. CONFIG["cred_pool"] = CredentialPool(files, scan=not files, blocks_path=managed_auth_dir() / "model-site-blocks.json") CONFIG["cred"] = CONFIG["cred_pool"].first() - CONFIG["account_catalogs"] = {} # 在任何维护线程/预检启动前关闭静态兜底。 + CONFIG["account_catalogs"] = {} # Disable static fallback before maintenance starts. if credits_mod is not None: ledger = credits_mod.CreditLedger(managed_auth_dir() / "credits-ledger.json") CONFIG["ledger"] = ledger CONFIG["model_cache"] = credits_mod.ModelCatalogCache( managed_auth_dir() / "model-catalog.json", ttl=args.model_catalog_ttl) - CONFIG["cred_pool"].set_ledger(ledger) # 先验证持久余额所属身份,再发布目录(包括空表)。 + CONFIG["cred_pool"].set_ledger(ledger) # Verify balance ownership before publishing catalogs. _publish_model_cache() runtime_management.install(sys.modules[__name__]) threading.Thread(target=_refresher_loop, args=(CONFIG["cred_pool"],), @@ -3581,7 +3431,7 @@ def main(): sys.stderr.write(f" 脱敏 : 已启用({mode})\n") sys.stderr.write("按 Ctrl+C 退出。\n\n") - # 启动时写一条标记 + # Record service startup. _log(f"==== converter 启动 ====") try: diff --git a/docker-compose.yml b/docker-compose.yml index f90eeee..0942c94 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -# 使用兼容旧版 Compose 的语法;.env 自动参与变量插值,Shell 环境优先。 +# Support legacy Compose syntax; shell variables override automatic .env interpolation. version: "3.8" @@ -10,20 +10,19 @@ services: ports: - "${CODEBUDDY2API_BIND:-127.0.0.1}:${CODEBUDDY2API_PORT:-8787}:8787" volumes: - # .info 凭证、control.sqlite3、logs.sqlite3 及 SQLite WAL/SHM 共用持久目录。 - # SQLite 需要可写的本地文件系统;不要只挂载单个数据库文件。 + # Persist credentials, SQLite databases and WAL/SHM files in one writable local directory. - "${CODEBUDDY2API_AUTH_PATH:-./auth}:/data/auth" environment: CODEBUDDY2API_KEY: ${CODEBUDDY2API_KEY:-} CODEBUDDY2API_ADMIN_CSRF: ${CODEBUDDY2API_ADMIN_CSRF:-true} - # 未设置时不注入环境变量,保留 WebUI 配置能力。 + # Unset variables remain configurable through the WebUI. CODEBUDDY2API_KEEP_TOOL_METADATA: CODEBUDDY2API_MAX_IMAGES: ${CODEBUDDY2API_MAX_IMAGES:-16} CODEBUDDY2API_IMAGE_POLICY: ${CODEBUDDY2API_IMAGE_POLICY:-truncate} CODEBUDDY2API_MAX_REQUEST_BYTES: ${CODEBUDDY2API_MAX_REQUEST_BYTES:-33554432} CODEBUDDY2API_LOG_BODY_LIMIT: ${CODEBUDDY2API_LOG_BODY_LIMIT:-65536} CODEBUDDY2API_LOG: ${CODEBUDDY2API_LOG:-} - # 容器内固定 0.0.0.0;对外暴露边界由上方 BIND 端口映射控制(默认仅回环) + # Bind container interfaces; host port mapping controls external access. CODEBUDDY2API_ALLOW_OPEN_NOAUTH: "true" CODEBUDDY_AUTH_DIR: /data/auth command: > diff --git a/docs/advanced.md b/docs/advanced.md index 9833c7a..3c6a225 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -79,6 +79,8 @@ Use a source/image build and Compose configuration containing this feature; recr | `POST /admin/sync` | Synchronize all enabled accounts' balances, catalogs and usage; no check-in or trial claims | | `POST /admin/credentials/{id}/{action}` | Single-account `refresh`, `checkin`, `sync`, `travel-status` (query only), `travel` (claim then dispatch), or `trial` (one-time trial credits) | +Travel results include `phase`, optional safe `error_kind`/`http_status`/`code`, and snapshot `remaining_seconds`. `claimed`/`departed` remain true for confirmed writes even if a later query sets `ok=false` and `stale=true`; query status before another attempt. + Pages use `/dashboard/*`, management APIs use `/admin/*`, and clients retain `/v1/*`. `/cn` and `/intl` API prefixes are not registered. Automatic model routing requires no client URL changes. Management requires an API key. The WebUI exchanges that key for an HttpOnly management Cookie, which only authorizes `/admin/*`, not `/v1/*`. API clients send `Authorization: Bearer ` or `X-Api-Key`. An empty key preserves legacy unauthenticated inference only, not management. `/health` never exposes account, path or exception details. diff --git a/docs/advanced.zh-CN.md b/docs/advanced.zh-CN.md index 3353409..d51a1eb 100644 --- a/docs/advanced.zh-CN.md +++ b/docs/advanced.zh-CN.md @@ -79,6 +79,8 @@ Compose 会显式传入部分环境变量及 CLI 参数,删除 `.env` 中的 | `POST /admin/sync` | 同步全部启用账号的余额、目录和用量,不签到、不领取试用 | | `POST /admin/credentials/{id}/{action}` | 单账号 `refresh`、`checkin`、`sync`、`travel-status`(仅查询)、`travel`(领取后派出)或 `trial`(一次性体验积分) | +旅行结果包含 `phase`、可选的安全诊断 `error_kind`/`http_status`/`code` 和查询时的 `remaining_seconds`。后续查询失败会设置 `ok=false`、`stale=true`,但保留已确认的 `claimed`/`departed`;再次操作前先查询核验。 + 页面使用 `/dashboard/*`,管理 API 使用 `/admin/*`,客户端保留原 `/v1/*`;不注册 `/cn`、`/intl` API 前缀。模型自动选路不要求客户端改变地址。 管理必须配置 API key;WebUI 使用同 key 建立 HttpOnly 管理 Cookie,Cookie 仅授权 `/admin/*`,不能用于 `/v1/*`。命令行 API 请求携带 `Authorization: Bearer ` 或 `X-Api-Key`。空 key 仅保留推理接口的历史无鉴权行为,不开放管理;`/health` 不返回账号、路径或异常详情。 diff --git a/docs/webui.md b/docs/webui.md index fe2e557..f964225 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -17,7 +17,8 @@ Management is locked without a key. After changing it, sign in again and restart - Each row offers Token refresh, check-in and balance sync; batch check-in and sync stay separate. Sync updates balances, catalogs and usage without check-in, travel or trial claims. Busy maintenance returns 409; a client timeout does not cancel server work. - International WorkBuddy trial credits require manual confirmation; the drawer displays the result. “Refresh claim status” only reads the local ledger and never claims. On network or persistence failure, verify status before another attempt; environment-driven automatic claims are retired. - Automatic check-in and travel are persisted per account and apply live: on by default for domestic accounts, off internationally. International check-in can be enabled without a code update; inactive or unconfirmed activities never authorize claims. Disabled accounts run no automatic tasks. - - Domestic check-in is followed by travel when its independent switch is on; already-checked-in accounts and accounts with automatic check-in off still check travel. “Travel status” only queries; “Claim / dispatch” claims arrivals, then rechecks idle state and the daily limit before randomly choosing location 1–4. Traveling accounts are not dispatched, and failed writes are not blindly retried. + - Domestic travel follows its own switch, including when check-in is already complete or disabled. “Travel status” only queries; “Claim / dispatch” claims arrivals, rechecks idle state and the daily limit, then chooses a current upstream location. Invalid location configuration stops dispatch. + - Writes are followed by a status check and are never blindly retried. The console retains confirmed claims/dispatches when that check fails, shows the failure stage and snapshot travel time, and leaves unreturned reward amounts unknown. - Saving a preference does not claim immediately; it affects subsequent maintenance and cannot retract sent requests. The console shows last results, partial completion and uncertainty, retaining history on failure. - **Models:** add independent mappings with public/upstream IDs and local enablement. Choose either specific accounts or a region with an optional product filter; switching modes clears the opposite binding. Unavailable candidates never cause out-of-scope fallback. - **Logs:** filter requests and inspect failed attempts. Closing details or switching log type cancels pending detail loads. Clearing details keeps historical statistics. diff --git a/docs/webui.zh-CN.md b/docs/webui.zh-CN.md index 74e2a03..755f0ca 100644 --- a/docs/webui.zh-CN.md +++ b/docs/webui.zh-CN.md @@ -17,7 +17,8 @@ - 每行可刷新 Token、签到、同步余额;顶部批量签到和同步分开。同步仅更新余额、目录和用量,不签到、不旅行、不领取试用。维护繁忙时返回 409;客户端超时不代表后台任务已取消。 - 国际 WorkBuddy 的“一次性体验积分”需手动确认领取,结果在抽屉内显示;“刷新领取状态”只读本地记录,不发领取请求。网络失败或记录保存异常时先核对状态,勿连续点击;环境变量自动领取已停用。 - 自动签到、自动旅行按账号保存并热生效,国内默认开启,国际默认关闭。国际签到可随时开启,不需更新代码;官方活动未开放或状态不明时不领取。账号停用期间不执行自动任务。 - - 国内签到处理后按自动旅行开关继续旅行;已签到或关闭自动签到时,旅行仍按独立开关检查。手动“旅行状态”只查询,“旅行领派”先领取已到达奖励,再确认空闲且未达上限后随机派往 1–4 号地点;旅行中不派出,写请求失败不盲目重试。 + - 国内旅行按独立开关执行,已签到或关闭自动签到不影响旅行。“旅行状态”只查询;“旅行领派”领取到达奖励,重新确认空闲且未达上限后,从上游当前地点中随机派遣;地点配置无效时不派出。 + - 写操作后只读核验,失败不盲目重试。界面保留已确认的领取或派遣,显示失败阶段和查询时剩余时间;未返回的奖励金额保持未知。 - 保存开关不会立即领取,从后续维护起生效;关闭不撤回已发请求。界面显示上次结果,部分成功与状态未确认单独提示,失败保留历史。 - **模型路由**:新增模型映射,独立设置对外 ID、上游 ID 与启停;指定账号和指定区域二选一,区域内可筛产品。切换方式清除另一种绑定,候选不可用时不会越界回退。 - **日志审计**:筛选请求、查看失败详情。关闭详情或切换日志类型会取消未完成的详情加载。清空明细会保留历史统计。 diff --git a/examples/codex-codebuddy.example.toml b/examples/codex-codebuddy.example.toml index 005ef94..60038f3 100644 --- a/examples/codex-codebuddy.example.toml +++ b/examples/codex-codebuddy.example.toml @@ -1,59 +1,24 @@ -# ============================================================ -# CodeBuddy/WorkBuddy 订阅 -> OpenAI 兼容客户端 —— provider 配置示例(片段) -# ------------------------------------------------------------ -# 前提:转换器已在本地运行(见 README.md) -# uv run converter.py --port 8787 -# 或: -# python3 converter.py --port 8787 -# -# ⚠️ 本文件只是「示例」。若你的客户端需要合并配置,请【手动】把下面的段落 -# 复制/合并进你自己的配置文件。本脚本绝不自动改写任何已有配置。 -# ============================================================ - - -# ── 方式一:Codex CLI(Responses API)────────────────────── -# 新版 Codex CLI 使用 Responses API(wire_api = "responses")。 -# 转换器已支持 POST /v1/responses 端点,可直接接入 Codex CLI。 -# -# 将以下配置合并到 ~/.codex/config.toml(或项目 codex.toml): +# Example provider configuration for a running local gateway. +# Merge the required sections manually; client setup details are in docs/clients.md. + + +# Codex CLI configuration for ~/.codex/config.toml or a project config. [model_providers.workbuddy] name = "WorkBuddy (via local converter)" base_url = "http://127.0.0.1:8787/v1" -wire_api = "responses" # ← 关键:使用 Responses API -env_key = "CODEBUDDY2API_KEY" # 转换器未启用 --api-key 时,可随便填一个环境变量名 +wire_api = "responses" # Use the Responses protocol. +env_key = "CODEBUDDY2API_KEY" # Supply the gateway API key through this environment variable. [profiles.workbuddy] -model = "glm-5.2" # 也可用 kimi-k2.7 / deepseek-v4-pro / auto 等 +model = "glm-5.2" # Select any enabled gateway model. model_provider = "workbuddy" -# 然后设置环境变量(值随便填,除非转换器启用了 --api-key): -# export CODEBUDDY2API_KEY=any-value -# -# 启动 Codex CLI: -# codex --profile workbuddy "你的任务描述" - - -# ── 方式二:Cherry Studio / ZCode 等 OpenAI 兼容客户端 ───── -# 这些客户端使用 Chat Completions API(/v1/chat/completions), -# 转换器原生支持,只需在客户端的「自定义模型 / OpenAI 兼容」里填: -# 接口地址 base_url : http://127.0.0.1:8787/v1 -# API Key : 留空(除非转换器启动时用了 --api-key) -# 模型名 model : glm-5.2 (或 kimi-k2.7 / deepseek-v4-pro / auto) - - -# ── 方式三:Claude Code(通过 CC Switch,Anthropic API)───── -# Claude Code 使用 Anthropic Messages API(POST /v1/messages)。 -# 转换器已内置 Anthropic 适配层,可通过 CC Switch 接入。 -# -# 启动转换器(强烈建议开启脱敏,避免后端审核拦截): -# uv run converter.py --desensitize -# 若需保留 Claude Code 完整 system prompt(不压缩),加 --no-compact: -# uv run converter.py --desensitize --no-compact -# -# 通过 CC Switch 配置模型提供商,指向 Anthropic 端点: -# base_url : http://127.0.0.1:8787/v1/messages -# API Key : 留空(除非转换器启动时用了 --api-key) -# 模型名 : deepseek-v4-pro (或 glm-5.2 / kimi-k2.7 / auto 等 CodeBuddy 支持的模型) -# -# 注意:模型名必须填 CodeBuddy 支持的名称,不做 Anthropic 名称到 CodeBuddy 名称的自动映射。 +# Set CODEBUDDY2API_KEY, then run: codex --profile workbuddy "Your task". + + +# Chat clients use http://127.0.0.1:8787/v1 with an enabled gateway model. + + +# Anthropic clients use /v1/messages with a gateway model ID, not an Anthropic model alias. +# See docs/clients.md for Claude Code, CC Switch and prompt-adaptation settings. diff --git a/scripts/check_version.py b/scripts/check_version.py index 894a8d8..c1fbd9e 100644 --- a/scripts/check_version.py +++ b/scripts/check_version.py @@ -1,4 +1,4 @@ -"""检查稳定版本格式及发布标签的一致性。""" +"""Validate stable version syntax and release-tag consistency.""" import os import re diff --git a/tests/test_admin_api.py b/tests/test_admin_api.py index d33133b..a3847e4 100644 --- a/tests/test_admin_api.py +++ b/tests/test_admin_api.py @@ -337,7 +337,7 @@ def test_tool_metadata_setting_is_hot_persisted_and_respects_locks(self): self.assertIs(self.store.snapshot()["settings"][key], True) def test_failover_settings_are_hot_and_persisted(self): - """换凭证重放与写超时开关都要能在系统设置里改完立即生效,不需要重启进程。""" + """Apply failover and write-timeout settings without restarting the process.""" current = self.client.get("/admin/settings", headers=self.headers).json() items = {item["key"]: item for item in current["items"]} for key, kind, default in (("failover_max", "integer", 0), ("retry_write_timeout", "boolean", False)): @@ -443,7 +443,7 @@ def test_valid_upload_and_replace_confirmation(self): self.assertFalse(response.json()["results"][0]["ok"]) def test_upload_normalizes_token_aliases_to_canonical_fields(self): - """只含 access_token/token 别名的凭据:落盘内容必须折叠为 accessToken,别名键移除。""" + """Persist token aliases as accessToken and remove alias keys.""" data = self.credential() auth = data.pop("auth") data["auth"] = {**{k: v for k, v in auth.items() if k != "accessToken"}, diff --git a/tests/test_anthropic_adapter.py b/tests/test_anthropic_adapter.py index eaa8715..898389b 100644 --- a/tests/test_anthropic_adapter.py +++ b/tests/test_anthropic_adapter.py @@ -1,14 +1,10 @@ #!/usr/bin/env python3 -""" -test_anthropic_adapter.py — 验证 Anthropic API 适配层的转换逻辑。 - -直接运行:python3 tests/test_anthropic_adapter.py -""" +"""Test Anthropic request and response adaptation.""" import json import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # 仓库根:允许直接运行本文件 +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. from app.adapters.anthropic_adapter import ( anthropic_request_to_chat, @@ -17,7 +13,7 @@ def test_simple_text_request(): - """测试:简单文本消息 + system 字符串。""" + """Convert plain text messages and string system instructions.""" req = { "model": "deepseek-v4-pro", "max_tokens": 4096, @@ -37,7 +33,7 @@ def test_simple_text_request(): def test_system_array(): - """测试:system 为 text block 数组。""" + """Convert system text-block arrays.""" req = { "model": "auto", "max_tokens": 1024, @@ -53,7 +49,7 @@ def test_system_array(): def test_text_and_tool_use(): - """测试:assistant 消息含 text + tool_use。""" + """Convert assistant text and tool_use blocks together.""" req = { "model": "deepseek-v4-pro", "max_tokens": 4096, @@ -89,7 +85,7 @@ def test_text_and_tool_use(): def test_tool_only_no_text(): - """测试:assistant 消息只有 tool_use,没有 text。""" + """Convert assistant tool calls without text.""" req = { "model": "auto", "max_tokens": 4096, @@ -120,7 +116,7 @@ def test_tool_only_no_text(): def test_tool_result(): - """测试:tool_result → tool 角色消息。""" + """Map tool_result blocks to Chat tool messages.""" req = { "model": "auto", "max_tokens": 4096, @@ -161,7 +157,7 @@ def test_tool_result(): def test_tool_result_with_user_text(): - """测试:同一 user 消息包含 text + tool_result。""" + """Preserve mixed user text and tool results.""" req = { "model": "auto", "max_tokens": 4096, @@ -182,7 +178,7 @@ def test_tool_result_with_user_text(): chat = anthropic_request_to_chat(req) msgs = chat["messages"] - # 工具结果必须先于普通 user 文本,保持 assistant(tool_calls) → tool 的相邻关系 + # Tool results must immediately follow assistant tool calls. assert msgs[0]["role"] == "tool" assert msgs[0]["tool_call_id"] == "toolu_xyz" assert msgs[0]["content"] == "output here" @@ -192,7 +188,7 @@ def test_tool_result_with_user_text(): def test_tools_conversion(): - """测试:Anthropic tools 格式 → Chat 格式。""" + """Convert Anthropic tools to Chat function definitions.""" req = { "model": "deepseek-v4-pro", "max_tokens": 4096, @@ -219,7 +215,7 @@ def test_tools_conversion(): def test_string_content(): - """测试:content 为简单字符串(不是 blocks 数组)。""" + """Accept plain string content instead of block arrays.""" req = { "model": "auto", "max_tokens": 1024, @@ -235,7 +231,7 @@ def test_string_content(): def test_stream_converter_text(): - """测试:Chat SSE 文本流 → Anthropic SSE 事件流。""" + """Convert Chat text deltas to Anthropic SSE events.""" conv = AnthropicStreamConverter(model="deepseek-v4-pro") chunks = [ @@ -250,7 +246,7 @@ def test_stream_converter_text(): for line in chunks: result = conv.feed_line(line) if result: - # Anthropic SSE 格式:event: xxx\ndata: {...}\n\n + # Parse Anthropic named SSE events. for evt_block in result.strip().split("\n\n"): if not evt_block: continue @@ -276,7 +272,6 @@ def test_stream_converter_text(): types = [e["type"] for e in all_events] - # 必须包含的事件类型 assert "message_start" in types assert "content_block_start" in types assert "content_block_delta" in types @@ -284,16 +279,13 @@ def test_stream_converter_text(): assert "message_delta" in types assert "message_stop" in types - # 验证 content_block_start 的 type=text cbs = [e for e in all_events if e["type"] == "content_block_start"][0] assert cbs["content_block"]["type"] == "text" - # 验证 text_delta deltas = [e for e in all_events if e["type"] == "content_block_delta"] assert len(deltas) >= 2 assert deltas[0]["delta"]["type"] == "text_delta" - # 验证 stop_reason md = [e for e in all_events if e["type"] == "message_delta"][0] assert md["delta"]["stop_reason"] == "end_turn" @@ -301,7 +293,7 @@ def test_stream_converter_text(): def test_stream_converter_tool_use(): - """测试:Chat SSE tool_calls → Anthropic tool_use 事件。""" + """Convert Chat tool-call deltas to Anthropic tool_use events.""" conv = AnthropicStreamConverter(model="deepseek-v4-pro") chunks = [ @@ -341,17 +333,14 @@ def test_stream_converter_tool_use(): assert "message_delta" in types assert "message_stop" in types - # 验证 tool_use content_block cbs = [e for e in all_events if e["type"] == "content_block_start"][0] assert cbs["content_block"]["type"] == "tool_use" assert cbs["content_block"]["name"] == "Bash" - # 验证 input_json_delta deltas = [e for e in all_events if e["type"] == "content_block_delta"] for d in deltas: assert d["delta"]["type"] == "input_json_delta" - # 验证 stop_reason md = [e for e in all_events if e["type"] == "message_delta"][0] assert md["delta"]["stop_reason"] == "tool_use" @@ -359,7 +348,7 @@ def test_stream_converter_tool_use(): def test_nonstream_response(): - """测试:非流式响应对象生成。""" + """Build a complete non-streaming Message response.""" conv = AnthropicStreamConverter(model="deepseek-v4-pro") conv.feed_line('data: {"id":"c1","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":null}]}') conv.feed_line('data: {"id":"c1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":1,"total_tokens":6}}') @@ -379,7 +368,7 @@ def test_nonstream_response(): def test_nonstream_response_tool_use(): - """测试:非流式响应含 tool_use。""" + """Include tool_use blocks in non-streaming responses.""" conv = AnthropicStreamConverter(model="deepseek-v4-pro") conv.feed_line('data: {"id":"c2","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_abc","type":"function","function":{"name":"Bash","arguments":"{\\"cmd\\": \\"ls\\"}"}}]}}]}') conv.feed_line('data: {"id":"c2","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}') @@ -395,7 +384,7 @@ def test_nonstream_response_tool_use(): def test_empty_messages(): - """测试:无 messages 的请求。""" + """Handle requests without messages.""" req = { "model": "auto", "max_tokens": 1024, @@ -408,7 +397,7 @@ def test_empty_messages(): print("✅ test_empty_messages") def test_disable_parallel_tool_use_is_mapped(): - """tool_choice.disable_parallel_tool_use 必须端到端传到上游,不接受后丢失。""" + """Preserve disable_parallel_tool_use through upstream adaptation.""" base = {"model": "auto", "max_tokens": 64, "messages": [{"role": "user", "content": "hi"}], "tools": [{"name": "t", "input_schema": {"type": "object"}}]} @@ -424,7 +413,7 @@ def test_disable_parallel_tool_use_is_mapped(): print("✅ test_disable_parallel_tool_use_is_mapped") def test_stop_sequences_are_mapped(): - """stop_sequences 映射为上游 stop;显式 stop 优先;错误类型显式拒绝。""" + """Map stop_sequences with explicit stop precedence and reject invalid types.""" base = {"model": "auto", "max_tokens": 64, "messages": [{"role": "user", "content": "hi"}]} chat = anthropic_request_to_chat({**base, "stop_sequences": ["\n\n", "END"]}) @@ -439,7 +428,7 @@ def test_stop_sequences_are_mapped(): print("✅ test_stop_sequences_are_mapped") def test_tool_result_is_error_is_preserved(): - """is_error:true 与成功结果同正文时必须可区分:失败被编码进正文前缀。""" + """Distinguish failed tool results by a content prefix.""" def conv(is_error): block = {"type": "tool_result", "tool_use_id": "toolu_1", "content": "exit 1"} if is_error is not None: @@ -449,7 +438,7 @@ def conv(is_error): assert conv(True).startswith("[tool execution failed]\nexit 1") assert conv(False) == "exit 1" assert conv(None) == "exit 1" - # 含图片的失败结果:标记为前置文本块,不做字符串拼接 + # Preserve images and prepend tool failure as a separate text block. req = {"model": "auto", "max_tokens": 64, "messages": [{"role": "user", "content": [ {"type": "tool_result", "tool_use_id": "toolu_2", "is_error": True, "content": [ {"type": "image", "source": {"type": "url", "url": "https://synthetic.invalid/x.png"}}, diff --git a/tests/test_audit_store.py b/tests/test_audit_store.py index 6badbb3..01d080e 100644 --- a/tests/test_audit_store.py +++ b/tests/test_audit_store.py @@ -136,12 +136,12 @@ def test_thread_lock_timeout_is_visible(self): self.assertEqual(self.store.storage()["last_error"], "TimeoutError") def test_v1_ingest_table_migrates_with_backfilled_timestamps(self): - """v1 库(ingest 无 created_at)打开即迁移:列回填、索引建立、保留期内仍可去重。""" + """Migrate v1 ingest timestamps and indexes while preserving retained deduplication.""" with tempfile.TemporaryDirectory() as td: path = Path(td) / "audit.sqlite3" with closing(sqlite3.connect(str(path))) as db: db.execute("BEGIN IMMEDIATE") - db.execute("CREATE TABLE ingest (id TEXT PRIMARY KEY, kind TEXT NOT NULL)") # v1 形状 + db.execute("CREATE TABLE ingest (id TEXT PRIMARY KEY, kind TEXT NOT NULL)") # Legacy schema db.execute("INSERT INTO ingest VALUES('legacy-req', 'request')") db.execute("PRAGMA user_version=1") db.execute("COMMIT") @@ -175,7 +175,7 @@ def test_logical_budget_and_retention_never_delete_aggregate(self): self.store.record_request(self.record("old", started_at=time.time() - 31 * 86400)) self.assertIsNone(self.store.get_request("old")) self.assertEqual(self.store.dashboard(90)["summary"]["requests"], 2) - # 去重行与明细同截止期过期:超过保留期的旧 ID 不再判定为重复(防重放仅限保留期内) + # Deduplication expires with request details rather than blocking IDs permanently. self.assertTrue(self.store.record_request(self.record("old"))["recorded"]) health = self.store.storage() for name in ("db_bytes", "wal_bytes", "shm_bytes"): @@ -422,7 +422,7 @@ def test_incremental_retention_hides_expired_details_and_preserves_stats(self): if not health["pending_cleanup"]: break self.assertFalse(health["pending_cleanup"]) - # 去重行与明细同截止期过期:ingest 数归零,聚合统计不受影响 + # Deduplication expires with details without affecting aggregate statistics. self.assertEqual(self.assert_accounting(), (0, 0, 0, 0)) self.assertEqual(self.aggregate_snapshot(), aggregates) self.assertFalse(health["degraded"]) diff --git a/tests/test_auth_oauth.py b/tests/test_auth_oauth.py index 0e1c912..197372f 100644 --- a/tests/test_auth_oauth.py +++ b/tests/test_auth_oauth.py @@ -1,8 +1,5 @@ #!/usr/bin/env python3 -"""test_auth_oauth.py — 验证 auth_oauth.py 的入库校验/.info 拼装/OAuth 状态机与 converter 保活调度。 - -直接运行:python3 tests/test_auth_oauth.py -""" +"""Test credential validation, OAuth state transitions, persistence and keepalive scheduling.""" import base64 import json @@ -12,7 +9,7 @@ import threading from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # 仓库根:允许直接运行本文件 +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. from app import auth_oauth from app.auth_oauth import ( @@ -37,25 +34,24 @@ def _cred(uid="u1", domain="www.codebuddy.cn", token=None): def test_validate_cred_data(): assert validate_cred_data(_cred()) == ("u1", None) - assert validate_cred_data(_cred(domain="www.workbuddy.ai"))[1] is None # 国际站 - assert validate_cred_data(_cred(domain="copilot.tencent.com"))[1] is None # 新版 Keycloak issuer - # domain 缺失时靠 JWT issuer 兜底 + assert validate_cred_data(_cred(domain="www.workbuddy.ai"))[1] is None # International site + assert validate_cred_data(_cred(domain="copilot.tencent.com"))[1] is None # Keycloak issuer + # Fall back to the JWT issuer when the domain is absent. c = _cred() c["auth"].pop("domain") assert validate_cred_data(c) == ("u1", None) - # accounts[0] 兜底(无 account 键) + # Fall back to accounts[0] when account is absent. c = _cred() c["accounts"] = [c.pop("account")] assert validate_cred_data(c) == ("u1", None) - # 拒绝项 assert validate_cred_data(None)[1] - assert validate_cred_data({"account": {"uid": "u"}})[1] # 缺 token - assert validate_cred_data({"auth": {"accessToken": "t", "domain": "www.codebuddy.cn"}})[1] # 缺 uid + assert validate_cred_data({"account": {"uid": "u"}})[1] # Missing token + assert validate_cred_data({"auth": {"accessToken": "t", "domain": "www.codebuddy.cn"}})[1] # Missing UID bad = _cred(domain="evil.example.com") bad["auth"]["accessToken"] = "not-a-jwt" uid, err = validate_cred_data(bad) assert uid is None and "允许列表" in err - # 时间戳必须有限且合理:NaN/Infinity/bool/0/超范围都拒绝 + # Reject nonfinite, Boolean, zero and out-of-range timestamps. for field in ("expiresAt", "lastRefreshTime"): for bad_ts in (float("nan"), float("inf"), float("-inf"), True, 0, -1, 4102444800000, 99999999999999, 10**1000, -(10**1000)): @@ -67,9 +63,9 @@ def test_validate_cred_data(): c["auth"][field] = valid_ts assert validate_cred_data(c) == ("u1", None) c = _cred() - c["auth"]["expiresAt"] = 1893456000000 # 2030-01-01 毫秒 + c["auth"]["expiresAt"] = 1893456000000 # 2030-01-01 in milliseconds assert validate_cred_data(c) == ("u1", None) - # 严格解析拒绝非标准常量 + # Reject nonstandard JSON constants. from app.auth_oauth import loads_strict for text in ('{"a": NaN}', '{"a": Infinity}', '{"a": -Infinity}'): try: @@ -87,8 +83,8 @@ def test_helpers(): assert _normalize_origin("") == "" assert _token_issuer_origin(_jwt("https://www.workbuddy.cn/auth/realms/c")) == "https://www.workbuddy.cn" assert _token_issuer_origin("bad") == "" - assert _norm_ts(1_700_000_000) == 1_700_000_000_000 # 秒 → 毫秒 - assert _norm_ts(1_700_000_000_000) == 1_700_000_000_000 # 毫秒保持 + assert _norm_ts(1_700_000_000) == 1_700_000_000_000 # Seconds to milliseconds + assert _norm_ts(1_700_000_000_000) == 1_700_000_000_000 # Milliseconds unchanged assert _norm_ts("1700000000") == 1_700_000_000_000 assert _norm_ts(True) is None and _norm_ts(-1) is None and _norm_ts("x") is None print("✅ test_helpers") @@ -102,18 +98,18 @@ def test_build_auth_file(): c = build_auth_file(tok, acc) a, au = c["account"], c["auth"] assert a["uid"] == "u9" and a["lastLogin"] is True and a["pluginEnabled"] is True - assert a["type"] == "personal" and a["extra"] == 1 # 保留上游额外字段 + assert a["type"] == "personal" and a["extra"] == 1 # Preserve extra fields. assert au["accessToken"] == "at" and au["refreshToken"] == "rt" - assert au["tokenType"] == "Bearer" and au["idToken"] == "keep-me" # 不裁剪白名单 + assert au["tokenType"] == "Bearer" and au["idToken"] == "keep-me" # Preserve upstream metadata. assert abs(au["expiresAt"] - (now + 7200_000)) < 2000 assert au["expiresIn"] <= 7200 and au["lastRefreshTime"] >= now - 2000 assert au["refreshExpiresAt"] > au["expiresAt"] assert c["accounts"] == c["allAccounts"] and c["accounts"][0]["uid"] == "u9" - # snake_case + expiresAt 秒级时间戳兼容 + # Accept snake_case aliases and second-based expiry. c2 = build_auth_file({"access_token": "x", "refresh_token": "y", "expires_at": 1_800_000_000}, {"uid": "u"}) assert c2["auth"]["accessToken"] == "x" and c2["auth"]["expiresAt"] == 1_800_000_000_000 - # 无过期信息 + # Missing expiry metadata c3 = build_auth_file({"accessToken": "x"}, {"uid": "u"}) assert c3["auth"]["expiresIn"] == 0 and "expiresAt" not in c3["auth"] print("✅ test_build_auth_file") @@ -124,7 +120,7 @@ def test_merge_existing_accounts(): existing = {"allAccounts": [{"uid": "old1"}, {"uid": "new", "stale": True}, {"uid": "old2"}]} merged = merge_existing_accounts(cred, existing) uids = [a["uid"] for a in merged["allAccounts"]] - assert uids == ["old1", "old2", "new"] # 去重且新账号最后 + assert uids == ["old1", "old2", "new"] # Deduplicate and append the new account. assert merged["accounts"][-1]["uid"] == "new" same = merge_existing_accounts(build_auth_file({"accessToken": "x"}, {"uid": "new"}), None) assert [a["uid"] for a in same["allAccounts"]] == ["new"] @@ -140,7 +136,7 @@ def json(self): class _FakeClient: - """按 URL 路由的假 httpx.Client;calls 记录 (method, path)。""" + """Route synthetic HTTP requests by URL and record method/path calls.""" routes = {} calls = [] @@ -184,17 +180,17 @@ def test_oauth_full_flow(): m = _manager(routes) r = m.start("cn") assert r["login_id"].startswith("oa_") and r["expires_in"] == 600 - assert r["verification_uri"] == "https://www.codebuddy.cn/login?state=st-1" # 缺省兜底链接 - assert m.poll(r["login_id"]) == {"done": False} # 未授权 + assert r["verification_uri"] == "https://www.codebuddy.cn/login?state=st-1" # Default authorization URL + assert m.poll(r["login_id"]) == {"done": False} # Authorization pending granted["v"] = True r2 = m.poll(r["login_id"]) assert r2["done"] and r2["uid"] == "u1" and r2["nickname"] == "甲" assert r2["cred"]["auth"]["accessToken"] == "at" assert _FakeClient.last_headers["Authorization"] == "Bearer at" assert _FakeClient.last_headers["X-Domain"] == "www.codebuddy.cn" - r3 = m.poll(r["login_id"]) # 重复轮询取缓存结果 + r3 = m.poll(r["login_id"]) # Reuse the cached result. assert r3["done"] and r3["uid"] == "u1" - assert validate_cred_data(r3["cred"])[1] is None # 产出必过入库校验 + assert validate_cred_data(r3["cred"])[1] is None # Validate before persistence. print("✅ test_oauth_full_flow") @@ -234,7 +230,7 @@ def test_oauth_edge_cases(): m = _manager({"/auth/state": {"code": 0, "data": {"state": "s", "authUrl": "https://x/qr"}}, "/auth/token": {"code": 0, "data": {"accessToken": "at"}}, "/login/account": {"code": 0, "data": {}}}) - assert m.start("intl")["verification_uri"] == "https://x/qr" # 优先用上游 authUrl + assert m.start("intl")["verification_uri"] == "https://x/qr" # Prefer upstream authUrl. try: m.start("xx") raise AssertionError("应拒绝未知站点") @@ -247,19 +243,19 @@ def test_oauth_edge_cases(): except RuntimeError as e: assert "限流" in str(e) assert m2.poll("oa_ghost")["error"] == "登录请求不存在或已过期" - # 账号接口无 uid + # Missing account UID m_acc = _manager({"/auth/state": {"code": 0, "data": {"state": "s"}}, "/auth/token": {"code": 0, "data": {"accessToken": "at"}}, "/login/account": {"code": 0, "data": {}}}) r = m_acc.start("cn") rr = m_acc.poll(r["login_id"]) assert rr["done"] and "uid" in rr["error"] - # 超时 + # Expired authorization m3 = _manager({"/auth/state": {"code": 0, "data": {"state": "s"}}}, timeout_s=1) r = m3.start() m3._states[r["login_id"]]["expires_at"] = time.time() - 1 assert m3.poll(r["login_id"])["error"] == "登录超时,请重新发起" - # 上游抖动视为未完成 + # Transient upstream failures leave authorization pending. class _Boom(_FakeClient): def get(self, url, headers=None): raise ConnectionError("boom") @@ -272,7 +268,7 @@ def get(self, url, headers=None): class _StubCM: - """假 CredentialManager:summary 可控,refresh_if_due 记录调度调用。""" + """Provide controllable credential summaries and record refresh scheduling.""" def __init__(self, summary, fail=False): self._s = summary self._lock = threading.RLock() @@ -314,18 +310,18 @@ def test_keepalive_refresh(): es = [{"id": f"/tmp/{i}.info", "cm": cm, "fail_until": 0.0, "uid": str(i)} for i, cm in enumerate([fresh, stale, never, expiring, failing])] pool = _pool_with(es) - pool.cooldown = lambda cm, reason="", **kw: None # 单测不触发熔断副作用 + pool.cooldown = lambda cm, reason="", **kw: None # Isolate refresh scheduling from cooldowns. pool.refresh_due() - assert fresh.refreshed == 0 # 刚刷过 → 不动 - assert stale.refreshed == 1 # >24h → 保活 - assert never.refreshed == 1 # 无记录 → 保活 - assert expiring.refreshed == 1 # 临期 → 原有逻辑 - assert failing.refreshed == 1 # 保活失败已尝试 - assert es[4]["keepalive_after"] > now # 失败后退避 1h + assert fresh.refreshed == 0 # Recently refreshed + assert stale.refreshed == 1 # Idle longer than 24 hours + assert never.refreshed == 1 # No prior refresh + assert expiring.refreshed == 1 # Near expiry + assert failing.refreshed == 1 # Failed refresh attempted + assert es[4]["keepalive_after"] > now # Back off after failure. failing.refreshed = 0 pool.refresh_due() - assert failing.refreshed == 0 # 退避期内不再骚扰 - # 关掉保活 + assert failing.refreshed == 0 # Respect retry backoff. + # Disable keepalive. stale2 = _StubCM({"token_expired": False, "token_expires_at": far_future, "last_refresh_time": 0}) pool2 = _pool_with([{"id": "/tmp/x.info", "cm": stale2, "fail_until": 0.0, "uid": "x"}]) pool2.refresh_due(keepalive_s=0) @@ -334,7 +330,7 @@ def test_keepalive_refresh(): def test_oauth_endpoint_import(tmp_path=None): - """poll 完成后的入库路径:同 uid 覆盖已有文件并热加载(不起服务,直接调处理函数)。""" + """Persist completed OAuth results and reload matching account files without starting a server.""" with tempfile.TemporaryDirectory() as td: d = Path(td) old = _cred(uid="u-old") @@ -345,7 +341,7 @@ def test_oauth_endpoint_import(tmp_path=None): cred = build_auth_file({"accessToken": "new-at", "refreshToken": "new-rt", "domain": "www.codebuddy.cn", "expiresIn": 7200}, {"uid": "u1", "nickname": "新"}) - # 模拟端点入库段:同 uid 覆盖 named.info + # Replace the existing named credential for the same UID. target = next((f for f in sorted(d.glob("*.info")) if converter._cred_uid(f) == "u1"), None) assert target and target.name == "named.info" existing = json.loads(target.read_text(encoding="utf-8")) @@ -355,7 +351,7 @@ def test_oauth_endpoint_import(tmp_path=None): assert final["auth"]["accessToken"] == "new-at" assert final["account"]["lastLogin"] is True assert validate_cred_data(final)[1] is None - # 新账号(无同名文件)→ .info + # New accounts use a UID-based filename. assert not (d / "u2.info").exists() cred2 = build_auth_file({"accessToken": "z", "domain": "www.codebuddy.cn"}, {"uid": "u2"}) (d / "u2.info").write_text(json.dumps(cred2), encoding="utf-8") diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 3d7575d..897e562 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -1,12 +1,9 @@ #!/usr/bin/env python3 -"""CLI/WorkBuddy 产品目录解析、请求隔离与 schema2 缓存回归;纯 mock/临时目录。 - -运行:.venv/bin/python -B tests/test_catalog.py -""" +"""Test product catalog parsing, request isolation and versioned caches with offline fixtures.""" import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # 仓库根:允许直接运行本文件 +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. import base64 from concurrent.futures import ThreadPoolExecutor @@ -190,7 +187,7 @@ def test_fetch_calls_selector_and_uses_international_host(self): "app.credits.select_product_models", wraps=select_product_models) as select: factory.return_value.__enter__.return_value = client self.assertEqual(fetch_model_catalog(token, "CLI/test"), [data["models"][1]]) - # 选择器与账号根表两个作用域共用一次拉取,解析各调一次。 + # Parse selector and account scopes from the same upstream response. select.assert_has_calls([call(data, "cli"), call(data, "cli", scope="account")]) self.assertEqual(select.call_count, 2) self.assertEqual(client.get.call_count, 1, "两个作用域必须共用一次 /v3/config") diff --git a/tests/test_catalog_scope.py b/tests/test_catalog_scope.py index 924aab9..359a24b 100644 --- a/tests/test_catalog_scope.py +++ b/tests/test_catalog_scope.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 -"""账号候选目录、选择器兼容性、缓存隔离与倍率的离线回归。""" +"""Test account catalogs, selector compatibility, cache isolation and model rates offline.""" import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # 仓库根:允许直接运行本文件 +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. import base64 import json @@ -23,7 +23,7 @@ def jwt(issuer): def config_payload(): - """账号根表四项、cli agent 只声明其中两项,形状对齐官方 /v3/config。""" + """Model an official catalog with four root entries and two CLI selector entries.""" def entry(identifier, credits, tools=True): item = {"id": identifier, "name": identifier, "credits": credits} if tools: @@ -120,7 +120,7 @@ def test_older_entries_without_root_are_tolerated(self): with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "model-catalog.json" cache = ModelCatalogCache(path) - cache.put("domestic", [{"id": "legacy"}]) # 升级前写的缓存 + cache.put("domestic", [{"id": "legacy"}]) # Legacy cache entry self.assertEqual(cache.serves("domestic"), []) self.assertEqual(ModelCatalogCache(path).models("domestic"), [{"id": "legacy"}]) @@ -148,7 +148,7 @@ def test_cached_serves_is_a_copy(self): class AccountScopeHelperTests(unittest.TestCase): - """converter._account_scope:选择器优先、根表补名字、缺目录不外借。""" + """Preserve selector metadata, supplement root names and forbid borrowing missing catalogs.""" def test_scope_models_is_the_subset(self): account = {"models": [{"id": "picker"}], "serves": [{"id": "root"}]} @@ -174,7 +174,7 @@ def test_legacy_cache_without_root_falls_back_to_the_subset(self): class FreeTierWithRootScopeTests(unittest.TestCase): - """根表补进来的名字按它自己的倍率计费,绝不把 x0.00 借给付费模型。""" + """Use each root model's own rate without borrowing a free selector rate.""" def setUp(self): data = config_payload() diff --git a/tests/test_credential_runtime.py b/tests/test_credential_runtime.py index 06b4cf4..787b8b8 100644 --- a/tests/test_credential_runtime.py +++ b/tests/test_credential_runtime.py @@ -1,8 +1,8 @@ -"""凭证真实刷新、并发去重、扫描与熔断回归;只使用临时凭据和 mock 上游。""" +"""Test credential refresh, concurrency, discovery and cooldowns with temporary files and mocks.""" import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # 仓库根:允许直接运行本文件 +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. import json import os @@ -136,7 +136,7 @@ def test_explicit_import_reloads_without_replacing_manager(self): self.assertIs(pool.first(), manager) self.assertEqual(pool._entries[0]["fail_until"], 0) self.assertIn("reimported-synthetic-token", manager.get_headers()["Authorization"]) - self.assertIsNone(pool.pick("session", "glm-5.3-flash")) # 配额冷却不随重新登录清除 + self.assertIsNone(pool.pick("session", "glm-5.3-flash")) # Relogin preserves quota cooldown. def test_duplicate_notice_is_not_emitted_on_every_request(self): path = self.credential(age=0) diff --git a/tests/test_credit_identity_dedupe.py b/tests/test_credit_identity_dedupe.py index f1677c4..d669d95 100644 --- a/tests/test_credit_identity_dedupe.py +++ b/tests/test_credit_identity_dedupe.py @@ -1,18 +1,11 @@ #!/usr/bin/env python3 -"""test_credit_identity_dedupe.py — 同一账号在多个凭据路径下重复记账时,余额只算一次。 - -ledger 以凭据绝对路径为键、路径只作索引(见 CreditLedger.bind_identity)。换 -CODEBUDDY_AUTH_DIR / 搬动项目目录时把旧的 credits-ledger.json 一起带过来,同一身份 -就会留在两个键上,aggregate_credits 逐键相加会把一份余额算成两份。 - -直接运行:python3 tests/test_credit_identity_dedupe.py -""" +"""Count each account balance once across duplicate credential paths.""" import sys import tempfile from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # 仓库根:允许直接运行本文件 +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. from app import credits from app.credits import CreditLedger, aggregate_credits, dedupe_by_identity @@ -34,7 +27,7 @@ def _snapshot(cred_id, identity, balance): def test_same_identity_counted_once(): - """一账号两路径:合计等于一份余额,而不是两倍。""" + """Count one account's balance once across duplicate file paths.""" snap = {} snap.update(_snapshot("/old/auth/x.info", IDENTITY, _balance(100))) snap.update(_snapshot("/new/auth/x.info", IDENTITY, _balance(100))) @@ -45,7 +38,7 @@ def test_same_identity_counted_once(): def test_distinct_identities_still_sum(): - """不同账号照旧累加:去重不能把多账号池子折叠成一条。""" + """Retain independent balances for different accounts.""" snap = {"/auth/x.info": {"identity": IDENTITY, "credits": _balance(100)}, "/auth/y.info": {"identity": OTHER, "credits": _balance(50)}} agg = aggregate_credits(snap) @@ -55,7 +48,7 @@ def test_distinct_identities_still_sum(): def test_unbound_entries_are_preserved(): - """未绑定身份的历史条目全部保留:归属未知时不得静默丢数据。""" + """Preserve historical entries with unknown ownership.""" snap = {"legacy-a": {"credits": _balance(10)}, "legacy-b": {"credits": _balance(20)}, "no-credits": {}, @@ -66,7 +59,7 @@ def test_unbound_entries_are_preserved(): def test_freshest_record_wins(): - """同身份取 fetched_at 最新的一条,旧路径上的过期余额不再虚增总额。""" + """Prefer the newest balance snapshot for the same account.""" snap = _snapshot("/old/auth/x.info", IDENTITY, _balance(900, 900, fetched_at=10.0)) snap["/new/auth/x.info"] = {"identity": IDENTITY, "credits": _balance(10, 900, fetched_at=20.0)} agg = aggregate_credits(snap) @@ -76,18 +69,18 @@ def test_freshest_record_wins(): def test_empty_record_does_not_shadow_balance(): - """刚 bind 还没刷到积分的条目,不应盖掉同身份已有的余额。""" + """Keep an existing balance over an unsynchronized duplicate entry.""" snap = _snapshot("/old/auth/x.info", IDENTITY, _balance(120, fetched_at=10.0)) snap["/new/auth/x.info"] = {"identity": IDENTITY, "credits": {}} assert aggregate_credits(snap)["remaining"] == 120.0 - # 反过来:有数据的那条胜出,与写入顺序无关 + # Prefer the populated balance independently of insertion order. flipped = dict(reversed(list(snap.items()))) assert aggregate_credits(flipped)["remaining"] == 120.0 print("✅ test_empty_record_does_not_shadow_balance") def test_groups_are_not_cross_merged(): - """国内/国际各一份余额属于两个身份,折叠后两站数值都不丢。""" + """Preserve independent domestic and international balances.""" snap = {"/old/x.info": {"identity": IDENTITY, "credits": _balance(100)}, "/new/x.info": {"identity": IDENTITY, "credits": _balance(100)}, "/old/i.info": {"identity": OTHER, "credits": _balance(30, intl=True)}, @@ -99,7 +92,7 @@ def test_groups_are_not_cross_merged(): def test_ledger_reload_keeps_duplicate_rows(): - """去重发生在读取口径:落盘的重复行不删(看板仍能看到),但合计不再翻倍。""" + """Deduplicate aggregate reads without deleting persisted account rows.""" with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "credits-ledger.json" ledger = CreditLedger(path) diff --git a/tests/test_credits.py b/tests/test_credits.py index ebc2517..338e59a 100644 --- a/tests/test_credits.py +++ b/tests/test_credits.py @@ -1,8 +1,5 @@ #!/usr/bin/env python3 -"""test_credits.py — 验证 credits.py 的签到判定/域名选择/积分分段/ledger 与快过期优先调度。 - -直接运行:python3 tests/test_credits.py -""" +"""Test check-in, billing hosts, credit segments, persistence and expiry-aware scheduling.""" import base64 import json @@ -15,7 +12,7 @@ import httpx -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # 仓库根:允许直接运行本文件 +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. from app import credits from app.credits import ( @@ -41,7 +38,7 @@ def test_issuer_origin(): def test_hosts_for_token(): assert hosts_for_token(_jwt("https://www.codebuddy.cn/auth/realms/copilot")) == ["https://www.codebuddy.cn"] assert hosts_for_token(_jwt("https://www.workbuddy.ai/auth/realms/copilot")) == ["https://www.workbuddy.ai"] - # 无显式提示时与 site_routing 一致,仅默认国内 CLI,绝不再遍历另一产品。 + # Absent identity hints default only to domestic CLI, never another product. assert hosts_for_token("bad") == ["https://www.codebuddy.cn"] for brand in ("codebuddy", "workbuddy"): for suffix in ("cn", "ai"): @@ -60,7 +57,7 @@ def test_hosts_for_token(): def test_financial_hints_rejected_before_network(): - """显式未知/不安全提示与跨地域、跨产品冲突均在建立 Client 前拒绝。""" + """Reject unsafe or conflicting identity hints before opening a client.""" invalid = [ ("opaque", "unknown.example"), ("opaque", "http://www.codebuddy.cn"), @@ -80,13 +77,13 @@ def test_financial_hints_rejected_before_network(): with TestCase().assertRaises(ValueError): operation(token, domain=domain) factory.assert_not_called() - # 对外纯解析函数保留宽松解析兼容性,不把它当作受信任路由。 + # Permissive parsing alone must not authorize routing. assert token_issuer_origin(_jwt("https://unknown.example/x")) == "https://unknown.example" print("test_financial_hints_rejected_before_network passed") def test_financial_profile_hosts_and_web_headers(): - """各品牌不混用,签到走桌面 Bearer 协议,余额和用量保留 Web 协议。""" + """Keep product-specific billing and desktop check-in headers isolated.""" for domain in ("www.codebuddy.cn", "copilot.tencent.com", "www.workbuddy.cn", "www.codebuddy.ai", "www.workbuddy.ai"): host = "https://" + ("www.codebuddy.cn" if domain == "copilot.tencent.com" else domain) @@ -122,7 +119,7 @@ def test_financial_profile_hosts_and_web_headers(): def test_financial_failures_stay_on_profile(): - """保留原 POST 尝试次数/同 host 签到路径,失败也不转发 token 到其他产品。""" + """Preserve request counts and same-host check-in without cross-product token forwarding.""" for domain in ("", "www.codebuddy.cn", "www.workbuddy.cn", "www.codebuddy.ai", "www.workbuddy.ai"): host = "https://" + (domain or "www.codebuddy.cn") for failure in (404, 401, "network"): @@ -156,9 +153,9 @@ def test_classify_checkin(): assert classify_checkin_result(True, 0, "ok")["ok"] is True r = classify_checkin_result(True, 10001, "今日已签到,请勿重复") assert r["ok"] is True and r["already"] is True - r = classify_checkin_result(True, 10001, "活动未开启") # 10001 但文案是未开启 → 不算已签 + r = classify_checkin_result(True, 10001, "活动未开启") # Inactive does not mean already claimed. assert r["ok"] is False and r["inactive"] is True - assert classify_checkin_result(False, 0, "x")["ok"] is False # HTTP 非 2xx 不算成功 + assert classify_checkin_result(False, 0, "x")["ok"] is False # Non-2xx cannot succeed. assert classify_checkin_result(True, 1, "fail")["ok"] is False assert classify_checkin_result(True, None, "")["ok"] is False print("✅ test_classify_checkin") @@ -166,20 +163,20 @@ def test_classify_checkin(): def test_extract_segments(): accounts = [ - { # 有切片明细:展开,过期字段优先 DeductionEndTime + { # Expand slices and prefer their deduction expiry. "PackageName": "月度包", "PackageCode": "p1", "SlicePeriodUsageDetails": [ {"SlicePeriodCapacityRemainPrecise": "300", "SlicePeriodCapacitySizePrecise": "500", "DeductionEndTime": 1700000100}, - {"SlicePeriodCapacityRemainPrecise": "0"}, # 余量 0 被过滤 + {"SlicePeriodCapacityRemainPrecise": "0"}, # Filter zero balances. ], }, - { # 无明细:周期字段 + { # Use package-period fields when slices are absent. "PackageName": "赠送包", "PackageCode": "p2", "CycleCapacityRemainPrecise": "200.5", "CycleCapacitySizePrecise": "500", "ExpiredTime": "2027-01-01 00:00:00", }, - {"PackageName": "空包", "CapacityRemain": 0}, # 余量 0 过滤 + {"PackageName": "空包", "CapacityRemain": 0}, # Filter zero balances. ] segs = extract_segments(accounts) assert len(segs) == 2, segs @@ -192,15 +189,15 @@ def test_extract_segments(): def test_merge_and_sort_segments(): segs = merge_segments([ {"remaining": 100, "total": 100, "expires_at": 3000, "source": "包A", "package_code": "a"}, - {"remaining": 50, "total": 50, "expires_at": 3000, "source": "包A", "package_code": "a"}, # 同包同期 → 合并 + {"remaining": 50, "total": 50, "expires_at": 3000, "source": "包A", "package_code": "a"}, # Merge matching package periods. {"remaining": 200, "total": 200, "expires_at": 1000, "source": "包B", "package_code": "b"}, - {"remaining": 999, "total": 999, "expires_at": None, "source": "永久", "package_code": ""}, # 无过期排最后 + {"remaining": 999, "total": 999, "expires_at": None, "source": "永久", "package_code": ""}, # Sort unknown expiry last. {"remaining": 0, "total": 0, "expires_at": 500, "source": "空", "package_code": "c"}, ]) assert len(segs) == 3 - assert segs[0]["package_code"] == "b" # 最早过期排最前 - assert segs[1]["remaining"] == 150 and segs[1]["total"] == 150 # 合并结果 - assert segs[2]["expires_at"] is None # 无过期时间排最后 + assert segs[0]["package_code"] == "b" # Earliest expiry first + assert segs[1]["remaining"] == 150 and segs[1]["total"] == 150 # Merged balance + assert segs[2]["expires_at"] is None # Unknown expiry last print("✅ test_merge_and_sort_segments") @@ -208,13 +205,13 @@ def test_soonest_expiry(): now = time.time() segs = [ {"remaining": 10, "expires_at": now + 86400}, - {"remaining": 10, "expires_at": now + 3600}, # 最早未过期 - {"remaining": 10, "expires_at": now - 100}, # 已过期,不算 + {"remaining": 10, "expires_at": now + 3600}, # Earliest active expiry + {"remaining": 10, "expires_at": now - 100}, # Exclude expired credits. {"remaining": 10, "expires_at": None}, ] assert soonest_expiry(segs, now=now) == now + 3600 - assert soonest_expiry([{"remaining": 10, "expires_at": now - 1}], now=now) is None # 全过期 - assert soonest_expiry([{"remaining": 10, "expires_at": None}], now=now) is None # 无过期时间 + assert soonest_expiry([{"remaining": 10, "expires_at": now - 1}], now=now) is None # All expired + assert soonest_expiry([{"remaining": 10, "expires_at": None}], now=now) is None # No known expiry assert soonest_expiry([], now=now) is None print("✅ test_soonest_expiry") @@ -227,16 +224,16 @@ def test_ledger(tmp_path=None): assert not ledger.checkin_done("c1", day) ledger.mark_checkin("c1", day, True, 0, "ok") assert ledger.checkin_done("c1", day) - assert not ledger.checkin_done("c1", "1999-01-01") # 跨日重新签 + assert not ledger.checkin_done("c1", "1999-01-01") # Check-in is scoped to one day. now = time.time() ledger.update_credits("c1", {"credits": 300.0, "count": 1, "segments": [ {"remaining": 300, "total": 300, "expires_at": now + 7200, "source": "包", "package_code": "x"}], "soonest_expiry": now + 7200}) assert ledger.soonest_expiry_of("c1") == now + 7200 - assert ledger.soonest_expiry_of("c2") is None # 无数据 + assert ledger.soonest_expiry_of("c2") is None # No balance data - # 持久化往返 + # Persistence round trip ledger2 = CreditLedger(path) assert ledger2.checkin_done("c1", day) assert ledger2.soonest_expiry_of("c1") == now + 7200 @@ -246,12 +243,12 @@ def test_ledger(tmp_path=None): def test_ledger_remove_and_entry(): - """同路径换账号/站点时彻底清旧状态;快照不泄露内部引用。""" + """Clear stale state on identity changes and return independent snapshots.""" with tempfile.TemporaryDirectory() as td: path = Path(td) / "ledger.json" ledger = CreditLedger(path) assert ledger.entry("missing") == {} - assert ledger.snapshot() == {} # 只读 entry 不创建条目 + assert ledger.snapshot() == {} # Reads do not create entries. result = {"credits": 10, "intl": False, "segments": [ {"remaining": 10, "total": 10, "expires_at": time.time() + 3600}]} ledger.update_credits("same-path", result) @@ -267,7 +264,7 @@ def test_ledger_remove_and_entry(): assert ledger.entry("same-path")["credits"]["segments"][0]["remaining"] == 10 assert ledger.checkin_done("same-path", "2026-01-01") ledger.remove("same-path") - ledger.remove("missing") # 幂等且不创建幽灵条目 + ledger.remove("missing") # Idempotent removal without creating entries. assert ledger.entry("same-path") == {} assert ledger.soonest_expiry_of("same-path") is None reloaded = CreditLedger(path) @@ -282,7 +279,7 @@ def test_ledger_remove_and_entry(): def test_ledger_threaded_entries(): - """不同凭证并发更新/删除与深拷贝读取仍可持久化。""" + """Persist concurrent credential updates, removals and snapshot reads safely.""" from concurrent.futures import ThreadPoolExecutor with tempfile.TemporaryDirectory() as td: path = Path(td) / "ledger.json" @@ -307,7 +304,7 @@ def update(cred_id): def test_daily_checkin_http(monkey_response=None): - """mock httpx:首 host 404 换 path 后 code=0 成功。""" + """Exercise same-host check-in path fallback with mocked responses.""" calls = [] class FakeResp: @@ -335,12 +332,12 @@ def post(self, url, headers=None, json=None, timeout=None): finally: credits.httpx.Client = orig assert r["ok"] is True, r - assert calls == ["https://www.codebuddy.cn" + path for path in credits.CHECKIN_PATHS] # 只换 path + assert calls == ["https://www.codebuddy.cn" + path for path in credits.CHECKIN_PATHS] # Same-host fallback only print("✅ test_daily_checkin_http") def test_fetch_credits_http(): - """mock httpx:get-user-resource 返回切片明细,验证汇总与最早过期。""" + """Aggregate mocked credit slices and their earliest expiry.""" now = time.time() class FakeResp: @@ -378,10 +375,10 @@ def post(self, url, headers=None, json=None, timeout=None): def test_pick_expiry_priority(): - """凭证池 pick:快过期积分的凭证优先;同级轮询;无数据排最后。""" + """Prefer expiring credits, rotate equal candidates and place unknown balances last.""" import converter with tempfile.TemporaryDirectory() as td: - # 三个假凭证文件 + # Synthetic credential files paths = [] for i, uid in enumerate(["u1", "u2", "u3"]): p = Path(td) / f"cred{i}.info" @@ -393,7 +390,7 @@ def test_pick_expiry_priority(): ledger = CreditLedger(Path(td) / "ledger.json") pool.set_ledger(ledger) - # 无数据时:全部同级,轮询 + # Unknown balances share round-robin priority. seen = {pool.pick(None).path.name for _ in range(3)} assert len(seen) == 3, seen @@ -406,11 +403,11 @@ def test_pick_expiry_priority(): {"remaining": 10, "total": 10, "expires_at": now + 86400, "source": "s", "package_code": "b"}], "soonest_expiry": now + 86400}) - # cred2 最早过期 → 恒优先 + # Prefer cred2's earlier expiry. for _ in range(3): assert pool.pick(None).path.name == "cred2.info" - # cred2 数据清空后 → cred0 优先(cred1 无数据排最后) + # After clearing cred2, prefer cred0 over unknown balances. ledger.update_credits(ids[2], {"credits": 0, "count": 0, "segments": [], "soonest_expiry": None}) for _ in range(2): assert pool.pick(None).path.name == "cred0.info" @@ -418,7 +415,7 @@ def test_pick_expiry_priority(): def test_fetch_model_catalog(): - """mock httpx:/v3/config 返回模型表;校验 cli 平台头与解析。""" + """Validate CLI catalog headers and model parsing with mocked config responses.""" seen_headers = {} class FakeResp: @@ -446,13 +443,13 @@ def get(self, url, headers=None, timeout=None): finally: credits.httpx.Client = orig assert [m["id"] for m in models] == ["glm-9.9", "img-1"] - assert seen_headers.get("x-client-platform") == "cli" # 必须 cli,否则 400 + assert seen_headers.get("x-client-platform") == "cli" # Required by the catalog endpoint. assert httpx.Headers(seen_headers)["user-agent"] == "CLI/9.9" print("✅ test_fetch_model_catalog") def test_current_models_merge(): - """显式国内内部视图:已知目录不补静态模型,明确空表不回退。""" + """Preserve explicit domestic catalogs without static expansion or empty-list fallback.""" import converter with patch.dict(converter.CONFIG, {"cred_pool": None, "cred": None, "model_catalogs": {}, "account_catalogs": None, "model_cache": None, "ledger": None, @@ -461,29 +458,29 @@ def test_current_models_merge(): {"id": "hunyuan-image", "supportsToolCall": False}, ]}): out = converter.current_models(region="cn") - assert out == ["glm-9.9", "auto"] # 图像模型不进表,调度别名保留 + assert out == ["glm-9.9", "auto"] # Exclude image models and retain the scheduling alias. assert not [m for m in out if m.endswith("-free")] assert "deepseek-v4.1-flash" in converter.DEFAULT_MODELS - assert "glm-5.3" not in out # 已知目录禁止借用默认表扩大产品能力 + assert "glm-5.3" not in out # Do not expand known product capabilities with defaults. converter.CONFIG["models_remote"] = [] assert converter.current_models(region="cn") == [] converter.CONFIG["models_remote"] = None - # 无账号的旧式内部展示兜底不代表生产账号获得该目录的路由权限。 + # Legacy display fallback does not authorize production account routing. assert converter.current_models(region="cn") == converter.DEFAULT_MODELS print("✅ test_current_models_merge") def test_credits_to_usd(): - """单价→美元换算:0.014 元/Credit @ 汇率 7.15。""" + """Convert credit prices to USD using the configured exchange rate.""" assert abs(credits.credits_to_usd(1000) - 1000 * 0.014 / 7.15) < 1e-9 assert credits.credits_to_usd(0) == 0.0 - assert abs(credits.credits_to_usd(50000, 0.014, 7.0) - 100.0) < 1e-9 # 汇率可覆盖 + assert abs(credits.credits_to_usd(50000, 0.014, 7.0) - 100.0) < 1e-9 # Exchange-rate override assert credits.CREDIT_PRICE_CNY == 0.014 and credits.USAGE_MAX_DAYS == 30 print("✅ test_credits_to_usd") def test_aggregate_credits(): - """ledger 汇总:按国内/国际分组累加、额度差已用、最早过期;空条目容错。""" + """Aggregate regional balances, quota usage and expiry while tolerating empty entries.""" snap = {"a": {"credits": {"intl": False, "segments": [ {"remaining": 100, "total": 200, "expires_at": 500}, {"remaining": 50, "total": 50, "expires_at": 900}]}}, @@ -493,8 +490,8 @@ def test_aggregate_credits(): {"remaining": 300, "total": 400, "expires_at": 700}]}}, "d": {"credits": {"segments": [{"remaining": 30, "total": 10, "expires_at": None}]}}} agg = credits.aggregate_credits(snap) - assert agg["remaining"] == 480, agg # 国内 180 + 国际 300 - assert agg["used_by_quota"] == 200, agg # 国内 100 + 国际 100;负差不计 + assert agg["remaining"] == 480, agg # Domestic 180 plus international 300 + assert agg["used_by_quota"] == 200, agg # Sum regional usage without negative differences. assert agg["soonest_expiry"] == 500, agg g = agg["groups"] assert g["domestic"]["remaining"] == 180 and g["domestic"]["used_by_quota"] == 100, g @@ -506,7 +503,7 @@ def test_aggregate_credits(): def _fake_client(pages, seen=None): - """按 pageNum 返回预置响应的 httpx.Client 替身。""" + """Return predefined HTTP responses by page number.""" class FakeResp: status_code = 200 def __init__(self, payload): @@ -536,11 +533,11 @@ def _with_client(fake, fn): def test_fetch_request_usage_rejects_invalid_success_payloads(): - """HTTP 200 但业务码失败或结构缺失:必须报错,不能当作零用量。""" + """Reject HTTP 200 responses with business errors or missing usage structure.""" token = _jwt("https://www.codebuddy.cn/x") for pages in ([{"code": 1059, "msg": "rate limited", "data": {"total": 0, "data": []}}], - [{"code": 0, "data": {}}], # 缺 data.data/total - [{"data": {"data": [], "total": 0}}], # code 缺失但结构完整 → 合法空 + [{"code": 0, "data": {}}], # Missing nested data and total + [{"data": {"data": [], "total": 0}}], # Valid empty structure without code ): try: result = _with_client(_fake_client(pages), @@ -549,12 +546,12 @@ def test_fetch_request_usage_rejects_invalid_success_payloads(): assert pages[0].get("code") not in (0, None) or "data" not in pages[0].get("data", {}) \ or "data" not in pages[0]["data"] else: - assert pages[0].get("code") is None and result["requests"] == 0 # 合法空结果照旧可用 + assert pages[0].get("code") is None and result["requests"] == 0 # Preserve valid empty results. print("✅ test_fetch_request_usage_rejects_invalid_success_payloads") def test_fetch_credits_distinguishes_empty_from_missing_structure(): - """Accounts 键存在但为空 = 合法零余额;结构整体缺失 = 报错,不得覆盖缓存为零。""" + """Distinguish confirmed zero balances from missing response structure.""" token = _jwt("https://www.codebuddy.cn/x") empty = _with_client(_fake_client([{"code": 0, "data": {"Response": {"Data": {"Accounts": []}}}}]), lambda: credits.fetch_credits(token)) @@ -569,7 +566,7 @@ def test_fetch_credits_distinguishes_empty_from_missing_structure(): def test_fetch_credits_paginates_until_short_page(): - """积分包超过一页时翻页累加;不足一页停止;达到页数上限标记 partial。""" + """Aggregate credit pages, stop on short pages and mark capped results partial.""" token = _jwt("https://www.codebuddy.cn/x") account = lambda i: {"PackageName": f"p{i}", "PackageCode": f"c{i}", "SlicePeriodUsageDetails": [{"SlicePeriodCapacityRemainPrecise": "1", @@ -587,7 +584,7 @@ def test_fetch_credits_paginates_until_short_page(): assert len(seen) == credits.CREDITS_MAX_PAGES assert result["partial"] is True - # 第 2 页的瞬时空响应也要重试:不能在非首页把空页当作结束 + # Retry transient empty later pages before treating them as pagination completion. calls = {"n": 0} sequence = [full_page, {"code": 0, "data": {"Response": {"Data": {"Accounts": []}}}}, short_page] @@ -612,7 +609,7 @@ def json(self): def test_fetch_request_usage_marks_partial_at_page_cap(): - """用量明细达到页数上限且 total 更大时必须标记 partial。""" + """Mark usage partial when the page cap is below the advertised total.""" token = _jwt("https://www.codebuddy.cn/x") big_total = credits.USAGE_MAX_PAGES * credits.USAGE_PAGE_SIZE + 1 row = {"requestTime": "2026-09-01 10:00:00", "model": "m", "credit": 0.01} @@ -623,7 +620,7 @@ def test_fetch_request_usage_marks_partial_at_page_cap(): def test_sync_usage_keeps_per_account_snapshots_on_failure(): - """单账号同步失败:聚合保留其上次成功快照并标记 stale/partial,不再整体覆盖丢失。""" + """Retain a failed account's prior usage snapshot with explicit stale and partial flags.""" import converter with tempfile.TemporaryDirectory() as td: paths = [] @@ -679,7 +676,7 @@ def check_billing_stale(names): assert converter._billing_totals()["used_source"] == "quota_delta" failing.clear() - # 首轮即有账号失败且无任何历史快照:也必须标 stale/partial,不能装作精确 + # Expose first-sync failures even when no historical snapshot exists. failing.add("token-u2") converter._sync_usage(pool) view = converter.CONFIG["usage_daily"] @@ -704,7 +701,7 @@ def check_billing_stale(names): failing.add("token-u2") converter._sync_usage(pool) view = converter.CONFIG["usage_daily"] - assert view["total_credits"] == 30.0 and view["requests"] == 3 # u2 历史保留 + assert view["total_credits"] == 30.0 and view["requests"] == 3 # Retain u2's snapshot. assert view["partial"] is True and view["stale_accounts"] == ["u2.info"] failing.clear() @@ -712,13 +709,13 @@ def check_billing_stale(names): "total_credits": 25.0, "requests": 4, "partial": False} converter._sync_usage(pool) view = converter.CONFIG["usage_daily"] - assert view["total_credits"] == 35.0 and view["partial"] is False # 成功后自愈 + assert view["total_credits"] == 35.0 and view["partial"] is False # Clear staleness after recovery. paths[1].unlink() pool.prune() converter._sync_usage(pool) view = converter.CONFIG["usage_daily"] - assert view["total_credits"] == 10.0 # 凭证删除后其快照不再计入 + assert view["total_credits"] == 10.0 # Exclude deleted credentials. with patch.object(converter.model_policy, "credential_enabled", return_value=False): converter._sync_usage(pool) @@ -737,7 +734,7 @@ def check_billing_stale(names): def test_fetch_request_usage_paging(): - """mock 分页明细:跨页聚合 credit,按 日期×模型 归并;请求天数夹到 30 天。""" + """Aggregate usage pages by day and model within the supported 30-day window.""" pages = [ {"code": 0, "data": {"total": 3, "data": [ {"requestTime": "2026-09-01 10:00:00", "model": "glm-5.3", "credit": 0.5}, @@ -770,18 +767,18 @@ def post(self, url, headers=None, json=None, timeout=None): u = credits.fetch_request_usage(_jwt("https://www.codebuddy.cn/x"), days=365) finally: credits.httpx.Client = orig - assert seen["pages"] == [1, 2], seen # 按 total 停止分页 + assert seen["pages"] == [1, 2], seen # Stop at the advertised total. assert u["requests"] == 3 and abs(u["total_credits"] - 0.75) < 1e-9 and u["partial"] is False assert u["by_day"]["2026-09-01"]["glm-5.3"] == 0.75 - assert "hy4-preview" in u["by_day"]["2026-09-02"] # 免费模型 0 credit 也计入请求数 + assert "hy4-preview" in u["by_day"]["2026-09-02"] # Count zero-credit requests. import time as _t span = (_t.mktime(_t.strptime(seen["days"][0][:19], "%Y-%m-%d %H:%M:%S"))) - assert abs((_t.time() - span) - 30 * 86400) < 3600 # days=365 被夹回 30(官方 >31 天返回空) + assert abs((_t.time() - span) - 30 * 86400) < 3600 # Clamp to the supported window. print("✅ test_fetch_request_usage_paging") def test_billing_balance_identity(): - """核心不变式:hard_limit_usd − total_usage/100 == 剩余余额(One-API 算法自洽)。""" + """Preserve the subscription limit minus usage equals balance identity.""" import converter with tempfile.TemporaryDirectory() as td: led = credits.CreditLedger(Path(td) / "ledger.json") @@ -791,7 +788,7 @@ def test_billing_balance_identity(): saved_led, saved_usage = converter.CONFIG.get("ledger"), converter.CONFIG.get("usage_daily") try: converter.CONFIG["ledger"] = led - # 明细可用:已用取官方明细(真实消耗 200 credits) + # Prefer official usage details over quota differences. day_map = {"2026-09-01": {"glm-5.3": 200.0}} converter.CONFIG["usage_daily"] = {"by_day": day_map, "groups": {"domestic": {"by_day": day_map, "total_credits": 200.0, @@ -800,22 +797,22 @@ def test_billing_balance_identity(): t = converter._billing_totals() assert t["remaining"] == 1000.0 and t["used"] == 200.0 and t["quota"] == 1200.0 assert t["used_source"] == "official_usage_detail" - # 端点级恒等式:客户端按 hard_limit_usd − total_usage/100 算出的正是真实剩余 + # Subscription limit minus usage must equal the remaining balance. sub = converter.billing_subscription(None, None) usage = converter.billing_usage(None, None, None, None) - assert sub["codebuddy_partial"] is False # 数据完整时显式 False + assert sub["codebuddy_partial"] is False # Explicitly report complete data. assert abs(sub["hard_limit_usd"] - usage["total_usage"] / 100 - t["remaining_usd"]) < 0.01 assert sub["codebuddy_credits_remaining"] == 1000.0 assert sub["plan"]["title"].startswith("CodeBuddy Credits") assert usage["object"] == "list" assert usage["daily_costs"][0]["line_items"][0]["name"] == "glm-5.3" - # 明细缺失:回退额度差(1600-1000=600)且恒等式仍成立 + # Quota-difference fallback must preserve the balance identity. converter.CONFIG["usage_daily"] = None t2 = converter._billing_totals() assert t2["used"] == 600.0 and t2["used_source"] == "quota_delta" assert abs(t2["quota_usd"] - t2["used_usd"] - t2["remaining_usd"]) < 0.01 - assert converter.billing_usage(None, None, None, None)["daily_costs"] == [] # 无明细则不出 daily_costs - # 区间过滤只统计窗口内明细 + assert converter.billing_usage(None, None, None, None)["daily_costs"] == [] # No fabricated daily details + # Include only usage within the requested interval. converter.CONFIG["usage_daily"] = {"by_day": {"2026-09-01": {"glm-5.3": 200.0}, "2026-09-05": {"glm-5.3": 50.0}}, "groups": {"domestic": {"by_day": { @@ -834,7 +831,7 @@ def test_billing_balance_identity(): def test_billing_usage_prices_each_day_by_site(): - """两站单价不同且用量发生在不同天:逐日金额必须按本站单价,而不是全局平均价。""" + """Apply each region's credit rate to its own daily usage.""" import converter with tempfile.TemporaryDirectory() as td: led = credits.CreditLedger(Path(td) / "ledger.json") @@ -845,7 +842,7 @@ def test_billing_usage_prices_each_day_by_site(): saved = (converter.CONFIG.get("ledger"), converter.CONFIG.get("usage_daily")) try: converter.CONFIG["ledger"] = led - # 国内 100 credits @ $0.014/7.15 在 09-01;国际 100 credits @ $0.03 在 09-02 + # Each region's 100-credit usage falls on a different day. converter.CONFIG["usage_daily"] = { "by_day": {"2026-09-01": {"m": 100.0}, "2026-09-02": {"m": 100.0}}, "groups": {"domestic": {"by_day": {"2026-09-01": {"m": 100.0}}, @@ -858,10 +855,10 @@ def test_billing_usage_prices_each_day_by_site(): import time as _t d1 = _t.mktime(_t.strptime("2026-09-01", "%Y-%m-%d")) d2 = _t.mktime(_t.strptime("2026-09-02", "%Y-%m-%d")) - cn_cents = 100 * 0.014 / 7.15 * 100 # ≈ 19.58 美分 + cn_cents = 100 * 0.014 / 7.15 * 100 # Approximately 19.58 cents assert abs(days[d1][0]["cost"] - cn_cents) < 0.01, days[d1] - assert abs(days[d2][0]["cost"] - 300.0) < 0.01, days[d2] # 100 × $0.03 = 300 美分 - # 恒等式:Σdaily ≈ total_usage(全量口径取 used_usd) + assert abs(days[d2][0]["cost"] - 300.0) < 0.01, days[d2] # 100 credits at USD 0.03 + # Daily amounts sum to total monetary usage. assert abs(sum(i["cost"] for d in usage["daily_costs"] for i in d["line_items"]) - usage["total_usage"]) < 0.02 finally: @@ -870,7 +867,7 @@ def test_billing_usage_prices_each_day_by_site(): def test_billing_intl_split(): - """国内/国际分组折算:单价各按站点,合计与恒等式仍成立。""" + """Preserve regional pricing and additive balance identities.""" import converter with tempfile.TemporaryDirectory() as td: led = credits.CreditLedger(Path(td) / "ledger.json") @@ -881,17 +878,17 @@ def test_billing_intl_split(): saved = (converter.CONFIG.get("ledger"), converter.CONFIG.get("usage_daily")) try: converter.CONFIG["ledger"] = led - converter.CONFIG["usage_daily"] = None # 无明细,走额度差口径 + converter.CONFIG["usage_daily"] = None # Use quota-difference fallback. t = converter._billing_totals() assert t["groups"]["domestic"]["credits_remaining"] == 1000.0 assert t["groups"]["international"]["credits_remaining"] == 500.0 - cn_usd = 1000 * 0.014 / 7.15 # 国内:CNY 单价 / 汇率 + cn_usd = 1000 * 0.014 / 7.15 # Convert domestic CNY pricing to USD. assert abs(t["groups"]["domestic"]["balance_usd"] - cn_usd) < 0.01, t["groups"] assert abs(t["groups"]["international"]["balance_usd"] - 15.0) < 0.01 # 500×$0.03 assert abs(t["remaining_usd"] - (cn_usd + 15.0)) < 0.01 - # 跨组线性可加,余额恒等式不被破坏 + # Regional amounts remain additive without breaking the balance identity. assert abs(t["quota_usd"] - t["used_usd"] - t["remaining_usd"]) < 0.01, t - # 人民币合计:国内按元计,国际按美元×汇率 + # Convert international USD amounts before combining CNY totals. assert abs(t["remaining_cny"] - (1000 * 0.014 + 500 * 0.03 * 7.15)) < 0.01, t sub = converter.billing_subscription(None, None) assert sub["codebuddy_sites"]["international"]["credits_remaining"] == 500.0 @@ -902,7 +899,7 @@ def test_billing_intl_split(): def test_current_models_intl_condition(): - """默认视图合并有额度的国际来源;显式地域内部过滤仍相互隔离。""" + """Merge eligible international models while preserving explicit regional isolation.""" import converter with tempfile.TemporaryDirectory() as td, patch.dict(converter.CONFIG, { "cred_pool": None, "cred": None, "model_catalogs": {}, "ledger": None, @@ -911,12 +908,12 @@ def test_current_models_intl_condition(): "models_intl": [{"id": "gpt-5.5", "supportsToolCall": True}, {"id": "img-1", "supportsToolCall": False}]}): assert converter.current_models("cn") == ["glm-5.3", "auto"] - assert converter.current_models("intl") == [] # 无可信国际余额 + assert converter.current_models("intl") == [] # No trusted international balance assert set(converter.current_models()) == {"glm-5.3", "auto"} led = credits.CreditLedger(Path(td) / "l.json") led.update_credits("ai", {"credits": 0.0, "segments": [], "intl": True}) converter.CONFIG["ledger"] = led - assert converter.current_models("intl") == [] # 国际额度为 0 + assert converter.current_models("intl") == [] # Empty international balance assert set(converter.current_models()) == {"glm-5.3", "auto"} led.update_credits("ai", {"credits": 120.0, "segments": [ {"remaining": 120.0, "total": 120.0, "expires_at": None}], "intl": True}) @@ -924,13 +921,13 @@ def test_current_models_intl_condition(): assert converter.current_models("cn") == ["glm-5.3", "auto"] assert set(converter.current_models()) == {"glm-5.3", "gpt-5.5", "auto"} converter.CONFIG["models_intl"] = [] - assert converter.current_models("intl") == [] # 有额度也不绕过明确空表 + assert converter.current_models("intl") == [] # Positive balance cannot override an empty catalog. assert set(converter.current_models()) == {"glm-5.3", "auto"} print("✅ test_current_models_intl_condition") def test_guard_model(): - """表外模型本地拦截为 404;表内/别名/关闭开关时放行。""" + """Reject unknown models unless an explicit guard bypass authorizes them.""" import converter from fastapi import HTTPException saved = (converter.CONFIG.get("model_guard"), converter.CONFIG.get("models_remote")) @@ -938,10 +935,10 @@ def test_guard_model(): converter.CONFIG["model_guard"] = True converter.CONFIG["models_remote"] = [{"id": "glm-5.3", "supportsToolCall": True}] converter.invalidate_model_table() - converter.guard_model("glm-5.3") # 表内 - converter.guard_model("auto") # 已知非空国内 CLI 目录的旧调度别名 + converter.guard_model("glm-5.3") # Known model + converter.guard_model("auto") # Legacy alias in a known nonempty domestic CLI catalog with TestCase().assertRaises(HTTPException) as raised: - converter.guard_model("") # 显式空模型不是默认模型别名 + converter.guard_model("") # Empty IDs are not default-model aliases. assert raised.exception.status_code == 400 assert raised.exception.detail["error"]["param"] == "model" try: @@ -953,14 +950,14 @@ def test_guard_model(): else: raise AssertionError("表外模型必须被本地拦截") converter.CONFIG["model_guard"] = False - converter.guard_model("gpt-9.9") # 关闭开关后放行 + converter.guard_model("gpt-9.9") # Explicitly disabled model guard finally: converter.CONFIG["model_guard"], converter.CONFIG["models_remote"] = saved print("✅ test_guard_model") def test_model_catalog_cache(): - """模型表缓存:TTL 命中免拉云端、持久化可重载、站点组判定正确。""" + """Test catalog TTL, persisted reload and regional cache grouping.""" with tempfile.TemporaryDirectory() as td: p = Path(td) / "catalog.json" c1 = credits.ModelCatalogCache(p, ttl=3600) @@ -968,13 +965,13 @@ def test_model_catalog_cache(): assert c1.age("domestic") is None c1.put("domestic", [{"id": "glm-5.3", "supportsToolCall": True}]) assert c1.fresh("domestic") and len(c1.models("domestic")) == 1 - assert not c1.fresh("international") # 未拉过的组不暴露 - c2 = credits.ModelCatalogCache(p, ttl=3600) # 重新加载:持久化生效 + assert not c1.fresh("international") # Unsynchronized cache group + c2 = credits.ModelCatalogCache(p, ttl=3600) # Reload persisted data. assert c2.fresh("domestic") and c2.models("domestic")[0]["id"] == "glm-5.3" assert c2.age("domestic") >= 0 c3 = credits.ModelCatalogCache(p, ttl=60) c3._data["groups"]["domestic"]["fetched_at"] = time.time() - 120 - assert not c3.fresh("domestic") # TTL 过期后需重拉 + assert not c3.fresh("domestic") # Expired cache requires refresh. g = credits.ModelCatalogCache.group_for_token assert g(_jwt("https://www.codebuddy.cn/x")) == "domestic" assert g(_jwt("https://www.workbuddy.ai/x")) == "international" diff --git a/tests/test_deployment.py b/tests/test_deployment.py index 976899d..6d82a43 100644 --- a/tests/test_deployment.py +++ b/tests/test_deployment.py @@ -1,8 +1,8 @@ -"""部署模板、Docker 运行文件与 Python 3.12 语法的离线回归。""" +"""Test deployment templates, Docker runtime files and Python 3.12 syntax offline.""" import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # 仓库根:允许直接运行本文件 +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. import ast import pathlib @@ -16,7 +16,7 @@ from textwrap import dedent -ROOT = Path(__file__).resolve().parents[1] # 仓库根 +ROOT = Path(__file__).resolve().parents[1] RUNTIME_DEFAULTS = { "max_images": 16, "image_policy": "truncate", "max_request_bytes": 33554432, "log_body_limit": 65536, @@ -51,12 +51,12 @@ def docker_sources(): def _local_dependency(source: str, module: str) -> str | None: - """把一条 import 映射到仓库内的运行时文件;外部依赖返回 None。""" - if module.startswith("."): # 包内相对导入:相对当前文件所在目录解析 + """Resolve repository runtime imports, returning None for external dependencies.""" + if module.startswith("."): # Resolve package-relative imports from the source directory. base = pathlib.PurePosixPath(source).parent candidate = (base / module.lstrip(".").replace(".", "/")).with_suffix(".py") return str(candidate) if (ROOT / str(candidate)).is_file() else None - if not module.startswith("app."): # 仅校验仓库内运行时模块 + if not module.startswith("app."): # Validate only repository runtime modules. return None candidate = pathlib.PurePosixPath(module.replace(".", "/")).with_suffix(".py") if (ROOT / str(candidate)).is_file(): @@ -122,7 +122,7 @@ def test_docker_runtime_file_set_imports_in_isolation(self): target = Path(directory) / filename target.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(ROOT / filename, target) - # 不在仓库内导入运行时;临时 HOME、净环境及审计钩子阻断联网和凭据访问。 + # Isolate imports and block network or credential access with a temporary HOME and audit hooks. command = dedent("""\ import os import sys diff --git a/tests/test_developer_role.py b/tests/test_developer_role.py index 992ece9..b82bf21 100644 --- a/tests/test_developer_role.py +++ b/tests/test_developer_role.py @@ -1,13 +1,5 @@ #!/usr/bin/env python3 -"""developer 角色归一化回归测试。 - -上游(copilot.tencent.com / workbuddy.ai)会把 role:"developer" 拒绝为 -非官方通道(HTTP 400 / code 11128 "Illegal API invocation from an unapproved -channel");官方 CLI/WorkBuddy 只发 "system",而 pi 等 OpenAI 兼容 harness 把 -系统提示词以 "developer" 发送。归一化时不得修改调用方的原始 messages。 - -直接运行:python -B tests/test_developer_role.py -""" +"""Normalize developer roles for upstream compatibility without mutating caller messages.""" import copy import sys @@ -35,7 +27,7 @@ def _prepare(messages, *, desensitize=False, **extra): class DeveloperRoleNormalization(unittest.TestCase): def test_leading_developer_becomes_system(self): - """pi 的典型形态:单条 developer + user,必须变成 system 且不再补占位 system。""" + """Convert leading developer instructions without adding duplicate system messages.""" body = _prepare([ {"role": "developer", "content": "You are an expert coding assistant."}, {"role": "user", "content": "hi"}, @@ -44,7 +36,7 @@ def test_leading_developer_becomes_system(self): self.assertEqual(body["messages"][0]["content"], "You are an expert coding assistant.") def test_developer_moved_to_front_when_not_first(self): - """developer 不在首位时,归一化后仍应被搬到首条 system 位置。""" + """Move normalized developer instructions to the first system position.""" body = _prepare([ {"role": "user", "content": "hi"}, {"role": "developer", "content": "rules"}, @@ -53,7 +45,7 @@ def test_developer_moved_to_front_when_not_first(self): self.assertEqual(body["messages"][0]["content"], "rules") def test_existing_system_first_is_untouched(self): - """workbuddy 形态:本来就是 system,行为不变。""" + """Preserve existing system-message behavior.""" body = _prepare([ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "hi"}, @@ -62,7 +54,7 @@ def test_existing_system_first_is_untouched(self): self.assertEqual(body["messages"][0]["content"], "You are a helpful assistant.") def test_no_developer_role_leaks_upstream(self): - """任意组合下,发往上游的 messages 都不允许再出现 developer。""" + """Never send developer roles upstream.""" body = _prepare([ {"role": "developer", "content": "a"}, {"role": "user", "content": "b"}, @@ -72,14 +64,14 @@ def test_no_developer_role_leaks_upstream(self): self.assertNotIn("developer", [m["role"] for m in body["messages"]]) def test_missing_system_still_gets_placeholder(self): - """完全没有 system/developer 时,仍保留原有占位逻辑。""" + """Retain placeholder instructions when no system or developer message exists.""" body = _prepare([{"role": "user", "content": "hi"}]) self.assertEqual(body["messages"][0]["role"], "system") self.assertEqual(body["messages"][0]["content"], "You are a helpful assistant.") class CallerPayloadNotMutated(unittest.TestCase): - """归一化只改发往上游的副本;调用方原始 payload 必须保持 deep-equal。""" + """Normalize upstream copies without mutating caller-owned payloads.""" def _assert_untouched(self, raw_messages, *, desensitize): raw_body = {"model": "auto", "messages": raw_messages, "stream": False} diff --git a/tests/test_harness_context.py b/tests/test_harness_context.py index ccfa66b..a887768 100644 --- a/tests/test_harness_context.py +++ b/tests/test_harness_context.py @@ -1,4 +1,4 @@ -"""结构化 harness 提取回归;仅使用内存文本,不调用上游。""" +"""Test structured harness extraction using in-memory text without upstream calls.""" import copy from pathlib import Path import subprocess diff --git a/tests/test_identity_sync.py b/tests/test_identity_sync.py index 369ef6f..4a1da3c 100644 --- a/tests/test_identity_sync.py +++ b/tests/test_identity_sync.py @@ -1,11 +1,8 @@ -"""账号/租户目录、路径复用和启动屏障回归;仅临时合成凭据与 mock,无联网。 - -运行:.venv/bin/python -B -m unittest -v tests/test_identity_sync.py -""" +"""Test account catalogs, path reuse and startup synchronization with synthetic offline credentials.""" import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # 仓库根:允许直接运行本文件 +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. import io import json @@ -25,7 +22,7 @@ def scopes(items): - """选择器子集与账号根表来自同一次拉取;测试里让两者相同,只验证账号隔离与调度。""" + """Use matching selector/root fixtures to isolate account routing behavior.""" return {"picker": items, "account": items} @@ -69,7 +66,7 @@ def setUp(self): self.addCleanup(c.invalidate_model_table) def write_credential(self, name="slot.info", **kwargs): - # 与真实导入一致使用原子替换,避免同大小原地写入命中文件系统时间戳粒度。 + # Atomic replacement avoids timestamp-resolution ambiguity from equal-size in-place writes. return c.atomic_write_credential(self.root, name, json.dumps(credential(**kwargs)).encode("utf-8")) def configure(self, *paths): @@ -267,7 +264,7 @@ def test_zero_balance_account_leaves_paid_models_but_keeps_free_ones(self): self.assertTrue(self.pool._eligible(entries["A"], "free-only")) self.assertEqual({self.picked_uid("paid-only") for _ in range(6)}, {"B"}) self.assertEqual(self.picked_uid("free-only"), "A") - # 余额恢复后重新进入付费模型轮询。 + # Restored balances rejoin paid-model rotation. self.ledger.update_credits(entries["A"]["id"], { "credits": 100, "intl": False, "segments": [], "soonest_expiry": None}) self.assertEqual({self.picked_uid("paid-only") for _ in range(6)}, {"A", "B"}) diff --git a/tests/test_login.py b/tests/test_login.py index 15b4d37..c18f7ca 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 -"""登录命令的扫码轮询、凭据保存和退出行为;运行 python3 tests/test_login.py。""" +"""Test browser login polling, credential persistence and command exit behavior.""" import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # 仓库根:允许直接运行本文件 +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. import contextlib import io @@ -82,7 +82,7 @@ def test_login_command_polls_saves_and_does_not_start_server(self): self.assertEqual(auth_oauth.validate_cred_data(credential), ("u1", None)) if os.name != "nt": self.assertEqual(stat.S_IMODE(saved.stat().st_mode), 0o600) - self.assertIsNone(pool.pick(None, region="cn")) # 内部过滤不能把国际账号当作国内账号。 + self.assertIsNone(pool.pick(None, region="cn")) # Never treat international credentials as domestic. self.assertEqual(pool.pick(None).summary()["uid"], "u1") self.assertIn("账号已保存", self.stdout.getvalue()) self.assert_no_tokens_printed() diff --git a/tests/test_model_site_blocks.py b/tests/test_model_site_blocks.py index 75e63ad..03d32b8 100644 --- a/tests/test_model_site_blocks.py +++ b/tests/test_model_site_blocks.py @@ -1,9 +1,5 @@ #!/usr/bin/env python3 -"""(后端, 模型) 避让回归:官方回 11102「该站点无此模型」后不再反复派发,且能自动绕开/自愈。 - -合成凭据 + 临时目录,不访问网络、不读取本机 auth/。 -运行:python -B tests/test_model_site_blocks.py -""" +"""Test backend/model backoff, routing and recovery with synthetic offline credentials.""" import json import os @@ -16,7 +12,7 @@ from fastapi import HTTPException -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # 仓库根:允许直接运行本文件 +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. import converter from app.model_blocks import ModelBlocks @@ -35,7 +31,7 @@ def _error_body(code, message, request_id="0198f5a6b7c8d9e0f1a2b3c4d5e6f7a8"): def test_parse_not_servable_reads_code_field(): - """11102 只在独立 code 字段上命中,比对整字段而不是整段文本。""" + """Match unsupported-model codes only in dedicated response fields.""" message = f"model [{MODEL}] service info not found" assert converter._parse_not_servable(_error_body(11102, message), 404) == ("11102", message) assert converter._parse_not_servable(_error_body(11102, "no such model"), 400) is not None @@ -43,7 +39,7 @@ def test_parse_not_servable_reads_code_field(): def test_parse_not_servable_ignores_other_errors(): - """额度、认证、审核等错误不能进避让表:它们是可重试的凭证级问题。""" + """Exclude quota, authentication and filter errors from model backoff.""" cases = [ (_error_body(11001, "quota exceeded"), 429), (_error_body(1002, "token expired"), 401), @@ -52,8 +48,8 @@ def test_parse_not_servable_ignores_other_errors(): (b"", 404), (_error_body("other", "internal error"), 400), (json.dumps({"error": {"message": "rate limit reached"}}).encode(), 429), - (_error_body("other", "boom", request_id="req-11102"), 404), # 11102 只在 requestId 里 - (_error_body(11102, "service info not found"), 429), # 只认 400/404 + (_error_body("other", "boom", request_id="req-11102"), 404), # Incidental request ID + (_error_body(11102, "service info not found"), 429), # Only 400/404 qualifies. ] for raw, status in cases: assert converter._parse_not_servable(raw, status) is None, (raw, status) @@ -61,15 +57,15 @@ def test_parse_not_servable_ignores_other_errors(): def test_parse_not_servable_reads_wrapped_error_object(): - """OpenAI 风格的 {"error": {...}} 包装同样能识别。""" + """Recognize unsupported-model errors inside OpenAI error envelopes.""" raw = json.dumps({"error": {"code": "11102", "message": "model service info not found"}}).encode() assert converter._parse_not_servable(raw, 404) is not None - assert converter._parse_not_servable(b'{"requestId": "11102"}', 404) is None # 只有 ID 不算 + assert converter._parse_not_servable(b'{"requestId": "11102"}', 404) is None # IDs are not error codes. print("✅ test_parse_not_servable_reads_wrapped_error_object") class ModelBlocksTests(unittest.TestCase): - """避让表本身:TTL 半开、指数退避、落盘与立即解除。""" + """Test backoff expiry, exponential delays, persistence and immediate clearing.""" def test_expiry_is_half_open_not_blacklist(self): blocks = ModelBlocks(ttl_s=60, max_ttl_s=600) @@ -77,7 +73,7 @@ def test_expiry_is_half_open_not_blacklist(self): blocks.note("https://a", "m", code="11102", now=now) self.assertTrue(blocks.blocked("https://a", "m", now=now)) self.assertEqual(blocks.until("https://a", "m", now=now + 59), blocks.until("https://a", "m", now=now)) - self.assertFalse(blocks.blocked("https://a", "m", now=now + 61)) # 到期放行重试 + self.assertFalse(blocks.blocked("https://a", "m", now=now + 61)) # Allow probes after expiry. self.assertEqual(blocks.until("https://a", "m", now=now + 61), 0.0) def test_repeated_hits_back_off(self): @@ -94,7 +90,7 @@ def test_clear_on_success(self): blocks.note("https://a", "m", now=now) self.assertTrue(blocks.clear("https://a", "m", now=now + 1)) self.assertFalse(blocks.blocked("https://a", "m", now=now + 1)) - self.assertFalse(blocks.clear("https://a", "m", now=now + 1)) # 幂等 + self.assertFalse(blocks.clear("https://a", "m", now=now + 1)) # Idempotent clearing def test_persistence_roundtrip(self): with tempfile.TemporaryDirectory() as tmp: @@ -125,7 +121,7 @@ def test_isolated_per_endpoint_and_model(self): class PoolRoutingTests(unittest.TestCase): - """池内路由:绕开缺模型的后端,全部后端都缺时快速失败,实测通了自动解除。""" + """Route around unavailable models and clear backoff after confirmed availability.""" def setUp(self): self.root = Path(self.enterContext(tempfile.TemporaryDirectory())) @@ -166,17 +162,17 @@ def cred_for(self, model=MODEL, region=None): model, region=region) def test_missing_model_is_not_picked_again(self): - """国际站回 11102 之后:请求自动落到国内站,而不是继续打国际站。""" + """Route to an eligible domestic backend after an international unsupported-model response.""" cm, _ = self.by_endpoint[INTL_ENDPOINT] self.pool.note_status(cm, 404, model=MODEL, raw=_error_body(11102, "service info not found")) self.assertTrue(self.pool._blocks.blocked(INTL_ENDPOINT, MODEL)) - self.assertIsNone(self.pool.model_block_until(MODEL)) # 还有后端可派发 + self.assertIsNone(self.pool.model_block_until(MODEL)) # Another backend remains eligible. (picked_cm, _generation), _headers = self.cred_for() self.assertIs(picked_cm, self.by_endpoint[DOMESTIC_ENDPOINT][0]) self.assertFalse(self.pool._blocks.blocked(DOMESTIC_ENDPOINT, MODEL)) def test_error_code_field_decodes_to_block(self): - """note_status 是唯一入口:404/400 + 11102 记避让,429 走原模型冷却。""" + """Separate unsupported-model responses from quota cooldowns.""" cm, cid = self.by_endpoint[DOMESTIC_ENDPOINT] self.pool.note_status(cm, 429, model=MODEL, raw=_error_body(4290, "quota")) self.assertFalse(self.pool._blocks.blocked(DOMESTIC_ENDPOINT, MODEL)) @@ -186,7 +182,7 @@ def test_error_code_field_decodes_to_block(self): self.assertTrue(self.pool._blocks.blocked(INTL_ENDPOINT, MODEL)) def test_fast_failure_when_no_backend_serves_it(self): - """所有后端都没有该模型:404 明确回给客户端,不再拿空回复让下游编故事。""" + """Return HTTP 404 when every backend lacks the requested model.""" for endpoint in (DOMESTIC_ENDPOINT, INTL_ENDPOINT): cm, _ = self.by_endpoint[endpoint] self.pool.note_status(cm, 404, model=MODEL, raw=_error_body(11102, "service info not found")) @@ -198,12 +194,12 @@ def test_fast_failure_when_no_backend_serves_it(self): detail = caught.exception.detail["error"] self.assertIn(MODEL, detail["message"]) self.assertEqual(detail["type"], "invalid_request_error") - # 避让是 (后端, 模型) 粒度:同后端的别的模型照旧派发 + # Backend/model backoff does not affect other models on the same backend. (picked_cm, _), _headers = self.cred_for(model=OTHER_MODEL) self.assertIn(picked_cm, [cm for cm, _ in self.by_endpoint.values()]) def test_block_is_reported_when_only_one_backend_lists_the_model(self): - """目录里只有一个后端能服务它,而那个后端已避让:回 404,不要给下游可重试的 503。""" + """Return HTTP 404 when the only capable backend is blocked.""" converter.CONFIG["model_catalogs"][INTL_PROFILE] = [ {"id": OTHER_MODEL, "name": OTHER_MODEL, "supportsToolCall": True, "credits": {"input": 1, "output": 2}}] @@ -215,7 +211,7 @@ def test_block_is_reported_when_only_one_backend_lists_the_model(self): self.cred_for() self.assertEqual(caught.exception.status_code, 404) self.assertIn(MODEL, caught.exception.detail["error"]["message"]) - # 国际站目录里还有的模型照常派发,避让没有被扩大化。 + # Other international models remain eligible. (picked_cm, _), _headers = self.cred_for(model=OTHER_MODEL) self.assertIn(picked_cm, [cm for cm, _ in self.by_endpoint.values()]) @@ -314,7 +310,7 @@ def test_single_profile_passthrough_still_reports_measured_rejection(self): def test_region_scoped_fast_failure(self): - """只在国际站内全部避让时才拒 intl 请求;cn 请求照旧通过。""" + """Keep international model backoff isolated from domestic requests.""" cm, _ = self.by_endpoint[INTL_ENDPOINT] self.pool.note_status(cm, 404, model=MODEL, raw=_error_body(11102, "service info not found")) self.assertIsNotNone(self.pool.model_block_until(MODEL, region="intl")) @@ -325,7 +321,7 @@ def test_region_scoped_fast_failure(self): self.assertIsNotNone(self.cred_for(region="cn")) def test_success_clears_the_block(self): - """后端悄悄上线该模型:一次 200 就解除避让,不必等 TTL。""" + """Clear model backoff immediately after a successful response.""" cm, _ = self.by_endpoint[INTL_ENDPOINT] self.pool.note_status(cm, 404, model=MODEL, raw=_error_body(11102, "service info not found")) self.assertTrue(self.pool._blocks.blocked(INTL_ENDPOINT, MODEL)) @@ -334,7 +330,7 @@ def test_success_clears_the_block(self): self.assertFalse(self.pool.note_model_ok(cm, MODEL)) def test_blocks_survive_restart(self): - """避让表落盘:重启后不必重新踩一次坑。""" + """Restore persisted model backoff across restarts.""" cm, _ = self.by_endpoint[INTL_ENDPOINT] self.pool.note_status(cm, 404, model=MODEL, raw=_error_body(11102, "service info not found")) reopened = converter.CredentialPool( @@ -344,7 +340,7 @@ def test_blocks_survive_restart(self): self.assertEqual([row["endpoint"] for row in reopened.model_blocks_detail()], [INTL_ENDPOINT]) def test_alias_auto_is_blocked_under_client_name(self): - """intl 把 auto 改写成 default-model 送上去:避让仍记在客户端可见的名字上。""" + """Track the public auto model despite its international upstream alias.""" cm, _ = self.by_endpoint[INTL_ENDPOINT] self.pool.note_status(cm, 404, model="default-model", raw=_error_body(11102, "service info not found")) @@ -353,7 +349,7 @@ def test_alias_auto_is_blocked_under_client_name(self): if __name__ == "__main__": - # CI 用 python -B 直接执行每个测试文件且不装 pytest:先跑模块级检查,再交给 unittest。 + # Direct CI execution runs module-level checks before unittest without requiring pytest. for fn in (test_parse_not_servable_reads_code_field, test_parse_not_servable_ignores_other_errors, test_parse_not_servable_reads_wrapped_error_object): diff --git a/tests/test_nonstream_disconnect.py b/tests/test_nonstream_disconnect.py index 000edd5..df9ca87 100644 --- a/tests/test_nonstream_disconnect.py +++ b/tests/test_nonstream_disconnect.py @@ -1,15 +1,9 @@ #!/usr/bin/env python3 -"""`stream=false` 的聚合窗口监听下游断连:取消上游、归还名额、审计如实,三协议一致。 - -流式端点由 Starlette 的 `listen_for_disconnect` 兜住,非流式端点没有对应机制。这里钉住 -聚合路径的边界,并覆盖换凭证重放途中挂断、以及调用方自己取消外层任务两种情形。 - -运行:.venv/bin/python -B -m unittest -v tests/test_nonstream_disconnect.py -""" +"""Test non-streaming disconnect cancellation, capacity release and auditing across all protocols.""" import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # 仓库根:允许直接运行本文件 +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. import asyncio import json @@ -27,7 +21,7 @@ from tests import test_region_routing as fixtures HANG_MARKER = "synthetic-hang-up" -# 客户端已经不在了:唯一不可接受的是一个看起来正常的推理响应 +# Disconnected clients must not receive a successful inference response. QUIET_STATUSES = (None, 204) STEP = 2 ENDPOINTS = fixtures.GENERATIONS @@ -40,17 +34,13 @@ def error_body(message="synthetic rejection", code="rate_limit"): @contextmanager def allow_failover(times: int): - """打开换凭证重放开关(等价于 --failover-max N)。""" + """Enable the requested credential failover budget.""" with patch.dict(converter.CONFIG, {"failover_max": times}): yield class _HangingStream(httpx.AsyncByteStream): - """永不结束的上游 SSE:占住整个聚合窗口。 - - 两个独立标记:`read_cancelled` 只在 `__aiter__` 的 `finally` 置位,证明读取被取消; - `stream_closed` 由 httpx 的 `Response.aclose()` 调到底,证明连接真的还回去了。 - """ + """Keep upstream SSE pending and independently record read cancellation and connection closure.""" def __init__(self, read_cancelled, closed): self.read_cancelled = read_cancelled @@ -58,7 +48,7 @@ def __init__(self, read_cancelled, closed): async def __aiter__(self): try: - await asyncio.Event().wait() # 第一段永远不来,等价于上游卡住 + await asyncio.Event().wait() # Keep the first upstream segment pending. yield b"" finally: self.read_cancelled.set() @@ -69,7 +59,7 @@ async def aclose(self): class NonStreamDisconnectTests(fixtures.RegionRoutingTests): - """自己驱动 ASGI:需要一个能随时吐出 `http.disconnect` 的 receive,TestClient 给不了。""" + """Drive ASGI directly to control downstream disconnect timing.""" def setUp(self): super().setUp() @@ -77,7 +67,7 @@ def setUp(self): self.reset_upstream() def reset_upstream(self): - """每个协议子例各自从干净的上游状态起(subTest 共用同一次 setUp)。""" + """Reset upstream state independently for each protocol subtest.""" self.upstream_seen = asyncio.Event() self.read_cancelled = asyncio.Event() self.stream_closed = asyncio.Event() @@ -85,7 +75,7 @@ def reset_upstream(self): self.hang_uids = [] self.fail_over_first = False - # --- 上游夹具:带标记的那一枪卡在首段之前;被点名时先让第一枪吃 429 --- + # Inject a pending upstream read, optionally preceded by a quota rejection. def handle_upstream(self, request): if HANG_MARKER in request.content.decode("utf-8", "replace"): self.hang_attempts += 1 @@ -94,15 +84,14 @@ def handle_upstream(self, request): return httpx.Response(429, json=error_body(), headers={"content-type": "application/json"}) self.upstream_seen.set() - # 用 stream= 而不是 content=:后者会把流再包一层,自定义 aclose 收不到关闭回调 + # stream= preserves the custom aclose callback without wrapping the content. return httpx.Response(200, stream=_HangingStream(self.read_cancelled, self.stream_closed), headers={"content-type": "text/event-stream"}) return super().handle_upstream(request) - # --- 驱动 --- def gated(self, limit, store=None): - """本用例私有的名额闸:`converter.app` 自带的那个跨用例复用,会把状态串到别的测试上。""" + """Create a private concurrency gate so test cases cannot share admission state.""" application = converter.app if store is not None: application = FastAPI() @@ -119,7 +108,7 @@ def scope(self, endpoint="chat/completions"): "headers": [(b"host", b"testserver"), (b"content-type", b"application/json")]} def receive(self, payload, hangup): - """一份正常请求体;之后把 `http.disconnect` 攥在手里,等这个「客户端」真的挂断。""" + """Provide a normal request body and a controllable disconnect event.""" pending = [{"type": "http.request", "body": json.dumps(payload).encode(), "more_body": False}] @@ -151,7 +140,7 @@ async def drain_records(self, store, expected=1): self.fail(f"审计没有落库:{store.list_records()}") async def hang_up(self, gate, endpoint, sent, hangup=None): - """发出聚合请求,等它卡在上游之后再让「客户端」挂断。""" + """Disconnect after the non-streaming request blocks on upstream output.""" hangup = hangup or asyncio.Event() task = asyncio.ensure_future( gate(self.scope(endpoint), self.receive(self.hang_payload(endpoint), hangup), @@ -159,11 +148,11 @@ async def hang_up(self, gate, endpoint, sent, hangup=None): await asyncio.wait_for(self.upstream_seen.wait(), STEP) self.assertFalse(self.read_cancelled.is_set(), "客户端还没走,这枪不该结束") hangup.set() - await asyncio.wait_for(task, STEP) # 修复前:没人监听断连,这一句必然超时 + await asyncio.wait_for(task, STEP) # Disconnect must finish within the deadline. return task async def succeed_after(self, gate, endpoint, sent): - """同一个名额闸上再打一发正常请求:名额必须已经还给网关。""" + """Verify a subsequent request can reuse the released concurrency slot.""" await asyncio.wait_for( gate(self.scope(endpoint), self.receive(self.payload(endpoint), asyncio.Event()), self.collect(sent)), STEP) @@ -173,7 +162,7 @@ def store_for(self, name): self.addCleanup(store.close) return store - # --- 盲区本体:三个协议的聚合端点同一口径 --- + # Non-streaming disconnects across all protocols def test_hangup_during_aggregation_cancels_and_closes_the_upstream_call(self): for endpoint in ENDPOINTS: with self.subTest(endpoint=endpoint): @@ -209,7 +198,7 @@ async def scenario(): self.assertEqual(record["outcome"], "cancelled", record) self.assertFalse(record["streaming"], record) - # --- 换凭证重放途中挂断:整段重放是一个可取消单元 --- + # Failover remains cancellable as one request. def test_hangup_after_credential_failover_cancels_the_whole_sequence(self): for endpoint in ENDPOINTS: with self.subTest(endpoint=endpoint): @@ -240,7 +229,7 @@ async def scenario(): self.assertEqual(self.started_statuses(follow_up), [200], "挂断之后名额必须立刻可用") - # --- 调用方自己取消外层任务:同样不许把上游留在半关状态 --- + # Caller cancellation must fully close upstream resources. def test_outer_task_cancellation_cancels_and_closes_the_upstream_call(self): for endpoint in ENDPOINTS: with self.subTest(endpoint=endpoint): @@ -267,7 +256,7 @@ async def scenario(): self.assertEqual(hung_up[0]["outcome"], "cancelled", hung_up[0]) self.assertEqual(self.started_statuses(follow_up), [200], follow_up) - # --- 对照一:客户端没走,聚合请求必须照常完成(新监听不许误伤) --- + # Connected clients continue to receive complete responses. def test_aggregation_completes_while_the_client_is_still_there(self): for endpoint in ENDPOINTS: with self.subTest(endpoint=endpoint): @@ -285,7 +274,7 @@ async def scenario(): self.assertIn(b"ok", b"".join(m.get("body", b"") for m in sent)) self.assertFalse(self.read_cancelled.is_set(), "正常请求不该被断连监听打断") - # --- 对照二:流式的同一场景在修复之前就已经成立 --- + # Streaming disconnect control case def test_streaming_hangup_already_closes_the_upstream(self): for endpoint in ENDPOINTS: with self.subTest(endpoint=endpoint): diff --git a/tests/test_reasoning.py b/tests/test_reasoning.py index da290e1..4b9a548 100644 --- a/tests/test_reasoning.py +++ b/tests/test_reasoning.py @@ -1,8 +1,5 @@ #!/usr/bin/env python3 -"""reasoning_content(思考)透传回归测试:聚合、伪流式重放、Anthropic/Responses 映射。 - -直接运行:python3 tests/test_reasoning.py -""" +"""Test reasoning aggregation, SSE replay and Anthropic/Responses mappings.""" import asyncio import json @@ -10,7 +7,7 @@ from pathlib import Path import unittest -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # 仓库根:允许直接运行本文件 +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. import httpx @@ -31,7 +28,7 @@ def _parse_sse_events(raw: str) -> list[dict]: - """把 SSE 文本解析为事件 dict 列表(兼容有无 event: 行)。""" + """Parse SSE data events with optional event-name lines.""" events = [] for block in raw.strip().split("\n\n"): if not block.strip(): @@ -43,7 +40,7 @@ def _parse_sse_events(raw: str) -> list[dict]: class TestChatAggregation(unittest.TestCase): - """Chat SSE 聚合必须保留 reasoning_content。""" + """Preserve reasoning_content during Chat SSE aggregation.""" def test_collect_stream_keeps_reasoning(self): resp = httpx.Response(200, content=_SSE.encode("utf-8")) @@ -77,14 +74,14 @@ def test_sse_replay_reasoning_before_content(self): content += delta.get("content") or "" self.assertEqual(reasoning, "思考一思考二") self.assertIn("正文", content) - # reasoning 分片必须先于 content 分片 + # Reasoning deltas precede text deltas. first_reasoning = next(i for i, l in enumerate(lines) if "reasoning_content" in l) first_content = next(i for i, l in enumerate(lines) if '"content": "正' in l or '"content":"正' in l) self.assertLess(first_reasoning, first_content) class TestReplayEnvelope(unittest.TestCase): - """聚合重放的 chunk 必须带完整 chat.completion.chunk 信封(id/object/created),usage chunk 同样。""" + """Include complete Chat chunk envelopes in content and usage replay events.""" def test_replayed_chunks_share_stable_envelope(self): merged = _merge_chat_sse_text(_SSE) lines = _chat_result_to_sse_lines(merged) @@ -100,7 +97,7 @@ def test_replayed_chunks_share_stable_envelope(self): class TestAnthropicThinking(unittest.TestCase): - """reasoning_content 必须映射为 Anthropic thinking content block。""" + """Map reasoning_content to Anthropic thinking blocks.""" def test_stream_thinking_events(self): conv = AnthropicStreamConverter(model="m") @@ -115,7 +112,7 @@ def test_stream_thinking_events(self): deltas = [e for e in events if e["type"] == "content_block_delta" and e["delta"]["type"] == "thinking_delta"] self.assertEqual("".join(d["delta"]["thinking"] for d in deltas), "思考一思考二") - # thinking 块在 text 块开始前已关闭 + # Thinking closes before text begins. stops = [e for e in events if e["type"] == "content_block_stop"] self.assertEqual(stops[0]["index"], 0) @@ -139,7 +136,7 @@ def test_no_reasoning_no_thinking_block(self): class TestResponsesReasoning(unittest.TestCase): - """reasoning_content 必须映射为 Responses reasoning item(位于 message 之前)。""" + """Place Responses reasoning items before message items.""" def test_stream_reasoning_item(self): conv = ResponsesStreamConverter(model="m") diff --git a/tests/test_refusal.py b/tests/test_refusal.py index 983cce5..3d8f352 100644 --- a/tests/test_refusal.py +++ b/tests/test_refusal.py @@ -1,12 +1,9 @@ #!/usr/bin/env python3 -"""拒绝文本与空终止回归;仅内存 SSE、MockTransport,不读凭据或写日志。 - -运行:python3 tests/test_refusal.py -""" +"""Test refusals and empty stream termination using in-memory SSE and MockTransport.""" import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # 仓库根:允许直接运行本文件 +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. from copy import deepcopy import json @@ -69,7 +66,7 @@ def test_refusal_only_stream_and_nonstream_preserve_exact_text(self): self.assertEqual(events(raw)[-1]["type"], "message_stop") else: final = events(raw)[-1] - # 拒绝/过滤是 incomplete:文字原文保留,但不得伪装成 completed + # Preserve refusal text while reporting an incomplete response. expected_type = "response.completed" if finish == "stop" else "response.incomplete" self.assertEqual(final["type"], expected_type) self.assertEqual(adapted_text(final["response"], False), REFUSAL) @@ -233,9 +230,7 @@ def test_empty_stop_done_errors_on_all_six_paths_without_normal_completion_or_re for tools in (False, True): with self.subTest(route=route, stream=stream, tools=tools): response = self.request(route, stream, tools) - # 空终止在落第一个字节之前就能判定,所以流式与非流式同一口径:真实 502。 - # 过去流式回 200 + 带内 error 帧,下游 SDK 解析不到 choices / - # response.completed,会把失败读成「模型答了个空」并静默结束会话。 + # Detect empty termination before committing headers and return HTTP 502. self.assertEqual(response.status_code, 502, response.text) self.assertIn("error", response.json().get("detail", response.json())) self.assertNotIn("data: [DONE]", response.text) diff --git a/tests/test_region_routing.py b/tests/test_region_routing.py index 6325787..7cda846 100644 --- a/tests/test_region_routing.py +++ b/tests/test_region_routing.py @@ -1,12 +1,8 @@ -"""原 /v1 接口自动地域/产品路由回归:合成凭据,httpx 全部由 MockTransport 接管。 - -运行:.venv/bin/python -B -m unittest -v tests/test_region_routing.py -不启动维护线程,不读取本机 auth/.env,不依赖在线目录或真实账号。 -""" +"""Test automatic product/region routing through existing /v1 URLs with synthetic offline credentials.""" import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # 仓库根:允许直接运行本文件 +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. from copy import deepcopy import json @@ -262,11 +258,11 @@ def test_zero_multiplier_model_prefers_free_accounts(self): for _ in range(6): request, _ = self.post_ok(endpoint, self.payload(endpoint), {free}) self.assertEqual(request.headers["x-user-id"], free) - # 计费账号只在免费账号不可用时兜底。 + # Paid accounts are fallback candidates only when free accounts are unavailable. self.pool.note_status(self.entries[free]["cm"], 429, model="shared-model") request, _ = self.post_ok(endpoint, self.payload(endpoint), set(PROFILES) - {free}) self.assertNotEqual(request.headers["x-user-id"], free) - # 冷却换绑后该会话黏在计费账号上;解除冷却应重绑回免费账号。 + # Rebind paid sticky sessions when a free account becomes eligible again. self.pool.note_status(self.entries[free]["cm"], 429, model="other-model") with self.pool._lock: self.pool._model_fail.clear() @@ -275,7 +271,7 @@ def test_zero_multiplier_model_prefers_free_accounts(self): def test_zero_multiplier_model_requires_declared_credits_field(self): tables = catalogs() - # 只给 intl-cli 声明零倍率,其余账号目录仍是不带 credits 字段的同名模型。 + # Declare a zero rate only for intl-cli, not the same model on other accounts. tables["intl-cli"] = [dict(model("shared-model"), credits="x0.00"), model("intl-cli-exclusive")] tables["intl-work"] = [model("shared-model"), model("intl-work-exclusive")] self.configure(tables=tables) @@ -284,7 +280,7 @@ def test_zero_multiplier_model_requires_declared_credits_field(self): for _ in range(4): request, _ = self.post_ok("chat/completions", self.payload(), {"intl-cli"}) self.assertEqual(request.headers["x-user-id"], "intl-cli") - # 免费账号不可用后,未声明 credits 的账号按普通轮询调度,不会被误判为免费。 + # Unknown rates use ordinary rotation rather than free-tier priority. self.pool.note_status(self.entries["intl-cli"]["cm"], 429, model="shared-model") seen = set() for _ in range(6): @@ -302,8 +298,8 @@ def test_free_account_sticky_rebinds_only_while_it_stays_best(self): payload = self.payload() request, _ = self.post_ok("chat/completions", payload, {free}) self.assertEqual(request.headers["x-user-id"], free) - self.post_ok("chat/completions", payload, {free}) # 黏绑保持 - # 免费账号改为计费后,旧黏绑必须重绑到仍然免费的账号。 + self.post_ok("chat/completions", payload, {free}) # Preserve the sticky binding. + # Rebind when the sticky account becomes paid and another free account remains. tables[free] = [dict(model("shared-model"), credits="x0.03"), model(free + "-exclusive")] tables["cn-cli"] = [dict(model("shared-model"), credits="x0.00"), model("cn-cli-exclusive")] self.account_catalogs(tables) @@ -388,7 +384,7 @@ def test_unknown_or_zero_balance_is_not_a_rotation_candidate(self): self.post_ok("chat/completions", self.payload(), expected) def test_account_root_models_route_when_the_picker_subset_omits_them(self): - """选择器省略的账号根表候选仍可经原模型名进入三协议路由。""" + """Route account-root candidates omitted from selectors through all three protocols.""" tables = catalogs() self.configure(tables=tables) for profile in PROFILES: @@ -403,13 +399,13 @@ def test_account_root_models_route_when_the_picker_subset_omits_them(self): endpoint, self.payload(endpoint, profile + "-root-only"), {profile}) self.assertEqual(sent["model"], profile + "-root-only") self.assertEqual(request.url.host, HOSTS[profile]) - # exclusive 模型仍然只在自家账号上可用:根表按账号取,不会把 A 的能力借给 B。 + # Account-root capabilities cannot be borrowed by another account. self.post_rejected("chat/completions", self.payload("chat/completions", "no-such-model")) ids = {item["id"] for item in self.client.get("/v1/models").json()["data"]} self.assertIn("cn-cli-root-only", ids, "对外模型表要能报出实际发得出去的模型") def test_unusable_root_models_are_not_advertised(self): - """根表里不支持工具调用的模型(图像档等)不进对外列表,也不参与路由。""" + """Exclude root models without tool support from public catalogs and routing.""" tables = catalogs() self.configure(tables=tables) self.account_catalogs(tables, serves={ @@ -515,18 +511,18 @@ def test_models_is_merged_and_count_tokens_remains_local(self): def test_models_exposes_credits_multiplier_per_profile(self): tables = catalogs() - # 默认目录的 credits 是 {input, output} 对象(官方新版形态),只有字符串倍率可解析。 + # Object-shaped credit rates remain unknown; only supported scalar rates are parsed. tables["cn-cli"] = [model("shared-model", credits="x0.00"), model("cn-cli-only", credits="x0.03")] tables["intl-cli"] = [model("shared-model", credits="x0.34"), model("intl-cli-only", credits="x0.03")] self.configure(tables=tables) data = {item["id"]: item for item in self.client.get("/v1/models").json()["data"]} shared = data["shared-model"] - self.assertEqual(shared["credits"], 0.0) # 取各来源最小值 - # 只有给出可解析字符串倍率的来源进入分组;cn-work / intl-work 仍是对象形态,故不列出。 + self.assertEqual(shared["credits"], 0.0) # Minimum eligible source rate + # Group only sources with parseable rates, excluding WorkBuddy's object-shaped rates. self.assertEqual(shared["credits_by_profile"], {"cn-cli": 0.0, "intl-cli": 0.34}) self.assertEqual(data["cn-cli-only"]["credits"], 0.03) self.assertEqual(data["cn-cli-only"]["credits_by_profile"], {"cn-cli": 0.03}) - # 标准 OpenAI 字段必须保留。 + # Retain standard OpenAI fields. for item in data.values(): self.assertEqual(item["object"], "model") self.assertIsInstance(item["created"], int) @@ -539,7 +535,7 @@ def test_models_omits_unknown_and_unusable_multipliers(self): dict(model("cn-cli-only-2"), credits="not-a-multiplier")] self.configure(tables=tables) data = {item["id"]: item for item in self.client.get("/v1/models").json()["data"]} - self.assertEqual(data["shared-model"]["credits"], 0.03) # 只有 cn-cli 给出可解析倍率 + self.assertEqual(data["shared-model"]["credits"], 0.03) # Only cn-cli has a parseable rate. self.assertEqual(data["shared-model"]["credits_by_profile"], {"cn-cli": 0.03}) self.assertIsNone(data["cn-cli-only"]["credits"]) self.assertEqual(data["cn-cli-only"]["credits_by_profile"], {}) diff --git a/tests/test_request_limits.py b/tests/test_request_limits.py index df5d40f..1768983 100644 --- a/tests/test_request_limits.py +++ b/tests/test_request_limits.py @@ -1,13 +1,9 @@ #!/usr/bin/env python3 -"""Local synthetic image-policy/adapter/projection regression tests. - -Run: .venv/bin/python -B tests/test_request_limits.py -No converter import, account access, image decoding or upstream requests. -""" +"""Test image limits and protocol adaptation with synthetic data and no upstream access.""" import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # 仓库根:允许直接运行本文件 +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. from copy import deepcopy import json diff --git a/tests/test_responses_adapter.py b/tests/test_responses_adapter.py index 57e0199..d319e13 100644 --- a/tests/test_responses_adapter.py +++ b/tests/test_responses_adapter.py @@ -1,14 +1,10 @@ #!/usr/bin/env python3 -""" -test_responses_adapter.py — 验证 Responses API 适配层的转换逻辑。 - -直接运行:python3 tests/test_responses_adapter.py -""" +"""Test Responses request and response adaptation.""" import json import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # 仓库根:允许直接运行本文件 +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. from app.adapters.responses_adapter import ( responses_request_to_chat, @@ -19,7 +15,7 @@ def test_simple_text_request(): - """测试:简单文本 input → messages 转换。""" + """Convert plain text input into Chat messages.""" req = { "model": "glm-5.2", "input": "Hello, how are you?", @@ -34,7 +30,7 @@ def test_simple_text_request(): def test_array_input_request(): - """测试:数组 input(user + assistant + function_call + function_call_output)。""" + """Convert mixed message, function-call and function-output items.""" req = { "model": "glm-5.2", "input": [ @@ -65,7 +61,7 @@ def test_array_input_request(): def test_tools_conversion(): - """测试:Responses 扁平 tools 格式 → Chat 嵌套格式。""" + """Convert flat Responses tools to nested Chat definitions.""" req = { "model": "glm-5.2", "input": "test", @@ -84,7 +80,7 @@ def test_tools_conversion(): def test_max_output_tokens(): - """测试:max_output_tokens → max_tokens。""" + """Map max_output_tokens to max_tokens.""" req = {"model": "glm-5.2", "input": "test", "max_output_tokens": 4096} chat = responses_request_to_chat(req) assert chat["max_tokens"] == 4096 @@ -92,7 +88,7 @@ def test_max_output_tokens(): def test_developer_role(): - """测试:developer role → system。""" + """Normalize developer roles to system roles.""" req = {"model": "glm-5.2", "input": [ {"role": "developer", "content": "Be concise."}, {"role": "user", "content": "Hi"}, @@ -104,7 +100,7 @@ def test_developer_role(): def test_typed_developer_message_request(): - """测试:typed message + developer role 也能映射为 system。""" + """Normalize developer roles inside typed messages.""" req = { "model": "glm-5.2", "input": [ @@ -119,7 +115,7 @@ def test_typed_developer_message_request(): def test_desensitize_harness_user_and_tools(): - """测试:harness user 注入块会被摘要,tool 描述会脱敏,真实 user 不改。""" + """Compact trusted harness input and tool metadata while preserving real user text.""" body = { "messages": [ {"role": "system", "content": "Refuse exploit development."}, @@ -146,7 +142,7 @@ def test_desensitize_harness_user_and_tools(): def test_compact_harness_messages_and_strip_tool_metadata(): - """测试:Codex 注入长提示被压缩,tool 描述可直接裁掉;user 原话不被整段替换。""" + """Compact long Codex templates without replacing the user's actual request.""" body = { "messages": [ {"role": "system", "content": "You are a coding agent running in the Codex CLI. # How you work\nUse sandbox and escalation."}, @@ -176,7 +172,7 @@ def test_compact_harness_messages_and_strip_tool_metadata(): def test_no_compact_still_prunes_codex_runtime_metadata(): - """测试:保留全文模式仍会裁掉 Codex 注入的运行时元数据大段文本。""" + """Remove trusted runtime metadata even when full conversation text is preserved.""" body = { "messages": [ { @@ -224,7 +220,7 @@ def test_no_compact_still_prunes_codex_runtime_metadata(): assert "# AGENTS.md instructions" not in harness_text assert "Repository instructions and durable user context are provided." in harness_text assert "Environment context is provided by the harness." in harness_text - # skill 里的 kill 会被 desensitize_text 插入零宽空格,断言前先剥离,避免与脱敏逻辑耦合 + # Remove inserted separators to isolate harness compaction from term adaptation. assert "Runtime skill metadata is available" in harness_text.replace("​", "") assert harness_text.strip().replace("​", "").endswith("test") assert out["messages"][2]["content"] == "test" @@ -232,7 +228,7 @@ def test_no_compact_still_prunes_codex_runtime_metadata(): def test_responses_projection_compacts_codex_harness_and_tools(): - """测试:Codex 风格请求会在首发阶段直接投影为短 system + 极简 schema。""" + """Project Codex requests into short system context and minimal tool schemas.""" body = { "messages": [ { @@ -288,7 +284,7 @@ def test_responses_projection_compacts_codex_harness_and_tools(): def test_responses_projection_preserves_recent_tool_chain_and_summarizes_history(): - """测试:较早轮次会被摘要,最近 tool 链保持完整。""" + """Summarize older turns while retaining the recent tool chain.""" big_output = "Chunk ID: a1\nWall time: 0.0\nProcess exited with code 0\nOutput:\n" + "\n".join( f"line {i}" for i in range(40) ) @@ -367,7 +363,7 @@ def test_responses_projection_preserves_recent_tool_chain_and_summarizes_history def test_responses_projection_shrinks_large_tool_arguments(): - """测试:超长 tool arguments 会压缩成结构化 JSON 摘要。""" + """Compact oversized tool arguments into structured JSON summaries.""" long_cmd = "echo " + ("x" * 1600) body = { "messages": [ @@ -409,11 +405,7 @@ def _agentic_tool(): def test_responses_projection_keeps_user_text_sharing_harness_message(): - """测试:harness 与用户原话同条时,用户原文和 reminder 正文必须保留。 - - 回归:旧实现在 user 命中 harness 标记时直接 continue,整条消息连同 - 用户真话一起被丢掉,后端完全看不到用户这一轮说了什么。 - """ + """Preserve user and reminder text embedded in messages containing harness context.""" body = { "model": "auto", "tools": _agentic_tool(), @@ -437,7 +429,7 @@ def test_responses_projection_keeps_user_text_sharing_harness_message(): def test_responses_projection_keeps_last_user_when_it_carries_harness(): - """测试:最坏情况下(含真话的 harness 恰为最后一条 user)用户真话仍保留。""" + """Preserve real text when the final user message also contains harness context.""" body = { "model": "auto", "tools": _agentic_tool(), @@ -456,10 +448,10 @@ def test_responses_projection_keeps_last_user_when_it_carries_harness(): def test_stream_converter_text(): - """测试:Chat SSE 文本流 → Responses 事件流。""" + """Convert Chat text SSE into Responses events.""" conv = ResponsesStreamConverter(model="glm-5.2") - # 模拟 Chat SSE chunks + # Synthetic Chat SSE chunks chunks = [ 'data: {"id":"chatcmpl-1","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}', 'data: {"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}', @@ -476,13 +468,11 @@ def test_stream_converter_text(): if evt_line.startswith("data: "): all_events.append(json.loads(evt_line[6:])) - # 收尾 finish = conv.finish() for evt_line in finish.strip().split("\n\n"): if evt_line.startswith("data: "): all_events.append(json.loads(evt_line[6:])) - # 验证事件类型序列 types = [e["type"] for e in all_events] assert "response.created" in types assert "response.in_progress" in types @@ -494,11 +484,9 @@ def test_stream_converter_text(): assert "response.output_item.done" in types assert "response.completed" in types - # 验证最终文本 text_done = [e for e in all_events if e["type"] == "response.output_text.done"][0] assert text_done["text"] == "Hello world" - # 验证 completed response completed = [e for e in all_events if e["type"] == "response.completed"][0] resp = completed["response"] assert resp["status"] == "completed" @@ -510,7 +498,7 @@ def test_stream_converter_text(): def test_stream_converter_function_call(): - """测试:Chat SSE tool_calls → Responses function_call 事件。""" + """Convert Chat tool-call SSE into Responses function-call events.""" conv = ResponsesStreamConverter(model="glm-5.2") chunks = [ @@ -540,7 +528,6 @@ def test_stream_converter_function_call(): assert "response.function_call_arguments.done" in types assert "response.completed" in types - # 验证 function call arguments args_done = [e for e in all_events if e["type"] == "response.function_call_arguments.done"][0] assert args_done["arguments"] == '{"cmd": "ls"}' @@ -548,7 +535,7 @@ def test_stream_converter_function_call(): def test_nonstream_response(): - """测试:非流式 Response 对象生成。""" + """Build a non-streaming Response object.""" conv = ResponsesStreamConverter(model="glm-5.2") conv.feed_line('data: {"id":"c1","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":null}]}') conv.feed_line('data: {"id":"c1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":1,"total_tokens":6}}') @@ -564,7 +551,7 @@ def test_nonstream_response(): def test_finish_reason_maps_to_terminal_status(): - """长度截断与内容过滤不得标记 completed:流式发 response.incomplete,非流式带 incomplete_details。""" + """Report truncated or filtered output as incomplete in streaming and aggregated responses.""" conv = ResponsesStreamConverter(model="glm-5.2") conv.feed_line('data: {"id":"c2","choices":[{"index":0,"delta":{"content":"partial"},"finish_reason":null}]}') conv.feed_line('data: {"id":"c2","choices":[{"index":0,"delta":{},"finish_reason":"length"}]}') @@ -590,7 +577,7 @@ def test_finish_reason_maps_to_terminal_status(): def test_stream_events_carry_sequence_and_item_ids(): - """每个事件带递增 sequence_number;text/reasoning/argument 增量事件带所属 item_id。""" + """Emit monotonic sequence numbers and item IDs on every corresponding delta.""" conv = ResponsesStreamConverter(model="glm-5.2") chunks = [ 'data: {"id":"s1","choices":[{"index":0,"delta":{"reasoning_content":"想"},"finish_reason":null}]}', @@ -614,7 +601,7 @@ def test_stream_events_carry_sequence_and_item_ids(): def test_usage_maps_cached_tokens_and_omits_when_unknown(): - """上游 cached_tokens/cache_read_input_tokens 透传;都没有时不出 input_tokens_details。""" + """Preserve known cache counters and omit details when upstream counters are absent.""" conv = ResponsesStreamConverter(model="m") conv.feed_line('data: {"id":"u1","choices":[{"index":0,"delta":{"content":"x"},"finish_reason":null}]}') conv.feed_line('data: {"id":"u1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":1,"total_tokens":10,"prompt_tokens_details":{"cached_tokens":7}}}') @@ -634,7 +621,7 @@ def test_usage_maps_cached_tokens_and_omits_when_unknown(): def test_reasoning_effort_and_text_format_are_mapped(): - """reasoning.effort / text.format 进入上游请求;顶层字段优先;不支持的 format 显式报错。""" + """Map reasoning and text format options with explicit top-level precedence.""" chat = responses_request_to_chat({"input": "hi", "reasoning": {"effort": "high"}, "text": {"format": {"type": "json_object"}}}) assert chat["reasoning_effort"] == "high" @@ -642,7 +629,7 @@ def test_reasoning_effort_and_text_format_are_mapped(): chat = responses_request_to_chat({"input": "hi", "reasoning_effort": "low", "reasoning": {"effort": "high"}}) - assert chat["reasoning_effort"] == "low" # 顶层显式字段优先 + assert chat["reasoning_effort"] == "low" # Explicit top-level fields take precedence. chat = responses_request_to_chat({"input": "hi", "text": {"format": { "type": "json_schema", "name": "answer", "strict": True, @@ -664,7 +651,7 @@ def test_reasoning_effort_and_text_format_are_mapped(): print("✅ test_reasoning_effort_and_text_format_are_mapped") def test_parallel_tool_calls_roundtrip(): - """parallel_tool_calls 透传到上游请求,且响应对象如实回报请求值而非固定 True。""" + """Preserve the requested parallel_tool_calls setting in upstream and response objects.""" chat = responses_request_to_chat({"input": "hi", "parallel_tool_calls": False}) assert chat["parallel_tool_calls"] is False conv = ResponsesStreamConverter(model="m", parallel_tool_calls=False) diff --git a/tests/test_runtime_endpoints.py b/tests/test_runtime_endpoints.py index c7d1ec6..c6ed1b9 100644 --- a/tests/test_runtime_endpoints.py +++ b/tests/test_runtime_endpoints.py @@ -1,8 +1,8 @@ -"""网关限额、协议集成、网络失败与配置回归;所有上游调用均由 MockTransport 接管。""" +"""Test gateway limits, protocol integration and transport failures using MockTransport.""" import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # 仓库根:允许直接运行本文件 +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. import asyncio import contextlib @@ -77,7 +77,7 @@ def handle(self, request): return self.respond(request) def test_stateful_responses_fields_are_rejected(self): - """previous_response_id/conversation 依赖服务端历史:本网关无状态,必须显式 400。""" + """Reject server-side conversation references in this stateless gateway.""" for field, value in (("previous_response_id", "resp_abc"), ("conversation", "conv_abc")): with self.subTest(field=field): self.requests.clear() @@ -90,7 +90,7 @@ def test_stateful_responses_fields_are_rejected(self): self.assertEqual(len(self.requests), 0) def test_multiple_candidates_are_rejected_before_reaching_upstream(self): - """聚合路径无法保持多候选独立:n 只能缺省或恰为 1。""" + """Accept only one completion in the aggregation path.""" for n in (2, 0, "2", True, 1.5): with self.subTest(n=n): self.requests.clear() @@ -108,7 +108,7 @@ def test_multiple_candidates_are_rejected_before_reaching_upstream(self): self.assertEqual(len(self.requests), 1) def test_tool_arguments_must_be_objects_of_declared_tools(self): - """解析成功不等于正确:非对象参数或未声明的函数名都不健康。""" + """Reject non-object tool arguments and unknown declared-tool names.""" body = {"tools": TOOLS} call = lambda name, args: [{"id": "c1", "function": {"name": name, "arguments": args}}] healthy = converter._tool_calls_healthy @@ -120,12 +120,12 @@ def test_tool_arguments_must_be_objects_of_declared_tools(self): self.assertFalse(healthy(call("synthetic_tool", bad_args), body)) self.assertFalse(healthy(call("undeclared", "{}"), body)) self.assertFalse(healthy(call("", "{}"), body)) - # 未声明任何工具的请求不做名称核对:合法 JSON 对象的工具调用仍算健康 + # Without declared tools, valid object arguments do not require a name match. self.assertTrue(healthy(call("anything", "{}"), {"tools": []})) self.assertTrue(healthy(call("anything", "{}"), None)) def test_count_tokens_estimates_instead_of_constant_zero(self): - """计数端点返回随输入增长的估算值,而不是伪装精确的常量 0。""" + """Return input-dependent token estimates rather than a fabricated constant.""" short = self.client.post("/v1/messages/count_tokens", json={ "model": "auto", "messages": [{"role": "user", "content": "hi"}]}) self.assertEqual(short.status_code, 200, short.text) @@ -138,13 +138,13 @@ def test_count_tokens_estimates_instead_of_constant_zero(self): self.assertGreater(big, small) cjk = self.client.post("/v1/messages/count_tokens", json={ "model": "auto", "messages": [{"role": "user", "content": "汉" * 100}]}) - self.assertGreaterEqual(cjk.json()["input_tokens"], 100) # 非 ASCII 不按 4 字符折算低估 + self.assertGreaterEqual(cjk.json()["input_tokens"], 100) # Do not apply ASCII estimates to CJK text. bad = self.client.post("/v1/messages/count_tokens", content=b"{ not json", headers={"Content-Type": "application/json"}) self.assertEqual(bad.status_code, 400) def test_inference_errors_follow_the_client_protocol_shape(self): - """OpenAI 路由顶层 error;Anthropic 路由 error 对象;/admin 保持 detail 包装。""" + """Shape protocol-specific inference errors while retaining admin detail envelopes.""" converter.CONFIG["api_key"] = "secret" try: for route in ("/v1/chat/completions", "/v1/responses"): @@ -160,7 +160,7 @@ def test_inference_errors_follow_the_client_protocol_shape(self): body = response.json() self.assertEqual(body["type"], "error") self.assertEqual(body["error"]["type"], "authentication_error") - # Anthropic 约定 404 → not_found_error,即使内层写的是 invalid_request_error + # Anthropic HTTP 404 uses not_found_error despite the upstream error type. converter.CONFIG["model_guard"] = True try: missing = self.client.post("/v1/messages", json={ @@ -178,7 +178,7 @@ def test_inference_errors_follow_the_client_protocol_shape(self): converter.CONFIG["api_key"] = "" def test_omitted_stream_defaults_to_nonstream_and_bad_type_rejected(self): - """省略 stream 按协议默认非流式返回完整 JSON;非布尔 stream 显式 400。""" + """Default to non-streaming JSON and reject non-Boolean stream values.""" bodies = { "/v1/chat/completions": {"model": "auto", "messages": [{"role": "user", "content": "hi"}]}, "/v1/responses": {"model": "auto", "input": [{"role": "user", "content": "hi"}]}, @@ -200,7 +200,7 @@ def test_omitted_stream_defaults_to_nonstream_and_bad_type_rejected(self): self.assertEqual(response.headers["content-type"], "application/json") def test_discarded_tool_generations_are_recorded_with_usage(self): - """损坏工具调用触发的额外生成:每次丢弃都带用量记入 attempts;预算可配。""" + """Audit every discarded tool-repair generation within the configured retry budget.""" good = {"tool_calls": [{"index": 0, "id": "ok", "type": "function", "function": {"name": "synthetic_tool", "arguments": "{}"}}]} bad = {"tool_calls": [{"index": 0, "id": "bad", "type": "function", @@ -222,17 +222,17 @@ def flaky(request): retries = [kw for stage, kw in attempts if stage == "tool_args_retry"] self.assertEqual(len(retries), 1) self.assertEqual(retries[0]["attempt"], 1) - self.assertIn("total_tokens", retries[0]) # 被丢弃的生成用量不再消失 + self.assertIn("total_tokens", retries[0]) # Audit discarded generation usage. converter.CONFIG["tool_call_max_retry"] = 0 try: self.respond = lambda request: httpx.Response(200, content=sse(bad, "tool_calls")) self.requests.clear() - nonstream = dict(payload, stream=False) # 非流式:错误直接体现为 HTTP 状态码 + nonstream = dict(payload, stream=False) # Surface failures as HTTP status codes. response = self.client.post(ROUTES[0], json=nonstream) self.assertEqual(response.status_code, 502, response.text) - self.assertEqual(len(self.requests), 1) # 预算 0:不重试 - # 耗尽预算的末次生成也必须带着用量出现在 attempts 里 + self.assertEqual(len(self.requests), 1) # Zero budget disables retries. + # Audit usage from the final exhausted attempt as well. exhausted = [kw for stage, kw in attempts if stage == "tool_args_exhausted"] self.assertEqual(len(exhausted), 1) self.assertIn("total_tokens", exhausted[0]) @@ -240,15 +240,14 @@ def flaky(request): converter.CONFIG["tool_call_max_retry"] = 3 def test_credential_selection_runs_off_the_event_loop(self): - """_route_chat 内含线程锁/文件锁/同步刷新:三个端点都必须经线程池调用它。""" + """Offload blocking credential routing to the thread pool for all protocols.""" import inspect import re src = inspect.getsource(converter) direct = re.findall(r"^\s+(?:body|chat_body), cred, headers, url = _route_chat\(", src, re.M) pooled = re.findall(r"await run_in_threadpool\(_route_chat", src) self.assertEqual(direct, []) - # 三个端点各一次,另外换凭证重放(_routed_stream / _routed_fetch)还要再路由一次: - # 只要没有任何直调(direct 为空),线程池约束就仍然成立。 + # All initial and failover routing must run outside the event-loop thread. self.assertGreaterEqual(len(pooled), 3) def test_tool_metadata_policy_reaches_all_protocols(self): @@ -583,7 +582,7 @@ def handler(request): class InboundBodyLimitTests(unittest.TestCase): - """入站原始字节限量:解析前 413,chunked 同样受限,/admin 不受影响。""" + """Reject oversized inference bodies before parsing without affecting admin routes.""" def _app(self, limit): from app.inbound_limits import InboundBodyLimitMiddleware @@ -611,7 +610,7 @@ def test_over_limit_rejected_before_parsing_and_under_limit_passes(self): self.assertEqual(big.json()["error"]["code"], "request_too_large") self.assertNotIn("detail", big.json()) big_admin = client.post("/admin/x", content=b"x" * 2048) - self.assertEqual(big_admin.status_code, 200) # 管理路由不在此限量范围 + self.assertEqual(big_admin.status_code, 200) # Admin routes are outside this limit. def test_chunked_body_is_counted_and_rejected(self): from app.inbound_limits import InboundBodyLimitMiddleware @@ -633,7 +632,7 @@ async def send(message): import asyncio asyncio.run(middleware({"type": "http", "method": "POST", "path": "/v1/chat/completions"}, receive, send)) - self.assertFalse(reached) # 超限请求不进入下游 + self.assertFalse(reached) # Reject over-budget requests before dispatch. self.assertEqual(sent[0]["status"], 413) @@ -702,7 +701,7 @@ async def test_real_disconnect_closes_the_stream_and_releases_capacity(self): class ConcurrencyLimitTests(unittest.IsolatedAsyncioTestCase): - """并发上限:名额占满立即 503(含 Retry-After),释放后恢复。""" + """Reject excess concurrency with Retry-After and recover after slots are released.""" async def test_full_gate_returns_503_and_recovers(self): from app.inbound_limits import ConcurrencyLimitMiddleware @@ -825,11 +824,11 @@ def test_defaults(self): "admin_csrf": True, "keep_tool_metadata": False}) def test_open_binding_without_key_requires_explicit_opt_in(self): - # 非回环 + 空 key:默认拒启(SystemExit 2) + # Reject unauthenticated public binding by default. self.configure(flags=("--host", "0.0.0.0"), invalid=True) - # 显式放行环境变量后可启动 + # Explicit configuration allows unauthenticated binding. self.configure(env={"CODEBUDDY2API_ALLOW_OPEN_NOAUTH": "true"}, flags=("--host", "0.0.0.0")) - # 非回环但设了 key:正常 + # Configured authentication permits public binding. self.configure(env={"CODEBUDDY2API_KEY": "k"}, flags=("--host", "0.0.0.0")) def test_persisted_host_is_validated_after_configuration_resolution(self): diff --git a/tests/test_safe_logging.py b/tests/test_safe_logging.py index 1bb0610..bfb3378 100644 --- a/tests/test_safe_logging.py +++ b/tests/test_safe_logging.py @@ -2,7 +2,7 @@ import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # 仓库根:允许直接运行本文件 +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. import copy import json @@ -188,7 +188,7 @@ def test_long_malicious_prefixes_finish_in_bounded_subprocess(self): ''' result = subprocess.run( [sys.executable, "-B", "-c", code], - cwd=Path(__file__).resolve().parents[1], # 仓库根:子进程需 import 应用模块 + cwd=Path(__file__).resolve().parents[1], # Allow application imports in the subprocess. capture_output=True, text=True, timeout=10, check=True, ) self.assertEqual(result.stdout.strip(), "ok") diff --git a/tests/test_security.py b/tests/test_security.py index 78c2bc7..9d218c9 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -1,8 +1,8 @@ -"""凭据导入、健康接口和会话标识的安全回归测试。""" +"""Test credential imports, public health and session identity security.""" import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # 仓库根:允许直接运行本文件 +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. import asyncio import hashlib diff --git a/tests/test_site_routing.py b/tests/test_site_routing.py index 16ad9ec..581d922 100644 --- a/tests/test_site_routing.py +++ b/tests/test_site_routing.py @@ -2,7 +2,7 @@ import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # 仓库根:允许直接运行本文件 +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. import base64 import copy diff --git a/tests/test_stream_failover.py b/tests/test_stream_failover.py index 2bc0372..2313375 100644 --- a/tests/test_stream_failover.py +++ b/tests/test_stream_failover.py @@ -1,21 +1,9 @@ #!/usr/bin/env python3 -"""流式换凭证重放回归(`--failover-max`):本地换账号重试,而不是把 429/502 甩给下游。 - -背景:流式请求在「一个字节都还没发给下游」时失败,已经被 `_preflight_stream` 还原成真实 -状态码(见 tests/test_stream_status_contract.py)。但还原成 429 只是诚实,不是解决问题 —— -限流/认证/网关抖动这类失败换一个账号大概率就能成,下游(尤其 Codex CLI)不该看到 502。 - -这里钉住重放的边界: - - 默认关闭(`failover_max=0`)时行为与上游完全一致:一次都不多重放,如实回真实状态码; - - 开启后只在确定「上游没收下请求体 / 上游用 HTTP 状态码拒绝」时重放,且必须换凭证; - - 审核拒绝、聚合器合成的 502(上游已回 200,可能已计费)永不重放; - - 重放次数有上界,换不出别的凭证时如实回第一次的状态码,绝不死循环。 -运行:.venv/bin/python -B -m unittest -v tests/test_stream_failover.py -""" +"""Test bounded pre-response credential failover, routing restrictions and billing-risk auditing.""" import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # 仓库根:允许直接运行本文件 +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. import json import unittest @@ -33,11 +21,11 @@ REPLAYABLE_STATUS = (401, 403, 429, 502, 503, 504) DETERMINISTIC_STATUS = (400, 404, 405, 413, 422) -# 上游手里没有任何正文:换连接/换账号重放安全 +# Connection failures occur before the request body is sent. REPLAYABLE_TRANSPORT = (httpx.ConnectError, httpx.ConnectTimeout) -# 写超时:正文没写完是确定的,是否已按半截正文计费看不到,只有显式 opt-in 才参与重放 +# Incomplete writes may already be billed and require explicit replay opt-in. WRITE_TIMEOUT_TRANSPORT = (httpx.WriteTimeout,) -# 请求体已经发出去了(甚至响应已经开始):上游可能已处理并计费,禁止重放 +# Other post-send transport failures must not replay potentially billed requests. AMBIGUOUS_TRANSPORT = (httpx.ReadError, httpx.ReadTimeout, httpx.WriteError, httpx.RemoteProtocolError) FAILOVER_LOG = "换凭证重放" @@ -48,11 +36,7 @@ def error_body(message="synthetic rejection", code="rate_limit"): def filtered_sse(): - """上游正常回 200、结果却是审核拒绝:聚合路径会在 `fetch` 返回**之前**就记上这次失败。 - - 只用 `finish_reason` 触发检测(`ContentFilterDetector.feed` 认 `content_filter`), - 不依赖任何拒绝文案,免得上游改措辞就把测试带崩。 - """ + """Return HTTP-success SSE with an explicit filter finish reason independent of refusal wording.""" chunks = [ {"id": "synthetic-completion", "choices": [{"index": 0, "delta": {"role": "assistant", "content": "blocked"}, "finish_reason": None}]}, @@ -66,13 +50,13 @@ def filtered_sse(): @contextmanager def allow_failover(times: int): - """打开换凭证重放开关(等价于 --failover-max N)。""" + """Enable the requested credential failover budget.""" with patch.dict(converter.CONFIG, {"failover_max": times}): yield class StreamFailoverTests(fixtures.RegionRoutingTests): - """复用四档合成凭证 + MockTransport,只把「被点名的那一个凭证」改成会失败。""" + """Use four synthetic credential profiles and inject failures into a selected account.""" def setUp(self): super().setUp() @@ -86,7 +70,7 @@ def setUp(self): self.allowed_profiles = set(fixtures.PROFILES) def fresh_pool(self): - """重建凭证池:401/403 熔断与 429 冷却都是池内状态,子例之间必须清干净。""" + """Reset pool authentication and quota cooldowns between subtests.""" self.configure() self.allowed_profiles = set(fixtures.PROFILES) @@ -97,12 +81,12 @@ def handle_upstream(self, request): uid = request.headers.get("x-user-id") if self.arm_next: self.arm_next = False - self.poison_uid = uid # 不依赖轮询顺序:下一个被选中的凭证开始失败 + self.poison_uid = uid # Select the failing credential independently of rotation order. if self.poison_uid is not None and uid == self.poison_uid: self.requests.append(request) if self.poison_once: - self.poison_uid = None # 只掐第一枪:后面那一枪是同一凭证上的重放 - return self.poison(request) # 可以返回错误状态,也可以直接抛传输层异常 + self.poison_uid = None # Fail only the initial transport attempt. + return self.poison(request) # Inject HTTP or transport failures. return super().handle_upstream(request) def poison_with_status(self, status, body=None): @@ -120,7 +104,7 @@ def raise_transport(request): self.poison = raise_transport def poison_transport_once(self, error_type): - """只让第一枪失败:测「同一连接/同一凭证」的底层重放,换凭证那条日志压根到不了。""" + """Fail the first attempt to exercise same-credential transport replay.""" self.poison_with_transport(error_type) self.poison_once = True @@ -136,7 +120,7 @@ def uids(self, requests): def failover_lines(self): return [line for line in self.logs if FAILOVER_LOG in line] - # --- 开启后:下游只看到一次正常成功 --- + # Successful failover produces one downstream response. def test_429_is_replayed_on_another_credential(self): with allow_failover(1): self.poison_with_status(429) @@ -149,12 +133,7 @@ def test_429_is_replayed_on_another_credential(self): self.assertEqual(len(self.failover_lines()), 1, self.failover_lines()) def test_replay_log_separates_the_maybe_billed_class(self): - """受理期拒绝不标风险;502/504 可能已被后端处理并计费,必须在日志里单独标出来。 - - 重放的取舍不是「省钱 vs 花钱」:这类失败连响应头都没有,那次结果对下游永远拿不到, - 不重放也退不回额度,只是把一次已付费的请求换成一段断掉的会话。所以保留重放,但要 - 如实标注,便于事后按官方用量明细核对。 - """ + """Flag possible billing for replayed gateway failures but not admission rejections.""" for status, marked in ((429, False), (401, False), (403, False), (503, False), (502, True), (504, True)): with self.subTest(status=status): @@ -186,10 +165,10 @@ def test_every_stream_endpoint_can_fail_over(self): self.assertEqual(len(set(self.uids(sent))), 2) def test_failover_limit_bounds_upstream_attempts(self): - self.response_status = 429 # 所有凭证都失败 + self.response_status = 429 # Reject every credential. for maximum, expected in ((1, 2), (2, 3)): with self.subTest(failover_max=maximum): - self.fresh_pool() # 上一轮的 429 冷却会让这一轮少打一次上游 + self.fresh_pool() # Remove cooldown state from the preceding subtest. self.requests.clear() self.logs.clear() with allow_failover(maximum): @@ -202,7 +181,7 @@ def test_failover_limit_bounds_upstream_attempts(self): self.assertEqual(len(set(self.uids(self.requests))), expected, "每轮都必须是新凭证") def test_last_resort_surfaces_the_real_status(self): - """换不出别的凭证(只剩一个账号)时,如实回第一次的状态码,且不再打上游。""" + """Preserve the failure status when no alternate credential exists.""" self.configure(profiles=("cn-cli",)) self.allowed_profiles = {"cn-cli"} with allow_failover(3): @@ -212,7 +191,7 @@ def test_last_resort_surfaces_the_real_status(self): self.assertEqual(len(sent), 1, "无凭证可换时不得重复打上游") def test_reroute_cannot_loop_back_to_the_same_credential(self): - """重放选回同一个凭证时(单凭证池/黏绑)必须立刻收敛,不能死循环。""" + """Stop failover when routing selects an already tried credential.""" self.configure(profiles=("cn-cli", "cn-work")) self.allowed_profiles = set(fixtures.PROFILES) with allow_failover(5): @@ -223,18 +202,17 @@ def test_reroute_cannot_loop_back_to_the_same_credential(self): self.assertEqual(len(set(self.uids(self.requests))), len(self.requests), "同一凭证不得被打两次") - # --- 重路由必须沿用客户端请求的模型,不得被上游改写名绕过 --- + # Rerouting must preserve policy for the original client model ID. AUTO_PROFILES = ("intl-work", "intl-cli") def arm_auto(self, profiles=None): - """账号目录都含 default-model:国际站会把 `auto` 改写成它,国内站不会 —— 所以放宽只有 - 在「改写后的名字」上查规则才会发生,站点绑定那条用例要靠国内站账号当靶子。""" + """Advertise default-model across regions to detect policy bypass after auto alias rewriting.""" self.auto_profiles = tuple(profiles or self.AUTO_PROFILES) self.configure(profiles=self.auto_profiles, tables={profile: [fixtures.model("default-model")] for profile in self.auto_profiles}) def bind_auto(self, name, **rule): - """建一个管理库并给 `auto` 下一条路由策略。""" + """Create an explicit routing policy for the auto model.""" from app import model_policy from app.control_store import ControlStore @@ -245,7 +223,7 @@ def bind_auto(self, name, **rule): return store def auto_post(self): - """发一次 model=auto 的流式请求,返回下游响应与真正打出去的上游请求。""" + """Send an auto-model request and return downstream and captured upstream responses.""" self.allowed_profiles = set(self.auto_profiles) before = len(self.requests) response = self.client.post("/v1/chat/completions", @@ -253,7 +231,7 @@ def auto_post(self): return response, self.requests[before:] def test_auto_is_rewritten_on_the_international_site(self): - """夹具自检:国际站确实把 auto 发成了 default-model,否则下面几条等于没测。""" + """Verify the fixture rewrites international auto requests to default-model.""" self.arm_auto() response, sent = self.auto_post() self.assertEqual(response.status_code, 200, response.text) @@ -262,11 +240,7 @@ def test_auto_is_rewritten_on_the_international_site(self): self.assertEqual(_json.loads(sent[0].content)["model"], "default-model") def test_failover_cannot_widen_the_credential_binding(self): - """把 `auto` 只绑到 A:A 失败后不许放宽给 B。 - - `_route_chat` 会把 auto 改写成 default-model 再发出去;若拿改写后的正文重新选 - 凭证,策略查的是 default-model(没有规则),绑定在 auto 上的限制整个失效。 - """ + """Preserve auto-model credential restrictions when failover reroutes an aliased request.""" self.arm_auto() self.bind_auto("bind.sqlite3", credential_ids=[self.entries["intl-work"]["account_key"]]) with allow_failover(1): @@ -277,7 +251,7 @@ def test_failover_cannot_widen_the_credential_binding(self): f"绑定 auto 的账号失败后不得放宽给别的账号:{self.uids(sent)}") def test_failover_cannot_widen_the_site_binding(self): - """同上,按站点绑定:`auto` 限定 intl 时,重放不许跑到国内站。""" + """Keep international auto routing within its region during failover.""" self.arm_auto(("intl-work", "cn-work")) self.bind_auto("region.sqlite3", region="intl") with allow_failover(3): @@ -287,7 +261,7 @@ def test_failover_cannot_widen_the_site_binding(self): self.assertEqual(len(sent), 1, f"站点绑定被放宽:{self.uids(sent)}") def test_reroute_uses_the_pristine_body_model(self): - """直接钉住重路由入参:每一轮选凭证看的都必须是客户端请求的模型名。""" + """Use the client's original model ID for every credential selection.""" self.arm_auto() self.bind_auto("spy.sqlite3", region="intl") seen = [] @@ -305,11 +279,7 @@ def spy(payload, body, rid, **kwargs): f"重路由拿到了被改写的模型名:{seen}") def test_same_credential_write_timeout_replay_carries_the_risk_note(self): - """评审 P2:底层连接上的写超时重放也必须带代价标记,两层同一口径。 - - 第一次写超时、同一凭证第二次就成 —— 这条路径到不了换凭证那行日志,`failover_max=0` - 时更是完全不经过它,所以标注只能由 `open_backend_stream` 的重试回调自己带上。 - """ + """Flag same-credential write-timeout billing risk independently of credential failover.""" store, client = self.audited_client() with patch.dict(converter.CONFIG, {"retry_write_timeout": True, "failover_max": 0}): self.poison_transport_once(httpx.WriteTimeout) @@ -326,7 +296,7 @@ def test_same_credential_write_timeout_replay_carries_the_risk_note(self): self.assertIn("write_timeout_retry", stages, stages) def test_connect_retry_stays_untagged(self): - """建连失败不标记风险:上游手里没有正文,重放确定不重复计费。""" + """Exclude pre-send connection failures from possible billing warnings.""" self.audited_client() with patch.dict(converter.CONFIG, {"failover_max": 0}): self.poison_transport_once(httpx.ConnectError) @@ -336,7 +306,7 @@ def test_connect_retry_stays_untagged(self): self.assertEqual(len(lines), 1, self.logs) self.assertNotIn("上游可能已处理该请求", lines[0]) - # --- 默认关闭:与上游一致,一次都不多重放 --- + # Failover remains disabled by default. def test_disabled_by_default_replays_nothing(self): self.assertEqual(converter.CONFIG["failover_max"], 0, "默认必须关闭,行为与上游一致") for status in REPLAYABLE_STATUS: @@ -353,9 +323,9 @@ def test_disabled_by_default_replays_nothing(self): self.assertEqual(self.failover_lines(), []) self.stream = True - # --- 审计口径:重放救回来的请求不得留在 error --- + # Recovered requests have successful audit outcomes. def audited_client(self, name="failover-audit"): - store = AuditStore(self.root / f"{name}.sqlite3") # 一条用例要对比两行审计时分开落盘 + store = AuditStore(self.root / f"{name}.sqlite3") # Isolate compared audit records. self.addCleanup(store.close) application = FastAPI() application.router.routes = list(converter.app.router.routes) @@ -368,7 +338,7 @@ def only_record(self, store): return records[0] def test_replayed_request_is_audited_as_success(self): - """重放成功的请求审计必须是 success,不能又退回「error + 200」这个骗人的签名。""" + """Audit recovered requests as successful rather than error outcomes with HTTP 200.""" store, client = self.audited_client() with allow_failover(1): self.poison_with_status(429) @@ -386,11 +356,7 @@ def test_replayed_request_is_audited_as_success(self): "恢复标记必须点名「被撤销的那一次失败」本身") def test_content_filter_after_replay_is_still_audited_as_filtered(self): - """换到的账号回了审核拒绝:那是这一枪的真实结果,不能被上一枪 429 的重放抹掉。 - - 没有修复前的样子:`outcome=success` + `error_code` 为空 + 恢复标记写着 - `content_filter` —— 等于把一次被拦截的请求记成了一次干净的成功。 - """ + """Retain a replacement account's filter failure when recovering an earlier quota rejection.""" store, client = self.audited_client() with allow_failover(1), patch.object(fixtures, "success_sse", filtered_sse): self.poison_once = True @@ -405,7 +371,7 @@ def test_content_filter_after_replay_is_still_audited_as_filtered(self): json.dumps(record["attempts"], ensure_ascii=False), record["attempts"]) def test_content_filter_audit_matches_the_unreplayed_request(self): - """同一次审核拒绝,走没走过重放必须是同一行审计:重放不该改变账单口径。""" + """Keep filter-refusal accounting consistent whether failover occurred or not.""" with patch.object(fixtures, "success_sse", filtered_sse): store, client = self.audited_client("filter-without-replay") plain = client.post("/v1/chat/completions", json=self.payload(stream=False)) @@ -424,7 +390,7 @@ def test_content_filter_audit_matches_the_unreplayed_request(self): self.assertEqual(record[key], baseline[key], (key, record, baseline)) def test_content_filter_after_replay_survives_the_aggregated_stream(self): - """带 tools 的流式在预取里就跑完整聚合,`_stream_plan` 那条顺序陷阱一模一样。""" + """Preserve filter failures observed during tool-stream preflight aggregation.""" store, client = self.audited_client() payload = self.payload(stream=True) payload["tools"] = [{"type": "function", "function": {"name": "synthetic_tool", @@ -448,7 +414,7 @@ def test_unreplayed_failure_is_still_audited_as_error(self): self.assertEqual(record["outcome"], "error", record) self.assertEqual(record["error_code"], "upstream_429", record) - # --- 重放判定矩阵:只重放「上游确定没收下/没处理」的失败 --- + # Retry classification and ambiguous failures def test_replayable_http_statuses(self): for status in REPLAYABLE_STATUS: with self.subTest(status=status): @@ -462,7 +428,7 @@ def test_deterministic_http_statuses_are_not_replayed(self): converter.UpstreamHTTPError(status, b'{"error":{"code":"bad_request"}}'))) def test_aggregator_synthesized_502_is_not_replayed(self): - """上游已经回了 200,聚合器合成的 502 可能对应已计费的请求:不换账号重放。""" + """Never fail over a synthetic collection error after upstream HTTP 200.""" self.assertFalse(converter._failover_safe( converter.UpstreamResponseError(502, json.dumps(error_body(code="empty_response")).encode()))) with allow_failover(2): @@ -476,7 +442,7 @@ def test_aggregator_synthesized_502_is_not_replayed(self): self.assertEqual(self.failover_lines(), []) def test_filter_rejection_is_never_replayed(self): - """内容审核是模型的真实答复,换账号只会再撞同一堵墙,还会白烧一次额度。""" + """Treat content filtering as a terminal model response, not a failover trigger.""" refusal = error_body("请求包含违规内容,已被拦截", code="content_filter") for status in (403, 429): with self.subTest(status=status): @@ -501,13 +467,13 @@ def test_transport_before_request_body_fails_over(self): response, sent = self.stream_post() self.assertEqual(response.status_code, 200, response.text) self.assertIn("data: [DONE]", response.text) - # 建连失败/写超时在 open_backend_stream 内部已换连接重试一次,再换凭证重放一次 + # Transport retry precedes the bounded credential failover attempt. self.assertGreaterEqual(len(sent), 2) self.assertNotEqual(len(set(self.uids(sent))), 1, "必须换过凭证") self.assertEqual(len(self.failover_lines()), 1, self.failover_lines()) def test_write_timeout_needs_an_explicit_opt_in(self): - """默认:写超时按歧义处理——如实回 502,一次都不多重放。""" + """Return HTTP 502 without replaying ambiguous write timeouts by default.""" self.assertEqual(converter.CONFIG["retry_write_timeout"], False, "默认必须关闭") for error_type in WRITE_TIMEOUT_TRANSPORT: with self.subTest(error=error_type.__name__): @@ -522,7 +488,7 @@ def test_write_timeout_needs_an_explicit_opt_in(self): self.assertEqual(self.failover_lines(), []) def test_write_timeout_opt_in_replays_and_flags_the_billing_risk(self): - """开启 `--retry-write-timeout`:重放救回会话,但必须在日志里标出计费歧义。""" + """Flag possible billing when explicit write-timeout replay recovers a request.""" for error_type in WRITE_TIMEOUT_TRANSPORT: with self.subTest(error=error_type.__name__): self.fresh_pool() @@ -539,7 +505,7 @@ def test_write_timeout_opt_in_replays_and_flags_the_billing_risk(self): self.assertIn("上游可能已处理该请求", lines[0], lines[0]) def test_write_timeout_opt_in_does_not_replay_ambiguous_transport(self): - """开关只管写超时:读超时/中途 reset 这些歧义失败照旧禁止重放。""" + """Keep read failures and midstream resets non-replayable despite write-timeout opt-in.""" with patch.dict(converter.CONFIG, {"retry_write_timeout": True}): for error_type in AMBIGUOUS_TRANSPORT: with self.subTest(error=error_type.__name__): @@ -553,7 +519,7 @@ def test_write_timeout_opt_in_does_not_replay_ambiguous_transport(self): self.assertEqual(self.failover_lines(), []) def test_failover_switches_are_hot_public_settings(self): - """两个开关都必须能在大控制台「系统设置」里改,且不需要重启进程。""" + """Apply both replay settings through the WebUI without restarting.""" from app import settings self.assertEqual(converter.CONFIG["failover_max"], 0) @@ -569,7 +535,7 @@ def test_failover_switches_are_hot_public_settings(self): self.assertIn(key, listed) self.assertFalse(listed[key]["locked"], listed[key]) with self.assertRaises(ValueError): - settings.validate_settings({"failover_max": 99}) # 上界 10 + settings.validate_settings({"failover_max": 99}) # Maximum budget is 10. with self.assertRaises(ValueError): settings.validate_settings({"retry_write_timeout": "yes"}) @@ -587,7 +553,7 @@ def test_ambiguous_transport_never_fails_over(self): self.assertEqual(self.failover_lines(), []) def test_non_streaming_request_is_replayed_too(self): - """非流式一个字节都没回下游,判定同一流式口径:换凭证重放,下游只看到一次成功。""" + """Apply the same pre-response credential failover policy to non-streaming requests.""" with allow_failover(1): self.stream = False self.poison_with_status(429) diff --git a/tests/test_stream_status_contract.py b/tests/test_stream_status_contract.py index 541eae9..a3aecf0 100644 --- a/tests/test_stream_status_contract.py +++ b/tests/test_stream_status_contract.py @@ -1,18 +1,9 @@ #!/usr/bin/env python3 -"""流式状态码契约回归:上游在落第一个字节之前就把请求判死时,不许回 HTTP 200。 - -下游(Codex CLI、Claude Code、任意 OpenAI 兼容 SDK)是按 chunk / 事件解析的:一个既没有 -choices、也等不到 response.completed 的 200 流会被读成「模型答了个空」,会话静默结束 —— -既不重试也不报错,审计里还记成一次成功。StreamingResponse 一旦被迭代就把响应头发出去, -而打上游发生在生成器里面,所以过去只有 stream=false 才吃得到真实状态码。 - -这里钉住修好的口径:**同一个失败,流式与非流式必须给同一个状态码**;而真的中途断流 -(字节已经发出去了)仍然只能在流内报错,收不回状态码。 -""" +"""Preserve upstream HTTP errors before streaming starts and report midstream failures in-band.""" import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # 仓库根:允许直接运行本文件 +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. import asyncio import json @@ -30,8 +21,7 @@ ROUTES = ("/v1/chat/completions", "/v1/responses", "/v1/messages") TOOLS = [{"type": "function", "function": {"name": "synthetic_tool", "parameters": {"type": "object"}}}] -# 建连失败/建连超时:上游手里没有正文,open_backend_stream 会换新连接重放一次; -# 中途 reset、协议错误、以及写超时(正文没写完 ≠ 上游没处理)都属于歧义请求,默认不重放。 +# Retry pre-send connection failures once; ambiguous post-send failures do not replay by default. REPLAYABLE = (httpx.ConnectError, httpx.ConnectTimeout) AMBIGUOUS = (httpx.ReadError, httpx.ReadTimeout, httpx.RemoteProtocolError, httpx.WriteError, httpx.WriteTimeout) @@ -87,7 +77,7 @@ def post(self, route, *, stream, tools=False, model="auto"): payload["tools"] = TOOLS return self.client.post(route, json=payload) - # --- 上游用 HTTP 状态码说的不:流式必须原样转出去 --- + # Preserve upstream rejection status before streaming starts. def test_upstream_http_status_is_not_swallowed_by_streaming(self): for route in ROUTES: for status in HTTP_STATUSES: @@ -103,7 +93,7 @@ def test_upstream_http_status_is_not_swallowed_by_streaming(self): self.assertEqual(plain.status_code, status, plain.text) self.assertEqual(len(self.requests), 1, "失败不得重放上游") - # --- 传输层失败(写超时/中途 reset):非流式一直是 502,流式过去是 200 --- + # Surface transport failures consistently across response modes. def test_transport_failure_is_502_on_both_stream_modes(self): for route in ROUTES: for error_type in REPLAYABLE + AMBIGUOUS: @@ -120,7 +110,7 @@ def raise_transport(request): self.assertIn(error_type.__name__, response.text, response.text) self.assertEqual(len(self.requests), expected, response.text) - # --- 一个字节都没发出去时,不得留下「看起来成功」的流 --- + # Pre-response failures must not produce a successful stream. def test_failed_stream_never_carries_a_success_terminal(self): self.respond = lambda request: httpx.Response(429, json={"error": {"message": "slow down"}}) for route in ROUTES: @@ -133,7 +123,7 @@ def test_failed_stream_never_carries_a_success_terminal(self): for marker in TERMINALS: self.assertNotIn(marker, response.text) - # --- 已经吐过字节的中途断流:状态码收不回来,只能在流内报错(不许过度修正)--- + # Midstream failures remain in-band because headers are already committed. def test_break_after_first_byte_stays_in_band(self): class Partial(httpx.AsyncByteStream): async def __aiter__(self): @@ -142,7 +132,7 @@ async def __aiter__(self): for route in ROUTES: if route == "/v1/responses": - continue # Responses 端点总是先聚合再落字节,失败时一个字节都没发出去 → 502 + continue # Responses aggregation fails before sending bytes and returns HTTP 502. with self.subTest(route=route): self.respond = lambda request: httpx.Response(200, stream=Partial(), headers={"content-type": "text/event-stream"}) @@ -153,7 +143,7 @@ async def __aiter__(self): self.assertNotIn("data: [DONE]", response.text) self.assertNotIn('"message_stop"', response.text) - # --- 成功路径不能被预取吞掉第一段 --- + # Preflight must retain the first successful segment. def test_happy_stream_still_delivers_every_event_once(self): for route in ROUTES: for tools in (False, True): @@ -175,15 +165,7 @@ def test_happy_stream_still_delivers_every_event_once(self): class PreflightDisconnectTests(unittest.IsolatedAsyncioTestCase): - """预取窗口里的下游断连:取消要打得断挂起的上游读,并发名额当场归还。 - - 用真实的中间件顺序手工驱动 ASGI。评审 P1 的场景:预取曾在端点里直接 `await`,而 - `StreamingResponse.__call__` 是把 `stream_response` 和 `listen_for_disconnect` 放进同一个 - 任务组跑的 —— 端点返回之前没有谁在消费 `http.disconnect`。于是「上游首段卡住 + 客户端已经 - 走了」会一路挂到读超时,`ConcurrencyLimitMiddleware` 的名额跟着陪葬,单并发部署整个网关 - 变 503。这里钉住:断连必须当场收尾(首段之前、以及已经开始流式之后两种),并且下一次请求 - 拿得到名额。 - """ + """Cancel preflight reads and release concurrency slots on downstream disconnect.""" PAYLOAD = {"model": "auto", "stream": True, "messages": [{"role": "user", "content": "hi"}]} @@ -197,8 +179,7 @@ def setUp(self): self.enterContext(patch.object(converter, "_log")) self.enterContext(patch.object(converter, "_note_cred_status")) self.enterContext(patch.object(converter, "_note_cred_model_ok")) - # 名额层套在真实 app 外面(与 runtime_management.install 的层次一致)。 - # 每次新建实例:信号量挂在中间件实例上,测试之间不能互相借位。 + # Match production middleware order with a private concurrency gate for each test. self.app = ConcurrencyLimitMiddleware(converter.app.build_middleware_stack(), converter.CONFIG) self.stuck = asyncio.Event() @@ -206,12 +187,12 @@ def setUp(self): self.mode = "before-first-segment" async def upstream(self, url, headers, body, model_name="?", t0=0.0, rid="", cred=None): - """假上游:按模式卡在首段之前或之后;收尾一定要 await,跟真实 httpx 一样。""" + """Block synthetic upstream reads before or after the first segment with async cleanup.""" try: if self.mode != "before-first-segment": yield "data: " + json.dumps({"choices": [{"index": 0, "delta": {"content": "ok"}}], "model": model_name}) + "\n\n" - self.stuck.set() # 「已经挂在上游上」/「首段已经交出去」的信号 + self.stuck.set() # Signal that the upstream is pending. if self.mode == "done": yield "data: [DONE]\n\n" return @@ -221,7 +202,7 @@ async def upstream(self, url, headers, body, model_name="?", t0=0.0, rid="", cre self.closed += 1 async def drive(self, *, disconnect): - """跑一次请求;返回 (响应状态码或 None, 断连后是否在超时内收尾)。""" + """Return response status and whether disconnect cleanup completed within the deadline.""" sent, queue = [], asyncio.Queue() async def receive(): @@ -240,27 +221,27 @@ async def send(message): task = asyncio.create_task(self.app(scope, receive, send)) try: await asyncio.wait_for(self.stuck.wait(), 2) - if self.mode == "after-first-segment": # 等响应头真的发出去 + if self.mode == "after-first-segment": # Wait until headers are sent. for _ in range(400): if any(m["type"] == "http.response.start" for m in sent): break await asyncio.sleep(0.005) if disconnect: await queue.put({"type": "http.disconnect"}) - await asyncio.wait_for(task, 2) # 收尾不干净就会在这里超时(= 名额被占) + await asyncio.wait_for(task, 2) # Cleanup must release capacity promptly. finally: task.cancel() start = next((m for m in sent if m["type"] == "http.response.start"), None) return start["status"] if start else None, sent async def test_disconnect_before_first_segment_aborts_without_a_response(self): - """首段还没来就断连:一个字节都不该发出去,上游要当场关掉。""" + """Close upstream work without emitting bytes when preflight is cancelled.""" status, sent = await self.drive(disconnect=True) self.assertIsNone(status, f"客户端已经走了, yet 发出了响应头:{sent}") self.assertEqual(self.closed, 1) async def test_slot_is_returned_so_the_next_request_still_runs(self): - """名额归还:断连之后紧接着的请求必须是正常响应,而不是「并发已满」的 503。""" + """Release admission capacity so a request after disconnect can succeed.""" await self.drive(disconnect=True) self.stuck = asyncio.Event() self.mode = "done" @@ -268,7 +249,7 @@ async def test_slot_is_returned_so_the_next_request_still_runs(self): self.assertEqual(status, 200) async def test_disconnect_after_streaming_started_closes_the_upstream(self): - """已经开始流式之后断连:取消照常打到挂起的上游读,生成器被关干净。""" + """Cancel pending reads and close generators after streaming has begun.""" self.mode = "after-first-segment" status, sent = await self.drive(disconnect=True) self.assertEqual(status, 200, sent) @@ -276,21 +257,14 @@ async def test_disconnect_after_streaming_started_closes_the_upstream(self): class TeardownCloseTests(unittest.IsolatedAsyncioTestCase): - """`_close_stream` 要扛得住「当前任务正在被反复取消」这件事。 - - 直接 `await agen.aclose()` 是不行的:anyio 的取消作用域用 `call_soon` 自循环,每个事件 - 循环周期重投一次取消,生成器 finally 里那个 await(httpx 在这里关连接)做到一半就被打断 - —— 实测要么永远等不到 `closed`,要么半关。收尾因此放进独立任务(不属于那个作用域,没人再 - 取消它),再尽量当场等它做完。这里钉住「停在 yield 上被取消」这一种:帧没在自己内部被撕开, - 正是 `_close_stream` 负责的那一段。 - """ + """Complete isolated generator cleanup despite repeated cancellation of the calling task.""" async def test_cleanup_await_completes_inside_a_cancelled_scope(self): done = [] async def upstream(): try: - yield "data: x\n\n" # 停在 yield 上被关:真实场景是「两段之间」 + yield "data: x\n\n" # Pause between segments before closure. finally: await asyncio.sleep(0.01) done.append("closed") diff --git a/tests/test_travel.py b/tests/test_travel.py index 169df1b..50f415a 100644 --- a/tests/test_travel.py +++ b/tests/test_travel.py @@ -1,4 +1,4 @@ -"""Travel state transitions, fixed domestic origin, and uncertain write outcomes.""" +"""Travel protocol, dynamic locations, and uncertain write outcomes.""" import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) @@ -11,6 +11,11 @@ from app import travel from app.credits import CreditLedger +CONFIG = {'locations': [{'id': 21, 'name': '海边书店'}, {'id': 37, 'name': '山间茶馆'}]} +IDLE = {'state': 'idle', 'daily_limit_reached': False} +TRAVELING = {'state': 'traveling', 'location': {'id': 21, 'name': '海边书店'}, + 'daily_limit_reached': False, 'arrive_at': 300, 'server_now': 100} + class TravelTests(unittest.TestCase): def run_trip(self, responses, profile='cn-work', **kwargs): @@ -21,40 +26,75 @@ def handle(request): value = next(pending) if isinstance(value, Exception): raise value + if isinstance(value, httpx.Response): + return value status, body = value if isinstance(value, tuple) else (200, {'code': 0, 'data': value}) return httpx.Response(status, json=body) client = httpx.Client(transport=httpx.MockTransport(handle), follow_redirects=False) - with patch('app.travel.httpx.Client', return_value=client), patch('app.travel.random.choice', return_value=2): + with patch('app.travel.httpx.Client', return_value=client), \ + patch('app.travel.random.choice', side_effect=lambda choices: choices[0]): result = travel.perform('synthetic-token', profile, **kwargs) return result, requests - def test_arrival_claims_rechecks_then_departs(self): + def test_arrival_claims_rechecks_then_configures_departs_and_rechecks(self): result, calls = self.run_trip([ {'state': 'arrived', 'daily_limit_reached': False}, {'reward_credit': 8}, - {'state': 'idle', 'daily_limit_reached': False}, {'location': {'id': 2}, 'arrive_at': 300, 'server_now': 100}]) + IDLE, CONFIG, {}, TRAVELING]) self.assertTrue(result['ok']) self.assertTrue(result['claimed']) self.assertTrue(result['departed']) self.assertEqual(result['claimed_credit'], 8) - self.assertEqual(result['location_name'], '商场店铺') + self.assertEqual(result['location_name'], '海边书店') + self.assertEqual(result['remaining_seconds'], 200) + self.assertEqual(result['phase'], 'after_depart') self.assertEqual([(c.method, c.url.path) for c in calls], [ ('GET', travel.PREFIX+'status'), ('POST', travel.PREFIX+'claim'), - ('GET', travel.PREFIX+'status'), ('POST', travel.PREFIX+'depart')]) + ('GET', travel.PREFIX+'status'), ('GET', travel.PREFIX+'config'), + ('POST', travel.PREFIX+'depart'), ('GET', travel.PREFIX+'status')]) for call in calls: self.assertEqual(call.url.host, 'www.workbuddy.cn') self.assertEqual(call.headers['authorization'], 'Bearer synthetic-token') + self.assertEqual(call.headers['x-product-code'], 'workbuddy') self.assertNotIn('x-device-token', call.headers) self.assertNotIn('/v2', call.url.path) + if call.method == 'GET': + self.assertEqual(call.content, b'') + else: + self.assertEqual(call.headers['content-type'], 'application/json') self.assertEqual(json.loads(calls[1].content), {}) - self.assertEqual(json.loads(calls[3].content), {'location_id': 2}) + self.assertEqual(json.loads(calls[4].content), {'location_id': 21}) + + def test_claim_success_accepts_missing_null_and_empty_data_without_inventing_credit(self): + for receipt in ({'code': 0}, {'code': 0, 'data': None}, {'code': 0, 'data': {}}): + with self.subTest(receipt=receipt): + result, calls = self.run_trip([ + {'state': 'arrived', 'reward_credit': 8}, (200, receipt), + {**IDLE, 'daily_limit_reached': True}]) + self.assertTrue(result['ok']) + self.assertTrue(result['claimed']) + self.assertIsNone(result['claimed_credit']) + self.assertFalse(result['departed']) + self.assertEqual(len(calls), 3) - def test_idle_can_depart_without_checkin(self): - result, calls = self.run_trip([{'state': 'idle', 'daily_limit_reached': False}, {}]) + def test_idle_departs_without_checkin_and_uses_only_configured_locations(self): + result, calls = self.run_trip([IDLE, {'locations': [{'id': 99, 'name': '新地点'}]}, {}, + {**TRAVELING, 'location': {'id': 99}}], profile='cn-cli') + self.assertTrue(result['ok']) self.assertTrue(result['departed']) self.assertFalse(result['claimed']) - self.assertEqual(len(calls), 2) + self.assertEqual(result['location_name'], '新地点') + self.assertEqual(json.loads(calls[2].content), {'location_id': 99}) + + def test_empty_departure_receipt_requires_fresh_status(self): + for receipt in ({'code': 0}, {'code': 0, 'data': None}): + with self.subTest(receipt=receipt): + result, calls = self.run_trip([IDLE, CONFIG, (200, receipt), TRAVELING]) + self.assertTrue(result['ok']) + self.assertTrue(result['departed']) + self.assertEqual(result['arrive_at'], 300) + self.assertEqual(len(calls), 4) - def test_traveling_and_limit_do_not_write(self): + def test_traveling_and_limit_do_not_fetch_config_or_write(self): for state, limit in [('traveling', False), ('idle', True)]: with self.subTest(state=state): result, calls = self.run_trip([{'state': state, 'daily_limit_reached': limit}]) @@ -70,30 +110,97 @@ def test_arrival_can_claim_even_when_dispatch_limit_reached(self): self.assertFalse(result['departed']) self.assertEqual(len(calls), 3) - def test_read_only_never_claims_even_on_arrival(self): - result, calls = self.run_trip([{'state': 'arrived', 'reward_credit': 7}], read_only=True) - self.assertTrue(result['ok']) - self.assertFalse(result['claimed']) - self.assertEqual(len(calls), 1) + def test_read_only_never_claims_or_fetches_config(self): + for state in ('idle', 'traveling', 'arrived'): + with self.subTest(state=state): + result, calls = self.run_trip([{'state': state, 'reward_credit': 7}], read_only=True, + can_write=lambda: False) + self.assertTrue(result['ok']) + self.assertFalse(result['claimed']) + self.assertFalse(result['departed']) + self.assertEqual(len(calls), 1) def test_missing_invalid_and_ambiguous_status_stop_writes(self): - for data in ({}, {'state': None}, {'state': 'unknown'}, {'state': ['idle']}, + for data in ({}, {'state': None}, {'state': 'unknown'}, {'state': ['idle']}, {'state': {}}, {'state': 'idle'}, {'state': 'idle', 'daily_limit_reached': 0}, {'state': 'idle', 'daily_limit_reached': 'false'}): with self.subTest(data=data): result, calls = self.run_trip([data]) self.assertFalse(result['ok']) + self.assertTrue(result['stale']) self.assertEqual(len(calls), 1) - def test_business_failure_is_not_a_success(self): - for body in ({'code': 10001, 'msg': 'synthetic-secret'}, {'code': False, 'data': {'state': 'idle'}}, - {'code': 0}, [], None): + def test_business_failure_is_not_success_and_only_safe_diagnostics_are_returned(self): + cases = [({'code': 10001, 'msg': 'synthetic-secret'}, 'business', 10001), + ({'code': False, 'data': IDLE}, 'protocol', None), + ({'code': '0', 'data': IDLE}, 'protocol', None), + ({'code': 0.0, 'data': IDLE}, 'protocol', None), + ({'code': 2**99, 'data': IDLE}, 'protocol', None), + ({'code': 0}, 'protocol', 0), ([], 'protocol', None), (None, 'protocol', None)] + for body, kind, code in cases: with self.subTest(body=body): result, calls = self.run_trip([(200, body)]) self.assertFalse(result['ok']) + self.assertEqual(result['http_status'], 200) + self.assertEqual(result['code'], code) + self.assertEqual(result['error_kind'], kind) + self.assertEqual(result['phase'], 'status') self.assertNotIn('synthetic-secret', str(result)) self.assertEqual(len(calls), 1) + def test_invalid_write_envelope_never_confirms_a_claim(self): + for body in ({'code': 0, 'data': []}, {'code': False}, {'code': '0'}, {'data': {}}, None): + with self.subTest(body=body): + result, calls = self.run_trip([{'state': 'arrived'}, (200, body)]) + self.assertFalse(result['claimed']) + self.assertEqual(result['phase'], 'claim') + self.assertEqual(len(calls), 2) + + def test_http_errors_preserve_status_without_redirects_or_body_leaks(self): + for status in (302, 401, 403, 404, 429, 500, 503): + with self.subTest(status=status): + response = httpx.Response(status, text='synthetic-secret', headers={'Location': 'https://untrusted.invalid/'}) + result, calls = self.run_trip([response]) + self.assertFalse(result['ok']) + self.assertEqual(result['http_status'], status) + self.assertEqual(result['error_kind'], 'http') + self.assertIsNone(result['code']) + self.assertNotIn('synthetic-secret', str(result)) + self.assertEqual(len(calls), 1) + result, _ = self.run_trip([(429, {'code': 123, 'msg': 'synthetic-secret'})]) + self.assertEqual(result['code'], 123) + result, _ = self.run_trip([httpx.Response(200, text='synthetic-secret')]) + self.assertEqual(result['error_kind'], 'protocol') + + def test_invalid_config_never_falls_back_to_fixed_locations(self): + invalid = [{}, {'locations': []}, {'locations': None}, {'locations': {}}, + {'locations': [None]}, {'locations': [{'id': True, 'name': '地点'}]}, + {'locations': [{'id': '1', 'name': '地点'}]}, {'locations': [{'id': 0, 'name': '地点'}]}, + {'locations': [{'id': 1, 'name': ''}]}, {'locations': [{'id': 1, 'name': 'x'*81}]}, + {'locations': [{'id': 1, 'name': 'a\nb'}]}, {'locations': [{'id': 1}]}, + {'locations': [{'id': 1, 'name': 'A'}, {'id': 1, 'name': 'B'}]}, + {'locations': [{'id': n, 'name': '地点'} for n in range(1, 102)]}] + for config in invalid: + with self.subTest(config=config): + result, calls = self.run_trip([IDLE, config]) + self.assertFalse(result['departed']) + self.assertFalse(result['ok']) + self.assertEqual(result['phase'], 'config') + self.assertEqual(result['error_kind'], 'protocol') + self.assertEqual([c.method for c in calls], ['GET', 'GET']) + + def test_config_failure_preserves_confirmed_claim(self): + result, calls = self.run_trip([{'state': 'arrived'}, (200, {'code': 0}), IDLE, + (404, {'code': 12, 'msg': 'synthetic-secret'})]) + self.assertTrue(result['claimed']) + self.assertFalse(result['departed']) + self.assertFalse(result['ok']) + self.assertEqual(result['phase'], 'config') + self.assertEqual(result['http_status'], 404) + self.assertIn('已领取', result['message']) + self.assertNotIn('synthetic-secret', str(result)) + self.assertEqual(len(calls), 4) + def test_failed_claim_and_failed_post_claim_query_do_not_depart(self): for response in [(500, {'code': -1}), httpx.ReadTimeout('synthetic-secret')]: result, calls = self.run_trip([{'state': 'arrived'}, response]) @@ -102,19 +209,55 @@ def test_failed_claim_and_failed_post_claim_query_do_not_depart(self): self.assertTrue(result['stale']) self.assertEqual(len(calls), 2) self.assertNotIn('synthetic-secret', str(result)) - result, calls = self.run_trip([{'state': 'arrived'}, {'reward_credit': 7}, (503, {})]) + result, calls = self.run_trip([{'state': 'arrived'}, (200, {'code': 0}), (503, {})]) self.assertTrue(result['claimed']) self.assertFalse(result['ok']) self.assertFalse(result['departed']) + self.assertEqual(result['phase'], 'after_claim') self.assertEqual(len(calls), 3) def test_failed_depart_is_not_replayed_and_preserves_confirmed_claim(self): - result, calls = self.run_trip([{'state': 'arrived'}, {'reward_credit': 8}, - {'state': 'idle', 'daily_limit_reached': False}, httpx.ReadTimeout('uncertain write')]) + for error, kind in [(httpx.ReadTimeout('synthetic-secret'), 'timeout'), + (httpx.WriteTimeout('synthetic-secret'), 'timeout'), + (httpx.ConnectError('synthetic-secret'), 'network')]: + result, calls = self.run_trip([{'state': 'arrived'}, {'reward_credit': 8}, IDLE, CONFIG, error]) + self.assertFalse(result['ok']) + self.assertTrue(result['claimed']) + self.assertFalse(result['departed']) + self.assertTrue(result['stale']) + self.assertEqual(result['error_kind'], kind) + self.assertEqual(result['phase'], 'depart') + self.assertEqual(len(calls), 5) + self.assertNotIn('synthetic-secret', str(result)) + + def test_post_depart_failure_retains_action_without_old_state_or_guessed_times(self): + result, calls = self.run_trip([ + {**IDLE, 'reward_credit': 8, 'server_now': 50, 'arrive_at': 70}, CONFIG, + (200, {'code': 0}), httpx.ReadTimeout('synthetic-secret')]) + self.assertFalse(result['ok']) + self.assertTrue(result['departed']) + self.assertTrue(result['stale']) + self.assertEqual(result['state'], 'unknown') + self.assertEqual(result['phase'], 'after_depart') + for field in ('reward_credit', 'server_now', 'arrive_at', 'remaining_seconds', 'daily_limit_reached'): + self.assertIsNone(result[field]) + self.assertEqual(len(calls), 4) + self.assertIn('勿重复派出', result['message']) + + def test_idle_after_depart_is_uncertain_and_never_dispatches_again(self): + result, calls = self.run_trip([IDLE, CONFIG, {}, IDLE]) + self.assertTrue(result['departed']) self.assertFalse(result['ok']) - self.assertTrue(result['claimed']) - self.assertFalse(result['departed']) self.assertTrue(result['stale']) + self.assertEqual(result['state'], 'idle') + self.assertEqual(len(calls), 4) + + def test_arrival_after_depart_does_not_start_another_claim_loop(self): + result, calls = self.run_trip([IDLE, CONFIG, {}, {'state': 'arrived'}]) + self.assertTrue(result['ok']) + self.assertTrue(result['departed']) + self.assertFalse(result['claimed']) + self.assertEqual(result['state'], 'arrived') self.assertEqual(len(calls), 4) def test_eventually_consistent_arrival_is_not_claimed_twice(self): @@ -131,12 +274,36 @@ def test_setting_change_during_query_stops_claim_or_depart(self): self.assertTrue(result['skipped']) self.assertEqual(len(calls), 1) decisions = iter([True, True, False]) - result, calls = self.run_trip([{'state': 'arrived'}, {'reward_credit': 8}, {'state': 'idle', 'daily_limit_reached': False}], - can_write=lambda: next(decisions)) + result, calls = self.run_trip([{'state': 'arrived'}, {'reward_credit': 8}, IDLE], can_write=lambda: next(decisions)) self.assertTrue(result['claimed']) self.assertFalse(result['departed']) self.assertEqual(len(calls), 3) + def test_setting_or_lease_change_during_config_stops_dispatch(self): + decisions = iter([True, True, False]) + result, calls = self.run_trip([IDLE, CONFIG], can_write=lambda: next(decisions)) + self.assertFalse(result['departed']) + self.assertTrue(result['skipped']) + self.assertEqual(result['phase'], 'config') + self.assertEqual(len(calls), 2) + + def test_initial_cancellation_never_queries(self): + result, calls = self.run_trip([], can_write=lambda: False) + self.assertTrue(result['skipped']) + self.assertEqual(calls, []) + + def test_status_preserves_dynamic_name_and_only_computes_known_durations(self): + result, _ = self.run_trip([TRAVELING], read_only=True) + self.assertEqual(result['location_id'], 21) + self.assertEqual(result['location_name'], '海边书店') + self.assertEqual(result['remaining_seconds'], 200) + for value in (None, True, '300', -1, float('inf'), float('nan')): + with self.subTest(value=value): + self.assertIsNone(travel._status({**TRAVELING, 'arrive_at': value})['remaining_seconds']) + self.assertEqual(travel._status({**TRAVELING, 'arrive_at': 1})['remaining_seconds'], 0) + self.assertIsNone(travel._status({**TRAVELING, 'state': 'arrived'})['remaining_seconds']) + self.assertIsNone(travel._status({**TRAVELING, 'location': {'id': True, 'name': 'bad'}})['location_name']) + def test_international_never_opens_a_network_client(self): with patch('app.travel.httpx.Client') as client: for profile in ('intl-work', 'intl-cli', None, 'unknown'): @@ -148,10 +315,13 @@ def test_failure_keeps_last_success_without_recursive_growth(self): ledger = CreditLedger(Path(folder)/'ledger.json') travel.remember(ledger, 'one', {'ok': True, 'state': 'traveling', 'message': '旅行中'}) for _ in range(3): - travel.remember(ledger, 'one', {'ok': False, 'state': 'unknown', 'stale': True, 'message': '查询失败'}) - last = ledger.entry('one')['travel']['last_success'] - self.assertEqual(last['state'], 'traveling') - self.assertNotIn('last_success', last) + travel.remember(ledger, 'one', {'ok': False, 'departed': True, 'state': 'unknown', 'stale': True, + 'phase': 'after_depart', 'http_status': 503, 'message': '查询失败'}) + saved = ledger.entry('one')['travel'] + self.assertTrue(saved['departed']) + self.assertEqual(saved['phase'], 'after_depart') + self.assertEqual(saved['last_success']['state'], 'traveling') + self.assertNotIn('last_success', saved['last_success']) if __name__ == '__main__': diff --git a/tests/test_trial_rewards.py b/tests/test_trial_rewards.py index 0eafe31..1d74331 100644 --- a/tests/test_trial_rewards.py +++ b/tests/test_trial_rewards.py @@ -1,12 +1,8 @@ -"""Offline trial tests: synthetic headers, MockTransport, disposable ledger directories. - -Run: python -B -m unittest -v tests/test_trial_rewards.py -No converter import, credentials, external scripts, or live HTTP requests. -""" +"""Test trial claims with synthetic headers, MockTransport and disposable ledgers.""" import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # 仓库根:允许直接运行本文件 +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager @@ -101,7 +97,7 @@ def respond(request): with mock_http(respond) as factory: self.assertTrue(trial.claim_trial(supplied)["ok"]) - # 与网关其它 HTTP 调用一致,保留部署环境代理支持。 + # Preserve deployment proxy support consistently with other HTTP clients. factory.assert_called_once_with(timeout=12.0, follow_redirects=False) self.assertEqual(supplied, original) self.assertEqual(len(calls), 1) diff --git a/tests/test_upstream_io.py b/tests/test_upstream_io.py index e432962..2a5300b 100644 --- a/tests/test_upstream_io.py +++ b/tests/test_upstream_io.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 -"""Chat SSE 边界回归;仅使用内存数据和 MockTransport。""" +"""Test Chat SSE boundaries with in-memory data and MockTransport.""" import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # 仓库根:允许直接运行本文件 +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. import json import unittest @@ -223,16 +223,16 @@ def test_refusal_text_and_content_filter_text_are_preserved(self): class OutputBudgetTests(unittest.TestCase): - """聚合收集预算与错误体有界读取。""" + """Test collection budgets and bounded error-body reads.""" def test_collect_budget_aborts_oversized_aggregation(self): - acc = ChatSSEAccumulator(max_collect_bytes=10) # 两片各 8B,第二片超预算 + acc = ChatSSEAccumulator(max_collect_bytes=10) # The second eight-byte chunk exceeds the budget. acc.feed_line('data: {"choices":[{"index":0,"delta":{"content":"12345678"}}]}') with self.assertRaises(UpstreamResponseError) as caught: acc.feed_line('data: {"choices":[{"index":0,"delta":{"content":"12345678"}}]}') self.assertEqual(caught.exception.status, 502) self.assertIn(b"response_too_large", caught.exception.raw) - # 预算内不受影响 + # Within-budget output remains valid. acc = ChatSSEAccumulator(max_collect_bytes=1024) acc.feed_line('data: {"choices":[{"index":0,"delta":{"content":"ok"},"finish_reason":"stop"}]}') acc.feed_line("data: [DONE]") @@ -317,7 +317,7 @@ def handler(request): self.assertEqual(requests[0].method, "POST") async def test_body_not_accepted_is_replayed_on_a_fresh_connection(self): - """建连失败 / 建连超时:上游手里没有正文,换新连接重放一次不会重复计费。""" + """Retry connection failures once before any request body is accepted.""" real_client = httpx.AsyncClient for error_type in (httpx.ConnectError, httpx.ConnectTimeout): with self.subTest(error=error_type.__name__): @@ -339,7 +339,7 @@ def handler(request): self.assertEqual([request.method for request in attempts], ["POST", "POST"]) async def test_write_timeout_is_not_replayed_without_the_opt_in(self): - """写超时只证明正文没写完,证不了上游没处理已收到的部分:默认不重放。""" + """Do not replay incomplete writes by default because accepted bytes may be processed.""" real_client = httpx.AsyncClient for error_type in upstream_io.WRITE_TIMEOUT: with self.subTest(error=error_type.__name__): @@ -358,7 +358,7 @@ def handler(request): self.assertEqual(len(attempts), 1, "默认必须一次都不重放") async def test_write_timeout_is_replayed_only_when_opted_in(self): - """`retry_write_timeout=True` 是运维显式承担计费歧义,重放行为与建连失败一致。""" + """Allow explicit write-timeout replay with acknowledged billing ambiguity.""" real_client = httpx.AsyncClient for error_type in upstream_io.WRITE_TIMEOUT: with self.subTest(error=error_type.__name__): @@ -381,7 +381,7 @@ def handler(request): self.assertEqual([request.method for request in attempts], ["POST", "POST"]) async def test_ambiguous_transport_failures_never_replay_post(self): - """请求体已经发出(甚至响应已经开始)的失败有计费歧义,一律交给调用方按协议返回。""" + """Propagate ambiguous post-send failures without automatic replay.""" real_client = httpx.AsyncClient for error_type in (httpx.ReadError, httpx.ReadTimeout, httpx.WriteError, httpx.RemoteProtocolError): diff --git a/tests/test_version.py b/tests/test_version.py index 73b2706..5f156a9 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1,8 +1,8 @@ -"""版本文件、应用版本及发布标签校验。""" +"""Validate version files, application version and release tags.""" import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # 仓库根:允许直接运行本文件 +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # Allow direct execution. import tempfile import unittest diff --git a/tests/test_webui_integration.py b/tests/test_webui_integration.py index 88282a5..15f29c7 100644 --- a/tests/test_webui_integration.py +++ b/tests/test_webui_integration.py @@ -87,7 +87,7 @@ def test_custom_models_keep_upstream_and_binding_independent(self): self.post_rejected("chat/completions", self.payload(selected_model=created[0]["id"]), (404,)) bindings = {row["id"]: row["bindings"] for row in self.management.admin_credential_inventory()} self.assertEqual(bindings[self.entries["cn-work"]["account_key"]], ["studio-cn"]) - self.policy(enabled=False) # 停用目录名称不改变独立自建路由的启停与范围。 + self.policy(enabled=False) # Catalog toggles do not alter independent custom routes. published = {row["id"] for row in self.client.get("/v1/models").json()["data"]} self.assertNotIn("shared-model", published) self.assertTrue({"studio-cn", "studio-intl"} <= published) diff --git a/tests/test_workbuddy_filter.py b/tests/test_workbuddy_filter.py index 600d17f..251df2f 100644 --- a/tests/test_workbuddy_filter.py +++ b/tests/test_workbuddy_filter.py @@ -1,4 +1,4 @@ -"""WorkBuddy 请求适配、审核识别和重试边界;仅使用 MockTransport。""" +"""Test WorkBuddy adaptation, filter detection and retry boundaries using MockTransport.""" import asyncio import copy import json @@ -39,7 +39,7 @@ def reply(text=REFUSAL, *, field="content", finish="stop"): def payload(route, *, stream=False, tools=False, identity=IDENTITY.lower()): - # 小写身份走 Responses 的保守投影,保留可供兜底压缩的模板。 + # Lowercase identity uses conservative Responses projection and retains fallback context. system = (identity + ".\n" + BRANCH + ": main\n## Planning\n" + "Read relevant source files, preserve project conventions, and verify changes with tests.\n" * 5) user = {"role": "user", "content": "List the repository files."} @@ -305,8 +305,7 @@ def test_http_and_sse_filter_errors_are_preserved_without_auth_cooldown_or_retry self.assert_one_request() self.credential_status.assert_not_called() self.failures.assert_called_once_with("content_filter") - # 审核拒绝同样落在第一个字节之前,流式必须与 stream=False 吃同一个真实 - # 状态码;一律 200 + 带内 error 会让下游把失败当成空回答静默结束会话。 + # Pre-response filter failures use the same HTTP status in both response modes. self.assertEqual(response.status_code, 502 if status == 200 else status, response.text) self.assertIn("content_filter", response.text) @@ -373,8 +372,7 @@ def respond(req): self.respond = respond response = self.post(route, stream=stream) self.assert_one_request() - # chat / messages 的真流式此时已经吐过拒绝文本,收不回状态码,只能带内 - # 报错;responses 总是先聚合再落字节,失败时一个字节都没发出去 → 真实 502。 + # Chat/Messages may already have sent bytes; Responses still fails before headers. already_streamed = stream and route != "/v1/responses" self.assertEqual(response.status_code, 200 if already_streamed else 502, response.text) diff --git a/tests/test_workbuddy_templates.py b/tests/test_workbuddy_templates.py index ca6be79..1ed7558 100644 --- a/tests/test_workbuddy_templates.py +++ b/tests/test_workbuddy_templates.py @@ -1,8 +1,5 @@ #!/usr/bin/env python3 -"""WorkBuddy 固定模板适配回归;不依赖服务、网络或第三方测试框架。 - -直接运行:python3 -B tests/test_workbuddy_templates.py -""" +"""Test WorkBuddy template adaptation without services, network access or external test frameworks.""" import copy import re @@ -213,7 +210,7 @@ def test_no_compact_still_allows_existing_runtime_metadata_pruning(self): actual = out["messages"][0]["content"] self.assertIn(CODEBUDDY + "." + behavior, actual) self.assertNotIn("runtime-payload-sentinel", actual) - self.assertIn("tool-inventory-sentinel", actual) # 无明确闭合边界的尾段保留。 + self.assertIn("tool-inventory-sentinel", actual) # Preserve tails without a trusted closing boundary. self.assertIn("Environment context is provided by the harness.", actual) self.assertNotIn("Runtime tool, agent,", actual) self.assertEqual(desensitize_body(out, compact_harness=False), out) @@ -282,7 +279,7 @@ def test_official_standalone_harness_only_replaces_fixed_templates(self): for blocks in (False, True): for compact in (False, True): with self.subTest(header=header, identity=identity, blocks=blocks, compact=compact): - # 敏感词、标记和多空行是哨兵:gitStatus 不走词表或 runtime/compact 裁剪。 + # Sentinel terms, markers and whitespace must remain unchanged in gitStatus. tail = ( "\nCurrent branch: feature/Claude-Code-malware\n\n\n" "Status:\n M Anthropic.txt\n?? malware-notes.md\n" @@ -376,16 +373,16 @@ def test_real_user_quotes_and_incomplete_harness_markers_are_unchanged(self): class WordBoundaryTests(unittest.TestCase): - """敏感词只在真实词边界命中:含关键词的标识符/路径不得被插入零宽空格。""" + """Match complete template terms without changing keyword-containing identifiers or paths.""" def test_identifiers_and_paths_containing_terms_are_untouched(self): for text in ("~/.agents/skills/x", "skillset", "mysandbox", "attacksurface", - "data_exfiltration", "killall5", "weaponsmith"): # 含 kill/sandbox/attack/weapon 等词项 + "data_exfiltration", "killall5", "weaponsmith"): # Terms embedded in identifiers with self.subTest(text=text): self.assertEqual(desensitize_text(text), text) def test_standalone_terms_still_split(self): self.assertEqual(desensitize_text("kill the process"), "kill".replace("k", "k" + ZWSP, 1) + " the process") self.assertNotEqual(desensitize_text("Sandbox mode"), "Sandbox mode") - self.assertNotEqual(desensitize_text("a kill-switch"), "a kill-switch") # 连字符是边界 + self.assertNotEqual(desensitize_text("a kill-switch"), "a kill-switch") # Hyphens form word boundaries. self.assertEqual(desensitize_text("skills."), "skills.") diff --git a/web/src/Travel.tsx b/web/src/Travel.tsx new file mode 100644 index 0000000..5f9185a --- /dev/null +++ b/web/src/Travel.tsx @@ -0,0 +1,50 @@ +import { DataValue } from "./values"; + +export function TravelSummary({ trip }: { trip: Record | null }) { + if (!trip) return null; + const remaining = trip.remaining_seconds; + const minutes = + trip.state === "traveling" && + trip.stale !== true && + typeof remaining === "number" && + Number.isFinite(remaining) && + remaining >= 0 + ? Math.ceil(remaining / 60) + : null; + const knownInteger = (value: unknown): value is number => + typeof value === "number" && Number.isSafeInteger(value); + return ( + <> + {typeof trip.location_name === "string" && trip.location_name && ( + 旅行地点:{trip.location_name} + )} + {minutes !== null && ( + + 查询时剩余: + {minutes === 0 + ? "预计已到达,请查询核验" + : minutes >= 60 + ? `${Math.floor(minutes / 60)} 小时 ${minutes % 60} 分钟` + : `${minutes} 分钟`} + + )} + {trip.claimed === true && trip.claimed_credit == null && ( + 领取已确认,积分数额未返回 + )} + {typeof trip.phase === "string" && ( + + 阶段: + + {typeof trip.error_kind === "string" && ( + <> + {" · "} + + + )} + {knownInteger(trip.http_status) && ` · HTTP ${trip.http_status}`} + {knownInteger(trip.code) && ` · 业务码 ${trip.code}`} + + )} + + ); +} diff --git a/web/src/automation.test.tsx b/web/src/automation.test.tsx index 266f983..5b7c6fe 100644 --- a/web/src/automation.test.tsx +++ b/web/src/automation.test.tsx @@ -2,6 +2,7 @@ import { act, fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, expect, it, vi } from "vite-plus/test"; import { Credentials } from "./pages/Credentials"; import { api, credentialResponse, useResource } from "./api"; +import { TravelSummary } from "./Travel"; vi.mock("./api", async (original) => ({ ...(await original()), @@ -164,3 +165,94 @@ it("disables switches when an older backend omits them and rejects malformed typ "自动任务状态必须为布尔值", ); }); + +it("shows server-provided travel locations and snapshot durations without triggering requests", () => { + const { rows } = fixture(); + Object.assign(rows[0].travel, { + location_name: "海边书店", + remaining_seconds: 3661, + phase: "after_depart", + stale: false, + }); + const post = vi.spyOn(api, "post"); + render(); + expect(screen.getByText("旅行地点:海边书店")).toBeTruthy(); + expect(screen.getByText("查询时剩余:1 小时 2 分钟")).toBeTruthy(); + expect(screen.getByText("派遣后核验")).toBeTruthy(); + expect(post).not.toHaveBeenCalled(); +}); + +it("keeps confirmed departure visible when its status read fails", async () => { + fixture(); + vi.spyOn(api, "post").mockResolvedValue({ + data: { + results: [ + { + id: "cn", + name: "cn.info", + action: "travel", + ok: false, + departed: true, + stale: true, + state: "unknown", + phase: "after_depart", + error_kind: "http", + http_status: 503, + code: 123, + remaining_seconds: 300, + message: "派遣已确认,后续状态查询失败;勿重复派出", + }, + ], + }, + }); + render(); + await act(async () => fireEvent.click(screen.getByRole("button", { name: "旅行领派 cn.info" }))); + expect(screen.getByText("部分完成")).toBeTruthy(); + expect(screen.getByText("派遣后核验")).toBeTruthy(); + expect(screen.getByText("上游 HTTP 错误")).toBeTruthy(); + expect(screen.getByText(/HTTP 503/)).toBeTruthy(); + expect(screen.getByText(/业务码 123/)).toBeTruthy(); + expect(screen.queryByText(/查询时剩余/)).toBeNull(); +}); + +it("shows nested travel diagnostics independently from checkin", async () => { + fixture(); + vi.spyOn(api, "post").mockResolvedValue({ + data: { + results: [ + { + id: "cn", + name: "cn.info", + action: "checkin", + ok: false, + checkin_ok: true, + message: "签到成功;地点配置查询失败,未派出", + travel: { phase: "config", http_status: 404, error_kind: "http", code: 12 }, + }, + ], + }, + }); + render(); + await act(async () => fireEvent.click(screen.getByRole("button", { name: "签到 cn.info" }))); + expect(screen.getByText("部分完成")).toBeTruthy(); + expect(screen.getByText("地点配置")).toBeTruthy(); + expect(screen.getByText(/HTTP 404/)).toBeTruthy(); +}); + +it("does not invent a duration or a claimed credit amount", () => { + const { rerender } = render(); + expect(screen.getByText("领取已确认,积分数额未返回")).toBeTruthy(); + expect(screen.queryByText(/查询时剩余/)).toBeNull(); + for (const remaining of [null, true, "300", -1, NaN, Infinity]) { + rerender(); + expect(screen.queryByText(/查询时剩余/)).toBeNull(); + } + rerender(); + expect(screen.queryByText(/查询时剩余/)).toBeNull(); + rerender(); + expect(screen.queryByText(/查询时剩余/)).toBeNull(); + rerender(); + expect(screen.queryByText(/积分数额未返回/)).toBeNull(); + rerender(); + expect(screen.getByText(/预计已到达,请查询核验/)).toBeTruthy(); +}); diff --git a/web/src/pages/Credentials.tsx b/web/src/pages/Credentials.tsx index 50252ad..c800b03 100644 --- a/web/src/pages/Credentials.tsx +++ b/web/src/pages/Credentials.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { OAuth } from "../OAuth"; import { Trial } from "../Trial"; +import { TravelSummary } from "../Travel"; import { api, credentialResponse, @@ -224,6 +225,15 @@ export function Credentials() { : "未完成"} {text(r.message)} + ))} @@ -367,6 +377,7 @@ export function Credentials() { : "旅行仅适用于国内账号"} {trip?.stale === true && 状态可能已变化,请先查询核验} + {c.travel_supported === true && } {c.enabled === false && 账号停用期间不执行自动任务} {c.trial_supported === true && c.trial != null && ( 体验积分:{text(object(c.trial).message)} diff --git a/web/src/values.tsx b/web/src/values.tsx index 2c73b44..386ddaa 100644 --- a/web/src/values.tsx +++ b/web/src/values.tsx @@ -31,6 +31,10 @@ const labels: Record = { checkin: "签到状态", last_success: "上次成功状态", state: "阶段", + phase: "操作阶段", + error_kind: "失败类型", + http_status: "上游 HTTP 状态", + stale: "状态待核验", at: "记录时间", claimed: "本次已领取", departed: "本次已派出", @@ -157,7 +161,32 @@ const statuses: Record = { runtime: "运行事件", admin: "管理操作", }; +const travelStates: Record = { + idle: "空闲", + traveling: "旅行中", + arrived: "已到达", + unknown: "未知", + unavailable: "不可用", +}; +const travelPhases: Record = { + status: "状态查询", + claim: "领取", + after_claim: "领取后核验", + config: "地点配置", + depart: "派遣", + after_depart: "派遣后核验", +}; +const travelErrors: Record = { + http: "上游 HTTP 错误", + business: "上游业务拒绝", + protocol: "响应格式异常", + timeout: "请求超时", + network: "网络失败", +}; const times = new Set([ + "at", + "arrive_at", + "server_now", "started_at", "finished_at", "fetched_at", @@ -237,7 +266,13 @@ export function DataValue({ name, ) ? ownLabel(statuses, value) - : undefined; + : name === "phase" + ? ownLabel(travelPhases, value) + : name === "error_kind" + ? ownLabel(travelErrors, value) + : name === "state" + ? ownLabel(travelStates, value) + : undefined; return {label ?? (value || "—")}; } if (typeof value !== "object") return 未知; From 26bf1667fe73969ef2d89e73aa97e108e6e43e8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?= <277378677+maiphucgiang@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:41:59 +0800 Subject: [PATCH 2/6] Automate consented first-Buddy onboarding Persist account-scoped consent and one-shot onboarding conversations, then verify official task completion before adoption and travel. Support default-off environment preauthorization, daily warnings and safe audit details. Handle first_buddy without the generic task-acceptance endpoint and resume legacy acceptance-only records without replaying conversations. Synchronize confirmation UI, bilingual docs and deployment examples. Validation: all CI-style backend test scripts, 122 WebUI tests, frontend checks/build, and live onboarding through confirmed departure. --- .env.example | 3 + app/admin_api.py | 7 +- app/audit_store.py | 2 +- app/buddy.py | 308 +++++++++++++++++++++++++++++ app/buddy_task.py | 166 ++++++++++++++++ app/control_store.py | 128 ++++++++++++ app/credential_actions.py | 30 ++- app/runtime_management.py | 4 + app/travel.py | 66 ++++++- converter.py | 59 +++++- docker-compose.yml | 2 + docs/advanced.md | 6 + docs/advanced.zh-CN.md | 6 + docs/webui.md | 5 +- docs/webui.zh-CN.md | 5 +- tests/test_buddy.py | 359 ++++++++++++++++++++++++++++++++++ tests/test_buddy_actions.py | 155 +++++++++++++++ tests/test_buddy_task.py | 268 +++++++++++++++++++++++++ tests/test_travel.py | 3 +- web/src/Buddy.tsx | 139 +++++++++++++ web/src/Travel.tsx | 12 ++ web/src/buddy.test.tsx | 210 ++++++++++++++++++++ web/src/logs.test.tsx | 23 +++ web/src/pages/Credentials.tsx | 97 ++++++--- web/src/pages/Logs.tsx | 94 +++++---- web/src/values.tsx | 33 +++- 26 files changed, 2089 insertions(+), 101 deletions(-) create mode 100644 app/buddy.py create mode 100644 app/buddy_task.py create mode 100644 tests/test_buddy.py create mode 100644 tests/test_buddy_actions.py create mode 100644 tests/test_buddy_task.py create mode 100644 web/src/Buddy.tsx create mode 100644 web/src/buddy.test.tsx diff --git a/.env.example b/.env.example index 5a2e9ca..b4661c9 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,9 @@ CODEBUDDY2API_LOG_BODY_LIMIT=65536 # One-time trial credits are claimed manually from the WebUI. +# Preauthorize first-Buddy tasks, one bounded WorkBuddy chat (may use credits), adoption and travel. +CODEBUDDY2API_AUTO_ACCEPT_BUDDY=false + # Maximum credential failovers before response bytes are sent; zero disables replay. # Some gateway failures may already be billed; see docs/advanced.md for replay boundaries. CODEBUDDY2API_FAILOVER_MAX=0 diff --git a/app/admin_api.py b/app/admin_api.py index 34ba696..8aaa54e 100644 --- a/app/admin_api.py +++ b/app/admin_api.py @@ -25,12 +25,14 @@ CLEAR_CONFIRMATION = "清空全部日志与统计" -async def _body(request, maximum=65536): +async def _body(request, maximum=65536, *, allow_empty=False): data = bytearray() async for chunk in request.stream(): data.extend(chunk) if len(data) > maximum: raise ValueError("请求体超过大小限制") + if allow_empty and not data: + return {} try: value = json.loads(data) except (ValueError, UnicodeError, RecursionError): @@ -97,6 +99,9 @@ def settings_result(): if type(value) in (int, float): items.append({"key": name.lower(), "value": value, "stored": None, "source": "internal", "mode": "readonly", "type": "number", "label": label, "locked": True}) + items.append({"key": "auto_accept_buddy", "value": config.get("auto_accept_buddy") is True, + "stored": None, "source": config.get("auto_accept_buddy_source", "default"), + "mode": "startup", "type": "boolean", "label": "全部国内账号首次领猫预授权", "locked": True}) return {"revision": control.snapshot()["revision"], "items": items, "audit": audit.storage()} def audit_settings(values): diff --git a/app/audit_store.py b/app/audit_store.py index 1a7a19f..f6b3b6d 100644 --- a/app/audit_store.py +++ b/app/audit_store.py @@ -47,7 +47,7 @@ def safe_attempt(value: Any) -> dict: return {} result = {} for key in ("stage", "code", "error_code", "model", "upstream_model", "profile", - "credential", "usage_source", "outcome"): + "credential", "usage_source", "outcome", "consent_source", "agreement_revision", "conversation_id", "request_id"): clean = safe_label(value.get(key)) if clean is not None: result[key] = clean diff --git a/app/buddy.py b/app/buddy.py new file mode 100644 index 0000000..cca2df5 --- /dev/null +++ b/app/buddy.py @@ -0,0 +1,308 @@ +"""Prepare the first Buddy with explicit consent, eligibility checks and durable write reservations.""" +import hashlib +import json +import time + +import httpx + +from . import buddy_task + +HOST = "https://www.workbuddy.cn" +RETRY_SECONDS = 86400 +MAX_RESPONSE_BYTES = 1024 * 1024 +AGREEMENT_TITLE = "首领奖励领取确认协议" +AGREEMENT_TERMS = ( + "1. 用户确认当前账号为本人实际参与活动所使用的 WorkBuddy 账号,领取后的奖励与账号状态将进行绑定记录。", + "2. 用户理解本次首领 Buddy 奖励为活动体验型内容,实际展示样式、发放顺序及后续联动规则,平台有权根据活动节奏进行调整。", + "3. 用户同意在领取后,相关奖励状态、徽章点亮状态及页面展示进度会同步写入活动页,用于展示个人成长轨迹与后续任务解锁凭证。", + "4. 如因账号异常、作弊行为、批量注册或非正常使用路径触发风控,平台有权取消领取资格并回收对应奖励权益。", +) +AUTHORIZATION = ("同意自动接取并完成 first_buddy 新手任务,再确认官方协议、领取猫猫及派遣。" + "必要时向当前账号的 WorkBuddy 发起一次独立文本对话,最多请求 32 个输出 token;" + "优先零倍率模型,否则使用最低已知倍率模型,可能消耗少量积分。" + "不调用工具、访问文件、开付费盒子或执行其他奖励任务;结果不确定不重复对话。") +AGREEMENT_REVISION = hashlib.sha256("\n".join((*AGREEMENT_TERMS, AUTHORIZATION)).encode()).hexdigest() +OFFICIAL_URL = "https://www.workbuddy.cn/profile/growth-center" +_ENDPOINTS = { + "info": ("GET", "/activity/growth/buddy/info"), + "list": ("GET", "/activity/growth/buddy/list"), + "tasks": ("GET", "/v2/activity/growth/tasks"), + "agreement": ("GET", "/activity/growth/buddy/agreement"), + "agree": ("POST", "/activity/growth/buddy/agreement"), + "first": ("POST", "/activity/growth/buddy/first"), +} +_MESSAGES = { + "buddy_confirmation_required": "尚未领取猫猫,请确认首次领取后再派遣", + "buddy_not_eligible": "首次领猫任务不存在、已锁定或状态不支持,未继续操作", + "buddy_selection_required": "没有当前可用的猫猫,请到官方成长中心核验或选择已有 Buddy", + "buddy_unknown": "猫猫资格或状态未确认,未继续操作,请稍后查询核验", + "buddy_retry_later": "上次首领结果尚未确认或正在退避,请先核验猫猫状态,勿重复领取", + "buddy_changed": "设置或凭证已变化,未继续领猫或派遣", + "buddy_storage_error": "首领记录或审计无法保存,已停止后续操作,请检查存储状态", + "buddy_write_unconfirmed": "首领操作结果未确认,未派遣;请先查询核验,勿重复领取", + "buddy_reconciled": "猫猫已确认领取,本次未派遣,请查询旅行状态后继续", +} + + +class Failure(ValueError): + def __init__(self, kind, status=None, code=None): + super().__init__("Buddy response was not confirmed") + self.details = {"error_kind": kind, "http_status": status, "code": code} + + +def _request(client, token, operation): + method, path = _ENDPOINTS[operation] + headers = {"Authorization": f"Bearer {token}", "Accept": "application/json", "X-Product-Code": "workbuddy"} + kwargs = {"json": {"agree": True}} if operation == "agree" else {} + with client.stream(method, HOST + path, headers=headers, timeout=12, **kwargs) as response: + raw = bytearray() + for chunk in response.iter_bytes(): + if len(raw) + len(chunk) > MAX_RESPONSE_BYTES: + raise Failure("protocol", response.status_code) + raw.extend(chunk) + try: + payload = json.loads(raw) + except (ValueError, RecursionError): + raise Failure("protocol" if response.status_code == 200 else "http", response.status_code) from None + code = payload.get("code") if isinstance(payload, dict) else None + code = code if type(code) is int and -(2**31) <= code < 2**31 else None + if response.status_code != 200: + raise Failure("http", response.status_code, code) + if code is None or code != 0: + raise Failure("protocol" if code is None else "business", response.status_code, code) + data = payload.get("data") + if data is None and method == "POST": + return {} + if not isinstance(data, dict): + raise Failure("protocol", response.status_code, code) + return data + + +def auto_accept_from_env(environ): + raw = environ.get("CODEBUDDY2API_AUTO_ACCEPT_BUDDY", "false").lower() + if raw not in {"true", "false", "1", "0", "yes", "no", "on", "off"}: + raise ValueError("CODEBUDDY2API_AUTO_ACCEPT_BUDDY 必须是布尔值") + return raw in {"true", "1", "yes", "on"} + + +def _active(data): + if "buddy" not in data: + raise Failure("protocol", 200, 0) + value = data["buddy"] + if value is None: + return False + identity = value.get("instance_id") if isinstance(value, dict) else None + if type(identity) is int and 0 < identity < 2**63: + return True + if isinstance(identity, str) and identity.isascii() and identity.isdecimal() and 0 < len(identity) <= 19 and int(identity) > 0: + return True + raise Failure("protocol", 200, 0) + + +def confirmation(can_claim=False): + return {"can_claim": can_claim, "revision": AGREEMENT_REVISION, "title": AGREEMENT_TITLE, + "terms": list(AGREEMENT_TERMS), "authorization": AUTHORIZATION, "url": OFFICIAL_URL} + + +def audit_event(audit, identity, profile, stage, outcome, source=None, **fields): + if audit is None: + return False + try: + result = audit.event("admin", "buddy." + stage, { + "credential": identity, "profile": profile, "stage": stage, "outcome": outcome, + "consent_source": source, "agreement_revision": AGREEMENT_REVISION, **fields}) + return isinstance(result, dict) and result.get("ok") is True and result.get("recorded") is True + except Exception: + return False + + +def daily_warning(config, identity, profile, result): + """Deduplicate one prerequisite warning per account and local calendar day in the audit store.""" + if not result.get("buddy_blocked") or not identity: + return + audit = config.get("audit_store") + if audit is None: + return + event_id = "buddy-warning-" + hashlib.sha256((identity + ":" + time.strftime("%Y-%m-%d")).encode()).hexdigest() + try: + audit.event("runtime", "buddy.attention_required", { + "event_id": event_id, "credential": identity, "profile": profile, + "outcome": "warning", "stage": result.get("phase"), "code": result.get("reason"), + "status_code": result.get("http_status")}) + except Exception: + pass + + +def context(config, entry, consent_revision=None, *, headers=None, task_model=None): + return {"store": config.get("control_store"), "audit": config.get("audit_store"), + "identity": entry.get("account_key"), "profile": entry.get("profile"), + "headers": dict(headers or {}), "task_model": task_model, + "auto_accept": config.get("auto_accept_buddy") is True, "consent_revision": consent_revision} + + +def accept_consent(context, can_write): + """Persist explicit account consent independently of upstream eligibility or availability.""" + result = {"ok": False, "buddy_consent_accepted": False, "phase": "buddy_consent", "buddy_blocked": True} + try: + if context.get("consent_revision") != AGREEMENT_REVISION or not can_write(): + return {**result, "reason": "buddy_changed", "message": _MESSAGES["buddy_changed"]} + store, identity = context.get("store"), context.get("identity") + if store is None or not identity: + raise ValueError("Consent storage unavailable") + if not store.has_buddy_consent(identity, AGREEMENT_REVISION): + if not audit_event(context.get("audit"), identity, context.get("profile"), "consent", "success", "manual"): + raise ValueError("Consent audit unavailable") + if not can_write(): + return {**result, "reason": "buddy_changed", "message": _MESSAGES["buddy_changed"]} + store.save_buddy_consent(identity, AGREEMENT_REVISION) + return {"buddy_consent_accepted": True, "consent_source": "manual"} + except Exception: + return {**result, "reason": "buddy_storage_error", "message": _MESSAGES["buddy_storage_error"]} + + +def prepare(client, token, *, can_write, context=None): + context = context or {} + store, audit = context.get("store"), context.get("audit") + identity, profile = context.get("identity"), context.get("profile") + source = "manual" if context.get("consent_revision") == AGREEMENT_REVISION else ( + "environment" if context.get("auto_accept") is True else None) + result = {"buddy_ready": False, "buddy_claimed": False, "agreement_accepted": False, + "phase": "buddy_info", "auto_accept_buddy": context.get("auto_accept") is True} + attempt = None + + def stop(reason, **fields): + result.update(ok=False, skipped=True, buddy_blocked=True, reason=reason, + message=_MESSAGES[reason], **fields) + if result.get("buddy_consent_accepted"): + result["message"] = "同意已保存;" + result["message"] + return result + + def checkpoint(stage, outcome): + store.buddy_checkpoint(identity, attempt, stage, outcome, + agreed=result["agreement_accepted"], claimed=result["buddy_claimed"]) + + def event(stage, outcome, **fields): + return audit_event(audit, identity, profile, stage, outcome, source, **fields) + + try: + if store is not None and identity and store.has_buddy_consent(identity, AGREEMENT_REVISION): + result["buddy_consent_accepted"] = True + source = source or "manual" + if source is not None: + result["consent_source"] = source + active = _active(_request(client, token, "info")) + previous = store.buddy_record(identity) if store is not None and identity else None + if active: + if previous and previous["outcome"] != "success": + attempt = previous["attempt_id"] + source = previous["consent_source"] + result["consent_source"] = source + result["buddy_claimed"] = True + result["agreement_accepted"] = bool(previous["agreed"]) + if not event("reconciled", "success"): + return stop("buddy_storage_error") + checkpoint("reconciled", "success") + result["buddy_ready"] = True + return result + result["buddy_required"] = True + if source is None: + result["buddy_confirmation"] = confirmation() + result["phase"] = "buddy_list" + inventory = _request(client, token, "list") + rows, count = inventory.get("buddies"), inventory.get("count") + if not isinstance(rows, list) or type(count) is not int or count < 0 or count < len(rows): + raise Failure("protocol", 200, 0) + if rows or count or previous and previous["claimed"]: + return stop("buddy_selection_required") + if previous and previous["retry_at"] > time.time(): + return stop("buddy_retry_later", retry_at=previous["retry_at"]) + result["phase"] = "buddy_tasks" + task = buddy_task.first_task(_request(client, token, "tasks")) + if task is None: + return stop("buddy_not_eligible") + if source is not None: + progress = buddy_task.perform(client, token, task, context=context, can_write=can_write, + request=_request, event=event) + result.update(progress) + if not progress.get("buddy_task_completed"): + if result.get("buddy_consent_accepted"): + result["message"] = "同意已保存;" + result["message"] + return result + elif task["accept_status"] != "completed": + return stop("buddy_confirmation_required") + else: + result["buddy_task_completed"] = True + result["phase"] = "buddy_agreement" + agreed = _request(client, token, "agreement").get("agreed") + if type(agreed) is not bool: + raise Failure("protocol", 200, 0) + if source is None: + result["buddy_confirmation"] = confirmation(True) + return stop("buddy_confirmation_required") + result["consent_source"] = source + if not can_write(): + return stop("buddy_changed") + if store is None or audit is None or not identity: + return stop("buddy_storage_error") + attempt = store.reserve_buddy(identity, source, AGREEMENT_REVISION, retry_seconds=RETRY_SECONDS) + if attempt is None: + return stop("buddy_retry_later") + if not event("authorization_used", "success"): + return stop("buddy_storage_error") + if not agreed: + result["phase"] = "buddy_agree" + checkpoint("agree", "pending") + if not can_write(): + return stop("buddy_changed") + _request(client, token, "agree") + result["agreement_accepted"] = True + checkpoint("agree", "pending") + if not event("agreement", "success"): + return stop("buddy_storage_error") + result["phase"] = "buddy_first" + checkpoint("first", "pending") + if not can_write(): + return stop("buddy_changed") + _request(client, token, "first") + result["buddy_claimed"] = True + checkpoint("first", "pending") + if not event("first", "success"): + return stop("buddy_storage_error") + result["phase"] = "buddy_verify" + if not _active(_request(client, token, "info")): + checkpoint("verify", "uncertain") + return stop("buddy_write_unconfirmed", stale=True) + checkpoint("verify", "success") + result["buddy_ready"] = True + return result + except (httpx.HTTPError, Failure, buddy_task.TaskFailure) as error: + details = error.details if isinstance(error, (Failure, buddy_task.TaskFailure)) else { + "error_kind": "timeout" if isinstance(error, httpx.TimeoutException) else "network", + "http_status": None, "code": None} + if attempt: + try: + reconciled = False + if result["phase"] == "buddy_agree": + try: + result["agreement_accepted"] = _request(client, token, "agreement").get("agreed") is True + except (httpx.HTTPError, Failure): + pass + elif result["phase"] == "buddy_first": + try: + reconciled = _active(_request(client, token, "info")) + result["buddy_claimed"] = reconciled + except (httpx.HTTPError, Failure): + pass + if reconciled: + checkpoint("reconciled", "success") + if not event("reconciled", "success"): + return stop("buddy_storage_error", stale=True) + return stop("buddy_reconciled", stale=False, **details) + checkpoint(result["phase"], "uncertain") + event("failed", "error", status_code=details["http_status"], + code=str(details["code"]) if details["code"] is not None else None) + except Exception: + return stop("buddy_storage_error", stale=True) + return stop("buddy_write_unconfirmed" if attempt else "buddy_unknown", stale=True, **details) + except Exception: + return stop("buddy_storage_error", stale=True) diff --git a/app/buddy_task.py b/app/buddy_task.py new file mode 100644 index 0000000..10113fb --- /dev/null +++ b/app/buddy_task.py @@ -0,0 +1,166 @@ +"""Complete first-Buddy onboarding through one durable, account-scoped real conversation.""" +import json +import time + +import httpx + +from .site_routing import PROFILE_ENDPOINTS, profile_for_headers +from .upstream_io import ChatSSEAccumulator, UpstreamResponseError + +MAX_OUTPUT_TOKENS = 32 +MAX_CHAT_BYTES = 64 * 1024 +CHAT_SECONDS = 30 +PROMPT = "Say OK." +_MESSAGES = { + "buddy_task_pending": "新手任务等待官方状态更新,自动旅行将继续查询,不重复发送对话", + "buddy_task_unconfirmed": "新手任务请求结果未确认,已停止自动重发,请查询官方状态或检查日志", + "buddy_task_no_model": "当前账号没有可用且倍率已知的模型,请同步余额和目录后重试", + "buddy_task_unsupported": "自动新手对话需要国内 WorkBuddy 凭证,不借用其他账号或产品身份", + "buddy_task_changed": "设置或凭证已变化,已停止新手任务后续操作", + "buddy_task_storage_error": "新手任务记录或审计不可用,已停止后续操作", +} + + +class TaskFailure(ValueError): + def __init__(self, kind, status=None): + super().__init__("Buddy task response was not confirmed") + self.details = {"error_kind": kind, "http_status": status, "code": None} + + +def first_task(data): + rows = data.get("tasks") + if not isinstance(rows, list) or len(rows) > 1000 or any(not isinstance(row, dict) for row in rows): + raise TaskFailure("protocol", 200) + matches = [row for row in rows if row.get("task_code") == "first_buddy"] + if len(matches) != 1: + return None + task = matches[0] + if (task.get("locked") is not False or task.get("reward_buddy") is not True + or task.get("accept_status") not in {"not_accepted", "accepted", "in_progress", "completed"}): + return None + return task + + +def _chat(client, headers, record, model): + conversation, request_id = record["conversation_id"], record["request_id"] + headers = {**headers, "Accept": "text/event-stream", "Content-Type": "application/json", + "X-Conversation-ID": conversation, "X-Session-ID": conversation, + "X-Parent-Conversation-ID": conversation, "X-Request-ID": request_id, + "X-Root-Request-ID": request_id, "X-Conversation-Request-ID": request_id, + "X-Conversation-Message-ID": request_id, "X-Agent-Intent": "craft", + "X-Agent-Purpose": "conversation", "X-Agent-Type": "main"} + event = {"eventCode": "chat_request_send", "id": conversation, "extra": { + "inputLength": len(PROMPT), "requestModelId": model["id"], "requestModelName": model["name"], + "mode": "craft", "command": "", "expertId": ""}} + body = {"model": model["id"], "messages": [ + {"role": "system", "content": "Reply with OK only. Do not use tools."}, + {"role": "user", "content": PROMPT}], "stream": True, "stream_options": {"include_usage": True}, + "max_tokens": MAX_OUTPUT_TOKENS, "extra_vars": {"growthEvent": json.dumps([event], separators=(",", ":"))}} + started = time.monotonic() + with client.stream("POST", PROFILE_ENDPOINTS["cn-work"] + "/v2/chat/completions", headers=headers, + json=body, timeout=httpx.Timeout(15, connect=5, write=10, pool=5)) as response: + if response.status_code != 200: + raise TaskFailure("http", response.status_code) + if not response.headers.get("content-type", "").lower().startswith("text/event-stream"): + raise TaskFailure("protocol", 200) + raw = bytearray() + for chunk in response.iter_bytes(): + if time.monotonic() - started > CHAT_SECONDS: + raise TaskFailure("timeout", 200) + if len(raw) + len(chunk) > MAX_CHAT_BYTES: + raise TaskFailure("protocol", 200) + raw.extend(chunk) + accumulator = ChatSSEAccumulator(max_collect_bytes=MAX_CHAT_BYTES) + try: + for line in raw.decode("utf-8").splitlines(): + accumulator.feed_line(line) + result = accumulator.result() + except (UnicodeError, RecursionError, httpx.HTTPError, UpstreamResponseError): + raise TaskFailure("protocol", 200) from None + if result["tool_calls"] or accumulator.filter_detector.detected: + raise TaskFailure("protocol", 200) + usage = result.get("usage") or {} + total = usage.get("total_tokens") + return total if type(total) is int and 0 <= total <= 10**9 else None + + +def perform(client, token, task, *, context, can_write, request, event): + store, identity = context.get("store"), context.get("identity") + result = {"buddy_task_completed": False, "buddy_task_chat_sent": False, "phase": "buddy_task_verify"} + + def stop(reason, **fields): + return {**result, "ok": False, "skipped": True, "buddy_blocked": True, + "reason": reason, "message": _MESSAGES[reason], **fields} + + def complete(): + if not event("task_completed", "success"): + return stop("buddy_task_storage_error") + store.buddy_task_checkpoint(identity, completed=True) + return {**result, "buddy_task_completed": True} + + def verify(): + return first_task(request(client, token, "tasks")) + + def details(error): + return getattr(error, "details", {"error_kind": "timeout" if isinstance(error, httpx.TimeoutException) else "network", + "http_status": None, "code": None}) + + try: + if store is None or not identity: + return stop("buddy_task_storage_error") + previous = store.buddy_task_record(identity) + if previous: + result["buddy_task_chat_sent"] = bool(previous["chat_started"]) + if task["accept_status"] == "completed": + return complete() if previous and not previous["completed"] else {**result, "buddy_task_completed": True} + headers = context.get("headers") or {} + if (context.get("profile") != "cn-work" or profile_for_headers(headers) != "cn-work" + or headers.get("Authorization") != f"Bearer {token}"): + return stop("buddy_task_unsupported") + if previous and (previous["chat_started"] or previous["completed"]): + return stop("buddy_task_pending" if previous["chat_state"] == "success" else "buddy_task_unconfirmed") + selector = context.get("task_model") + model = selector() if callable(selector) else None + if not model: + return stop("buddy_task_no_model") + if not can_write(): + return stop("buddy_task_changed") + # first_buddy records real activity directly, including from not_accepted. + result["phase"] = "buddy_task_chat" + model = selector(model["id"]) + if not model: + return stop("buddy_task_no_model") + if not can_write(): + return stop("buddy_task_changed") + if not event("task_chat", "pending", model=model["id"], attempt=1, max_attempts=1): + return stop("buddy_task_storage_error") + if not can_write(): + return stop("buddy_task_changed") + reserved = store.reserve_buddy_task(identity, "chat", model=model["id"]) + if reserved is None: + return stop("buddy_task_unconfirmed") + if not can_write(): + return stop("buddy_task_changed") + if not selector(model["id"]): + return stop("buddy_task_no_model") + result["buddy_task_chat_sent"] = True + failure, usage = None, None + try: + usage = _chat(client, headers, reserved, model) + except (httpx.HTTPError, ValueError) as error: + failure = details(error) + store.buddy_task_checkpoint(identity, chat_state="uncertain" if failure else "success", total_tokens=usage) + if not event("task_chat", "error" if failure else "success", model=model["id"], + conversation_id=reserved["conversation_id"], request_id=reserved["request_id"], + total_tokens=usage, status_code=(failure or {}).get("http_status")): + return stop("buddy_task_storage_error") + result["phase"] = "buddy_task_verify" + task = verify() + if task and task["accept_status"] == "completed": + return complete() + return stop("buddy_task_unconfirmed" if failure else "buddy_task_pending", **(failure or {})) + except (httpx.HTTPError, ValueError) as error: + event("task_failed", "error", status_code=details(error).get("http_status")) + return stop("buddy_task_unconfirmed", **details(error)) + except Exception: + return stop("buddy_task_storage_error") diff --git a/app/control_store.py b/app/control_store.py index 3938ff1..1899a43 100644 --- a/app/control_store.py +++ b/app/control_store.py @@ -6,6 +6,8 @@ from pathlib import Path import sqlite3 import threading +import time +import uuid from .settings import validate_settings from .audit_store import _secure_path, safe_label @@ -84,6 +86,18 @@ def __init__(self, path): elif version != self.SCHEMA_VERSION: raise ValueError("管理数据库 schema 不受支持或已有库为空,未执行初始化") self._snapshot = self._load() + self._db.execute("CREATE TABLE IF NOT EXISTS buddy_bootstrap (" + "account_key TEXT PRIMARY KEY, attempt_id TEXT NOT NULL, attempted_at REAL NOT NULL, " + "retry_at REAL NOT NULL, stage TEXT NOT NULL, outcome TEXT NOT NULL, " + "consent_source TEXT NOT NULL, agreement_revision TEXT NOT NULL, " + "agreed INTEGER NOT NULL DEFAULT 0, claimed INTEGER NOT NULL DEFAULT 0)") + self._db.execute("CREATE TABLE IF NOT EXISTS buddy_consents (" + "account_key TEXT PRIMARY KEY, agreement_revision TEXT NOT NULL, accepted_at REAL NOT NULL)") + self._db.execute("CREATE TABLE IF NOT EXISTS buddy_tasks (" + "account_key TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, request_id TEXT NOT NULL, " + "accept_started INTEGER NOT NULL DEFAULT 0, chat_started INTEGER NOT NULL DEFAULT 0, " + "completed INTEGER NOT NULL DEFAULT 0, model TEXT, chat_state TEXT NOT NULL DEFAULT 'pending', " + "total_tokens INTEGER, updated_at REAL NOT NULL)") self._db.execute("COMMIT") except Exception: if self._db.in_transaction: @@ -178,6 +192,120 @@ def set_auto_travel(self, account_key, enabled): return self._update(None, lambda state: state["credentials"].setdefault( account_key, {"enabled": True}).update(auto_travel=enabled)) + def has_buddy_consent(self, identity, revision): + _identifier(identity, "账号指纹") + with self._lock: + return self._db.execute("SELECT 1 FROM buddy_consents WHERE account_key=? AND agreement_revision=?", + (identity, revision)).fetchone() is not None + + def save_buddy_consent(self, identity, revision): + _identifier(identity, "账号指纹") + _identifier(revision, "协议版本") + with self._lock: + self._db.execute("INSERT INTO buddy_consents VALUES(?,?,?) ON CONFLICT(account_key) DO UPDATE SET " + "agreement_revision=excluded.agreement_revision, accepted_at=excluded.accepted_at", + (identity, revision, time.time())) + + + def buddy_record(self, identity): + _identifier(identity, "账号指纹") + with self._lock: + cursor = self._db.execute("SELECT * FROM buddy_bootstrap WHERE account_key=?", (identity,)) + row = cursor.fetchone() + return dict(zip((column[0] for column in cursor.description), row)) if row else None + + def reserve_buddy(self, identity, source, revision, *, retry_seconds, now=None): + """Reserve first-claim writes across processes without changing configuration revisions.""" + _identifier(identity, "账号指纹") + _identifier(revision, "协议版本") + if source not in {"manual", "environment"}: + raise ValueError("确认来源无效") + now = time.time() if now is None else now + with self._lock: + self._db.execute("BEGIN IMMEDIATE") + try: + previous = self.buddy_record(identity) + if previous and (previous["claimed"] or previous["retry_at"] > now): + self._db.execute("COMMIT") + return None + attempt = uuid.uuid4().hex + self._db.execute( + "INSERT INTO buddy_bootstrap VALUES(?,?,?,?,?,?,?,?,0,0) " + "ON CONFLICT(account_key) DO UPDATE SET attempt_id=excluded.attempt_id, " + "attempted_at=excluded.attempted_at, retry_at=excluded.retry_at, stage=excluded.stage, " + "outcome=excluded.outcome, consent_source=excluded.consent_source, " + "agreement_revision=excluded.agreement_revision, agreed=0, claimed=0", + (identity, attempt, now, now + retry_seconds, "reserved", "pending", source, revision)) + self._db.execute("COMMIT") + return attempt + except Exception: + if self._db.in_transaction: + self._db.execute("ROLLBACK") + raise + + def buddy_checkpoint(self, identity, attempt, stage, outcome, *, agreed=False, claimed=False): + _identifier(stage, "首领阶段") + if outcome not in {"pending", "uncertain", "success"}: + raise ValueError("首领结果无效") + with self._lock: + updated = self._db.execute( + "UPDATE buddy_bootstrap SET stage=?, outcome=?, agreed=MAX(agreed,?), claimed=MAX(claimed,?) " + "WHERE account_key=? AND attempt_id=?", + (stage, outcome, int(agreed), int(claimed), identity, attempt)) + if updated.rowcount != 1: + raise ValueError("首领预留已变化") + + def buddy_task_record(self, identity): + _identifier(identity, "账号指纹") + with self._lock: + cursor = self._db.execute("SELECT * FROM buddy_tasks WHERE account_key=?", (identity,)) + row = cursor.fetchone() + return dict(zip((column[0] for column in cursor.description), row)) if row else None + + def reserve_buddy_task(self, identity, operation, *, model=None): + """Reserve at most one acceptance and one billable conversation per account across restarts.""" + _identifier(identity, "账号指纹") + if operation not in {"accept", "chat"}: + raise ValueError("新手任务操作无效") + if operation == "chat": + _identifier(model, "模型") + with self._lock: + self._db.execute("BEGIN IMMEDIATE") + try: + self._db.execute("INSERT OR IGNORE INTO buddy_tasks " + "(account_key,conversation_id,request_id,updated_at) VALUES(?,?,?,?)", + (identity, str(uuid.uuid4()), uuid.uuid4().hex, time.time())) + previous = self.buddy_task_record(identity) + if previous[operation + "_started"] or previous["completed"] or (operation == "accept" and previous["chat_started"]): + self._db.execute("COMMIT") + return None + if operation == "accept": + self._db.execute("UPDATE buddy_tasks SET accept_started=1,updated_at=? WHERE account_key=?", + (time.time(), identity)) + else: + self._db.execute("UPDATE buddy_tasks SET chat_started=1,model=?,updated_at=? WHERE account_key=?", + (model, time.time(), identity)) + record = self.buddy_task_record(identity) + self._db.execute("COMMIT") + return record + except Exception: + if self._db.in_transaction: + self._db.execute("ROLLBACK") + raise + + def buddy_task_checkpoint(self, identity, *, completed=False, chat_state=None, total_tokens=None): + _identifier(identity, "账号指纹") + if chat_state not in {None, "success", "uncertain"}: + raise ValueError("新手对话结果无效") + if total_tokens is not None and (type(total_tokens) is not int or not 0 <= total_tokens <= 10**9): + raise ValueError("新手对话用量无效") + with self._lock: + self._db.execute("UPDATE buddy_tasks SET completed=MAX(completed,?), " + "chat_state=COALESCE(?,chat_state),total_tokens=COALESCE(?,total_tokens),updated_at=? " + "WHERE account_key=?", + (int(completed), chat_state, total_tokens, time.time(), identity)) + + def close(self): with self._lock: self._db.close() diff --git a/app/credential_actions.py b/app/credential_actions.py index 1ecf99f..fa4c23a 100644 --- a/app/credential_actions.py +++ b/app/credential_actions.py @@ -4,13 +4,15 @@ from fastapi import HTTPException -from . import checkin, model_policy, travel, trial_management +from . import buddy, checkin, model_policy, travel, trial_management from .credential_io import credential_file_lock -def run(gateway, action, identity=None): +def run(gateway, action, identity=None, *, consent_revision=None): if action not in {"refresh", "checkin", "sync", "travel", "travel-status", "trial"} or (action in {"refresh", "travel", "travel-status", "trial"} and identity is None): raise HTTPException(404, "凭证操作不存在") + if consent_revision is not None and (action != "travel" or identity is None or consent_revision != buddy.AGREEMENT_REVISION): + raise HTTPException(400, "首领确认无效或协议版本已变化") config = gateway.CONFIG pool, ledger = config.get("cred_pool"), config.get("ledger") if pool is None or (action not in {"refresh", "trial"} and (ledger is None or gateway.credits_mod is None)): @@ -23,8 +25,8 @@ def run(gateway, action, identity=None): entries = [dict(e) for e in pool.entries() if identity is None or e.get("account_key") == identity] if identity is not None and not entries: raise HTTPException(404, "凭证不存在或身份已变化") - if action == "trial": - entries = entries[:1] # Duplicate files for one identity still represent one manual action. + if action in {"trial", "travel", "travel-status"}: + entries = entries[:1] # One account identity represents one manual action. results = [] for entry in entries: started = time.monotonic() @@ -34,11 +36,12 @@ def run(gateway, action, identity=None): else {"skipped": True, "message": "账号已人工停用"}) else: try: - result.update(_one(gateway, pool, ledger, entry, action)) + result.update(_one(gateway, pool, ledger, entry, action, consent_revision=consent_revision)) if (action == "checkin" and result.get("state") not in {"changed", "cancelled"} and model_policy.credential_auto_travel(config, entry)): followup = _one(gateway, pool, ledger, entry, "travel", automatic=True) - result.update(travel=followup, checkin_ok=result["ok"], ok=result["ok"] and followup["ok"], + result.update(travel=followup, checkin_ok=result["ok"], + ok=result["ok"] if followup.get("buddy_blocked") else result["ok"] and followup["ok"], message=result["message"] + ";" + followup["message"]) except Exception: # Upstream exception text may contain headers or credential file paths. @@ -55,6 +58,12 @@ def run(gateway, action, identity=None): status_code=result.get("status"), code=str(result["code"]) if result.get("code") is not None else None, duration_ms=(time.monotonic() - started) * 1000) + trip = result.get("travel") if action == "checkin" else result if action in {"travel", "travel-status"} else None + if isinstance(trip, dict): + details.update(outcome="success" if trip.get("ok") else "warning" if trip.get("buddy_blocked") else "error", + stage=trip.get("phase"), status_code=trip.get("http_status"), + code=trip.get("reason") or (str(trip["code"]) if trip.get("code") is not None else None), + consent_source=trip.get("consent_source")) audit.event("admin", "credential." + action, details) except Exception: pass @@ -66,7 +75,7 @@ def run(gateway, action, identity=None): gateway._HOUSEKEEP_LOCK.release() -def _one(gateway, pool, ledger, entry, action, *, automatic=False): +def _one(gateway, pool, ledger, entry, action, *, automatic=False, consent_revision=None): if action == "trial": return trial_management.perform(gateway, pool, entry) cm, cid = entry["cm"], entry["id"] @@ -95,9 +104,12 @@ def can_write(): return ((not automatic or model_policy.credential_auto_travel(gateway.CONFIG, entry)) and pool.apply_if_current(cm, generation, lambda: None)) result = travel.perform(gateway._bearer_token(headers), gateway.profile_for_headers(headers), - read_only=action == "travel-status", can_write=can_write) + read_only=action == "travel-status", can_write=can_write, + buddy_context=gateway._buddy_context(entry, headers, consent_revision)) if not pool.apply_if_current(cm, generation, lambda: travel.remember(ledger, cid, result)): - return {"ok": False, "message": "凭证已变化,旅行结果未写入,请刷新核验"} + return {**result, "ok": False, "stale": True, "message": "凭证已变化,旅行结果未写入,请刷新核验"} + if automatic: + buddy.daily_warning(gateway.CONFIG, entry.get("account_key"), entry.get("profile"), result) return result if action == "checkin": day = time.strftime("%Y-%m-%d") diff --git a/app/runtime_management.py b/app/runtime_management.py index 70d0ae2..34b5cd6 100644 --- a/app/runtime_management.py +++ b/app/runtime_management.py @@ -1,9 +1,11 @@ """Initialize management only for an explicitly started server, never at import.""" +import os import sqlite3 import sys from pathlib import Path +from . import buddy from .admin_api import install_admin from .audit_store import AuditStore from .control_store import ControlStore @@ -59,6 +61,8 @@ def close(self): def initialize(gateway, args, argv=None): config = gateway.CONFIG + config["auto_accept_buddy"] = buddy.auto_accept_from_env(os.environ) + config["auto_accept_buddy_source"] = "environment" if "CODEBUDDY2API_AUTO_ACCEPT_BUDDY" in os.environ else "default" root = gateway.managed_auth_dir() control = ControlStore(root / "control.sqlite3") config["control_store"] = control diff --git a/app/travel.py b/app/travel.py index 7027132..2e0dda3 100644 --- a/app/travel.py +++ b/app/travel.py @@ -5,15 +5,19 @@ import httpx +from . import buddy + HOST = "https://www.workbuddy.cn" PREFIX = "/activity/growth/buddy/travel/" TIMEOUT = 12.0 class _Failure(ValueError): - def __init__(self, kind, http_status=200, code=0): + def __init__(self, kind, http_status=200, code=0, reason=None): super().__init__("Travel response was not confirmed") self.diagnostics = {"error_kind": kind, "http_status": http_status, "code": code} + if reason: + self.diagnostics["reason"] = reason def supported(profile): @@ -51,12 +55,17 @@ def _request(client, token, operation, *, body=None): raise _Failure("protocol" if status == 200 else "http", status, None) from None code = payload.get("code") if isinstance(payload, dict) else None code = code if type(code) is int and -(2**31) <= code < 2**31 else None + message = payload.get("msg", "") if isinstance(payload, dict) else "" + reason = next((value for text, value in { + "no active buddy": "no_active_buddy", "daily limit": "daily_limit", + "already traveling": "already_traveling", "location not available": "location_unavailable", + }.items() if isinstance(message, str) and len(message) <= 512 and text in message.lower()), None) if status != 200: - raise _Failure("http", status, code) + raise _Failure("http", status, code, reason) if code is None: raise _Failure("protocol", status, None) if code != 0: - raise _Failure("business", status, code) + raise _Failure("business", status, code, reason) data = payload.get("data") # A successful claim may have no business data; reads still require a valid object. if data is None and operation in {"claim", "depart"}: @@ -96,14 +105,25 @@ def _status(data): "server_now": server_now, "remaining_seconds": remaining} -def perform(token, profile, *, read_only=False, can_write=lambda: True): +def perform(token, profile, *, read_only=False, can_write=lambda: True, buddy_context=None): if not supported(profile): return unavailable() result = {"ok": False, "state": "unknown", "claimed": False, "departed": False, "stale": False, "phase": "status"} phase = "status" if not read_only and not can_write(): return {**result, "skipped": True, "message": "设置或凭证已变化,未执行旅行操作"} + if not read_only and buddy_context and buddy_context.get("consent_revision") is not None: + accepted = buddy.accept_consent(buddy_context, can_write) + result.update(accepted) + if not accepted["buddy_consent_accepted"]: + return result try: + def record_departure(stage, outcome): + if not result.get("buddy_claimed"): + return True + context = buddy_context or {} + return buddy.audit_event(context.get("audit"), context.get("identity"), profile, stage, outcome, + result.get("consent_source")) with httpx.Client(follow_redirects=False) as client: result.update(_status(_request(client, token, "status"))) if read_only: @@ -134,6 +154,22 @@ def perform(token, profile, *, read_only=False, can_write=lambda: True): if not can_write(): result.update(skipped=True, message=prefix + "设置或凭证已变化,未发送派遣请求") return result + prepared = buddy.prepare(client, token, can_write=can_write, context=buddy_context) + phase = prepared["phase"] + result.update(prepared) + if not prepared["buddy_ready"]: + result["message"] = prefix + prepared["message"] + return result + if prepared.get("buddy_claimed"): + phase = "buddy_verify" + result.update(_status(_request(client, token, "status"))) + if result["state"] != "idle" or result["daily_limit_reached"] is not False: + result.update(ok=result["state"] != "idle" or result["daily_limit_reached"] is True, + skipped=True, message="猫猫已领取,旅行状态已变化,请先查询核验") + return result + if not can_write(): + result.update(skipped=True, message=prefix + "设置或凭证已变化,未发送派遣请求") + return result phase = "config" locations = _locations(_request(client, token, "config")) location_id = random.choice(tuple(locations)) @@ -141,11 +177,22 @@ def perform(token, profile, *, read_only=False, can_write=lambda: True): result.update(skipped=True, message=prefix + "设置或凭证已变化,未发送派遣请求") return result phase = "depart" + if not record_departure("departure_requested", "pending"): + result.update(buddy_blocked=True, reason="buddy_storage_error", + message="猫猫已领取,但派遣审计无法保存,未发送派遣请求") + return result + if result.get("buddy_claimed") and not can_write(): + result.update(skipped=True, message="猫猫已领取,但设置或凭证已变化,未发送派遣请求") + return result receipt = _request(client, token, "depart", body={"location_id": location_id}) # The action is confirmed, but its current state requires a fresh read. result.update(departed=True, state="unknown", stale=True, daily_limit_reached=None, location_id=location_id, location_name=locations[location_id], reward_credit=None, arrive_at=_number(receipt.get("arrive_at")), server_now=None, remaining_seconds=None) + if not record_departure("departure", "success"): + result.update(buddy_blocked=True, reason="buddy_storage_error", + message="派遣已确认,但审计无法保存,请查询最新状态,勿重复派遣") + return result phase = "after_depart" result.update(_status(_request(client, token, "status"))) if result["state"] == "idle": @@ -157,15 +204,24 @@ def perform(token, profile, *, read_only=False, can_write=lambda: True): "Buddy 已到达,待领取" if result["state"] == "arrived" else "Buddy 已派出,余额可另行同步")) return result except (httpx.HTTPError, ValueError, TypeError) as error: + if result.get("buddy_claimed") and phase in {"depart", "after_depart"}: + record_departure("departure_failed", "error") messages = {"status": "旅行状态查询失败,未执行写操作", "claim": "领取结果未确认,未派出;下次先查询状态", "after_claim": "领取已确认,后续状态查询失败,未派出", + "buddy_verify": "猫猫已领取,后续旅行状态查询失败,未派遣", "config": ("旅行积分已领取;" if result["claimed"] else "") + "地点配置查询失败,未派出", "depart": ("旅行积分已领取;" if result["claimed"] else "") + "派遣结果未确认;下次先查询状态", "after_depart": ("旅行积分已领取;" if result["claimed"] else "") + "派遣已确认,后续状态查询失败;勿重复派出"} diagnostics = error.diagnostics if isinstance(error, _Failure) else { "error_kind": "timeout" if isinstance(error, httpx.TimeoutException) else "network" if isinstance(error, httpx.HTTPError) else "protocol", "http_status": None, "code": None} - result.update(ok=False, stale=True, message=messages[phase], **diagnostics) + result.update(ok=False, stale=True, message=messages.get(phase, "猫猫准备状态未确认,未派遣"), **diagnostics) + refusal = {"no_active_buddy": "官方拒绝派遣:没有当前可用猫猫,请先领取或选择 Buddy", + "daily_limit": "官方拒绝派遣:今日派遣已达上限", + "already_traveling": "官方拒绝派遣:猫猫已在旅行,请查询最新状态", + "location_unavailable": "官方拒绝派遣:该地点暂时不可用"}.get(result.get("reason")) + if phase == "depart" and refusal: + result["message"] = ("旅行积分已领取;" if result["claimed"] else "") + refusal return result finally: result["phase"] = phase diff --git a/converter.py b/converter.py index 607a703..4f39e8b 100644 --- a/converter.py +++ b/converter.py @@ -48,7 +48,7 @@ def desensitize_body(body, roles=("system",), desensitize_harness_user=False, from app import auth_oauth from app import trial_rewards -from app import checkin as checkin_service, model_policy, travel +from app import buddy, checkin as checkin_service, model_policy, travel from app.model_blocks import ModelBlocks from app.client_hangup import ClientHungUp, await_or_hangup from app.observability import (AuditMiddleware, observe_recovery, observe_route, @@ -1073,6 +1073,38 @@ def _sync_error(pool, ledger, entry, generation, phase, error): _log(f"[{phase}] {Path(entry['id']).name} 同步失败(保留旧数据): {message}") +def _buddy_context(entry, headers, consent_revision=None): + def select_model(requested=None): + from app.audit_store import safe_label + pool = CONFIG.get("cred_pool") + account = (CONFIG.get("account_catalogs") or {}).get(entry.get("account_key")) or {} + if pool is None or entry.get("profile") != "cn-work" or account.get("profile") != "cn-work": + return None + with pool._lock: + current = next((item for item in pool._entries if item["cm"] is entry["cm"] + and item.get("account_key") == entry.get("account_key")), None) + if current is None or not pool._healthy(current): + return None + candidates = [] + for item in _usable_models(_account_scope(account, "serves")): + model = item["id"] + rate = _multiplier_value(item.get("credits")) + if (not safe_label(model) or model in {".", ".."} or requested and model != requested + or rate is None or not 0 <= rate < float("inf") or "custom" in (item.get("tags") or [])): + continue + rule = model_policy.rule_for(CONFIG, model) + if (rule["upstream_id"] != model or not pool._eligible(current, model, profile="cn-work", rule=rule) + or not pool._model_healthy(current, model) or not pool._model_servable(current, model)): + continue + name = item.get("name") + candidates.append((rate, model, name if isinstance(name, str) and len(name) <= 160 else model)) + if not candidates: + return None + _, model, name = min(candidates) + return {"id": model, "name": name} + return buddy.context(CONFIG, entry, consent_revision, headers=headers, task_model=select_model) + + def _sync_credits(pool, ledger, entry, *, checkin, failed, expected_identity=None): if not model_policy.credential_enabled(CONFIG, entry): return None @@ -1120,10 +1152,12 @@ def can_claim(): def can_travel(): return (model_policy.credential_auto_travel(CONFIG, entry) and pool.apply_if_current(cm, generation, lambda: None)) - trip = travel.perform(token, profile_for_headers(headers), can_write=can_travel) + trip = travel.perform(token, profile_for_headers(headers), can_write=can_travel, + buddy_context=_buddy_context(entry, headers)) if not pool.apply_if_current(cm, generation, lambda: travel.remember(ledger, cid, trip)): failed.add(cid) return None + buddy.daily_warning(CONFIG, entry.get("account_key"), entry.get("profile"), trip) except Exception as error: _sync_error(pool, ledger, entry, generation, "travel", error) if not model_policy.credential_enabled(CONFIG, entry): @@ -1768,9 +1802,9 @@ def admin_checkin(authorization: Optional[str] = Header(default=None), return _admin_credential_action("checkin") -def _admin_credential_action(action, identity=None): +def _admin_credential_action(action, identity=None, *, consent_revision=None): from app.credential_actions import run - return run(sys.modules[__name__], action, identity) + return run(sys.modules[__name__], action, identity, consent_revision=consent_revision) @app.post("/admin/sync") @@ -1781,11 +1815,20 @@ def admin_sync(authorization: Optional[str] = Header(default=None), @app.post("/admin/credentials/{identity}/{action}") -def admin_credential_action(identity: str, action: str, - authorization: Optional[str] = Header(default=None), - x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): +async def admin_credential_action(identity: str, action: str, request: Request, + authorization: Optional[str] = Header(default=None), + x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): _check_admin_auth(authorization, x_api_key) - return _admin_credential_action(action, identity) + from app.admin_api import _body + try: + body = await _body(request, 4096, allow_empty=True) + if body and (action != "travel" or set(body) != {"confirm_buddy", "agreement_revision"} + or body["confirm_buddy"] is not True or not isinstance(body["agreement_revision"], str)): + raise ValueError() + except ValueError: + raise HTTPException(400, "首领确认参数无效") from None + return await run_in_threadpool(_admin_credential_action, action, identity, + consent_revision=body.get("agreement_revision")) # --------------------------------------------------------------------------- diff --git a/docker-compose.yml b/docker-compose.yml index 0942c94..03d9e2c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,8 @@ services: environment: CODEBUDDY2API_KEY: ${CODEBUDDY2API_KEY:-} CODEBUDDY2API_ADMIN_CSRF: ${CODEBUDDY2API_ADMIN_CSRF:-true} + # First-Buddy onboarding may send one bounded real WorkBuddy conversation per account. + CODEBUDDY2API_AUTO_ACCEPT_BUDDY: ${CODEBUDDY2API_AUTO_ACCEPT_BUDDY:-false} # Unset variables remain configurable through the WebUI. CODEBUDDY2API_KEEP_TOOL_METADATA: CODEBUDDY2API_MAX_IMAGES: ${CODEBUDDY2API_MAX_IMAGES:-16} diff --git a/docs/advanced.md b/docs/advanced.md index 3c6a225..c996941 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -39,6 +39,12 @@ Compose explicitly passes some environment variables and CLI flags, so deleting Environment variables include `CODEBUDDY_AUTH_DIR`, `CODEBUDDY_IMPORT_DIR`, `CODEBUDDY2API_KEY`, `CODEBUDDY2API_ADMIN_CSRF`, `CODEBUDDY2API_KEEP_TOOL_METADATA`, `CODEBUDDY2API_LOG`, `CODEBUDDY2API_MAX_IMAGES`, `CODEBUDDY2API_IMAGE_POLICY`, `CODEBUDDY2API_MAX_REQUEST_BYTES`, `CODEBUDDY2API_LOG_BODY_LIMIT`, `CODEBUDDY2API_FAILOVER_MAX` and `CODEBUDDY2API_RETRY_WRITE_TIMEOUT`. See [deployment](deployment.md) for startup examples. +`CODEBUDDY2API_AUTO_ACCEPT_BUDDY` is startup-only and defaults to `false`. It preauthorizes enabled domestic accounts for first-Buddy onboarding, agreement and travel; automatic travel still respects its account switch. `first_buddy` needs no acceptance API: pending states, including `not_accepted`, allow one real domestic WorkBuddy conversation on that account. Prefer an eligible zero-rate model, otherwise the lowest known rate; request at most 32 output tokens with possible credit usage. Other reward tasks, paid boxes, pet switching and international trials are excluded. + +Manual `POST /admin/credentials/{id}/travel` returns `buddy_confirmation` with official terms and a separate `authorization` scope. Submit `{"confirm_buddy":true,"agreement_revision":""}` after consent; old adoption-only revisions are rejected. `can_claim` describes eligibility and never disables consent. + +`control.sqlite3` preserves consent and at most one onboarding conversation attempt per account across restarts. Historical acceptance records do not block an unsent conversation. Only the official `completed` state permits adoption; uncertain results resume read-only checks, never another billable conversation. Missing models or unhealthy storage stop writes. Keep this database when upgrading; travel-status and balance sync remain read-only. + Trial credits are manual-only for eligible `intl-work` accounts: use the credential row's claim drawer or `POST /admin/credentials/{id}/trial`. Startup, periodic maintenance and balance sync never claim. Results expose safe error categories, HTTP/business codes and retry time; response bodies are capped at 64 KiB and never returned to the browser. Success/already-claimed records persist in `auth/trial-ledger.json`; failures wait at least 24 hours before another manual attempt. Keep this file when upgrading. `CODEBUDDY2API_AUTO_TRIAL` and `--auto-trial` are retired: old startup options warn and do nothing; saved Boolean `auto_trial` settings are ignored on load. Remove them from deployment configuration. Before reverting to older code, check these old settings to avoid re-enabling automatic claims. diff --git a/docs/advanced.zh-CN.md b/docs/advanced.zh-CN.md index d51a1eb..21271bc 100644 --- a/docs/advanced.zh-CN.md +++ b/docs/advanced.zh-CN.md @@ -39,6 +39,12 @@ Compose 会显式传入部分环境变量及 CLI 参数,删除 `.env` 中的 环境变量包括 `CODEBUDDY_AUTH_DIR`、`CODEBUDDY_IMPORT_DIR`、`CODEBUDDY2API_KEY`、`CODEBUDDY2API_ADMIN_CSRF`、`CODEBUDDY2API_KEEP_TOOL_METADATA`、`CODEBUDDY2API_LOG`,以及 `CODEBUDDY2API_MAX_IMAGES`、`CODEBUDDY2API_IMAGE_POLICY`、`CODEBUDDY2API_MAX_REQUEST_BYTES`、`CODEBUDDY2API_LOG_BODY_LIMIT`、`CODEBUDDY2API_FAILOVER_MAX`、`CODEBUDDY2API_RETRY_WRITE_TIMEOUT`。启动示例见 [部署指南](deployment.zh-CN.md)。 +`CODEBUDDY2API_AUTO_ACCEPT_BUDDY` 仅启动读取,默认 `false`;预授权已启用国内账号完成首次领猫任务、协议及旅行,自动旅行仍受账号开关控制。`first_buddy` 无需单独接取,包含 `not_accepted` 在内的待完成状态可直接发起一次本账号的真实国内 WorkBuddy 对话;优先可用零倍率模型,否则取最低已知倍率,最多请求 32 个输出 token,可能消耗少量积分。不执行其他奖励任务、不付费开盒、不切猫、不领取国际试用积分。 + +手动 `POST /admin/credentials/{id}/travel` 返回 `buddy_confirmation`,包含官方条款与独立的 `authorization` 自动化范围。勾选后提交 `{"confirm_buddy":true,"agreement_revision":"<返回的版本>"}`;旧版仅领猫授权失效。`can_claim` 仅表示资格,不禁用同意。 + +`control.sqlite3` 保留同意及每账号最多一次新手对话尝试,重启不重复;旧接取记录不阻塞尚未发送的对话。仅官方任务 `completed` 才继续领猫;不确定时自动维护只回查,不重发可能扣费的对话。缺少可用模型或存储异常时停止写入;升级保留该数据库,旅行状态与余额同步不触发任务。 + 体验积分仅供符合官方资格的 `intl-work` 账号手动领取:使用凭证行的领取抽屉或 `POST /admin/credentials/{id}/trial`。启动、定时维护、余额同步均不领取。结果显示安全错误类别、HTTP 状态/业务码及重试时间,响应正文限制为 64 KiB 且不返回浏览器。成功或已领取记录保存在 `auth/trial-ledger.json`,失败至少等待 24 小时才能再次手动申请;升级时保留该文件。 `CODEBUDDY2API_AUTO_TRIAL` 和 `--auto-trial` 已停用:旧启动选项仅提示、不触发任务;控制库中的旧布尔 `auto_trial` 设置在加载时忽略。请从部署配置中移除;回滚旧代码前也须核对这些旧设置,避免重新启用自动领取。 diff --git a/docs/webui.md b/docs/webui.md index f964225..1c9caf3 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -18,6 +18,9 @@ Management is locked without a key. After changing it, sign in again and restart - International WorkBuddy trial credits require manual confirmation; the drawer displays the result. “Refresh claim status” only reads the local ledger and never claims. On network or persistence failure, verify status before another attempt; environment-driven automatic claims are retired. - Automatic check-in and travel are persisted per account and apply live: on by default for domestic accounts, off internationally. International check-in can be enabled without a code update; inactive or unconfirmed activities never authorize claims. Disabled accounts run no automatic tasks. - Domestic travel follows its own switch, including when check-in is already complete or disabled. “Travel status” only queries; “Claim / dispatch” claims arrivals, rechecks idle state and the daily limit, then chooses a current upstream location. Invalid location configuration stops dispatch. + - One agreement checkbox authorizes completing `first_buddy` through one real WorkBuddy conversation if needed, adoption and dispatch. No separate task acceptance is required. The conversation requests at most 32 output tokens and may use credits; an eligible zero-rate model is preferred. Official completion is checked before adoption, and uncertain conversations are not repeated. + - `CODEBUDDY2API_AUTO_ACCEPT_BUDDY=true` preauthorizes enabled domestic accounts, including future imports, after restart; default is false. Automatic follow-up respects the travel switch; no other reward tasks, paid boxes or trial claims run. + - Adoption consent and outcomes are audited. Blocked automatic travel adds at most one warning per account per local day, without failing check-in or balance sync; uncertain adoption waits at least 24 hours and only reconciles through reads. - Writes are followed by a status check and are never blindly retried. The console retains confirmed claims/dispatches when that check fails, shows the failure stage and snapshot travel time, and leaves unreturned reward amounts unknown. - Saving a preference does not claim immediately; it affects subsequent maintenance and cannot retract sent requests. The console shows last results, partial completion and uncertainty, retaining history on failure. - **Models:** add independent mappings with public/upstream IDs and local enablement. Choose either specific accounts or a region with an optional product filter; switching modes clears the opposite binding. Unavailable candidates never cause out-of-scope fallback. @@ -46,7 +49,7 @@ Data defaults to `auth/`, or `/data/auth` inside Docker. Local installs can set | File | Contents | |------|----------| | `*.info` | Official plaintext credentials; never migrated into SQLite | -| `control.sqlite3` | Gateway settings, model rules and credential metadata | +| `control.sqlite3` | Gateway settings, model rules, credential metadata and first-Buddy reservations | | `logs.sqlite3` | Request details and independent aggregate statistics | Auditing defaults to 30-day detail retention and a 256 MiB logical detail budget, **not a hard limit on database or directory disk usage**. Detail cleanup and eviction preserve aggregates. SQLite failure diagnostics have a separate budget, defaulting to 8192 bytes. Existing text logs are retained, not backfilled as precise statistics. diff --git a/docs/webui.zh-CN.md b/docs/webui.zh-CN.md index 755f0ca..541c11f 100644 --- a/docs/webui.zh-CN.md +++ b/docs/webui.zh-CN.md @@ -18,6 +18,9 @@ - 国际 WorkBuddy 的“一次性体验积分”需手动确认领取,结果在抽屉内显示;“刷新领取状态”只读本地记录,不发领取请求。网络失败或记录保存异常时先核对状态,勿连续点击;环境变量自动领取已停用。 - 自动签到、自动旅行按账号保存并热生效,国内默认开启,国际默认关闭。国际签到可随时开启,不需更新代码;官方活动未开放或状态不明时不领取。账号停用期间不执行自动任务。 - 国内旅行按独立开关执行,已签到或关闭自动签到不影响旅行。“旅行状态”只查询;“旅行领派”领取到达奖励,重新确认空闲且未达上限后,从上游当前地点中随机派遣;地点配置无效时不派出。 + - 勾选一次即自动办理 `first_buddy`:无需单独接取,必要时发起一次真实 WorkBuddy 对话,再领猫和派遣。对话最多请求 32 个输出 token,优先可用零倍率模型,可能消耗少量积分;确认官方任务完成才领猫,不确定时不重复对话。 + - `CODEBUDDY2API_AUTO_ACCEPT_BUDDY=true` 重启后预授权所有已启用国内账号(含后续导入),默认关闭。自动续办受旅行开关控制,不执行其他奖励任务、付费开盒或领取试用积分。 + - 首领授权与结果记录审计;自动旅行阻塞按账号、本地自然日最多一条警告,不影响签到成功和余额同步。首领结果不确定时只读核验,至少退避 24 小时。 - 写操作后只读核验,失败不盲目重试。界面保留已确认的领取或派遣,显示失败阶段和查询时剩余时间;未返回的奖励金额保持未知。 - 保存开关不会立即领取,从后续维护起生效;关闭不撤回已发请求。界面显示上次结果,部分成功与状态未确认单独提示,失败保留历史。 - **模型路由**:新增模型映射,独立设置对外 ID、上游 ID 与启停;指定账号和指定区域二选一,区域内可筛产品。切换方式清除另一种绑定,候选不可用时不会越界回退。 @@ -46,7 +49,7 @@ | 文件 | 内容 | |------|------| | `*.info` | 官方凭证原文;不迁入 SQLite | -| `control.sqlite3` | 网关设置、模型规则和凭证元数据 | +| `control.sqlite3` | 网关设置、模型规则、凭证元数据和首领猫猫预留 | | `logs.sqlite3` | 请求明细与独立聚合统计 | 审计默认保留 30 天明细,逻辑明细预算 256 MiB,**不是数据库或数据目录的磁盘硬上限**。明细清理和容量淘汰不删除聚合历史;SQLite 失败诊断预算独立配置,默认 8192 字节。旧文本日志保留,不回填为精确统计。 diff --git a/tests/test_buddy.py b/tests/test_buddy.py new file mode 100644 index 0000000..af1125e --- /dev/null +++ b/tests/test_buddy.py @@ -0,0 +1,359 @@ +"""Test consent, first-claim reservations and travel prerequisites using offline transports.""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from concurrent.futures import ThreadPoolExecutor +import json +import tempfile +import time +import unittest +from unittest.mock import patch + +import httpx + +from app import buddy, travel +from app.audit_store import AuditStore +from app.control_store import ControlStore + +ACTIVE = {"buddy": {"instance_id": 42}} +EMPTY = {"buddy": None} +LIST = {"buddies": [], "count": 0} +TASK = {"task_code": "first_buddy", "accept_status": "completed", "locked": False, "reward_buddy": True} +TASKS = {"tasks": [TASK]} +AGREEMENT = {"agreed": False} +IDLE = {"state": "idle", "daily_limit_reached": False} +CONFIG = {"locations": [{"id": 1, "name": "咖啡馆"}]} +TRIP = {"state": "traveling", "daily_limit_reached": False, "location": {"id": 1, "name": "咖啡馆"}} + + +class BuddyTests(unittest.TestCase): + def setUp(self): + self.root = Path(self.enterContext(tempfile.TemporaryDirectory())) + self.store = ControlStore(self.root / "control.sqlite3") + self.audit = AuditStore(self.root / "logs.sqlite3") + self.addCleanup(self.store.close) + self.addCleanup(self.audit.close) + self.context = {"identity": "account", "profile": "cn-work", "store": self.store, "audit": self.audit} + self.context["headers"] = {"Authorization": "Bearer synthetic-token", "X-Domain": "www.workbuddy.cn"} + self.calls = [] + + def client(self, responses, on_request=None): + pending = iter(responses) + def handle(request): + self.calls.append(request) + if on_request: + on_request(request) + value = next(pending) + if isinstance(value, Exception): + raise value + if isinstance(value, httpx.Response): + return value + return httpx.Response(200, json={"code": 0, "data": value}) + return httpx.Client(transport=httpx.MockTransport(handle), follow_redirects=False) + + def prepare(self, responses, *, consent=None, automatic=False, can_write=lambda: True, on_request=None): + context = {**self.context, "consent_revision": consent, "auto_accept": automatic} + with self.client(responses, on_request) as client: + return buddy.prepare(client, "synthetic-token", context=context, can_write=can_write) + + def posts(self): + return [call for call in self.calls if call.method == "POST"] + + def test_existing_buddy_needs_no_consent_or_reservation(self): + result = self.prepare([ACTIVE]) + self.assertTrue(result["buddy_ready"]) + self.assertEqual(len(self.calls), 1) + self.assertIsNone(self.store.buddy_record("account")) + + def test_missing_buddy_returns_account_scoped_confirmation_without_writes(self): + result = self.prepare([EMPTY, LIST, TASKS, AGREEMENT]) + self.assertEqual(result["reason"], "buddy_confirmation_required") + self.assertTrue(result["buddy_confirmation"]["can_claim"]) + self.assertEqual(result["buddy_confirmation"]["revision"], buddy.AGREEMENT_REVISION) + self.assertEqual(self.posts(), []) + self.assertIsNone(self.store.buddy_record("account")) + + def test_qualification_and_existing_collection_cannot_be_bypassed_by_environment(self): + for state in ("not_accepted", "accepted", "in_progress", "claimed", None): + with self.subTest(state=state): + result = self.prepare([EMPTY, LIST, {"tasks": [{**TASK, "accept_status": state}]}], automatic=True) + self.assertEqual(result["reason"], "buddy_task_no_model" if state in {"not_accepted", "accepted", "in_progress"} else "buddy_not_eligible") + result = self.prepare([EMPTY, {"buddies": [{"instance_id": 9}], "count": 1}], automatic=True) + self.assertEqual(result["reason"], "buddy_selection_required") + result = self.prepare([EMPTY, LIST, {"tasks": [{**TASK, "locked": True}]}], automatic=True) + self.assertEqual(result["reason"], "buddy_not_eligible") + self.assertEqual(self.posts(), []) + + def test_manual_consent_uses_only_agreement_and_first_endpoints(self): + result = self.prepare([EMPTY, LIST, TASKS, AGREEMENT, None, {"buddy": {}}, ACTIVE], consent=buddy.AGREEMENT_REVISION) + self.assertTrue(result["buddy_ready"], result) + self.assertTrue(result["buddy_claimed"]) + self.assertTrue(result["agreement_accepted"]) + self.assertEqual([r.url.path for r in self.posts()], ["/activity/growth/buddy/agreement", "/activity/growth/buddy/first"]) + self.assertEqual(json.loads(self.posts()[0].content), {"agree": True}) + self.assertEqual(self.posts()[1].content, b"") + for call in self.calls: + self.assertEqual(call.url.host, "www.workbuddy.cn") + self.assertEqual(call.headers["authorization"], "Bearer synthetic-token") + saved = self.store.buddy_record("account") + self.assertEqual(saved["outcome"], "success") + self.assertEqual(saved["claimed"], 1) + events = self.audit.list_records("admin")["items"] + self.assertEqual({e["action"] for e in events}, {"buddy.authorization_used", "buddy.agreement", "buddy.first"}) + self.assertTrue(all(e["details"]["consent_source"] == "manual" for e in events)) + self.assertNotIn("synthetic-token", json.dumps(events)) + self.assertEqual(self.store.snapshot()["revision"], 0) + + def test_environment_authorization_skips_previously_accepted_agreement(self): + result = self.prepare([EMPTY, LIST, TASKS, {"agreed": True}, None, ACTIVE], automatic=True) + self.assertTrue(result["buddy_ready"], result) + self.assertEqual(result["consent_source"], "environment") + self.assertEqual([r.url.path for r in self.posts()], ["/activity/growth/buddy/first"]) + + def test_stale_revision_is_not_consent(self): + result = self.prepare([EMPTY, LIST, TASKS, AGREEMENT], consent="old-version") + self.assertEqual(result["reason"], "buddy_confirmation_required") + self.assertEqual(self.posts(), []) + + def test_consent_is_saved_before_eligibility_and_automatically_resumed_after_restart(self): + client = self.client([IDLE, EMPTY, LIST, {"tasks": [{**TASK, "accept_status": "not_accepted"}]}]) + with patch.object(travel.httpx, "Client", return_value=client): + result = travel.perform("synthetic-token", "cn-work", buddy_context={ + **self.context, "consent_revision": buddy.AGREEMENT_REVISION}) + self.assertTrue(result["buddy_consent_accepted"]) + self.assertEqual(result["reason"], "buddy_task_no_model") + self.assertNotIn("buddy_confirmation", result) + self.assertEqual(self.posts(), []) + self.assertIsNone(self.store.buddy_record("account")) + self.assertTrue(self.store.has_buddy_consent("account", buddy.AGREEMENT_REVISION)) + self.assertEqual([r["action"] for r in self.audit.list_records("admin")["items"]], ["buddy.consent"]) + reopened = ControlStore(self.root / "control.sqlite3") + try: + self.context["store"] = reopened + result = self.prepare([EMPTY, LIST, TASKS, AGREEMENT, None, None, ACTIVE]) + finally: + reopened.close() + self.assertTrue(result["buddy_ready"], result) + self.assertEqual(result["consent_source"], "manual") + self.assertTrue(result["buddy_consent_accepted"]) + self.assertNotIn("buddy_confirmation", result) + self.assertEqual(len(self.posts()), 2) + + def test_consent_is_scoped_to_account_and_agreement_revision(self): + self.store.save_buddy_consent("other-account", buddy.AGREEMENT_REVISION) + self.store.save_buddy_consent("account", "old-revision") + result = self.prepare([EMPTY, LIST, TASKS, AGREEMENT]) + self.assertEqual(result["reason"], "buddy_confirmation_required") + self.assertEqual(self.posts(), []) + + def test_consent_storage_failure_stops_before_network_and_does_not_authorize_later(self): + for field in ("audit", "store"): + with self.subTest(field=field): + target = self.audit if field == "audit" else self.store + method = "event" if field == "audit" else "save_buddy_consent" + with patch.object(target, method, side_effect=OSError("secret")), patch.object(travel.httpx, "Client") as factory: + result = travel.perform("synthetic-token", "cn-work", buddy_context={ + **self.context, "consent_revision": buddy.AGREEMENT_REVISION}) + self.assertFalse(result["buddy_consent_accepted"]) + self.assertEqual(result["reason"], "buddy_storage_error") + self.assertFalse(self.store.has_buddy_consent("account", buddy.AGREEMENT_REVISION)) + factory.assert_not_called() + + def test_read_only_query_cannot_save_consent(self): + client = self.client([IDLE]) + with patch.object(travel.httpx, "Client", return_value=client): + travel.perform("synthetic-token", "cn-work", read_only=True, buddy_context={ + **self.context, "consent_revision": buddy.AGREEMENT_REVISION}) + self.assertFalse(self.store.has_buddy_consent("account", buddy.AGREEMENT_REVISION)) + + def test_unknown_prerequisite_and_invalid_envelopes_stop_writes(self): + cases = [[{}], [{"buddy": {}}], [{"buddy": {"instance_id": True}}], + [EMPTY, {}], [EMPTY, {"buddies": [], "count": False}], + [EMPTY, LIST, {"tasks": None}], [EMPTY, LIST, TASKS, {"agreed": "true"}], + [httpx.Response(200, json={"code": False, "data": ACTIVE})], + [httpx.Response(302, headers={"location": "https://untrusted.invalid"})], + [httpx.Response(200, content=b"x" * (buddy.MAX_RESPONSE_BYTES + 1))]] + for values in cases: + with self.subTest(values=str(values)[:80]): + result = self.prepare(values, automatic=True) + self.assertFalse(result["buddy_ready"]) + self.assertEqual(result["reason"], "buddy_unknown") + self.assertEqual(self.posts(), []) + + def test_write_failure_keeps_reservation_and_never_replays(self): + result = self.prepare([EMPTY, LIST, TASKS, AGREEMENT, None, httpx.ReadTimeout("synthetic-secret"), EMPTY], automatic=True) + self.assertFalse(result["buddy_ready"]) + self.assertTrue(result["agreement_accepted"]) + self.assertEqual(result["phase"], "buddy_first") + self.assertEqual(len(self.posts()), 2) + saved = self.store.buddy_record("account") + self.assertEqual(saved["outcome"], "uncertain") + self.assertGreater(saved["retry_at"], time.time()) + self.assertNotIn("synthetic-secret", str(result)) + again = self.prepare([EMPTY, LIST], automatic=True) + self.assertEqual(again["reason"], "buddy_retry_later") + self.assertEqual(len(self.posts()), 2) + reconciled = self.prepare([ACTIVE]) + self.assertTrue(reconciled["buddy_ready"]) + self.assertEqual(self.store.buddy_record("account")["outcome"], "success") + + def test_timeout_can_be_reconciled_without_replaying_or_dispatching(self): + result = self.prepare([EMPTY, LIST, TASKS, {"agreed": True}, httpx.ReadTimeout("secret"), ACTIVE], automatic=True) + self.assertEqual(result["reason"], "buddy_reconciled") + self.assertTrue(result["buddy_claimed"]) + self.assertFalse(result["buddy_ready"]) + self.assertEqual(len(self.posts()), 1) + self.assertEqual(self.store.buddy_record("account")["outcome"], "success") + + def test_receipt_persistence_failure_stops_followup_but_preserves_known_claim(self): + checkpoint = self.store.buddy_checkpoint + def fail_after_receipt(*args, **kwargs): + if kwargs.get("claimed"): + raise OSError("synthetic storage failure") + return checkpoint(*args, **kwargs) + with patch.object(self.store, "buddy_checkpoint", side_effect=fail_after_receipt): + result = self.prepare([EMPTY, LIST, TASKS, {"agreed": True}, None], automatic=True) + self.assertTrue(result["buddy_claimed"]) + self.assertEqual(result["reason"], "buddy_storage_error") + self.assertEqual(len(self.posts()), 1) + result = self.prepare([ACTIVE]) + self.assertTrue(result["buddy_ready"]) + self.assertEqual(len(self.posts()), 1) + + def test_agreement_audit_failure_prevents_first_claim(self): + event = self.audit.event + def failing_event(kind, action, details): + return {"ok": False} if action == "buddy.agreement" else event(kind, action, details) + with patch.object(self.audit, "event", side_effect=failing_event): + result = self.prepare([EMPTY, LIST, TASKS, AGREEMENT, None], automatic=True) + self.assertTrue(result["agreement_accepted"]) + self.assertEqual(result["reason"], "buddy_storage_error") + self.assertEqual([r.url.path for r in self.posts()], ["/activity/growth/buddy/agreement"]) + + def test_new_travel_or_daily_limit_after_adoption_prevents_dispatch(self): + for status in (TRIP, {**IDLE, "daily_limit_reached": True}, {"state": "idle"}): + with self.subTest(status=status): + identity = "account-" + str(len(self.calls)) + client = self.client([IDLE, EMPTY, LIST, TASKS, {"agreed": True}, None, ACTIVE, status]) + with patch.object(travel.httpx, "Client", return_value=client): + result = travel.perform("synthetic-token", "cn-work", buddy_context={ + **self.context, "identity": identity, "auto_accept": True}) + self.assertTrue(result["buddy_claimed"]) + self.assertFalse(result["departed"]) + self.assertFalse(any(r.url.path.endswith("/depart") for r in self.calls)) + + def test_revocation_during_departure_audit_prevents_dispatch(self): + allowed = [True] + event = self.audit.event + def revoke(kind, action, details): + if action == "buddy.departure_requested": + allowed[0] = False + return event(kind, action, details) + client = self.client([IDLE, EMPTY, LIST, TASKS, {"agreed": True}, None, ACTIVE, IDLE, CONFIG]) + with patch.object(travel.httpx, "Client", return_value=client), patch.object(self.audit, "event", side_effect=revoke): + result = travel.perform("synthetic-token", "cn-work", can_write=lambda: allowed[0], + buddy_context={**self.context, "auto_accept": True}) + self.assertTrue(result["buddy_claimed"]) + self.assertFalse(result["departed"]) + self.assertEqual(len(self.posts()), 1) + + def test_storage_and_audit_failure_block_first_write(self): + with patch.object(self.store, "reserve_buddy", side_effect=OSError("secret")): + result = self.prepare([EMPTY, LIST, TASKS, AGREEMENT], automatic=True) + self.assertEqual(result["reason"], "buddy_storage_error") + with patch.object(self.audit, "event", return_value={"ok": False}): + result = self.prepare([EMPTY, LIST, TASKS, AGREEMENT], automatic=True) + self.assertEqual(result["reason"], "buddy_storage_error") + self.assertEqual(self.posts(), []) + + def test_disabled_or_replaced_credential_stops_between_writes(self): + enabled = [True] + def change(request): + if request.method == "POST": + enabled[0] = False + result = self.prepare([EMPTY, LIST, TASKS, AGREEMENT, None], automatic=True, + can_write=lambda: enabled[0], on_request=change) + self.assertEqual(result["reason"], "buddy_changed") + self.assertTrue(result["agreement_accepted"]) + self.assertEqual(len(self.posts()), 1) + + def test_post_claim_read_failure_keeps_confirmed_claim(self): + result = self.prepare([EMPTY, LIST, TASKS, {"agreed": True}, None, httpx.ConnectError("secret")], automatic=True) + self.assertTrue(result["buddy_claimed"]) + self.assertFalse(result["buddy_ready"]) + self.assertEqual(self.store.buddy_record("account")["claimed"], 1) + self.assertEqual(len(self.posts()), 1) + + def test_travel_checks_buddy_and_rechecks_status_after_adoption(self): + context = {**self.context, "consent_revision": buddy.AGREEMENT_REVISION} + client = self.client([IDLE, EMPTY, LIST, TASKS, AGREEMENT, None, None, ACTIVE, IDLE, CONFIG, None, TRIP]) + with patch.object(travel.httpx, "Client", return_value=client): + result = travel.perform("synthetic-token", "cn-work", buddy_context=context) + self.assertTrue(result["ok"], result) + self.assertTrue(result["buddy_claimed"]) + self.assertTrue(result["departed"]) + self.assertEqual([call.url.path.rsplit("/", 1)[-1] for call in self.calls], + ["status", "info", "list", "tasks", "agreement", "agreement", "first", "info", "status", "config", "depart", "status"]) + + def test_unavailable_task_model_never_dispatches(self): + client = self.client([IDLE, EMPTY, LIST, {"tasks": [{**TASK, "accept_status": "not_accepted"}]}]) + with patch.object(travel.httpx, "Client", return_value=client): + result = travel.perform("synthetic-token", "cn-work", buddy_context={**self.context, "auto_accept": True}) + self.assertEqual(result["reason"], "buddy_task_no_model") + self.assertFalse(result["departed"]) + self.assertEqual(self.posts(), []) + + def test_read_only_and_international_never_bootstrap(self): + client = self.client([IDLE]) + with patch.object(travel.httpx, "Client", return_value=client): + result = travel.perform("synthetic-token", "cn-work", read_only=True, buddy_context={**self.context, "auto_accept": True}) + self.assertTrue(result["ok"]) + self.assertEqual(len(self.calls), 1) + with patch.object(travel.httpx, "Client") as factory: + travel.perform("synthetic-token", "intl-work", buddy_context={**self.context, "auto_accept": True}) + factory.assert_not_called() + + def test_warning_is_once_per_account_day_across_restarts(self): + config = {"audit_store": self.audit} + result = {"buddy_blocked": True, "phase": "buddy_tasks", "reason": "buddy_not_eligible"} + with patch.object(buddy.time, "strftime", return_value="2026-09-16"): + for _ in range(3): + buddy.daily_warning(config, "account", "cn-work", result) + reopened = AuditStore(self.root / "logs.sqlite3") + try: + buddy.daily_warning({"audit_store": reopened}, "account", "cn-work", result) + finally: + reopened.close() + buddy.daily_warning(config, "other-account", "cn-work", result) + with patch.object(buddy.time, "strftime", return_value="2026-09-17"): + buddy.daily_warning(config, "account", "cn-work", result) + events = self.audit.list_records("runtime")["items"] + self.assertEqual(len(events), 3) + self.assertTrue(all(e["details"]["outcome"] == "warning" for e in events)) + + def test_reservation_is_cross_connection_atomic_and_survives_restart(self): + other = ControlStore(self.root / "control.sqlite3") + try: + with ThreadPoolExecutor(max_workers=2) as executor: + attempts = list(executor.map(lambda store: store.reserve_buddy("account", "manual", buddy.AGREEMENT_REVISION, + retry_seconds=86400), [self.store, other])) + self.assertEqual(sum(value is not None for value in attempts), 1) + self.assertIsNotNone(other.buddy_record("account")) + self.assertIsNone(other.reserve_buddy("account", "manual", buddy.AGREEMENT_REVISION, retry_seconds=86400)) + finally: + other.close() + + def test_environment_boolean_is_strict_and_off_by_default(self): + self.assertFalse(buddy.auto_accept_from_env({})) + for value in ("1", "true", "TRUE", "yes", "on"): + self.assertTrue(buddy.auto_accept_from_env({"CODEBUDDY2API_AUTO_ACCEPT_BUDDY": value})) + for value in ("0", "false", "FALSE", "no", "off"): + self.assertFalse(buddy.auto_accept_from_env({"CODEBUDDY2API_AUTO_ACCEPT_BUDDY": value})) + for value in ("", "enabled", "2"): + with self.assertRaises(ValueError): + buddy.auto_accept_from_env({"CODEBUDDY2API_AUTO_ACCEPT_BUDDY": value}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_buddy_actions.py b/tests/test_buddy_actions.py new file mode 100644 index 0000000..5010939 --- /dev/null +++ b/tests/test_buddy_actions.py @@ -0,0 +1,155 @@ +"""Verify scoped confirmation, read-only settings and daily automation warnings over the admin API.""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import time +import unittest +from unittest.mock import patch + +import converter +from app import buddy, credits +from tests import test_credential_actions as fixtures + + +class BuddyActionTests(unittest.TestCase): + add_account = fixtures.CredentialActionTests.add_account + configure = fixtures.CredentialActionTests.configure + handle_upstream = fixtures.CredentialActionTests.handle_upstream + setUp = fixtures.CredentialActionTests.setUp + + def test_confirmation_is_current_account_and_current_agreement_only(self): + response = self.client.post(self.url + '/travel', json={ + 'confirm_buddy': True, 'agreement_revision': buddy.AGREEMENT_REVISION}) + self.assertEqual(response.status_code, 200) + context = self.travel_mock.call_args.kwargs['buddy_context'] + self.assertEqual(context['identity'], self.entry['account_key']) + self.assertEqual(context['consent_revision'], buddy.AGREEMENT_REVISION) + self.assertEqual(self.travel_mock.call_count, 1) + self.assertFalse(context['auto_accept']) + + def test_malformed_wrong_action_and_stale_confirmation_never_start_work(self): + for payload in ({'confirm_buddy': 'true', 'agreement_revision': buddy.AGREEMENT_REVISION}, + {'confirm_buddy': True}, {'agree': True}, + {'confirm_buddy': True, 'agreement_revision': 'old'}, + {'confirm_buddy': True, 'agreement_revision': 'x' * 5000}): + with self.subTest(payload=str(payload)[:90]): + self.assertEqual(self.client.post(self.url + '/travel', json=payload).status_code, 400) + self.assertEqual(self.client.post(self.url + '/checkin', json={ + 'confirm_buddy': True, 'agreement_revision': buddy.AGREEMENT_REVISION}).status_code, 400) + self.travel_mock.assert_not_called() + self.status_query.assert_not_called() + + def test_disabled_and_international_accounts_cannot_use_confirmation(self): + payload = {'confirm_buddy': True, 'agreement_revision': buddy.AGREEMENT_REVISION} + self.control.set_credential(self.entry['account_key'], False) + self.assertFalse(self.client.post(self.url + '/travel', json=payload).json()['ok']) + self.assertFalse(self.client.post('/admin/credentials/' + self.entries['intl-work']['account_key'] + '/travel', + json=payload).json()['ok']) + self.travel_mock.assert_not_called() + + def test_environment_authorization_is_visible_but_not_writable_in_webui(self): + converter.CONFIG.update(auto_accept_buddy=True, auto_accept_buddy_source='environment') + value = next(item for item in self.client.get('/admin/settings').json()['items'] if item['key'] == 'auto_accept_buddy') + self.assertIs(value['value'], True) + self.assertIs(value['locked'], True) + self.assertEqual(value['mode'], 'startup') + response = self.client.patch('/admin/settings', json={'revision': self.control.snapshot()['revision'], + 'settings': {'auto_accept_buddy': False}}) + self.assertEqual(response.status_code, 400) + self.client.post(self.url + '/travel') + self.assertTrue(self.travel_mock.call_args.kwargs['buddy_context']['auto_accept']) + self.assertIsNone(self.travel_mock.call_args.kwargs['buddy_context']['consent_revision']) + + def test_manual_checkin_preserves_success_and_emits_one_automation_warning(self): + self.travel_mock.return_value = {'ok': False, 'buddy_blocked': True, 'phase': 'buddy_tasks', + 'reason': 'buddy_not_eligible', 'message': '需要完成官方任务'} + with patch.object(credits, 'daily_checkin', return_value={'ok': True}): + for _ in range(2): + response = self.client.post(self.url + '/checkin').json() + self.assertTrue(response['ok']) + self.assertTrue(response['results'][0]['checkin_ok']) + self.assertFalse(response['results'][0]['travel']['ok']) + self.assertTrue(self.ledger.checkin_done(self.entry['id'], time.strftime('%Y-%m-%d'))) + warnings = self.audit.list_records('runtime')['items'] + self.assertEqual(len(warnings), 1) + self.assertEqual(warnings[0]['details']['outcome'], 'warning') + + def test_periodic_warning_does_not_block_balance_sync_or_repeat_each_sweep(self): + self.travel_mock.return_value = {'ok': False, 'buddy_blocked': True, 'phase': 'buddy_tasks', + 'reason': 'buddy_not_eligible', 'message': '需要完成官方任务'} + with patch.object(credits, 'daily_checkin', return_value={'ok': True}), \ + patch.object(credits, 'fetch_credits', return_value={'credits': 12, 'intl': False}): + for _ in range(2): + self.assertIsNotNone(converter._sync_credits(self.pool, self.ledger, self.entry, checkin=True, failed=set())) + self.assertEqual(self.ledger.entry(self.entry['id'])['credits']['credits'], 12) + warnings = [item for item in self.audit.list_records('runtime')['items'] if item['action'] == 'buddy.attention_required'] + self.assertEqual(len(warnings), 1) + + def test_stale_credential_retains_confirmed_result_without_overwriting_ledger(self): + def change(*args, **kwargs): + self.entry['cm'].invalidate() + return {'ok': True, 'buddy_claimed': True, 'agreement_accepted': True, 'message': '已领取'} + self.travel_mock.side_effect = change + result = self.client.post(self.url + '/travel').json()['results'][0] + self.assertFalse(result['ok']) + self.assertTrue(result['buddy_claimed']) + self.assertTrue(result['agreement_accepted']) + self.assertTrue(result['stale']) + self.assertNotIn('travel', self.ledger.entry(self.entry['id'])) + + def test_cookie_confirmation_requires_csrf(self): + self.client.headers['Authorization'] = '' + login = self.client.post('/admin/session', json={'api_key': 'synthetic-management-key'}, + headers={'Origin': 'https://testserver'}) + self.assertEqual(login.status_code, 200, login.text) + payload = {'confirm_buddy': True, 'agreement_revision': buddy.AGREEMENT_REVISION} + denied = self.client.post(self.url + '/travel', json=payload, headers={'Origin': 'https://testserver'}) + self.assertEqual(denied.status_code, 403) + self.travel_mock.assert_not_called() + allowed = self.client.post(self.url + '/travel', json=payload, headers={ + 'Origin': 'https://testserver', 'X-CSRF-Token': login.json()['csrf_token']}) + self.assertEqual(allowed.status_code, 200) + self.travel_mock.assert_called_once() + + def test_onboarding_model_uses_only_current_account_catalog_and_policy(self): + entry = self.entries['cn-work'] + other = self.entries['intl-work'] + key = entry['account_key'] + def model(name, rate): + return {'id': name, 'name': name, 'credits': rate, 'supportsToolCall': True} + converter.CONFIG['account_catalogs'] = { + key: {'profile': 'cn-work', 'models': [model('paid', 'x2'), model('free', 'x0'), model('unknown', None)]}, + other['account_key']: {'profile': 'intl-work', 'models': [model('borrowed', 'x0')]}} + selector = converter._buddy_context(entry, entry['cm'].get_headers())['task_model'] + self.assertEqual(selector()['id'], 'free') + self.assertIsNone(selector('borrowed')) + self.assertIsNone(selector('unknown')) + self.control.update_model('free', {'enabled': False}, self.control.snapshot()['revision']) + self.assertEqual(selector()['id'], 'paid') + self.control.update_model('paid', {'credential_ids': [other['account_key']]}, self.control.snapshot()['revision']) + self.assertIsNone(selector()) + self.assertIsNone(converter._buddy_context(self.entries['cn-cli'], {})['task_model']()) + + def test_onboarding_model_rechecks_disable_and_cooldown_without_refreshing_credentials(self): + entry = self.entries['cn-work'] + converter.CONFIG['account_catalogs'] = {entry['account_key']: {'profile': 'cn-work', 'models': [ + {'id': 'free', 'credits': 'x0', 'supportsToolCall': True}]}} + selector = converter._buddy_context(entry, entry['cm'].get_headers())['task_model'] + self.assertIsNotNone(selector()) + self.pool._model_fail[(entry['id'], 'free')] = time.time() + 3600 + self.assertIsNone(selector('free')) + self.pool._model_fail.clear() + self.control.set_credential(entry['account_key'], False) + self.assertIsNone(selector()) + + + def test_confirmation_requires_authentication(self): + response = self.client.post(self.url + '/travel', headers={'Authorization': ''}, json={ + 'confirm_buddy': True, 'agreement_revision': buddy.AGREEMENT_REVISION}) + self.assertEqual(response.status_code, 401) + self.travel_mock.assert_not_called() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_buddy_task.py b/tests/test_buddy_task.py new file mode 100644 index 0000000..9630ea9 --- /dev/null +++ b/tests/test_buddy_task.py @@ -0,0 +1,268 @@ +"""Exercise real-request onboarding contracts without external HTTP calls or credentials.""" +import hashlib +import json +import tempfile +import unittest +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +import sys +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from unittest.mock import patch + +import httpx + +from app import buddy, buddy_task, travel +from app.audit_store import AuditStore +from app.control_store import ControlStore +from tests.test_buddy import ACTIVE, EMPTY, LIST, TASK, AGREEMENT, IDLE, CONFIG, TRIP + + +def tasks(state): + return {"tasks": [{**TASK, "task_type": "beginner", "accept_status": state}]} + + +def sse(): + chunks = [{"choices": [{"delta": {"content": "OK"}, "finish_reason": "stop"}]}, + {"choices": [], "usage": {"prompt_tokens": 12, "completion_tokens": 1, "total_tokens": 13}}] + return httpx.Response(200, headers={"Content-Type": "text/event-stream"}, + text="".join("data: " + json.dumps(row) + "\n\n" for row in chunks) + "data: [DONE]\n\n") + + +class BuddyTaskTests(unittest.TestCase): + def setUp(self): + self.root = Path(self.enterContext(tempfile.TemporaryDirectory())) + self.store = ControlStore(self.root / "control.sqlite3") + self.audit = AuditStore(self.root / "logs.sqlite3") + self.addCleanup(self.store.close) + self.addCleanup(self.audit.close) + self.headers = {"Authorization": "Bearer synthetic-token", "X-Domain": "www.workbuddy.cn", + "X-User-Id": "fixture", "X-IDE-Type": "WorkBuddy"} + self.model = {"id": "fast-model", "name": "Fast"} + self.context = {"identity": "account", "profile": "cn-work", "store": self.store, "audit": self.audit, + "headers": self.headers, "task_model": lambda requested=None: self.model, + "auto_accept": True} + self.calls = [] + + def run_flow(self, responses, *, can_write=lambda: True, full=False, on_request=None, context=None): + pending = iter(responses) + def handle(request): + self.calls.append(request) + self.assertNotEqual(request.url.path, "/activity/growth/tasks/accept") + if on_request: + on_request(request) + value = next(pending) + if isinstance(value, Exception): + raise value + return value if isinstance(value, httpx.Response) else httpx.Response(200, json={"code": 0, "data": value}) + client = httpx.Client(transport=httpx.MockTransport(handle), follow_redirects=False) + if full: + with patch.object(travel.httpx, "Client", return_value=client): + return travel.perform("synthetic-token", "cn-work", buddy_context=context or self.context, can_write=can_write) + with client: + return buddy.prepare(client, "synthetic-token", context=context or self.context, can_write=can_write) + + def writes(self): + return [call for call in self.calls if call.method == "POST"] + + def test_one_checkbox_completes_unaccepted_task_without_acceptance_endpoint(self): + context = {**self.context, "auto_accept": False, "consent_revision": buddy.AGREEMENT_REVISION} + result = self.run_flow([IDLE, EMPTY, LIST, tasks("not_accepted"), sse(), + tasks("completed"), AGREEMENT, None, None, ACTIVE, IDLE, CONFIG, {}, TRIP], full=True, context=context) + self.assertTrue(result["ok"], result) + self.assertTrue(result["departed"]) + self.assertTrue(result["buddy_task_completed"]) + self.assertTrue(result["buddy_consent_accepted"]) + self.assertEqual([call.url.path for call in self.writes()], [ + "/v2/chat/completions", "/activity/growth/buddy/agreement", + "/activity/growth/buddy/first", "/activity/growth/buddy/travel/depart"]) + chat = self.writes()[0] + body = json.loads(chat.content) + self.assertEqual(body["max_tokens"], 32) + self.assertNotIn("tools", body) + self.assertEqual(body["messages"][-1]["content"], buddy_task.PROMPT) + growth = json.loads(body["extra_vars"]["growthEvent"]) + self.assertEqual(len(growth), 1) + self.assertEqual(growth[0]["eventCode"], "chat_request_send") + self.assertEqual(growth[0]["id"], chat.headers["X-Conversation-ID"]) + self.assertEqual(growth[0]["extra"]["requestModelId"], body["model"]) + self.assertEqual(growth[0]["extra"]["inputLength"], len(buddy_task.PROMPT)) + self.assertTrue(all(call.url.host == "www.workbuddy.cn" for call in self.calls)) + record = self.store.buddy_task_record("account") + self.assertEqual((record["accept_started"], record["chat_started"], record["completed"], record["total_tokens"]), (0, 1, 1, 13)) + self.assertEqual(record["request_id"], chat.headers["X-Request-ID"]) + rows = self.audit.list_records("admin")["items"] + self.assertIn("buddy.task_completed", {row["action"] for row in rows}) + receipt = next(row for row in rows if row["action"] == "buddy.task_chat" and row["details"]["outcome"] == "success") + self.assertEqual(receipt["details"]["total_tokens"], 13) + self.assertEqual(receipt["details"]["request_id"], record["request_id"]) + self.assertNotIn("synthetic-token", json.dumps(rows)) + self.assertNotIn(buddy_task.PROMPT, json.dumps(rows)) + + def test_no_consent_only_reads_and_offers_full_authorization_at_false_eligibility(self): + result = self.run_flow([EMPTY, LIST, tasks("not_accepted")], context={**self.context, "auto_accept": False}) + self.assertEqual(result["reason"], "buddy_confirmation_required") + self.assertFalse(result["buddy_confirmation"]["can_claim"]) + self.assertIn("32", result["buddy_confirmation"]["authorization"]) + self.assertIn("积分", result["buddy_confirmation"]["authorization"]) + self.assertEqual(self.writes(), []) + self.assertIsNone(self.store.buddy_task_record("account")) + + def test_old_adoption_only_consent_cannot_authorize_billable_onboarding(self): + old = hashlib.sha256("\n".join(buddy.AGREEMENT_TERMS).encode()).hexdigest() + self.store.save_buddy_consent("account", old) + self.assertNotEqual(old, buddy.AGREEMENT_REVISION) + result = self.run_flow([EMPTY, LIST, tasks("accepted")], context={**self.context, "auto_accept": False}) + self.assertEqual(result["reason"], "buddy_confirmation_required") + self.assertEqual(self.writes(), []) + + def test_pending_state_reconciles_after_restart_without_repeating_accept_or_chat(self): + result = self.run_flow([EMPTY, LIST, tasks("accepted"), sse(), tasks("in_progress")]) + self.assertEqual(result["reason"], "buddy_task_pending") + self.assertEqual(len(self.writes()), 1) + other = ControlStore(self.root / "control.sqlite3") + self.addCleanup(other.close) + self.context["store"] = other + result = self.run_flow([EMPTY, LIST, tasks("in_progress")]) + self.assertEqual(result["reason"], "buddy_task_pending") + self.assertEqual(len(self.writes()), 1) + result = self.run_flow([EMPTY, LIST, tasks("completed"), {"agreed": True}, None, ACTIVE]) + self.assertTrue(result["buddy_ready"], result) + self.assertEqual([r.url.path for r in self.writes()].count("/v2/chat/completions"), 1) + self.assertEqual(other.buddy_task_record("account")["completed"], 1) + + def test_uncertain_chat_is_never_replayed_and_can_finish_via_official_readback(self): + for error in (httpx.ReadTimeout("synthetic-secret"), httpx.WriteTimeout("synthetic-secret"), + httpx.ConnectError("synthetic-secret"), httpx.Response(429, text="synthetic-secret"), + httpx.Response(302, headers={"Location": "https://evil.invalid"}), + httpx.Response(200, headers={"Content-Type": "text/event-stream"}, text='data: {"choices": []}\n\n')): + with self.subTest(error=type(error).__name__): + self.context["identity"] = "case-" + str(len(self.calls)) + before = len(self.writes()) + result = self.run_flow([EMPTY, LIST, tasks("accepted"), error, tasks("in_progress")]) + self.assertEqual(result["reason"], "buddy_task_unconfirmed", result) + self.assertNotIn("synthetic-secret", json.dumps(result)) + result = self.run_flow([EMPTY, LIST, tasks("accepted")]) + self.assertEqual(result["reason"], "buddy_task_unconfirmed") + self.assertEqual(len(self.writes()), before + 1) + self.context["identity"] = "confirmed-after-timeout" + result = self.run_flow([EMPTY, LIST, tasks("accepted"), httpx.ReadTimeout("x"), tasks("completed"), + {"agreed": True}, None, ACTIVE]) + self.assertTrue(result["buddy_ready"], result) + + def test_historical_acceptance_record_resumes_once_with_preserved_consent(self): + reserved = self.store.reserve_buddy_task("account", "accept") + self.store.save_buddy_consent("account", buddy.AGREEMENT_REVISION) + context = {**self.context, "auto_accept": False} + result = self.run_flow([EMPTY, LIST, tasks("not_accepted"), sse(), tasks("not_accepted")], context=context) + self.assertEqual(result["reason"], "buddy_task_pending") + self.assertTrue(result["buddy_consent_accepted"]) + self.assertNotIn("buddy_confirmation", result) + self.assertEqual(len(self.writes()), 1) + checkpoint = self.store.buddy_task_record("account") + self.assertEqual(checkpoint["conversation_id"], reserved["conversation_id"]) + self.assertEqual((checkpoint["accept_started"], checkpoint["chat_started"]), (1, 1)) + reopened = ControlStore(self.root / "control.sqlite3") + self.addCleanup(reopened.close) + result = self.run_flow([EMPTY, LIST, tasks("not_accepted")], context={**context, "store": reopened}) + self.assertEqual(result["reason"], "buddy_task_pending") + self.assertEqual(len(self.writes()), 1) + + def test_existing_completed_task_skips_chat_without_acceptance_request(self): + result = self.run_flow([EMPTY, LIST, tasks("completed"), {"agreed": True}, None, ACTIVE]) + self.assertTrue(result["buddy_ready"], result) + self.assertNotIn("/v2/chat/completions", [r.url.path for r in self.writes()]) + + def test_reserved_chat_survives_crash_without_any_replay(self): + self.store.reserve_buddy_task("account", "chat", model="fast-model") + result = self.run_flow([EMPTY, LIST, tasks("accepted")]) + self.assertEqual(result["reason"], "buddy_task_unconfirmed") + self.assertEqual(self.writes(), []) + + def test_no_model_wrong_product_or_changed_credentials_do_not_send_writes(self): + for context, reason in (({**self.context, "task_model": lambda *args: None}, "buddy_task_no_model"), + ({**self.context, "profile": "cn-cli"}, "buddy_task_unsupported"), + ({**self.context, "headers": {**self.headers, "Authorization": "Bearer other"}}, "buddy_task_unsupported")): + result = self.run_flow([EMPTY, LIST, tasks("not_accepted")], context=context) + self.assertEqual(result["reason"], reason) + result = self.run_flow([EMPTY, LIST, tasks("not_accepted")], can_write=lambda: False) + self.assertEqual(result["reason"], "buddy_task_changed") + self.assertEqual(self.writes(), []) + + def test_disabling_during_task_query_prevents_chat_and_claim(self): + enabled = [True] + def disable(request): + if request.url.path.endswith("/growth/tasks"): + enabled[0] = False + result = self.run_flow([EMPTY, LIST, tasks("not_accepted")], + can_write=lambda: enabled[0], on_request=disable) + self.assertEqual(result["reason"], "buddy_task_changed") + self.assertEqual(self.writes(), []) + self.assertIsNone(self.store.buddy_task_record("account")) + + def test_audit_or_reservation_failure_blocks_network_writes(self): + for target, method in ((self.audit, "event"), (self.store, "reserve_buddy_task")): + with patch.object(target, method, side_effect=OSError("synthetic-secret")): + result = self.run_flow([EMPTY, LIST, tasks("accepted")]) + self.assertEqual(result["reason"], "buddy_task_storage_error") + self.assertEqual(self.writes(), []) + + def test_bounded_response_and_no_success_event_fabrication(self): + response = httpx.Response(200, headers={"Content-Type": "text/event-stream"}, content=b"x" * (buddy_task.MAX_CHAT_BYTES + 1)) + result = self.run_flow([EMPTY, LIST, tasks("accepted"), response, tasks("in_progress")]) + self.assertEqual(result["reason"], "buddy_task_unconfirmed") + self.assertEqual(result["error_kind"], "protocol") + self.assertFalse(self.store.buddy_task_record("account")["completed"]) + self.assertFalse(any(row["action"] == "buddy.task_completed" for row in self.audit.list_records("admin")["items"])) + + def test_expired_chat_deadline_stops_and_never_replays(self): + with patch.object(buddy_task, "CHAT_SECONDS", -1): + result = self.run_flow([EMPTY, LIST, tasks("accepted"), sse(), tasks("in_progress")]) + self.assertEqual(result["error_kind"], "timeout") + self.assertEqual(result["reason"], "buddy_task_unconfirmed") + self.run_flow([EMPTY, LIST, tasks("accepted")]) + self.assertEqual(len(self.writes()), 1) + + def test_model_disappearing_before_dispatch_stops_before_chat(self): + context = {**self.context, "task_model": lambda requested=None: self.model if requested is None else None} + result = self.run_flow([EMPTY, LIST, tasks("not_accepted")], context=context) + self.assertEqual(result["reason"], "buddy_task_no_model") + self.assertEqual(self.writes(), []) + + def test_verification_failure_after_real_chat_does_not_resend_on_resume(self): + result = self.run_flow([EMPTY, LIST, tasks("accepted"), sse(), httpx.ReadTimeout("secret")]) + self.assertEqual(result["reason"], "buddy_task_unconfirmed") + self.assertEqual(self.store.buddy_task_record("account")["chat_state"], "success") + result = self.run_flow([EMPTY, LIST, tasks("accepted")]) + self.assertEqual(result["reason"], "buddy_task_pending") + self.assertEqual(len(self.writes()), 1) + + def test_locked_first_task_does_not_start_model_request(self): + result = self.run_flow([EMPTY, LIST, {"tasks": [{**TASK, "locked": True}]}]) + self.assertEqual(result["reason"], "buddy_not_eligible") + self.assertEqual(self.writes(), []) + + + def test_all_official_pending_states_allow_one_conversation_and_require_completion(self): + for state in ("not_accepted", "accepted", "in_progress"): + with self.subTest(state=state): + self.context["identity"] = "account-" + state + count = len(self.writes()) + result = self.run_flow([EMPTY, LIST, tasks(state), sse(), tasks(state)]) + self.assertEqual(result["reason"], "buddy_task_pending") + self.assertFalse(result["buddy_claimed"]) + self.assertEqual(len(self.writes()), count + 1) + self.assertFalse(self.store.buddy_task_record(self.context["identity"])["completed"]) + + + def test_task_reservation_is_atomic_between_store_connections(self): + other = ControlStore(self.root / "control.sqlite3") + self.addCleanup(other.close) + with ThreadPoolExecutor(max_workers=2) as executor: + results = list(executor.map(lambda store: store.reserve_buddy_task("account", "chat", model="fast-model"), [self.store, other])) + self.assertEqual(sum(result is not None for result in results), 1) + self.assertIsNone(self.store.reserve_buddy_task("account", "accept")) + self.assertIsNotNone(self.store.reserve_buddy_task("another", "chat", model="fast-model")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_travel.py b/tests/test_travel.py index 50f415a..ded47dd 100644 --- a/tests/test_travel.py +++ b/tests/test_travel.py @@ -32,6 +32,7 @@ def handle(request): return httpx.Response(status, json=body) client = httpx.Client(transport=httpx.MockTransport(handle), follow_redirects=False) with patch('app.travel.httpx.Client', return_value=client), \ + patch('app.travel.buddy.prepare', return_value={'buddy_ready': True, 'phase': 'buddy_info'}), \ patch('app.travel.random.choice', side_effect=lambda choices: choices[0]): result = travel.perform('synthetic-token', profile, **kwargs) return result, requests @@ -280,7 +281,7 @@ def test_setting_change_during_query_stops_claim_or_depart(self): self.assertEqual(len(calls), 3) def test_setting_or_lease_change_during_config_stops_dispatch(self): - decisions = iter([True, True, False]) + decisions = iter([True, True, True, False]) result, calls = self.run_trip([IDLE, CONFIG], can_write=lambda: next(decisions)) self.assertFalse(result['departed']) self.assertTrue(result['skipped']) diff --git a/web/src/Buddy.tsx b/web/src/Buddy.tsx new file mode 100644 index 0000000..e13e54a --- /dev/null +++ b/web/src/Buddy.tsx @@ -0,0 +1,139 @@ +import { useEffect, useRef, useState } from "react"; +import { api, errorMessage, list, object, text, type Credential } from "./api"; +import { Drawer, ErrorNotice } from "./components"; +import { TravelSummary } from "./Travel"; +import s from "./ui.module.scss"; + +type Confirmation = { + can_claim: boolean; + revision: string; + title: string; + terms: string[]; + authorization: string; +}; +export function buddyConfirmation(value: unknown): Confirmation { + const data = object(value, "首领确认"); + if ( + typeof data.can_claim !== "boolean" || + typeof data.revision !== "string" || + !/^[a-f0-9]{64}$/.test(data.revision) || + typeof data.title !== "string" || + !data.title || + data.title.length > 120 || + !Array.isArray(data.terms) || + !data.terms.length || + data.terms.length > 12 || + typeof data.authorization !== "string" || + !data.authorization.trim() || + data.authorization.length > 2000 || + data.terms.some((term) => typeof term !== "string" || !term || term.length > 2000) + ) + throw new Error("首领确认内容无效,请刷新后重试"); + return data as Confirmation; +} + +export function Buddy({ + credential, + initial, + confirmation, + onClose, + onDone, +}: { + credential: Credential; + initial: Record; + confirmation: Confirmation; + onClose: () => void; + onDone: (result: Record) => void; +}) { + const [accepted, setAccepted] = useState(false); + const [busy, setBusy] = useState(false); + const [attempted, setAttempted] = useState(false); + const [status, setStatus] = useState(initial); + const [error, setError] = useState(null); + const controller = useRef(null); + useEffect(() => () => controller.current?.abort(), []); + const confirm = async () => { + if (controller.current || attempted || credential.enabled !== true) return; + const abort = new AbortController(); + controller.current = abort; + setAccepted(true); + setBusy(true); + setAttempted(true); + setError(null); + try { + const response = await api.post( + `/credentials/${encodeURIComponent(credential.id)}/travel`, + { confirm_buddy: true, agreement_revision: confirmation.revision }, + { signal: abort.signal, timeout: 180000 }, + ); + const results = list(object(response.data).results, "领猫与派遣结果"); + const result = results[0]; + if ( + results.length !== 1 || + result.id !== credential.id || + typeof result.ok !== "boolean" || + typeof result.message !== "string" + ) + throw new Error("领取账号或结果未确认"); + if (!abort.signal.aborted) { + setStatus(result); + onDone(result); + } + } catch (error) { + if (!abort.signal.aborted) + setError( + `${errorMessage(error)};请求失败不代表后台已停止,请关闭后查询状态,勿重复领取。`, + ); + } finally { + controller.current = null; + if (!abort.signal.aborted) setBusy(false); + } + }; + return ( + +

+ {credential.name ?? credential.id} +

+

{text(status.message)}

+ +

{confirmation.authorization}

+

{confirmation.title}

+ {confirmation.terms.map((term, index) => ( +

+ {term} +

+ ))} +

+ + 查看官方成长中心与活动规则 + + 。首次领猫任务通过实际对话自动生效,无需单独接取;不执行其他奖励任务或切换猫猫。 +

+ + + {busy &&

正在核验并申请,请勿重复点击…

} +
+ +
+

+ 勾选即保存同意并自动办理。官方状态更新延迟时,由已开启的自动旅行继续查询,不重复发送对话;关闭自动旅行可停止自动续办,关闭页面不撤销已发请求。 +

+
+ ); +} diff --git a/web/src/Travel.tsx b/web/src/Travel.tsx index 5f9185a..2fabb1e 100644 --- a/web/src/Travel.tsx +++ b/web/src/Travel.tsx @@ -31,6 +31,18 @@ export function TravelSummary({ trip }: { trip: Record | null } {trip.claimed === true && trip.claimed_credit == null && ( 领取已确认,积分数额未返回 )} + {trip.buddy_consent_accepted === true && 首领同意已保存,无需重复确认} + {trip.buddy_task_chat_sent === true && 新手对话已尝试,不自动重复发送} + {trip.buddy_task_completed === true && 官方新手任务已确认完成} + {trip.buddy_claimed === true && 猫猫已领取} + {trip.agreement_accepted === true && 官方协议已确认} + {trip.auto_accept_buddy === true && 首次领猫预授权:已开启} + {typeof trip.retry_at === "number" && Number.isFinite(trip.retry_at) && ( + + 首领退避至: + + + )} {typeof trip.phase === "string" && ( 阶段: diff --git a/web/src/buddy.test.tsx b/web/src/buddy.test.tsx new file mode 100644 index 0000000..4f11ecb --- /dev/null +++ b/web/src/buddy.test.tsx @@ -0,0 +1,210 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, expect, it, vi } from "vite-plus/test"; +import { Buddy, buddyConfirmation } from "./Buddy"; +import { Credentials } from "./pages/Credentials"; +import { api, useResource } from "./api"; + +vi.mock("./api", async (original) => ({ + ...(await original()), + useResource: vi.fn(), +})); +beforeEach(() => vi.restoreAllMocks()); +const confirmation = { + can_claim: true, + revision: "a".repeat(64), + title: "首领奖励领取确认协议", + terms: ["官方确认条款"], + authorization: + "自动接取并完成 first_buddy 新手任务,必要时发送一次真实对话,最多请求 32 个输出 token,可能消耗少量积分。", +}; +const credential = { + id: "cn", + name: "cn.info", + enabled: true, + profile: "cn-work", + travel_supported: true, +}; +const initial = { + id: "cn", + ok: false, + message: "请确认首次领猫", + buddy_confirmation: confirmation, +}; +function mount(canClaim = true, enabled = true) { + const onClose = vi.fn(); + const onDone = vi.fn(); + const view = render( + , + ); + return { ...view, onClose, onDone }; +} +it("does not submit on opening or cancelling an unchecked agreement", () => { + const post = vi.spyOn(api, "post"); + const { onClose } = mount(); + expect(screen.getByRole("checkbox")).toHaveProperty("checked", false); + expect(screen.getByRole("checkbox")).toHaveProperty("disabled", false); + fireEvent.click(screen.getByRole("button", { name: "取消" })); + expect(onClose).toHaveBeenCalledOnce(); + expect(post).not.toHaveBeenCalled(); +}); +it("checking consent submits one account and revision without a second confirmation button", async () => { + let finish!: (value: unknown) => void; + const post = vi.spyOn(api, "post").mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const { onDone } = mount(); + fireEvent.click(screen.getByRole("checkbox")); + fireEvent.click(screen.getByRole("checkbox")); + expect(post).toHaveBeenCalledTimes(1); + expect(post).toHaveBeenCalledWith( + "/credentials/cn/travel", + { confirm_buddy: true, agreement_revision: confirmation.revision }, + expect.objectContaining({ timeout: 180000 }), + ); + expect(screen.queryByRole("button", { name: "确认领取并派遣" })).toBeNull(); + expect(screen.getByRole("button", { name: "关闭抽屉" })).toHaveProperty("disabled", true); + await act(async () => + finish({ + data: { + results: [ + { + id: "cn", + ok: false, + buddy_consent_accepted: true, + buddy_claimed: true, + message: "猫猫已领取,派遣未确认", + }, + ], + }, + }), + ); + expect(onDone).toHaveBeenCalledOnce(); + expect(screen.getByText("猫猫已领取")).toBeTruthy(); + expect(screen.getByText("首领同意已保存,无需重复确认")).toBeTruthy(); + expect(screen.getByRole("checkbox")).toHaveProperty("disabled", true); +}); +it("allows consent while eligibility is false and shows the stored pending result", async () => { + const post = vi.spyOn(api, "post").mockResolvedValue({ + data: { + results: [ + { + id: "cn", + ok: false, + buddy_consent_accepted: true, + reason: "buddy_not_eligible", + message: "同意已保存;等待官方条件满足", + }, + ], + }, + }); + const { onDone } = mount(false); + expect(screen.getByRole("checkbox")).toHaveProperty("disabled", false); + await act(async () => fireEvent.click(screen.getByRole("checkbox"))); + expect(post).toHaveBeenCalledOnce(); + expect(onDone).toHaveBeenCalledOnce(); + expect(screen.getByText("首领同意已保存,无需重复确认")).toBeTruthy(); +}); +it("does not submit consent for a disabled account", () => { + const post = vi.spyOn(api, "post"); + mount(false, false); + expect(screen.getByRole("checkbox")).toHaveProperty("disabled", true); + fireEvent.click(screen.getByRole("checkbox")); + expect(post).not.toHaveBeenCalled(); +}); +it("does not retry ambiguous failures or accept another account's result", async () => { + const post = vi.spyOn(api, "post").mockRejectedValueOnce(new Error("network failed")); + const { onDone, unmount } = mount(); + await act(async () => fireEvent.click(screen.getByRole("checkbox"))); + expect(screen.getByText(/后台已停止/)).toBeTruthy(); + expect(screen.getByRole("checkbox")).toHaveProperty("disabled", true); + expect(onDone).not.toHaveBeenCalled(); + expect(post).toHaveBeenCalledTimes(1); + unmount(); + post.mockResolvedValueOnce({ data: { results: [{ id: "other", ok: true, message: "done" }] } }); + const second = mount(); + await act(async () => fireEvent.click(screen.getByRole("checkbox"))); + expect(screen.getByText(/领取账号或结果未确认/)).toBeTruthy(); + expect(second.onDone).not.toHaveBeenCalled(); +}); +it("opens confirmation only from a scoped manual travel result", async () => { + vi.mocked(useResource).mockReturnValue({ + data: [credential], + loading: false, + error: null, + reload: vi.fn(), + }); + const post = vi.spyOn(api, "post").mockResolvedValue({ + data: { + results: [{ ...initial, buddy_confirmation: { ...confirmation, can_claim: false } }], + }, + }); + render(); + await act(async () => fireEvent.click(screen.getByRole("button", { name: "旅行领派 cn.info" }))); + expect(screen.getByRole("dialog")).toBeTruthy(); + expect(screen.getByRole("checkbox", { name: /我已阅读并同意/ })).toHaveProperty( + "disabled", + false, + ); + expect(post).toHaveBeenCalledTimes(1); + expect(post).toHaveBeenCalledWith("/credentials/cn/travel", undefined, expect.anything()); +}); +it("discloses automatic tasks and possible credit usage before consent", () => { + const post = vi.spyOn(api, "post"); + mount(false); + expect(screen.getByText(confirmation.authorization)).toBeTruthy(); + expect(screen.getByText(/无需单独接取/)).toBeTruthy(); + expect(screen.getByRole("checkbox", { name: /自动完成新手任务/ })).toHaveProperty( + "disabled", + false, + ); + expect(post).not.toHaveBeenCalled(); +}); + +it("rejects incomplete consent text instead of accepting it", () => { + for (const value of [ + {}, + { ...confirmation, revision: "old" }, + { ...confirmation, authorization: undefined }, + { ...confirmation, authorization: "" }, + { ...confirmation, authorization: "x".repeat(2001) }, + { ...confirmation, terms: [] }, + { ...confirmation, can_claim: "true" }, + ]) + expect(() => buddyConfirmation(value)).toThrow(); +}); +it("shows partial completion when checkin succeeds but Buddy prerequisites block travel", async () => { + vi.mocked(useResource).mockReturnValue({ + data: [credential], + loading: false, + error: null, + reload: vi.fn(), + }); + vi.spyOn(api, "post").mockResolvedValue({ + data: { + results: [ + { + id: "cn", + name: "cn.info", + ok: true, + checkin_ok: true, + message: "签到成功;请先完成首领任务", + travel: { ok: false, buddy_blocked: true, phase: "buddy_tasks" }, + }, + ], + }, + }); + render(); + await act(async () => fireEvent.click(screen.getByRole("button", { name: "签到 cn.info" }))); + expect(screen.getByText("部分完成")).toBeTruthy(); + expect(screen.queryByText("已完成")).toBeNull(); + expect(screen.queryByRole("dialog")).toBeNull(); +}); diff --git a/web/src/logs.test.tsx b/web/src/logs.test.tsx index fdb9702..e8aa07a 100644 --- a/web/src/logs.test.tsx +++ b/web/src/logs.test.tsx @@ -120,3 +120,26 @@ describe("log detail request lifetime", () => { expect(screen.queryByRole("status")).toBeNull(); }); }); + +it("shows the persisted daily Buddy warning from event details", () => { + vi.mocked(useResource).mockReturnValue({ + data: { + items: [ + { + id: "buddy-warning", + action: "buddy.attention_required", + kind: "runtime", + details: { outcome: "warning", stage: "buddy_tasks" }, + }, + ], + has_more: false, + next_cursor: null, + }, + loading: false, + error: null, + reload: vi.fn(), + }); + render(); + fireEvent.click(screen.getByRole("tab", { name: "运行事件" })); + expect(screen.getByText("警告")).toBeTruthy(); +}); diff --git a/web/src/pages/Credentials.tsx b/web/src/pages/Credentials.tsx index c800b03..6511a06 100644 --- a/web/src/pages/Credentials.tsx +++ b/web/src/pages/Credentials.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { OAuth } from "../OAuth"; import { Trial } from "../Trial"; import { TravelSummary } from "../Travel"; +import { Buddy, buddyConfirmation } from "../Buddy"; import { api, credentialResponse, @@ -105,6 +106,11 @@ export function Credentials() { const [detail, setDetail] = useState(null); const [deleting, setDeleting] = useState(null); const [trialTarget, setTrialTarget] = useState(null); + const [buddyTarget, setBuddyTarget] = useState<{ + credential: Credential; + result: Record; + confirmation: ReturnType; + } | null>(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); @@ -131,6 +137,16 @@ export function Credentials() { throw new Error("未收到完整操作结果,请刷新列表核验,勿直接重复执行"); for (const r of results) if (r.travel !== undefined) object(r.travel, "旅行操作结果"); setMaintenance(results); + if (action === "travel" && credential) { + if (results.length !== 1 || results[0].id !== credential.id) + throw new Error("旅行账号或结果未确认,请刷新列表核验"); + if (results[0].buddy_confirmation !== undefined) + setBuddyTarget({ + credential, + result: results[0], + confirmation: buddyConfirmation(results[0].buddy_confirmation), + }); + } }) .catch((err: unknown) => setError(`${errorMessage(err)};请求失败不代表后台已停止,请刷新列表核验。`), @@ -207,35 +223,43 @@ export function Credentials() { {maintenance.length > 0 && (
    - {maintenance.map((r, i) => ( -
  • - {text(r.name)} - - {r.ok - ? "已完成" - : r.checkin_ok === true || - r.claimed === true || - r.departed === true || - (r.travel && - typeof r.travel === "object" && - (object(r.travel).claimed === true || object(r.travel).departed === true)) - ? "部分完成" - : r.skipped - ? "已跳过" - : "未完成"} - - {text(r.message)} - -
  • - ))} + {maintenance.map((r, i) => { + const complete = + r.ok === true && + !(r.travel && typeof r.travel === "object" && object(r.travel).ok === false); + return ( +
  • + {text(r.name)} + + {complete + ? "已完成" + : r.checkin_ok === true || + r.claimed === true || + r.departed === true || + r.buddy_claimed === true || + r.agreement_accepted === true || + (r.travel && + typeof r.travel === "object" && + (object(r.travel).claimed === true || + object(r.travel).departed === true)) + ? "部分完成" + : r.skipped + ? "已跳过" + : "未完成"} + + {text(r.message)} + +
  • + ); + })}
)} @@ -513,6 +537,21 @@ export function Credentials() { 添加账号或导入 .info 文件。 ))} + + {buddyTarget && ( + setBuddyTarget(null)} + onDone={(result) => { + setMaintenance([result]); + resource.reload(); + }} + /> + )} + {trialTarget && ( - {resource.data.items.map((item, i) => ( - - - {typeof item.started_at === "number" - ? new Date(item.started_at * 1000).toLocaleString("zh-CN") - : text(item.started_at)} - {text(item.id)} - - - {text(kind === "request" ? item.model : item.action)} - {text(kind === "request" ? item.profile : item.kind)} - - - - {text(item.outcome ?? item.level ?? item.status)} - - {item.status_code !== undefined && ( - HTTP {text(item.status_code)} + {resource.data.items.map((item, i) => { + const outcome = + kind === "request" ? item.outcome : object(item.details ?? {}).outcome; + return ( + + + {typeof item.started_at === "number" + ? new Date(item.started_at * 1000).toLocaleString("zh-CN") + : text(item.started_at)} + {text(item.id)} + + + {text(kind === "request" ? item.model : item.action)} + {text(kind === "request" ? item.profile : item.kind)} + + + + {outcome === "warning" + ? "警告" + : text(outcome ?? item.level ?? item.status)} + + {item.status_code !== undefined && ( + HTTP {text(item.status_code)} + )} + + {kind === "request" && ( + <> + {metric(item.duration_ms)} ms + + {metric(item.total_tokens)} + {metric(item.credit)} Credit + + )} - - {kind === "request" && ( - <> - {metric(item.duration_ms)} ms - - {metric(item.total_tokens)} - {metric(item.credit)} Credit - - - )} - - - - - ))} + + + + + ); + })} diff --git a/web/src/values.tsx b/web/src/values.tsx index 386ddaa..0c7d18e 100644 --- a/web/src/values.tsx +++ b/web/src/values.tsx @@ -27,6 +27,16 @@ const labels: Record = { trial: "一次性体验积分", can_claim: "可手动申请", retry_at: "最早重试时间", + buddy_consent_accepted: "首领同意已保存", + buddy_task_chat_sent: "新手对话已尝试", + buddy_task_completed: "官方新手任务已完成", + conversation_id: "新手会话 ID", + buddy_claimed: "猫猫已领取", + agreement_accepted: "协议已确认", + consent_source: "授权来源", + agreement_revision: "协议版本", + auto_accept_buddy: "首次领猫预授权", + buddy_blocked: "领猫前置条件阻塞", travel: "旅行状态", checkin: "签到状态", last_success: "上次成功状态", @@ -151,6 +161,7 @@ const statuses: Record = { error: "失败", success: "成功", cancelled: "已取消", + warning: "警告", circuit_open: "认证熔断", expired: "已过期", completed: "已完成", @@ -175,6 +186,21 @@ const travelPhases: Record = { config: "地点配置", depart: "派遣", after_depart: "派遣后核验", + buddy_consent: "保存首领同意", + task_accept: "接取官方新手任务", + task_chat: "执行新手对话", + task_completed: "官方任务已完成", + task_failed: "新手任务结果未确认", + buddy_task_accept: "接取官方新手任务", + buddy_task_chat: "执行新手对话", + buddy_task_verify: "核验官方任务完成状态", + buddy_info: "猫猫状态", + buddy_list: "已领取猫猫", + buddy_tasks: "首领资格", + buddy_agreement: "协议状态", + buddy_agree: "协议确认", + buddy_first: "首次领猫", + buddy_verify: "领猫后核验", }; const travelErrors: Record = { http: "上游 HTTP 错误", @@ -188,6 +214,7 @@ const times = new Set([ "arrive_at", "server_now", "started_at", + "retry_at", "finished_at", "fetched_at", "updated_at", @@ -266,13 +293,15 @@ export function DataValue({ name, ) ? ownLabel(statuses, value) - : name === "phase" + : name === "phase" || name === "stage" ? ownLabel(travelPhases, value) : name === "error_kind" ? ownLabel(travelErrors, value) : name === "state" ? ownLabel(travelStates, value) - : undefined; + : name === "consent_source" + ? ownLabel({ manual: "手动确认", environment: "环境变量预授权" }, value) + : undefined; return {label ?? (value || "—")}; } if (typeof value !== "object") return 未知; From 6b7f1449339b75d0bad45fd208a728d4797cc0f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?= <277378677+maiphucgiang@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:03:58 +0800 Subject: [PATCH 3/6] Prevent replay of unconfirmed first-Buddy claims --- app/buddy.py | 5 ++- app/control_store.py | 7 +++- docs/advanced.md | 2 +- docs/advanced.zh-CN.md | 2 +- docs/webui.md | 2 +- docs/webui.zh-CN.md | 2 +- tests/test_buddy.py | 92 +++++++++++++++++++++++++++++++++++++++++- 7 files changed, 105 insertions(+), 7 deletions(-) diff --git a/app/buddy.py b/app/buddy.py index cca2df5..edc4da1 100644 --- a/app/buddy.py +++ b/app/buddy.py @@ -6,6 +6,7 @@ import httpx from . import buddy_task +from .control_store import buddy_claim_reserved HOST = "https://www.workbuddy.cn" RETRY_SECONDS = 86400 @@ -39,7 +40,7 @@ "buddy_retry_later": "上次首领结果尚未确认或正在退避,请先核验猫猫状态,勿重复领取", "buddy_changed": "设置或凭证已变化,未继续领猫或派遣", "buddy_storage_error": "首领记录或审计无法保存,已停止后续操作,请检查存储状态", - "buddy_write_unconfirmed": "首领操作结果未确认,未派遣;请先查询核验,勿重复领取", + "buddy_write_unconfirmed": "首领操作结果未确认,未派遣;仅查询核验,不会自动重新领取", "buddy_reconciled": "猫猫已确认领取,本次未派遣,请查询旅行状态后继续", } @@ -214,6 +215,8 @@ def event(stage, outcome, **fields): raise Failure("protocol", 200, 0) if rows or count or previous and previous["claimed"]: return stop("buddy_selection_required") + if buddy_claim_reserved(previous): + return stop("buddy_write_unconfirmed", stale=True) if previous and previous["retry_at"] > time.time(): return stop("buddy_retry_later", retry_at=previous["retry_at"]) result["phase"] = "buddy_tasks" diff --git a/app/control_store.py b/app/control_store.py index 1899a43..a5a9d70 100644 --- a/app/control_store.py +++ b/app/control_store.py @@ -23,6 +23,11 @@ def _identifier(value, label): return value +def buddy_claim_reserved(record): + """Only pre-claim reservations may expire; a pending send can outlive its process.""" + return bool(record and (record["claimed"] or record["stage"] not in {"reserved", "agree", "buddy_agree"})) + + def validate_model(source, rule, models=None, known_models=(), *, legacy_scopes=False): _identifier(source, "模型规则 ID") if not isinstance(rule, dict) or set(rule) - {"public_id", "upstream_id", "custom", "enabled", "keep_original", "region", "profile", "credential_ids"}: @@ -225,7 +230,7 @@ def reserve_buddy(self, identity, source, revision, *, retry_seconds, now=None): self._db.execute("BEGIN IMMEDIATE") try: previous = self.buddy_record(identity) - if previous and (previous["claimed"] or previous["retry_at"] > now): + if previous and (buddy_claim_reserved(previous) or previous["retry_at"] > now): self._db.execute("COMMIT") return None attempt = uuid.uuid4().hex diff --git a/docs/advanced.md b/docs/advanced.md index c996941..9e3fff1 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -43,7 +43,7 @@ Environment variables include `CODEBUDDY_AUTH_DIR`, `CODEBUDDY_IMPORT_DIR`, `COD Manual `POST /admin/credentials/{id}/travel` returns `buddy_confirmation` with official terms and a separate `authorization` scope. Submit `{"confirm_buddy":true,"agreement_revision":""}` after consent; old adoption-only revisions are rejected. `can_claim` describes eligibility and never disables consent. -`control.sqlite3` preserves consent and at most one onboarding conversation attempt per account across restarts. Historical acceptance records do not block an unsent conversation. Only the official `completed` state permits adoption; uncertain results resume read-only checks, never another billable conversation. Missing models or unhealthy storage stop writes. Keep this database when upgrading; travel-status and balance sync remain read-only. +`control.sqlite3` preserves consent and at most one onboarding conversation attempt per account across restarts. Historical acceptance records do not block an unsent conversation. Only official task completion permits adoption. An unconfirmed first-claim send stays reserved across restarts and beyond 24 hours; only a read-confirmed outcome permits progress, never an automatic repeat POST. Pre-claim failures may resume after backoff. Keep this database when upgrading; travel-status and balance sync remain read-only. Trial credits are manual-only for eligible `intl-work` accounts: use the credential row's claim drawer or `POST /admin/credentials/{id}/trial`. Startup, periodic maintenance and balance sync never claim. Results expose safe error categories, HTTP/business codes and retry time; response bodies are capped at 64 KiB and never returned to the browser. Success/already-claimed records persist in `auth/trial-ledger.json`; failures wait at least 24 hours before another manual attempt. Keep this file when upgrading. diff --git a/docs/advanced.zh-CN.md b/docs/advanced.zh-CN.md index 21271bc..ff91c82 100644 --- a/docs/advanced.zh-CN.md +++ b/docs/advanced.zh-CN.md @@ -43,7 +43,7 @@ Compose 会显式传入部分环境变量及 CLI 参数,删除 `.env` 中的 手动 `POST /admin/credentials/{id}/travel` 返回 `buddy_confirmation`,包含官方条款与独立的 `authorization` 自动化范围。勾选后提交 `{"confirm_buddy":true,"agreement_revision":"<返回的版本>"}`;旧版仅领猫授权失效。`can_claim` 仅表示资格,不禁用同意。 -`control.sqlite3` 保留同意及每账号最多一次新手对话尝试,重启不重复;旧接取记录不阻塞尚未发送的对话。仅官方任务 `completed` 才继续领猫;不确定时自动维护只回查,不重发可能扣费的对话。缺少可用模型或存储异常时停止写入;升级保留该数据库,旅行状态与余额同步不触发任务。 +`control.sqlite3` 保留同意及每账号最多一次新手对话尝试,重启不重复;旧接取记录不阻塞尚未发送的对话。仅官方任务完成才继续领猫。首次领取已进入发送阶段但结果未确认时,跨重启、超过 24 小时仍保留预留,只读确认后才继续,不自动重发 POST;未进入领取阶段的失败可在退避后续办。升级保留该数据库,旅行状态与余额同步不触发任务。 体验积分仅供符合官方资格的 `intl-work` 账号手动领取:使用凭证行的领取抽屉或 `POST /admin/credentials/{id}/trial`。启动、定时维护、余额同步均不领取。结果显示安全错误类别、HTTP 状态/业务码及重试时间,响应正文限制为 64 KiB 且不返回浏览器。成功或已领取记录保存在 `auth/trial-ledger.json`,失败至少等待 24 小时才能再次手动申请;升级时保留该文件。 diff --git a/docs/webui.md b/docs/webui.md index 1c9caf3..bb6f34c 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -20,7 +20,7 @@ Management is locked without a key. After changing it, sign in again and restart - Domestic travel follows its own switch, including when check-in is already complete or disabled. “Travel status” only queries; “Claim / dispatch” claims arrivals, rechecks idle state and the daily limit, then chooses a current upstream location. Invalid location configuration stops dispatch. - One agreement checkbox authorizes completing `first_buddy` through one real WorkBuddy conversation if needed, adoption and dispatch. No separate task acceptance is required. The conversation requests at most 32 output tokens and may use credits; an eligible zero-rate model is preferred. Official completion is checked before adoption, and uncertain conversations are not repeated. - `CODEBUDDY2API_AUTO_ACCEPT_BUDDY=true` preauthorizes enabled domestic accounts, including future imports, after restart; default is false. Automatic follow-up respects the travel switch; no other reward tasks, paid boxes or trial claims run. - - Adoption consent and outcomes are audited. Blocked automatic travel adds at most one warning per account per local day, without failing check-in or balance sync; uncertain adoption waits at least 24 hours and only reconciles through reads. + - Adoption consent and outcomes are audited. Blocked automatic travel adds at most one warning per account per local day, without failing check-in or balance sync. Unconfirmed first-claim sends only reconcile through reads, even after 24 hours or a restart. - Writes are followed by a status check and are never blindly retried. The console retains confirmed claims/dispatches when that check fails, shows the failure stage and snapshot travel time, and leaves unreturned reward amounts unknown. - Saving a preference does not claim immediately; it affects subsequent maintenance and cannot retract sent requests. The console shows last results, partial completion and uncertainty, retaining history on failure. - **Models:** add independent mappings with public/upstream IDs and local enablement. Choose either specific accounts or a region with an optional product filter; switching modes clears the opposite binding. Unavailable candidates never cause out-of-scope fallback. diff --git a/docs/webui.zh-CN.md b/docs/webui.zh-CN.md index 541c11f..9952ab1 100644 --- a/docs/webui.zh-CN.md +++ b/docs/webui.zh-CN.md @@ -20,7 +20,7 @@ - 国内旅行按独立开关执行,已签到或关闭自动签到不影响旅行。“旅行状态”只查询;“旅行领派”领取到达奖励,重新确认空闲且未达上限后,从上游当前地点中随机派遣;地点配置无效时不派出。 - 勾选一次即自动办理 `first_buddy`:无需单独接取,必要时发起一次真实 WorkBuddy 对话,再领猫和派遣。对话最多请求 32 个输出 token,优先可用零倍率模型,可能消耗少量积分;确认官方任务完成才领猫,不确定时不重复对话。 - `CODEBUDDY2API_AUTO_ACCEPT_BUDDY=true` 重启后预授权所有已启用国内账号(含后续导入),默认关闭。自动续办受旅行开关控制,不执行其他奖励任务、付费开盒或领取试用积分。 - - 首领授权与结果记录审计;自动旅行阻塞按账号、本地自然日最多一条警告,不影响签到成功和余额同步。首领结果不确定时只读核验,至少退避 24 小时。 + - 首领授权与结果记录审计;自动旅行阻塞按账号、本地自然日最多一条警告,不影响签到成功和余额同步。首次领取发出后结果不确定时只读核验,超过 24 小时或重启也不自动重发。 - 写操作后只读核验,失败不盲目重试。界面保留已确认的领取或派遣,显示失败阶段和查询时剩余时间;未返回的奖励金额保持未知。 - 保存开关不会立即领取,从后续维护起生效;关闭不撤回已发请求。界面显示上次结果,部分成功与状态未确认单独提示,失败保留历史。 - **模型路由**:新增模型映射,独立设置对外 ID、上游 ID 与启停;指定账号和指定区域二选一,区域内可筛产品。切换方式清除另一种绑定,候选不可用时不会越界回退。 diff --git a/tests/test_buddy.py b/tests/test_buddy.py index af1125e..efe2257 100644 --- a/tests/test_buddy.py +++ b/tests/test_buddy.py @@ -192,12 +192,97 @@ def test_write_failure_keeps_reservation_and_never_replays(self): self.assertGreater(saved["retry_at"], time.time()) self.assertNotIn("synthetic-secret", str(result)) again = self.prepare([EMPTY, LIST], automatic=True) - self.assertEqual(again["reason"], "buddy_retry_later") + self.assertEqual(again["reason"], "buddy_write_unconfirmed") + self.assertNotIn("retry_at", again) self.assertEqual(len(self.posts()), 2) reconciled = self.prepare([ACTIVE]) self.assertTrue(reconciled["buddy_ready"]) self.assertEqual(self.store.buddy_record("account")["outcome"], "success") + def test_uncertain_claim_remains_reserved_after_expiry_and_restart(self): + self.store.save_buddy_consent("account", buddy.AGREEMENT_REVISION) + result = self.prepare([EMPTY, LIST, TASKS, {"agreed": True}, httpx.ReadTimeout("secret"), EMPTY], automatic=True) + self.assertEqual(result["reason"], "buddy_write_unconfirmed") + saved = self.store.buddy_record("account") + reopened = ControlStore(self.root / "control.sqlite3") + self.addCleanup(reopened.close) + self.context["store"] = reopened + for automatic in (False, True): + for delay in (1, 7 * 86400): + with self.subTest(automatic=automatic, delay=delay), patch.object(buddy.time, "time", return_value=saved["retry_at"] + delay): + result = self.prepare([EMPTY, LIST], automatic=automatic) + self.assertEqual(result["reason"], "buddy_write_unconfirmed") + self.assertNotIn("retry_at", result) + self.assertEqual(reopened.buddy_record("account"), saved) + self.assertIsNone(reopened.reserve_buddy("account", "manual", buddy.AGREEMENT_REVISION, retry_seconds=86400)) + self.assertEqual(len(self.posts()), 1) + reconciled = self.prepare([ACTIVE]) + self.assertTrue(reconciled["buddy_ready"]) + receipt = reopened.buddy_record("account") + self.assertEqual(receipt["attempt_id"], saved["attempt_id"]) + self.assertEqual((receipt["outcome"], receipt["claimed"]), ("success", 1)) + self.assertEqual(len(self.posts()), 1) + + def test_crash_during_first_claim_keeps_pending_send_terminal(self): + def crash(request): + if request.url.path.endswith("/buddy/first"): + raise KeyboardInterrupt() + with self.assertRaises(KeyboardInterrupt): + self.prepare([EMPTY, LIST, TASKS, {"agreed": True}], automatic=True, on_request=crash) + saved = self.store.buddy_record("account") + self.assertEqual((saved["stage"], saved["outcome"], saved["claimed"]), ("first", "pending", 0)) + reopened = ControlStore(self.root / "control.sqlite3") + self.addCleanup(reopened.close) + self.context["store"] = reopened + with patch.object(buddy.time, "time", return_value=saved["retry_at"] + 1): + result = self.prepare([EMPTY, LIST], automatic=True) + self.assertEqual(result["reason"], "buddy_write_unconfirmed") + self.assertEqual(reopened.buddy_record("account"), saved) + self.assertEqual(len(self.posts()), 1) + + def test_failed_readback_after_expiry_preserves_uncertain_claim(self): + self.prepare([EMPTY, LIST, TASKS, {"agreed": True}, httpx.ReadTimeout("secret"), EMPTY], automatic=True) + saved = self.store.buddy_record("account") + for responses in ([httpx.ReadTimeout("secret")], [EMPTY, httpx.ReadTimeout("secret")]): + with self.subTest(responses=len(responses)), patch.object(buddy.time, "time", return_value=saved["retry_at"] + 1): + result = self.prepare(responses, automatic=True) + self.assertEqual(result["reason"], "buddy_unknown") + self.assertEqual(self.store.buddy_record("account"), saved) + self.assertEqual(len(self.posts()), 1) + + def test_claim_stage_reservations_cannot_be_replaced_by_other_connections(self): + reopened = ControlStore(self.root / "control.sqlite3") + self.addCleanup(reopened.close) + for stage in ("first", "buddy_first", "verify", "buddy_verify", "future_claim"): + for outcome in ("pending", "uncertain"): + with self.subTest(stage=stage, outcome=outcome): + identity = stage + "-" + outcome + attempt = self.store.reserve_buddy(identity, "manual", buddy.AGREEMENT_REVISION, retry_seconds=86400, now=100) + self.store.buddy_checkpoint(identity, attempt, stage, outcome, agreed=True) + saved = self.store.buddy_record(identity) + with ThreadPoolExecutor(max_workers=2) as executor: + reservations = list(executor.map( + lambda store: store.reserve_buddy(identity, "environment", buddy.AGREEMENT_REVISION, retry_seconds=86400, now=86501), + [self.store, reopened])) + self.assertEqual(reservations, [None, None]) + self.assertEqual(reopened.buddy_record(identity), saved) + + def test_presend_failures_can_resume_after_backoff(self): + for stage, outcome in (("reserved", "pending"), ("agree", "pending"), ("buddy_agree", "uncertain")): + with self.subTest(stage=stage): + attempt = self.store.reserve_buddy(stage, "manual", buddy.AGREEMENT_REVISION, retry_seconds=86400, now=100) + self.store.buddy_checkpoint(stage, attempt, stage, outcome) + self.assertIsNone(self.store.reserve_buddy(stage, "manual", buddy.AGREEMENT_REVISION, retry_seconds=86400, now=101)) + renewed = self.store.reserve_buddy(stage, "manual", buddy.AGREEMENT_REVISION, retry_seconds=86400, now=86501) + self.assertIsNotNone(renewed) + self.assertNotEqual(renewed, attempt) + self.context["identity"] = "buddy_agree" + with patch.object(buddy.time, "time", return_value=172902): + result = self.prepare([EMPTY, LIST, TASKS, {"agreed": True}, None, ACTIVE], automatic=True) + self.assertTrue(result["buddy_ready"]) + self.assertEqual([request.url.path for request in self.posts()], ["/activity/growth/buddy/first"]) + + def test_timeout_can_be_reconciled_without_replaying_or_dispatching(self): result = self.prepare([EMPTY, LIST, TASKS, {"agreed": True}, httpx.ReadTimeout("secret"), ACTIVE], automatic=True) self.assertEqual(result["reason"], "buddy_reconciled") @@ -217,6 +302,11 @@ def fail_after_receipt(*args, **kwargs): self.assertTrue(result["buddy_claimed"]) self.assertEqual(result["reason"], "buddy_storage_error") self.assertEqual(len(self.posts()), 1) + saved = self.store.buddy_record("account") + with patch.object(buddy.time, "time", return_value=saved["retry_at"] + 1): + result = self.prepare([EMPTY, LIST], automatic=True) + self.assertEqual(result["reason"], "buddy_write_unconfirmed") + self.assertEqual(self.store.buddy_record("account"), saved) result = self.prepare([ACTIVE]) self.assertTrue(result["buddy_ready"]) self.assertEqual(len(self.posts()), 1) From a822c2dd613a4599a59db0c6550e42837f293f7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?= <277378677+maiphucgiang@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:23:13 +0800 Subject: [PATCH 4/6] Release definitively unsent Buddy chat reservations --- app/buddy_task.py | 22 +++++++++--- app/control_store.py | 20 +++++++++-- docs/advanced.md | 2 +- docs/advanced.zh-CN.md | 2 +- docs/webui.md | 2 +- docs/webui.zh-CN.md | 2 +- tests/test_buddy_task.py | 76 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 115 insertions(+), 11 deletions(-) diff --git a/app/buddy_task.py b/app/buddy_task.py index 10113fb..58526d0 100644 --- a/app/buddy_task.py +++ b/app/buddy_task.py @@ -92,6 +92,14 @@ def stop(reason, **fields): return {**result, "ok": False, "skipped": True, "buddy_blocked": True, "reason": reason, "message": _MESSAGES[reason], **fields} + def cancel_unsent(reason, reserved): + if not store.release_buddy_task(identity, reserved["request_id"]): + return stop("buddy_task_storage_error") + if not event("task_chat", "skipped", code=reason, model=reserved["model"], + conversation_id=reserved["conversation_id"], request_id=reserved["request_id"]): + return stop("buddy_task_storage_error") + return stop(reason) + def complete(): if not event("task_completed", "success"): return stop("buddy_task_storage_error") @@ -139,10 +147,16 @@ def details(error): reserved = store.reserve_buddy_task(identity, "chat", model=model["id"]) if reserved is None: return stop("buddy_task_unconfirmed") - if not can_write(): - return stop("buddy_task_changed") - if not selector(model["id"]): - return stop("buddy_task_no_model") + reason = None + try: + if not can_write(): + reason = "buddy_task_changed" + elif not selector(model["id"]): + reason = "buddy_task_no_model" + except Exception: + reason = "buddy_task_storage_error" + if reason: + return cancel_unsent(reason, reserved) result["buddy_task_chat_sent"] = True failure, usage = None, None try: diff --git a/app/control_store.py b/app/control_store.py index a5a9d70..0d82beb 100644 --- a/app/control_store.py +++ b/app/control_store.py @@ -268,7 +268,7 @@ def buddy_task_record(self, identity): return dict(zip((column[0] for column in cursor.description), row)) if row else None def reserve_buddy_task(self, identity, operation, *, model=None): - """Reserve at most one acceptance and one billable conversation per account across restarts.""" + """Reserve one conversation with a fresh owner token for each unsent attempt.""" _identifier(identity, "账号指纹") if operation not in {"accept", "chat"}: raise ValueError("新手任务操作无效") @@ -288,8 +288,9 @@ def reserve_buddy_task(self, identity, operation, *, model=None): self._db.execute("UPDATE buddy_tasks SET accept_started=1,updated_at=? WHERE account_key=?", (time.time(), identity)) else: - self._db.execute("UPDATE buddy_tasks SET chat_started=1,model=?,updated_at=? WHERE account_key=?", - (model, time.time(), identity)) + self._db.execute("UPDATE buddy_tasks SET chat_started=1,request_id=?,model=?," + "chat_state='pending',total_tokens=NULL,updated_at=? WHERE account_key=?", + (uuid.uuid4().hex, model, time.time(), identity)) record = self.buddy_task_record(identity) self._db.execute("COMMIT") return record @@ -298,6 +299,19 @@ def reserve_buddy_task(self, identity, operation, *, model=None): self._db.execute("ROLLBACK") raise + def release_buddy_task(self, identity, request_id): + """Release only the caller's known-unsent reservation, never a recorded outcome.""" + _identifier(identity, "账号指纹") + _identifier(request_id, "请求 ID") + with self._lock: + updated = self._db.execute( + "UPDATE buddy_tasks SET chat_started=0,model=NULL,updated_at=? " + "WHERE account_key=? AND request_id=? AND chat_started=1 AND completed=0 " + "AND chat_state='pending' AND total_tokens IS NULL", + (time.time(), identity, request_id)) + return updated.rowcount == 1 + + def buddy_task_checkpoint(self, identity, *, completed=False, chat_state=None, total_tokens=None): _identifier(identity, "账号指纹") if chat_state not in {None, "success", "uncertain"}: diff --git a/docs/advanced.md b/docs/advanced.md index 9e3fff1..58ae833 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -43,7 +43,7 @@ Environment variables include `CODEBUDDY_AUTH_DIR`, `CODEBUDDY_IMPORT_DIR`, `COD Manual `POST /admin/credentials/{id}/travel` returns `buddy_confirmation` with official terms and a separate `authorization` scope. Submit `{"confirm_buddy":true,"agreement_revision":""}` after consent; old adoption-only revisions are rejected. `can_claim` describes eligibility and never disables consent. -`control.sqlite3` preserves consent and at most one onboarding conversation attempt per account across restarts. Historical acceptance records do not block an unsent conversation. Only official task completion permits adoption. An unconfirmed first-claim send stays reserved across restarts and beyond 24 hours; only a read-confirmed outcome permits progress, never an automatic repeat POST. Pre-claim failures may resume after backoff. Keep this database when upgrading; travel-status and balance sync remain read-only. +`control.sqlite3` preserves consent and one actual onboarding conversation per account across restarts. A live preflight cancellation releases only its own unsent reservation; unknown or sent attempts are never released automatically. Historical acceptance records do not block an unsent conversation. Only official task completion permits adoption. Unconfirmed first-claim sends remain reserved beyond 24 hours and only reconcile through reads; pre-claim failures may resume after backoff. Keep this database when upgrading; travel-status and balance sync remain read-only. Trial credits are manual-only for eligible `intl-work` accounts: use the credential row's claim drawer or `POST /admin/credentials/{id}/trial`. Startup, periodic maintenance and balance sync never claim. Results expose safe error categories, HTTP/business codes and retry time; response bodies are capped at 64 KiB and never returned to the browser. Success/already-claimed records persist in `auth/trial-ledger.json`; failures wait at least 24 hours before another manual attempt. Keep this file when upgrading. diff --git a/docs/advanced.zh-CN.md b/docs/advanced.zh-CN.md index ff91c82..4fcc0d3 100644 --- a/docs/advanced.zh-CN.md +++ b/docs/advanced.zh-CN.md @@ -43,7 +43,7 @@ Compose 会显式传入部分环境变量及 CLI 参数,删除 `.env` 中的 手动 `POST /admin/credentials/{id}/travel` 返回 `buddy_confirmation`,包含官方条款与独立的 `authorization` 自动化范围。勾选后提交 `{"confirm_buddy":true,"agreement_revision":"<返回的版本>"}`;旧版仅领猫授权失效。`can_claim` 仅表示资格,不禁用同意。 -`control.sqlite3` 保留同意及每账号最多一次新手对话尝试,重启不重复;旧接取记录不阻塞尚未发送的对话。仅官方任务完成才继续领猫。首次领取已进入发送阶段但结果未确认时,跨重启、超过 24 小时仍保留预留,只读确认后才继续,不自动重发 POST;未进入领取阶段的失败可在退避后续办。升级保留该数据库,旅行状态与余额同步不触发任务。 +`control.sqlite3` 保留同意及每账号一次实际新手对话,重启不重复。发送前明确取消时仅撤销本次未发送预留,未知或已发送的尝试不自动释放;旧接取记录不阻塞尚未发送的对话。仅官方任务完成才继续领猫。首次领取结果不确定时,超过 24 小时仍保留预留、仅回查;未进入领取阶段的失败可在退避后续办。升级保留该数据库,旅行状态与余额同步不触发任务。 体验积分仅供符合官方资格的 `intl-work` 账号手动领取:使用凭证行的领取抽屉或 `POST /admin/credentials/{id}/trial`。启动、定时维护、余额同步均不领取。结果显示安全错误类别、HTTP 状态/业务码及重试时间,响应正文限制为 64 KiB 且不返回浏览器。成功或已领取记录保存在 `auth/trial-ledger.json`,失败至少等待 24 小时才能再次手动申请;升级时保留该文件。 diff --git a/docs/webui.md b/docs/webui.md index bb6f34c..927f8fc 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -18,7 +18,7 @@ Management is locked without a key. After changing it, sign in again and restart - International WorkBuddy trial credits require manual confirmation; the drawer displays the result. “Refresh claim status” only reads the local ledger and never claims. On network or persistence failure, verify status before another attempt; environment-driven automatic claims are retired. - Automatic check-in and travel are persisted per account and apply live: on by default for domestic accounts, off internationally. International check-in can be enabled without a code update; inactive or unconfirmed activities never authorize claims. Disabled accounts run no automatic tasks. - Domestic travel follows its own switch, including when check-in is already complete or disabled. “Travel status” only queries; “Claim / dispatch” claims arrivals, rechecks idle state and the daily limit, then chooses a current upstream location. Invalid location configuration stops dispatch. - - One agreement checkbox authorizes completing `first_buddy` through one real WorkBuddy conversation if needed, adoption and dispatch. No separate task acceptance is required. The conversation requests at most 32 output tokens and may use credits; an eligible zero-rate model is preferred. Official completion is checked before adoption, and uncertain conversations are not repeated. + - One agreement checkbox completes `first_buddy` through one real WorkBuddy conversation if needed, then adopts and dispatches. No separate task acceptance is required. The conversation requests at most 32 output tokens and may use credits; an eligible zero-rate model is preferred. Official completion is checked before adoption. Definitely unsent conversations may resume after settings or models recover; uncertain conversations are not repeated. - `CODEBUDDY2API_AUTO_ACCEPT_BUDDY=true` preauthorizes enabled domestic accounts, including future imports, after restart; default is false. Automatic follow-up respects the travel switch; no other reward tasks, paid boxes or trial claims run. - Adoption consent and outcomes are audited. Blocked automatic travel adds at most one warning per account per local day, without failing check-in or balance sync. Unconfirmed first-claim sends only reconcile through reads, even after 24 hours or a restart. - Writes are followed by a status check and are never blindly retried. The console retains confirmed claims/dispatches when that check fails, shows the failure stage and snapshot travel time, and leaves unreturned reward amounts unknown. diff --git a/docs/webui.zh-CN.md b/docs/webui.zh-CN.md index 9952ab1..7ec6900 100644 --- a/docs/webui.zh-CN.md +++ b/docs/webui.zh-CN.md @@ -18,7 +18,7 @@ - 国际 WorkBuddy 的“一次性体验积分”需手动确认领取,结果在抽屉内显示;“刷新领取状态”只读本地记录,不发领取请求。网络失败或记录保存异常时先核对状态,勿连续点击;环境变量自动领取已停用。 - 自动签到、自动旅行按账号保存并热生效,国内默认开启,国际默认关闭。国际签到可随时开启,不需更新代码;官方活动未开放或状态不明时不领取。账号停用期间不执行自动任务。 - 国内旅行按独立开关执行,已签到或关闭自动签到不影响旅行。“旅行状态”只查询;“旅行领派”领取到达奖励,重新确认空闲且未达上限后,从上游当前地点中随机派遣;地点配置无效时不派出。 - - 勾选一次即自动办理 `first_buddy`:无需单独接取,必要时发起一次真实 WorkBuddy 对话,再领猫和派遣。对话最多请求 32 个输出 token,优先可用零倍率模型,可能消耗少量积分;确认官方任务完成才领猫,不确定时不重复对话。 + - 勾选一次即自动办理 `first_buddy`:无需单独接取,必要时发起一次真实 WorkBuddy 对话,再领猫和派遣。对话最多请求 32 个输出 token,优先零倍率模型,可能消耗少量积分;确认官方任务完成才领猫。明确未发送时,设置或模型恢复后可续办;结果不确定时不重复对话。 - `CODEBUDDY2API_AUTO_ACCEPT_BUDDY=true` 重启后预授权所有已启用国内账号(含后续导入),默认关闭。自动续办受旅行开关控制,不执行其他奖励任务、付费开盒或领取试用积分。 - 首领授权与结果记录审计;自动旅行阻塞按账号、本地自然日最多一条警告,不影响签到成功和余额同步。首次领取发出后结果不确定时只读核验,超过 24 小时或重启也不自动重发。 - 写操作后只读核验,失败不盲目重试。界面保留已确认的领取或派遣,显示失败阶段和查询时剩余时间;未返回的奖励金额保持未知。 diff --git a/tests/test_buddy_task.py b/tests/test_buddy_task.py index 9630ea9..1c37103 100644 --- a/tests/test_buddy_task.py +++ b/tests/test_buddy_task.py @@ -172,6 +172,82 @@ def test_existing_completed_task_skips_chat_without_acceptance_request(self): self.assertTrue(result["buddy_ready"], result) self.assertNotIn("/v2/chat/completions", [r.url.path for r in self.writes()]) + def test_final_preflight_rejection_releases_unsent_chat_and_allows_resume(self): + for gate in ("setting", "model", "exception"): + with self.subTest(gate=gate): + identity = "resume-" + gate + self.context["identity"] = identity + before = len(self.writes()) + def can_write(): + if self.store.buddy_task_record(identity) is not None: + if gate == "exception": + raise OSError("synthetic-secret") + return False + return True + context = self.context + if gate == "model": + context = {**context, "task_model": lambda requested=None: self.model if self.store.buddy_task_record(identity) is None else None} + result = self.run_flow([EMPTY, LIST, tasks("not_accepted")], context=context, + can_write=(lambda: True) if gate == "model" else can_write) + reason = {"setting": "buddy_task_changed", "model": "buddy_task_no_model", "exception": "buddy_task_storage_error"}[gate] + self.assertEqual(result["reason"], reason) + self.assertFalse(result["buddy_task_chat_sent"]) + self.assertEqual(len(self.writes()), before) + self.assertNotIn("synthetic-secret", json.dumps(result)) + unsent = self.store.buddy_task_record(identity) + self.assertEqual(unsent["chat_started"], 0) + reopened = ControlStore(self.root / "control.sqlite3") + self.addCleanup(reopened.close) + result = self.run_flow([EMPTY, LIST, tasks("not_accepted"), sse(), tasks("completed"), + {"agreed": True}, None, ACTIVE], context={**self.context, "store": reopened}) + self.assertTrue(result["buddy_ready"], result) + self.assertEqual([r.url.path for r in self.writes()[before:]], ["/v2/chat/completions", "/activity/growth/buddy/first"]) + saved = self.store.buddy_task_record(identity) + self.assertNotEqual(saved["request_id"], unsent["request_id"]) + self.assertEqual((saved["chat_started"], saved["completed"]), (1, 1)) + events = self.audit.list_records("admin")["items"] + skipped = [row for row in events if row["action"] == "buddy.task_chat" + and row["details"].get("credential") == identity and row["details"].get("outcome") == "skipped"] + self.assertEqual(len(skipped), 1) + self.assertEqual(skipped[0]["details"]["request_id"], unsent["request_id"]) + + def test_stale_release_cannot_clear_a_new_owner_reservation(self): + first = self.store.reserve_buddy_task("account", "chat", model="fast-model") + other = ControlStore(self.root / "control.sqlite3") + self.addCleanup(other.close) + self.assertTrue(other.release_buddy_task("account", first["request_id"])) + second = other.reserve_buddy_task("account", "chat", model="fast-model") + self.assertNotEqual(first["request_id"], second["request_id"]) + self.assertFalse(self.store.release_buddy_task("account", first["request_id"])) + self.assertFalse(self.store.release_buddy_task("another-account", second["request_id"])) + self.assertEqual(other.buddy_task_record("account"), second) + + def test_recorded_chat_outcomes_cannot_be_released(self): + for state in ("success", "uncertain", "completed", "usage"): + with self.subTest(state=state): + row = self.store.reserve_buddy_task(state, "chat", model="fast-model") + if state == "completed": + self.store.buddy_task_checkpoint(state, completed=True) + elif state == "usage": + self.store.buddy_task_checkpoint(state, total_tokens=0) + else: + self.store.buddy_task_checkpoint(state, chat_state=state) + saved = self.store.buddy_task_record(state) + self.assertFalse(self.store.release_buddy_task(state, row["request_id"])) + self.assertEqual(self.store.buddy_task_record(state), saved) + + def test_release_failure_retains_reservation_and_never_sends_chat(self): + with patch.object(self.store, "release_buddy_task", side_effect=OSError("secret")): + result = self.run_flow([EMPTY, LIST, tasks("not_accepted")], + can_write=lambda: self.store.buddy_task_record("account") is None) + self.assertEqual(result["reason"], "buddy_task_storage_error") + self.assertEqual(self.writes(), []) + self.assertEqual(self.store.buddy_task_record("account")["chat_started"], 1) + result = self.run_flow([EMPTY, LIST, tasks("not_accepted")]) + self.assertEqual(result["reason"], "buddy_task_unconfirmed") + self.assertEqual(self.writes(), []) + + def test_reserved_chat_survives_crash_without_any_replay(self): self.store.reserve_buddy_task("account", "chat", model="fast-model") result = self.run_flow([EMPTY, LIST, tasks("accepted")]) From a84c3d4c826deeb9a9107b976d1a67acff6ce85d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?= <277378677+maiphucgiang@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:01:29 +0800 Subject: [PATCH 5/6] Persist travel writes until their state is reconciled --- app/control_store.py | 56 +++++++++++ app/travel.py | 75 ++++++++++++++- converter.py | 2 +- docs/advanced.md | 2 + docs/advanced.zh-CN.md | 2 + docs/webui.md | 2 +- docs/webui.zh-CN.md | 2 +- tests/test_buddy_actions.py | 19 +++- tests/test_travel.py | 183 +++++++++++++++++++++++++++++++++++- web/src/Travel.tsx | 2 + web/src/automation.test.tsx | 18 ++++ web/src/values.tsx | 3 + 12 files changed, 356 insertions(+), 10 deletions(-) diff --git a/app/control_store.py b/app/control_store.py index 0d82beb..038d899 100644 --- a/app/control_store.py +++ b/app/control_store.py @@ -96,6 +96,9 @@ def __init__(self, path): "retry_at REAL NOT NULL, stage TEXT NOT NULL, outcome TEXT NOT NULL, " "consent_source TEXT NOT NULL, agreement_revision TEXT NOT NULL, " "agreed INTEGER NOT NULL DEFAULT 0, claimed INTEGER NOT NULL DEFAULT 0)") + self._db.execute("CREATE TABLE IF NOT EXISTS travel_writes (" + "account_key TEXT PRIMARY KEY, attempt_id TEXT NOT NULL, operation TEXT NOT NULL, " + "phase TEXT NOT NULL, location_id INTEGER, reserved_at REAL NOT NULL, confirmed INTEGER NOT NULL DEFAULT 0)") self._db.execute("CREATE TABLE IF NOT EXISTS buddy_consents (" "account_key TEXT PRIMARY KEY, agreement_revision TEXT NOT NULL, accepted_at REAL NOT NULL)") self._db.execute("CREATE TABLE IF NOT EXISTS buddy_tasks (" @@ -260,6 +263,59 @@ def buddy_checkpoint(self, identity, attempt, stage, outcome, *, agreed=False, c if updated.rowcount != 1: raise ValueError("首领预留已变化") + def travel_write_record(self, identity): + _identifier(identity, "账号指纹") + with self._lock: + cursor = self._db.execute("SELECT * FROM travel_writes WHERE account_key=?", (identity,)) + row = cursor.fetchone() + return dict(zip((column[0] for column in cursor.description), row)) if row else None + + def reserve_travel_write(self, identity, operation, location_id=None, *, expected_attempt=None): + """Reserve one unresolved travel write per account without expiry-based replay.""" + _identifier(identity, "账号指纹") + if operation not in {"claim", "depart"}: + raise ValueError("旅行操作无效") + if operation == "depart" and (type(location_id) is not int or not 0 < location_id <= 2**31 - 1): + raise ValueError("派遣地点无效") + with self._lock: + self._db.execute("BEGIN IMMEDIATE") + try: + previous = self.travel_write_record(identity) + current_attempt = previous["attempt_id"] if previous else None + if current_attempt != expected_attempt or previous and previous["phase"] not in {"cancelled", "reconciled"}: + self._db.execute("COMMIT") + return None + attempt = uuid.uuid4().hex + self._db.execute( + "INSERT INTO travel_writes VALUES(?,?,?,'reserved',?,?,0) " + "ON CONFLICT(account_key) DO UPDATE SET attempt_id=excluded.attempt_id,operation=excluded.operation, " + "phase='reserved',location_id=excluded.location_id,reserved_at=excluded.reserved_at,confirmed=0", + (identity, attempt, operation, location_id, time.time())) + self._db.execute("COMMIT") + return attempt + except Exception: + if self._db.in_transaction: + self._db.execute("ROLLBACK") + raise + + def transition_travel_write(self, identity, attempt, phase): + """Late receipts cannot reopen or replace an already reconciled reservation.""" + _identifier(identity, "账号指纹") + expected = {"sent": ("reserved",), "confirmed": ("sent", "confirmed", "reconciled"), + "cancelled": ("reserved", "sent", "cancelled"), + "reconciled": ("reserved", "sent", "confirmed", "reconciled")}.get(phase) + if expected is None: + raise ValueError("旅行写入阶段无效") + with self._lock: + updated = self._db.execute( + "UPDATE travel_writes SET phase=CASE WHEN phase='reconciled' AND ?='confirmed' THEN phase ELSE ? END, " + "confirmed=MAX(confirmed,?) WHERE account_key=? AND attempt_id=? AND phase IN (" + + ",".join("?" for _ in expected) + ")", + (phase, phase, int(phase in {"confirmed", "reconciled"}), identity, attempt, *expected)) + if updated.rowcount != 1: + raise ValueError("旅行写入预留已变化") + + def buddy_task_record(self, identity): _identifier(identity, "账号指纹") with self._lock: diff --git a/app/travel.py b/app/travel.py index 2e0dda3..ec5486d 100644 --- a/app/travel.py +++ b/app/travel.py @@ -110,6 +110,50 @@ def perform(token, profile, *, read_only=False, can_write=lambda: True, buddy_co return unavailable() result = {"ok": False, "state": "unknown", "claimed": False, "departed": False, "stale": False, "phase": "status"} phase = "status" + context = buddy_context or {} + store, identity = context.get("store"), context.get("identity") + write_attempt, write_operation, write_sent = None, None, False + observed_attempt = None + + def write_store(method, *args, **kwargs): + if store is None or not identity: + raise _Failure("storage", None, None) + try: + return getattr(store, method)(identity, *args, **kwargs) + except Exception: + raise _Failure("storage", None, None) from None + + def pending_write(record): + nonlocal phase + operation = record["operation"] if record else write_operation + label, flag = ("领取", "claimed") if operation == "claim" else ("派遣", "departed") + phase = "after_" + operation + confirmed = bool(record and record["confirmed"]) + pending_key = "claim_pending" if operation == "claim" else "departure_pending" + result.update(ok=False, skipped=True, stale=True, **{flag: confirmed, pending_key: True}, + message="上次" + label + ("已确认,但状态尚未更新" if confirmed else "结果尚未确认") + ";仅查询核验,勿重复操作") + return result + + def start_write(operation, location_id=None): + nonlocal write_attempt, write_operation, write_sent + write_operation, write_sent = operation, False + write_attempt = write_store("reserve_travel_write", operation, location_id, expected_attempt=observed_attempt) + if write_attempt is None: + pending_write(write_store("travel_write_record")) + return False + try: + allowed = can_write() + except Exception: + write_store("transition_travel_write", write_attempt, "cancelled") + raise _Failure("storage", None, None) from None + if not allowed: + write_store("transition_travel_write", write_attempt, "cancelled") + result.update(skipped=True, message="设置或凭证已变化,未发送" + ("领取" if operation == "claim" else "派遣") + "请求") + return False + write_store("transition_travel_write", write_attempt, "sent") + write_sent = True + return True + if not read_only and not can_write(): return {**result, "skipped": True, "message": "设置或凭证已变化,未执行旅行操作"} if not read_only and buddy_context and buddy_context.get("consent_revision") is not None: @@ -125,22 +169,31 @@ def record_departure(stage, outcome): return buddy.audit_event(context.get("audit"), context.get("identity"), profile, stage, outcome, result.get("consent_source")) with httpx.Client(follow_redirects=False) as client: + previous = write_store("travel_write_record") if store is not None and identity else None + observed_attempt = previous["attempt_id"] if previous else None result.update(_status(_request(client, token, "status"))) + if previous and previous["phase"] not in {"cancelled", "reconciled"}: + pending_state = "arrived" if previous["operation"] == "claim" else "idle" + if previous["phase"] == "reserved" or result["state"] == pending_state: + return pending_write(previous) + write_store("transition_travel_write", previous["attempt_id"], "reconciled") if read_only: result.update(ok=True, message={"idle": "Buddy 空闲", "traveling": "Buddy 旅行中", "arrived": "Buddy 已到达,待领取"}[result["state"]]) return result if result["state"] == "arrived": - if not can_write(): - result.update(skipped=True, message="设置或凭证已变化,未发送领取请求") - return result phase = "claim" + if not start_write("claim"): + return result receipt = _request(client, token, "claim") result.update(claimed=True, claimed_credit=_number(receipt.get("reward_credit"))) + write_store("transition_travel_write", write_attempt, "confirmed") phase = "after_claim" result.update(_status(_request(client, token, "status"))) if result["state"] == "arrived": result.update(stale=True, message="领取已确认,但状态尚未更新,未派出") return result + write_store("transition_travel_write", write_attempt, "reconciled") + observed_attempt = write_attempt prefix = "旅行积分已领取;" if result["claimed"] else "" if result["state"] == "traveling": result.update(ok=True, skipped=True, message=prefix + "Buddy 旅行中,无需派遣") @@ -181,14 +234,14 @@ def record_departure(stage, outcome): result.update(buddy_blocked=True, reason="buddy_storage_error", message="猫猫已领取,但派遣审计无法保存,未发送派遣请求") return result - if result.get("buddy_claimed") and not can_write(): - result.update(skipped=True, message="猫猫已领取,但设置或凭证已变化,未发送派遣请求") + if not start_write("depart", location_id): return result receipt = _request(client, token, "depart", body={"location_id": location_id}) # The action is confirmed, but its current state requires a fresh read. result.update(departed=True, state="unknown", stale=True, daily_limit_reached=None, location_id=location_id, location_name=locations[location_id], reward_credit=None, arrive_at=_number(receipt.get("arrive_at")), server_now=None, remaining_seconds=None) + write_store("transition_travel_write", write_attempt, "confirmed") if not record_departure("departure", "success"): result.update(buddy_blocked=True, reason="buddy_storage_error", message="派遣已确认,但审计无法保存,请查询最新状态,勿重复派遣") @@ -198,12 +251,21 @@ def record_departure(stage, outcome): if result["state"] == "idle": result.update(message=prefix + "派遣已确认,但状态仍为空闲;请先查询核验,勿重复派出") return result + write_store("transition_travel_write", write_attempt, "reconciled") if result["location_name"] is None: result["location_name"] = locations.get(result["location_id"]) result.update(ok=True, stale=False, message=prefix + ( "Buddy 已到达,待领取" if result["state"] == "arrived" else "Buddy 已派出,余额可另行同步")) return result except (httpx.HTTPError, ValueError, TypeError) as error: + rejected = isinstance(error, _Failure) and (error.diagnostics.get("reason") is not None or ( + error.diagnostics["error_kind"] == "http" and error.diagnostics["http_status"] in {401, 403, 429})) + confirmed = result["claimed"] if write_operation == "claim" else result["departed"] + if write_attempt and not confirmed and (not write_sent or rejected or isinstance(error, (httpx.ConnectError, httpx.ConnectTimeout))): + try: + write_store("transition_travel_write", write_attempt, "cancelled") + except _Failure: + pass if result.get("buddy_claimed") and phase in {"depart", "after_depart"}: record_departure("departure_failed", "error") messages = {"status": "旅行状态查询失败,未执行写操作", "claim": "领取结果未确认,未派出;下次先查询状态", @@ -222,6 +284,9 @@ def record_departure(stage, outcome): "location_unavailable": "官方拒绝派遣:该地点暂时不可用"}.get(result.get("reason")) if phase == "depart" and refusal: result["message"] = ("旅行积分已领取;" if result["claimed"] else "") + refusal + if diagnostics["error_kind"] == "storage": + receipt = ("领取已确认,但" if write_operation == "claim" else "派遣已确认,但") if confirmed else "" + result["message"] = receipt + "旅行状态记录不可用,已停止后续操作;请先查询核验" return result finally: result["phase"] = phase diff --git a/converter.py b/converter.py index 4f39e8b..2a3d4d4 100644 --- a/converter.py +++ b/converter.py @@ -589,7 +589,7 @@ def sync_wait(self, periodic_delay): return max(0, min(periodic_delay, retry_delay)) def apply_if_current(self, cm, generation, update): - """Prevent stale requests from replacing a newer credential's cached state.""" + """Run updates only for enabled accounts with the current credential lease.""" with self._lock, cm._lock: entry = next((entry for entry in self._entries if entry["cm"] is cm), None) if entry is None or not model_policy.credential_enabled(CONFIG, entry): diff --git a/docs/advanced.md b/docs/advanced.md index 58ae833..50e53c0 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -45,6 +45,8 @@ Manual `POST /admin/credentials/{id}/travel` returns `buddy_confirmation` with o `control.sqlite3` preserves consent and one actual onboarding conversation per account across restarts. A live preflight cancellation releases only its own unsent reservation; unknown or sent attempts are never released automatically. Historical acceptance records do not block an unsent conversation. Only official task completion permits adoption. Unconfirmed first-claim sends remain reserved beyond 24 hours and only reconcile through reads; pre-claim failures may resume after backoff. Keep this database when upgrading; travel-status and balance sync remain read-only. +Travel claims and departures share an account-scoped write reservation. Uncertain results do not expire or replay; fresh status reads reconcile them without issuing upstream writes. Store failures stop claims and departures, and local readback updates preserve receipt ownership across processes. + Trial credits are manual-only for eligible `intl-work` accounts: use the credential row's claim drawer or `POST /admin/credentials/{id}/trial`. Startup, periodic maintenance and balance sync never claim. Results expose safe error categories, HTTP/business codes and retry time; response bodies are capped at 64 KiB and never returned to the browser. Success/already-claimed records persist in `auth/trial-ledger.json`; failures wait at least 24 hours before another manual attempt. Keep this file when upgrading. `CODEBUDDY2API_AUTO_TRIAL` and `--auto-trial` are retired: old startup options warn and do nothing; saved Boolean `auto_trial` settings are ignored on load. Remove them from deployment configuration. Before reverting to older code, check these old settings to avoid re-enabling automatic claims. diff --git a/docs/advanced.zh-CN.md b/docs/advanced.zh-CN.md index 4fcc0d3..a8e70d6 100644 --- a/docs/advanced.zh-CN.md +++ b/docs/advanced.zh-CN.md @@ -45,6 +45,8 @@ Compose 会显式传入部分环境变量及 CLI 参数,删除 `.env` 中的 `control.sqlite3` 保留同意及每账号一次实际新手对话,重启不重复。发送前明确取消时仅撤销本次未发送预留,未知或已发送的尝试不自动释放;旧接取记录不阻塞尚未发送的对话。仅官方任务完成才继续领猫。首次领取结果不确定时,超过 24 小时仍保留预留、仅回查;未进入领取阶段的失败可在退避后续办。升级保留该数据库,旅行状态与余额同步不触发任务。 +旅行奖励领取与派遣共用按账号隔离的写入预留,不确定结果不超时重放;状态查询只回查并更新本地确认记录,不发上游写请求。存储失败停止领取和派遣,跨进程确认不覆盖其他请求的记录。 + 体验积分仅供符合官方资格的 `intl-work` 账号手动领取:使用凭证行的领取抽屉或 `POST /admin/credentials/{id}/trial`。启动、定时维护、余额同步均不领取。结果显示安全错误类别、HTTP 状态/业务码及重试时间,响应正文限制为 64 KiB 且不返回浏览器。成功或已领取记录保存在 `auth/trial-ledger.json`,失败至少等待 24 小时才能再次手动申请;升级时保留该文件。 `CODEBUDDY2API_AUTO_TRIAL` 和 `--auto-trial` 已停用:旧启动选项仅提示、不触发任务;控制库中的旧布尔 `auto_trial` 设置在加载时忽略。请从部署配置中移除;回滚旧代码前也须核对这些旧设置,避免重新启用自动领取。 diff --git a/docs/webui.md b/docs/webui.md index 927f8fc..2738e6b 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -21,7 +21,7 @@ Management is locked without a key. After changing it, sign in again and restart - One agreement checkbox completes `first_buddy` through one real WorkBuddy conversation if needed, then adopts and dispatches. No separate task acceptance is required. The conversation requests at most 32 output tokens and may use credits; an eligible zero-rate model is preferred. Official completion is checked before adoption. Definitely unsent conversations may resume after settings or models recover; uncertain conversations are not repeated. - `CODEBUDDY2API_AUTO_ACCEPT_BUDDY=true` preauthorizes enabled domestic accounts, including future imports, after restart; default is false. Automatic follow-up respects the travel switch; no other reward tasks, paid boxes or trial claims run. - Adoption consent and outcomes are audited. Blocked automatic travel adds at most one warning per account per local day, without failing check-in or balance sync. Unconfirmed first-claim sends only reconcile through reads, even after 24 hours or a restart. - - Writes are followed by a status check and are never blindly retried. The console retains confirmed claims/dispatches when that check fails, shows the failure stage and snapshot travel time, and leaves unreturned reward amounts unknown. + - Reward claims and departures are durably reserved before sending. Stale or failed readbacks block repeat writes across restarts, without time-based expiry; only a reconciled state or definite pre-send cancellation/rejection releases them. Keep `control.sqlite3`. The console shows pending operations and leaves unknown rewards unset. - Saving a preference does not claim immediately; it affects subsequent maintenance and cannot retract sent requests. The console shows last results, partial completion and uncertainty, retaining history on failure. - **Models:** add independent mappings with public/upstream IDs and local enablement. Choose either specific accounts or a region with an optional product filter; switching modes clears the opposite binding. Unavailable candidates never cause out-of-scope fallback. - **Logs:** filter requests and inspect failed attempts. Closing details or switching log type cancels pending detail loads. Clearing details keeps historical statistics. diff --git a/docs/webui.zh-CN.md b/docs/webui.zh-CN.md index 7ec6900..2d99ff2 100644 --- a/docs/webui.zh-CN.md +++ b/docs/webui.zh-CN.md @@ -21,7 +21,7 @@ - 勾选一次即自动办理 `first_buddy`:无需单独接取,必要时发起一次真实 WorkBuddy 对话,再领猫和派遣。对话最多请求 32 个输出 token,优先零倍率模型,可能消耗少量积分;确认官方任务完成才领猫。明确未发送时,设置或模型恢复后可续办;结果不确定时不重复对话。 - `CODEBUDDY2API_AUTO_ACCEPT_BUDDY=true` 重启后预授权所有已启用国内账号(含后续导入),默认关闭。自动续办受旅行开关控制,不执行其他奖励任务、付费开盒或领取试用积分。 - 首领授权与结果记录审计;自动旅行阻塞按账号、本地自然日最多一条警告,不影响签到成功和余额同步。首次领取发出后结果不确定时只读核验,超过 24 小时或重启也不自动重发。 - - 写操作后只读核验,失败不盲目重试。界面保留已确认的领取或派遣,显示失败阶段和查询时剩余时间;未返回的奖励金额保持未知。 + - 奖励领取和派遣发送前持久化预留,回查失败或状态未更新时跨重启阻止重复写入,不按时间自动放行;读回状态已变化或明确未发送/拒绝后才可续办。保留 `control.sqlite3`;界面显示待核验操作,未知奖励金额不补值。 - 保存开关不会立即领取,从后续维护起生效;关闭不撤回已发请求。界面显示上次结果,部分成功与状态未确认单独提示,失败保留历史。 - **模型路由**:新增模型映射,独立设置对外 ID、上游 ID 与启停;指定账号和指定区域二选一,区域内可筛产品。切换方式清除另一种绑定,候选不可用时不会越界回退。 - **日志审计**:筛选请求、查看失败详情。关闭详情或切换日志类型会取消未完成的详情加载。清空明细会保留历史统计。 diff --git a/tests/test_buddy_actions.py b/tests/test_buddy_actions.py index 5010939..0abb205 100644 --- a/tests/test_buddy_actions.py +++ b/tests/test_buddy_actions.py @@ -8,7 +8,7 @@ from unittest.mock import patch import converter -from app import buddy, credits +from app import buddy, credential_actions, credits from tests import test_credential_actions as fixtures @@ -48,6 +48,23 @@ def test_disabled_and_international_accounts_cannot_use_confirmation(self): json=payload).json()['ok']) self.travel_mock.assert_not_called() + def test_travel_write_gate_rechecks_live_enablement_for_manual_and_automatic_actions(self): + for automatic in (False, True): + with self.subTest(automatic=automatic): + self.control.set_credential(self.entry['account_key'], True) + observed = [] + def in_flight(*args, **kwargs): + observed.append(kwargs['can_write']()) + self.control.set_credential(self.entry['account_key'], False) + observed.append(kwargs['can_write']()) + return {'ok': False, 'message': '账号已停用,未执行后续写操作'} + self.travel_mock.side_effect = in_flight + result = credential_actions._one(converter, self.pool, self.ledger, self.entry, 'travel', automatic=automatic) + self.assertEqual(observed, [True, False]) + self.assertFalse(result['ok']) + self.assertNotIn('travel', self.ledger.entry(self.entry['id'])) + + def test_environment_authorization_is_visible_but_not_writable_in_webui(self): converter.CONFIG.update(auto_accept_buddy=True, auto_accept_buddy_source='environment') value = next(item for item in self.client.get('/admin/settings').json()['items'] if item['key'] == 'auto_accept_buddy') diff --git a/tests/test_travel.py b/tests/test_travel.py index ded47dd..de15b11 100644 --- a/tests/test_travel.py +++ b/tests/test_travel.py @@ -10,6 +10,8 @@ import httpx from app import travel from app.credits import CreditLedger +from app.control_store import ControlStore +from concurrent.futures import ThreadPoolExecutor CONFIG = {'locations': [{'id': 21, 'name': '海边书店'}, {'id': 37, 'name': '山间茶馆'}]} IDLE = {'state': 'idle', 'daily_limit_reached': False} @@ -18,11 +20,21 @@ class TravelTests(unittest.TestCase): - def run_trip(self, responses, profile='cn-work', **kwargs): + def setUp(self): + self.root = Path(self.enterContext(tempfile.TemporaryDirectory())) + self.store = ControlStore(self.root / 'control.sqlite3') + self.addCleanup(self.store.close) + self.trip_counter = 0 + + def run_trip(self, responses, profile='cn-work', *, on_request=None, **kwargs): + self.trip_counter += 1 + kwargs.setdefault('buddy_context', {'store': self.store, 'identity': 'trip-' + str(self.trip_counter)}) requests = [] pending = iter(responses) def handle(request): requests.append(request) + if on_request: + on_request(request) value = next(pending) if isinstance(value, Exception): raise value @@ -253,6 +265,136 @@ def test_idle_after_depart_is_uncertain_and_never_dispatches_again(self): self.assertEqual(result['state'], 'idle') self.assertEqual(len(calls), 4) + def test_confirmed_departure_blocks_stale_idle_across_restart_until_observed(self): + context = {'store': self.store, 'identity': 'account'} + first, calls = self.run_trip([IDLE, CONFIG, {}, IDLE], buddy_context=context) + self.assertTrue(first['departed']) + saved = self.store.travel_write_record('account') + self.assertEqual(saved['phase'], 'confirmed') + reopened = ControlStore(self.root / 'control.sqlite3') + self.addCleanup(reopened.close) + context['store'] = reopened + for read_only in (False, True): + for days in (1, 7): + with self.subTest(read_only=read_only, days=days), patch.object(travel.time, 'time', return_value=saved['reserved_at'] + days * 86400): + result, retry = self.run_trip([IDLE], buddy_context=context, read_only=read_only) + self.assertTrue(result['departure_pending']) + self.assertTrue(result['departed']) + self.assertFalse(result['ok']) + self.assertEqual([r.method for r in retry], ['GET']) + self.assertEqual(reopened.travel_write_record('account'), saved) + result, query = self.run_trip([TRAVELING], buddy_context=context, read_only=True) + self.assertTrue(result['ok']) + self.assertEqual(reopened.travel_write_record('account')['phase'], 'reconciled') + result, next_trip = self.run_trip([{'state': 'arrived'}, {}, IDLE, CONFIG, {}, TRAVELING], buddy_context=context) + self.assertTrue(result['ok']) + self.assertEqual([r.url.path.rsplit('/', 1)[-1] for r in next_trip if r.method == 'POST'], ['claim', 'depart']) + self.assertNotEqual(reopened.travel_write_record('account')['attempt_id'], saved['attempt_id']) + + def test_ambiguous_departure_and_crash_remain_reserved_without_replay(self): + for index, error in enumerate((httpx.ReadTimeout('secret'), httpx.WriteTimeout('secret'), (200, {'code': False}))): + context = {'store': self.store, 'identity': 'ambiguous-' + str(index)} + result, calls = self.run_trip([IDLE, CONFIG, error], buddy_context=context) + self.assertFalse(result['ok']) + self.assertEqual(self.store.travel_write_record(context['identity'])['phase'], 'sent') + result, retry = self.run_trip([IDLE], buddy_context=context) + self.assertTrue(result['departure_pending']) + self.assertFalse(result['departed']) + self.assertEqual([r.method for r in retry], ['GET']) + context = {'store': self.store, 'identity': 'crashed'} + def crash(request): + if request.url.path.endswith('/depart'): + raise KeyboardInterrupt() + with self.assertRaises(KeyboardInterrupt): + self.run_trip([IDLE, CONFIG], buddy_context=context, on_request=crash) + result, retry = self.run_trip([IDLE], buddy_context=context) + self.assertTrue(result['departure_pending']) + self.assertEqual([r.method for r in retry], ['GET']) + + def test_known_unsent_or_rejected_departures_can_resume(self): + for index, response in enumerate((httpx.ConnectError('secret'), httpx.ConnectTimeout('secret'), + (401, {}), (403, {}), (429, {}), + (400, {'code': 400, 'msg': 'no active buddy'}))): + context = {'store': self.store, 'identity': 'rejected-' + str(index)} + result, calls = self.run_trip([IDLE, CONFIG, response], buddy_context=context) + self.assertFalse(result['ok']) + self.assertEqual(self.store.travel_write_record(context['identity'])['phase'], 'cancelled') + result, retry = self.run_trip([IDLE, CONFIG, {}, TRAVELING], buddy_context=context) + self.assertTrue(result['ok']) + self.assertEqual(sum(r.method == 'POST' for r in retry), 1) + + def test_final_cancellation_releases_only_its_own_departure(self): + context = {'store': self.store, 'identity': 'cancelled'} + result, calls = self.run_trip([IDLE, CONFIG], buddy_context=context, + can_write=lambda: self.store.travel_write_record('cancelled') is None) + self.assertTrue(result['skipped']) + self.assertEqual([r.method for r in calls], ['GET', 'GET']) + old = self.store.travel_write_record('cancelled') + self.assertEqual(old['phase'], 'cancelled') + result, retry = self.run_trip([IDLE, CONFIG, {}, TRAVELING], buddy_context=context) + self.assertTrue(result['ok']) + latest = self.store.travel_write_record('cancelled') + with self.assertRaises(ValueError): + self.store.transition_travel_write('cancelled', old['attempt_id'], 'cancelled') + self.assertEqual(self.store.travel_write_record('cancelled'), latest) + + def test_departure_storage_failures_never_allow_duplicate_writes(self): + result, calls = self.run_trip([IDLE, CONFIG], buddy_context={}) + self.assertEqual(result['error_kind'], 'storage') + self.assertFalse(any(r.method == 'POST' for r in calls)) + context = {'store': self.store, 'identity': 'storage'} + transition = self.store.transition_travel_write + def fail_receipt(identity, attempt, phase): + if phase == 'confirmed': + raise OSError('synthetic-secret') + return transition(identity, attempt, phase) + with patch.object(self.store, 'transition_travel_write', side_effect=fail_receipt): + result, calls = self.run_trip([IDLE, CONFIG, {}], buddy_context=context) + self.assertTrue(result['departed']) + self.assertEqual(result['error_kind'], 'storage') + self.assertNotIn('synthetic-secret', str(result)) + result, retry = self.run_trip([IDLE], buddy_context=context) + self.assertTrue(result['departure_pending']) + self.assertEqual([r.method for r in retry], ['GET']) + saved = self.store.travel_write_record('storage') + result, failed_query = self.run_trip([httpx.ReadTimeout('secret')], buddy_context=context) + self.assertFalse(result['ok']) + self.assertEqual(self.store.travel_write_record('storage'), saved) + + def test_stale_status_cannot_authorize_a_second_process_departure(self): + context = {'store': self.store, 'identity': 'race'} + def other_process(request): + if request.url.path.endswith('/status'): + attempt = self.store.reserve_travel_write('race', 'depart', 21) + self.store.transition_travel_write('race', attempt, 'sent') + self.store.transition_travel_write('race', attempt, 'reconciled') + result, calls = self.run_trip([IDLE, CONFIG], buddy_context=context, on_request=other_process) + self.assertTrue(result['departure_pending']) + self.assertFalse(any(r.method == 'POST' for r in calls)) + attempt = self.store.reserve_travel_write('reserved', 'depart', 21) + result, calls = self.run_trip([TRAVELING], buddy_context={'store': self.store, 'identity': 'reserved'}, read_only=True) + self.assertTrue(result['departure_pending']) + self.assertEqual(self.store.travel_write_record('reserved')['phase'], 'reserved') + + + def test_departure_reservation_is_atomic_and_late_receipt_cannot_reopen_it(self): + other = ControlStore(self.root / 'control.sqlite3') + self.addCleanup(other.close) + with ThreadPoolExecutor(max_workers=2) as executor: + attempts = list(executor.map(lambda store: store.reserve_travel_write('account', 'depart', 21), [self.store, other])) + self.assertEqual(sum(a is not None for a in attempts), 1) + attempt = next(a for a in attempts if a is not None) + self.store.transition_travel_write('account', attempt, 'sent') + other.transition_travel_write('account', attempt, 'reconciled') + self.store.transition_travel_write('account', attempt, 'confirmed') + self.assertEqual(self.store.travel_write_record('account')['phase'], 'reconciled') + next_attempt = other.reserve_travel_write('account', 'depart', 37, expected_attempt=attempt) + self.assertIsNotNone(next_attempt) + with self.assertRaises(ValueError): + self.store.transition_travel_write('account', attempt, 'reconciled') + self.assertEqual(other.travel_write_record('account')['attempt_id'], next_attempt) + + def test_arrival_after_depart_does_not_start_another_claim_loop(self): result, calls = self.run_trip([IDLE, CONFIG, {}, {'state': 'arrived'}]) self.assertTrue(result['ok']) @@ -267,6 +409,45 @@ def test_eventually_consistent_arrival_is_not_claimed_twice(self): self.assertTrue(result['claimed']) self.assertEqual(len(calls), 3) + def test_confirmed_claim_survives_restart_and_blocks_repeat_until_idle(self): + arrived = {'state': 'arrived', 'daily_limit_reached': False} + context = {'store': self.store, 'identity': 'claim-account'} + result, calls = self.run_trip([arrived, {'reward_credit': 8}, arrived], buddy_context=context) + self.assertTrue(result['claimed']) + self.assertFalse(result['ok']) + saved = self.store.travel_write_record('claim-account') + self.assertEqual((saved['operation'], saved['phase']), ('claim', 'confirmed')) + reopened = ControlStore(self.root / 'control.sqlite3') + self.addCleanup(reopened.close) + context['store'] = reopened + for read_only in (False, True): + with patch.object(travel.time, 'time', return_value=saved['reserved_at'] + 7 * 86400): + pending, retry = self.run_trip([arrived], buddy_context=context, read_only=read_only) + self.assertTrue(pending['claim_pending']) + self.assertTrue(pending['claimed']) + self.assertIsNone(pending.get('claimed_credit')) + self.assertEqual([r.method for r in retry], ['GET']) + self.assertEqual(reopened.travel_write_record('claim-account'), saved) + result, query = self.run_trip([IDLE], buddy_context=context, read_only=True) + self.assertTrue(result['ok']) + self.assertEqual(reopened.travel_write_record('claim-account')['phase'], 'reconciled') + result, next_trip = self.run_trip([IDLE, CONFIG, {}, TRAVELING], buddy_context=context) + self.assertTrue(result['ok']) + self.assertEqual([r.url.path.rsplit('/', 1)[-1] for r in next_trip if r.method == 'POST'], ['depart']) + + def test_uncertain_claim_and_post_claim_query_failure_are_not_replayed(self): + for index, responses in enumerate(([{'state': 'arrived'}, httpx.ReadTimeout('secret')], + [{'state': 'arrived'}, {}, httpx.ReadTimeout('secret')])): + context = {'store': self.store, 'identity': 'claim-uncertain-' + str(index)} + result, calls = self.run_trip(responses, buddy_context=context) + self.assertFalse(result['ok']) + saved = self.store.travel_write_record(context['identity']) + result, retry = self.run_trip([{'state': 'arrived'}], buddy_context=context) + self.assertTrue(result['claim_pending']) + self.assertEqual([r.method for r in retry], ['GET']) + self.assertEqual(self.store.travel_write_record(context['identity']), saved) + + def test_setting_change_during_query_stops_claim_or_depart(self): for state in ('arrived', 'idle'): decisions = iter([True, False]) diff --git a/web/src/Travel.tsx b/web/src/Travel.tsx index 2fabb1e..5295afe 100644 --- a/web/src/Travel.tsx +++ b/web/src/Travel.tsx @@ -31,6 +31,8 @@ export function TravelSummary({ trip }: { trip: Record | null } {trip.claimed === true && trip.claimed_credit == null && ( 领取已确认,积分数额未返回 )} + {trip.departure_pending === true && 派遣记录待核验,不重复派出} + {trip.claim_pending === true && 奖励领取待核验,不重复领取} {trip.buddy_consent_accepted === true && 首领同意已保存,无需重复确认} {trip.buddy_task_chat_sent === true && 新手对话已尝试,不自动重复发送} {trip.buddy_task_completed === true && 官方新手任务已确认完成} diff --git a/web/src/automation.test.tsx b/web/src/automation.test.tsx index 5b7c6fe..8e158a0 100644 --- a/web/src/automation.test.tsx +++ b/web/src/automation.test.tsx @@ -182,6 +182,24 @@ it("shows server-provided travel locations and snapshot durations without trigge expect(post).not.toHaveBeenCalled(); }); +it("shows durable travel write holds and storage failures without making requests", () => { + const post = vi.spyOn(api, "post"); + render( + , + ); + expect(screen.getByText("派遣记录待核验,不重复派出")).toBeTruthy(); + expect(screen.getByText("奖励领取待核验,不重复领取")).toBeTruthy(); + expect(screen.getByText("状态记录不可用")).toBeTruthy(); + expect(post).not.toHaveBeenCalled(); +}); + it("keeps confirmed departure visible when its status read fails", async () => { fixture(); vi.spyOn(api, "post").mockResolvedValue({ diff --git a/web/src/values.tsx b/web/src/values.tsx index 0c7d18e..79b898a 100644 --- a/web/src/values.tsx +++ b/web/src/values.tsx @@ -48,6 +48,8 @@ const labels: Record = { at: "记录时间", claimed: "本次已领取", departed: "本次已派出", + departure_pending: "派遣记录待核验", + claim_pending: "奖励领取待核验", claimed_credit: "本次领取积分", reward_credit: "旅行奖励积分", location_id: "地点编号", @@ -208,6 +210,7 @@ const travelErrors: Record = { protocol: "响应格式异常", timeout: "请求超时", network: "网络失败", + storage: "状态记录不可用", }; const times = new Set([ "at", From 6ea2c79c160b3c75ec9b2179cd094e868ff350db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?= <277378677+maiphucgiang@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:09:38 +0800 Subject: [PATCH 6/6] Preserve previously accepted Buddy agreements --- app/buddy.py | 3 +++ tests/test_buddy.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/app/buddy.py b/app/buddy.py index edc4da1..554091f 100644 --- a/app/buddy.py +++ b/app/buddy.py @@ -193,6 +193,8 @@ def event(stage, outcome, **fields): result["consent_source"] = source active = _active(_request(client, token, "info")) previous = store.buddy_record(identity) if store is not None and identity else None + if previous: + result["agreement_accepted"] = bool(previous["agreed"]) if active: if previous and previous["outcome"] != "success": attempt = previous["attempt_id"] @@ -239,6 +241,7 @@ def event(stage, outcome, **fields): agreed = _request(client, token, "agreement").get("agreed") if type(agreed) is not bool: raise Failure("protocol", 200, 0) + result["agreement_accepted"] = agreed if source is None: result["buddy_confirmation"] = confirmation(True) return stop("buddy_confirmation_required") diff --git a/tests/test_buddy.py b/tests/test_buddy.py index efe2257..c62312e 100644 --- a/tests/test_buddy.py +++ b/tests/test_buddy.py @@ -108,9 +108,38 @@ def test_manual_consent_uses_only_agreement_and_first_endpoints(self): def test_environment_authorization_skips_previously_accepted_agreement(self): result = self.prepare([EMPTY, LIST, TASKS, {"agreed": True}, None, ACTIVE], automatic=True) self.assertTrue(result["buddy_ready"], result) + self.assertTrue(result["agreement_accepted"]) + self.assertEqual(self.store.buddy_record("account")["agreed"], 1) + self.assertTrue(self.prepare([ACTIVE])["agreement_accepted"]) self.assertEqual(result["consent_source"], "environment") self.assertEqual([r.url.path for r in self.posts()], ["/activity/growth/buddy/first"]) + def test_preaccepted_agreement_survives_uncertain_claim_and_read_reconciliation(self): + for automatic in (False, True): + with self.subTest(automatic=automatic): + identity = "agreed-" + str(automatic) + self.context["identity"] = identity + result = self.prepare([EMPTY, LIST, TASKS, {"agreed": True}, httpx.ReadTimeout("secret"), EMPTY], + automatic=automatic, consent=None if automatic else buddy.AGREEMENT_REVISION) + self.assertTrue(result["agreement_accepted"]) + self.assertEqual(self.store.buddy_record(identity)["agreed"], 1) + reopened = ControlStore(self.root / "control.sqlite3") + self.addCleanup(reopened.close) + self.context["store"] = reopened + pending = self.prepare([EMPTY, LIST], automatic=automatic) + self.assertTrue(pending["agreement_accepted"]) + reconciled = self.prepare([ACTIVE]) + self.assertTrue(reconciled["agreement_accepted"]) + self.assertTrue(reconciled["buddy_ready"]) + self.assertTrue(all(r.url.path.endswith("/buddy/first") for r in self.posts())) + + def test_official_agreement_alone_does_not_authorize_first_claim(self): + result = self.prepare([EMPTY, LIST, TASKS, {"agreed": True}]) + self.assertTrue(result["agreement_accepted"]) + self.assertEqual(result["reason"], "buddy_confirmation_required") + self.assertEqual(self.posts(), []) + + def test_stale_revision_is_not_consent(self): result = self.prepare([EMPTY, LIST, TASKS, AGREEMENT], consent="old-version") self.assertEqual(result["reason"], "buddy_confirmation_required")